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

View file

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

View file

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

View file

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

View file

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