commcoach iteration 1 completed
This commit is contained in:
parent
16db2d91c6
commit
5486a87b9a
5 changed files with 128 additions and 15 deletions
|
|
@ -5,6 +5,7 @@ Interface to CommCoach database.
|
||||||
Uses the PostgreSQL connector for data access with strict user ownership.
|
Uses the PostgreSQL connector for data access with strict user ownership.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
|
@ -292,14 +293,23 @@ class CommcoachObjects:
|
||||||
|
|
||||||
contextSummaries = []
|
contextSummaries = []
|
||||||
for ctx in activeContexts:
|
for ctx in activeContexts:
|
||||||
|
goalProgress = _calcGoalProgress(ctx.get("goals"))
|
||||||
contextSummaries.append({
|
contextSummaries.append({
|
||||||
"id": ctx.get("id"),
|
"id": ctx.get("id"),
|
||||||
"title": ctx.get("title"),
|
"title": ctx.get("title"),
|
||||||
"category": ctx.get("category"),
|
"category": ctx.get("category"),
|
||||||
"sessionCount": ctx.get("sessionCount", 0),
|
"sessionCount": ctx.get("sessionCount", 0),
|
||||||
"lastSessionAt": ctx.get("lastSessionAt"),
|
"lastSessionAt": ctx.get("lastSessionAt"),
|
||||||
|
"goalProgress": goalProgress,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
allGoalProgress = []
|
||||||
|
for ctx in activeContexts:
|
||||||
|
gp = _calcGoalProgress(ctx.get("goals"))
|
||||||
|
if gp is not None:
|
||||||
|
allGoalProgress.append(gp)
|
||||||
|
overallGoalProgress = round(sum(allGoalProgress) / len(allGoalProgress)) if allGoalProgress else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"totalContexts": len(contexts),
|
"totalContexts": len(contexts),
|
||||||
"activeContexts": len(activeContexts),
|
"activeContexts": len(activeContexts),
|
||||||
|
|
@ -312,4 +322,21 @@ class CommcoachObjects:
|
||||||
"openTasks": self.getOpenTaskCount(userId, instanceId),
|
"openTasks": self.getOpenTaskCount(userId, instanceId),
|
||||||
"completedTasks": self.getCompletedTaskCount(userId, instanceId),
|
"completedTasks": self.getCompletedTaskCount(userId, instanceId),
|
||||||
"contexts": contextSummaries,
|
"contexts": contextSummaries,
|
||||||
|
"goalProgress": overallGoalProgress,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calcGoalProgress(goalsRaw) -> Optional[int]:
|
||||||
|
"""Calculate goal completion percentage from a context's goals JSON field."""
|
||||||
|
if not goalsRaw:
|
||||||
|
return None
|
||||||
|
goals = goalsRaw
|
||||||
|
if isinstance(goalsRaw, str):
|
||||||
|
try:
|
||||||
|
goals = json.loads(goalsRaw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(goals, list) or len(goals) == 0:
|
||||||
|
return None
|
||||||
|
done = sum(1 for g in goals if isinstance(g, dict) and g.get("status") in ("done", "completed"))
|
||||||
|
return round(done / len(goals) * 100)
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,7 @@ def registerFeature(catalogService) -> bool:
|
||||||
)
|
)
|
||||||
|
|
||||||
_syncTemplateRolesToDb()
|
_syncTemplateRolesToDb()
|
||||||
|
_registerScheduler()
|
||||||
|
|
||||||
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
|
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
|
||||||
return True
|
return True
|
||||||
|
|
@ -193,6 +194,16 @@ def registerFeature(catalogService) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _registerScheduler():
|
||||||
|
"""Register CommCoach scheduled jobs (daily reminders)."""
|
||||||
|
try:
|
||||||
|
from modules.shared.eventManagement import eventManager
|
||||||
|
from .serviceCommcoachScheduler import registerScheduledJobs
|
||||||
|
registerScheduledJobs(eventManager)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"CommCoach scheduler registration failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
|
||||||
def _syncTemplateRolesToDb() -> int:
|
def _syncTemplateRolesToDb() -> int:
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,23 @@ from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEven
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(context: RequestContext, action: str, resourceType: str = None, resourceId: str = None, details: str = ""):
|
||||||
|
"""Log an audit event for CommCoach. Non-blocking, best-effort."""
|
||||||
|
try:
|
||||||
|
from modules.shared.auditLogger import audit_logger
|
||||||
|
audit_logger.logEvent(
|
||||||
|
userId=str(context.user.id),
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
category="commcoach",
|
||||||
|
action=action,
|
||||||
|
resourceType=resourceType,
|
||||||
|
resourceId=resourceId,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/commcoach",
|
prefix="/api/commcoach",
|
||||||
tags=["CommCoach"],
|
tags=["CommCoach"],
|
||||||
|
|
@ -116,6 +133,7 @@ async def createContext(
|
||||||
|
|
||||||
created = interface.createContext(contextData)
|
created = interface.createContext(contextData)
|
||||||
logger.info(f"CommCoach context created: {created.get('id')} for user {userId}")
|
logger.info(f"CommCoach context created: {created.get('id')} for user {userId}")
|
||||||
|
_audit(context, "commcoach.context.created", "CoachingContext", created.get("id"), f"Title: {body.title}")
|
||||||
return {"context": created}
|
return {"context": created}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -208,6 +226,7 @@ async def archiveContext(
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(ctx, context)
|
||||||
|
|
||||||
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value})
|
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value})
|
||||||
|
_audit(context, "commcoach.context.archived", "CoachingContext", contextId)
|
||||||
return {"context": updated}
|
return {"context": updated}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -369,6 +388,7 @@ async def startSession(
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}")
|
logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}")
|
||||||
|
_audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Context: {contextId}")
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_newSessionEventGenerator(),
|
_newSessionEventGenerator(),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
|
|
@ -419,6 +439,7 @@ async def completeSession(
|
||||||
|
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
result = await service.completeSession(sessionId, interface)
|
result = await service.completeSession(sessionId, interface)
|
||||||
|
_audit(context, "commcoach.session.completed", "CoachingSession", sessionId)
|
||||||
return {"session": result}
|
return {"session": result}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,33 @@ def cleanupSessionEvents(sessionId: str):
|
||||||
_sessionEvents.pop(sessionId, None)
|
_sessionEvents.pop(sessionId, None)
|
||||||
|
|
||||||
|
|
||||||
|
CHUNK_WORD_SIZE = 4
|
||||||
|
CHUNK_DELAY_SECONDS = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
async def _emitChunkedResponse(sessionId: str, createdMsg: Dict[str, Any], fullText: str):
|
||||||
|
"""Emit response as messageChunk events for progressive display, then the full message."""
|
||||||
|
msgId = createdMsg.get("id")
|
||||||
|
words = fullText.split()
|
||||||
|
emitted = ""
|
||||||
|
for i in range(0, len(words), CHUNK_WORD_SIZE):
|
||||||
|
chunk = " ".join(words[i:i + CHUNK_WORD_SIZE])
|
||||||
|
emitted = (emitted + " " + chunk).strip() if emitted else chunk
|
||||||
|
await emitSessionEvent(sessionId, "messageChunk", {
|
||||||
|
"id": msgId,
|
||||||
|
"role": "assistant",
|
||||||
|
"chunk": chunk,
|
||||||
|
"accumulated": emitted,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(CHUNK_DELAY_SECONDS)
|
||||||
|
await emitSessionEvent(sessionId, "message", {
|
||||||
|
"id": msgId,
|
||||||
|
"role": "assistant",
|
||||||
|
"content": fullText,
|
||||||
|
"createdAt": createdMsg.get("createdAt"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class CommcoachService:
|
class CommcoachService:
|
||||||
"""Coaching orchestrator: processes messages, calls AI, extracts tasks and scores."""
|
"""Coaching orchestrator: processes messages, calls AI, extracts tasks and scores."""
|
||||||
|
|
||||||
|
|
@ -204,12 +231,7 @@ class CommcoachService:
|
||||||
messages = interface.getMessages(sessionId)
|
messages = interface.getMessages(sessionId)
|
||||||
interface.updateSession(sessionId, {"messageCount": len(messages)})
|
interface.updateSession(sessionId, {"messageCount": len(messages)})
|
||||||
|
|
||||||
await emitSessionEvent(sessionId, "message", {
|
await _emitChunkedResponse(sessionId, createdAssistantMsg, responseText)
|
||||||
"id": createdAssistantMsg.get("id"),
|
|
||||||
"role": "assistant",
|
|
||||||
"content": responseText,
|
|
||||||
"createdAt": createdAssistantMsg.get("createdAt"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if responseText:
|
if responseText:
|
||||||
try:
|
try:
|
||||||
|
|
@ -289,15 +311,7 @@ class CommcoachService:
|
||||||
createdMsg = interface.createMessage(assistantMsg)
|
createdMsg = interface.createMessage(assistantMsg)
|
||||||
interface.updateSession(sessionId, {"messageCount": 1})
|
interface.updateSession(sessionId, {"messageCount": 1})
|
||||||
|
|
||||||
await emitSessionEvent(sessionId, "message", {
|
await _emitChunkedResponse(sessionId, createdMsg, openingContent)
|
||||||
"id": createdMsg.get("id"),
|
|
||||||
"sessionId": sessionId,
|
|
||||||
"contextId": contextId,
|
|
||||||
"role": "assistant",
|
|
||||||
"content": openingContent,
|
|
||||||
"contentType": "text",
|
|
||||||
"createdAt": createdMsg.get("createdAt"),
|
|
||||||
})
|
|
||||||
if openingContent:
|
if openingContent:
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
|
|
@ -497,6 +511,24 @@ class CommcoachService:
|
||||||
logger.warning(f"Scoring failed: {e}")
|
logger.warning(f"Scoring failed: {e}")
|
||||||
competenceScore = None
|
competenceScore = None
|
||||||
|
|
||||||
|
# Generate insights
|
||||||
|
try:
|
||||||
|
insightPrompt = aiPrompts.buildInsightPrompt(messages, summary)
|
||||||
|
insightResponse = await self._callAi("Du generierst kurze Coaching-Insights.", insightPrompt)
|
||||||
|
if insightResponse and insightResponse.errorCount == 0 and insightResponse.content:
|
||||||
|
insights = aiPrompts.parseJsonResponse(insightResponse.content, [])
|
||||||
|
if isinstance(insights, list):
|
||||||
|
existingInsights = aiPrompts._parseJsonField(context.get("insights") if context else None, [])
|
||||||
|
for ins in insights[:3]:
|
||||||
|
insightText = ins.get("text", ins) if isinstance(ins, dict) else str(ins)
|
||||||
|
if insightText:
|
||||||
|
existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()})
|
||||||
|
await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId})
|
||||||
|
if contextId and existingInsights:
|
||||||
|
interface.updateContext(contextId, {"insights": json.dumps(existingInsights[-10:])})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Insight generation failed: {e}")
|
||||||
|
|
||||||
# Calculate duration
|
# Calculate duration
|
||||||
startedAt = session.get("startedAt", "")
|
startedAt = session.get("startedAt", "")
|
||||||
durationSeconds = 0
|
durationSeconds = 0
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,28 @@ Sessions:
|
||||||
Antworte NUR mit der Zusammenfassung."""
|
Antworte NUR mit der Zusammenfassung."""
|
||||||
|
|
||||||
|
|
||||||
|
def buildInsightPrompt(messages: List[Dict[str, Any]], summary: Optional[str] = None) -> str:
|
||||||
|
"""Build a prompt to generate coaching insights from a completed session."""
|
||||||
|
conversation = ""
|
||||||
|
for msg in messages[-15:]:
|
||||||
|
role = "Benutzer" if msg.get("role") == "user" else "Coach"
|
||||||
|
conversation += f"\n{role}: {msg.get('content', '')[:300]}"
|
||||||
|
|
||||||
|
summarySection = f"\nZusammenfassung: {summary[:500]}" if summary else ""
|
||||||
|
|
||||||
|
return f"""Generiere 1-3 kurze Coaching-Insights aus dieser Session.
|
||||||
|
Ein Insight ist eine praegende Erkenntnis oder ein Aha-Moment des Benutzers.
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH als JSON-Array:
|
||||||
|
[{{"text": "Erkenntnis in einem Satz"}}]
|
||||||
|
|
||||||
|
Nur echte Erkenntnisse, keine Banalitaeten. Wenn keine klaren Insights: leeres Array [].
|
||||||
|
{summarySection}
|
||||||
|
|
||||||
|
Gespraech:
|
||||||
|
{conversation}"""
|
||||||
|
|
||||||
|
|
||||||
def buildTaskExtractionPrompt(messages: List[Dict[str, Any]]) -> str:
|
def buildTaskExtractionPrompt(messages: List[Dict[str, Any]]) -> str:
|
||||||
"""Build a prompt to extract actionable tasks from a session."""
|
"""Build a prompt to extract actionable tasks from a session."""
|
||||||
recentForTasks = messages[-25:] if len(messages) > 25 else messages
|
recentForTasks = messages[-25:] if len(messages) > 25 else messages
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue