From 5486a87b9ac355f14376f411ebdf6ff1e332061e Mon Sep 17 00:00:00 2001
From: patrick-motsch
Date: Tue, 3 Mar 2026 22:04:52 +0100
Subject: [PATCH] commcoach iteration 1 completed
---
.../commcoach/interfaceFeatureCommcoach.py | 27 ++++++++
modules/features/commcoach/mainCommcoach.py | 11 ++++
.../commcoach/routeFeatureCommcoach.py | 21 +++++++
.../features/commcoach/serviceCommcoach.py | 62 ++++++++++++++-----
.../features/commcoach/serviceCommcoachAi.py | 22 +++++++
5 files changed, 128 insertions(+), 15 deletions(-)
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