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.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue