commcoach iteration 1 completed

This commit is contained in:
patrick-motsch 2026-03-03 22:04:52 +01:00
parent 16db2d91c6
commit 5486a87b9a
5 changed files with 128 additions and 15 deletions

View file

@ -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)

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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