diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py index 830aa261..eae7e168 100644 --- a/modules/features/commcoach/interfaceFeatureCommcoach.py +++ b/modules/features/commcoach/interfaceFeatureCommcoach.py @@ -5,6 +5,7 @@ Interface to CommCoach database. Uses the PostgreSQL connector for data access with strict user ownership. """ +import json import logging from typing import Dict, Any, List, Optional @@ -292,14 +293,23 @@ class CommcoachObjects: contextSummaries = [] for ctx in activeContexts: + goalProgress = _calcGoalProgress(ctx.get("goals")) contextSummaries.append({ "id": ctx.get("id"), "title": ctx.get("title"), "category": ctx.get("category"), "sessionCount": ctx.get("sessionCount", 0), "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 { "totalContexts": len(contexts), "activeContexts": len(activeContexts), @@ -312,4 +322,21 @@ class CommcoachObjects: "openTasks": self.getOpenTaskCount(userId, instanceId), "completedTasks": self.getCompletedTaskCount(userId, instanceId), "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) diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index c5a0a7c1..ff213f91 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -184,6 +184,7 @@ def registerFeature(catalogService) -> bool: ) _syncTemplateRolesToDb() + _registerScheduler() logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects") return True @@ -193,6 +194,16 @@ def registerFeature(catalogService) -> bool: 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: try: from modules.interfaces.interfaceDbApp import getRootInterface diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index 66a4b347..43c00291 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -31,6 +31,23 @@ from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEven 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( prefix="/api/commcoach", tags=["CommCoach"], @@ -116,6 +133,7 @@ async def createContext( created = interface.createContext(contextData) 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} @@ -208,6 +226,7 @@ async def archiveContext( _validateOwnership(ctx, context) updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value}) + _audit(context, "commcoach.context.archived", "CoachingContext", contextId) return {"context": updated} @@ -369,6 +388,7 @@ async def startSession( pass logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}") + _audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Context: {contextId}") return StreamingResponse( _newSessionEventGenerator(), media_type="text/event-stream", @@ -419,6 +439,7 @@ async def completeSession( service = CommcoachService(context.user, mandateId, instanceId) result = await service.completeSession(sessionId, interface) + _audit(context, "commcoach.session.completed", "CoachingSession", sessionId) return {"session": result} diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index 0778a978..1b886958 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -83,6 +83,33 @@ def cleanupSessionEvents(sessionId: str): _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: """Coaching orchestrator: processes messages, calls AI, extracts tasks and scores.""" @@ -204,12 +231,7 @@ class CommcoachService: messages = interface.getMessages(sessionId) interface.updateSession(sessionId, {"messageCount": len(messages)}) - await emitSessionEvent(sessionId, "message", { - "id": createdAssistantMsg.get("id"), - "role": "assistant", - "content": responseText, - "createdAt": createdAssistantMsg.get("createdAt"), - }) + await _emitChunkedResponse(sessionId, createdAssistantMsg, responseText) if responseText: try: @@ -289,15 +311,7 @@ class CommcoachService: createdMsg = interface.createMessage(assistantMsg) interface.updateSession(sessionId, {"messageCount": 1}) - await emitSessionEvent(sessionId, "message", { - "id": createdMsg.get("id"), - "sessionId": sessionId, - "contextId": contextId, - "role": "assistant", - "content": openingContent, - "contentType": "text", - "createdAt": createdMsg.get("createdAt"), - }) + await _emitChunkedResponse(sessionId, createdMsg, openingContent) if openingContent: try: from modules.interfaces.interfaceVoiceObjects import getVoiceInterface @@ -497,6 +511,24 @@ class CommcoachService: logger.warning(f"Scoring failed: {e}") 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 startedAt = session.get("startedAt", "") durationSeconds = 0 diff --git a/modules/features/commcoach/serviceCommcoachAi.py b/modules/features/commcoach/serviceCommcoachAi.py index ea58488b..5d050203 100644 --- a/modules/features/commcoach/serviceCommcoachAi.py +++ b/modules/features/commcoach/serviceCommcoachAi.py @@ -312,6 +312,28 @@ Sessions: 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: """Build a prompt to extract actionable tasks from a session.""" recentForTasks = messages[-25:] if len(messages) > 25 else messages