diff --git a/modules/features/commcoach/datamodelCommcoach.py b/modules/features/commcoach/datamodelCommcoach.py index 0ba636ff..090640c6 100644 --- a/modules/features/commcoach/datamodelCommcoach.py +++ b/modules/features/commcoach/datamodelCommcoach.py @@ -103,6 +103,7 @@ class CoachingSession(BaseModel): mandateId: str = Field(description="Mandate ID") instanceId: str = Field(description="Feature instance ID") status: CoachingSessionStatus = Field(default=CoachingSessionStatus.ACTIVE) + personaId: Optional[str] = Field(default=None, description="FK to CoachingPersona (Iteration 2)") summary: Optional[str] = Field(default=None, description="AI-generated session summary") coachNotes: Optional[str] = Field(default=None, description="JSON: AI internal notes for continuity") compressedHistorySummary: Optional[str] = Field(default=None, description="AI summary of older messages for long sessions") @@ -183,6 +184,62 @@ class CoachingUserProfile(BaseModel): updatedAt: Optional[str] = Field(default=None) +# ============================================================================ +# Iteration 2: Personas +# ============================================================================ + +class CoachingPersona(BaseModel): + """A roleplay persona for coaching sessions.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + userId: str = Field(description="Owner user ID ('system' for builtins)") + mandateId: Optional[str] = Field(default=None) + instanceId: Optional[str] = Field(default=None) + key: str = Field(description="Unique key, e.g. 'critical_cfo_f'") + label: str = Field(description="Display label, e.g. 'Kritische CFO'") + description: str = Field(description="Detailed role description for the AI") + systemPromptOverride: Optional[str] = Field(default=None, description="Full system prompt override for this persona") + gender: Optional[str] = Field(default=None, description="m or f") + category: str = Field(default="builtin", description="'builtin' or 'custom'") + isActive: bool = Field(default=True) + createdAt: Optional[str] = Field(default=None) + updatedAt: Optional[str] = Field(default=None) + + +# ============================================================================ +# Iteration 2: Documents +# ============================================================================ + +class CoachingDocument(BaseModel): + """A document attached to a coaching context.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + contextId: str = Field(description="FK to CoachingContext") + userId: str = Field(description="Owner user ID") + mandateId: str = Field(description="Mandate ID") + instanceId: Optional[str] = Field(default=None) + fileName: str = Field(description="Original file name") + mimeType: str = Field(default="application/octet-stream") + fileSize: int = Field(default=0) + extractedText: Optional[str] = Field(default=None, description="Text content extracted from file") + summary: Optional[str] = Field(default=None, description="AI-generated summary") + fileRef: Optional[str] = Field(default=None, description="Reference to file in storage") + createdAt: Optional[str] = Field(default=None) + + +# ============================================================================ +# Iteration 2: Badges / Gamification +# ============================================================================ + +class CoachingBadge(BaseModel): + """An achievement badge awarded to a user.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + userId: str = Field(description="Owner user ID") + mandateId: str = Field(description="Mandate ID") + instanceId: str = Field(description="Feature instance ID") + badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'") + awardedAt: Optional[str] = Field(default=None) + createdAt: Optional[str] = Field(default=None) + + # ============================================================================ # API Request/Response Models # ============================================================================ @@ -232,6 +289,25 @@ class UpdateProfileRequest(BaseModel): emailSummaryEnabled: Optional[bool] = None +class StartSessionRequest(BaseModel): + personaId: Optional[str] = None + + +class CreatePersonaRequest(BaseModel): + label: str + description: str + gender: Optional[str] = None + systemPromptOverride: Optional[str] = None + + +class UpdatePersonaRequest(BaseModel): + label: Optional[str] = None + description: Optional[str] = None + gender: Optional[str] = None + systemPromptOverride: Optional[str] = None + isActive: Optional[bool] = None + + class DashboardData(BaseModel): """Aggregated dashboard data for the user.""" totalContexts: int = 0 diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py index eae7e168..9e21f677 100644 --- a/modules/features/commcoach/interfaceFeatureCommcoach.py +++ b/modules/features/commcoach/interfaceFeatureCommcoach.py @@ -238,6 +238,98 @@ class CommcoachObjects: count += 1 return count + # ========================================================================= + # Personas + # ========================================================================= + + def getPersonas(self, userId: str, instanceId: str) -> List[Dict[str, Any]]: + from .datamodelCommcoach import CoachingPersona + builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"}) + custom = self.db.getRecordset(CoachingPersona, recordFilter={"userId": userId, "instanceId": instanceId}) + all = builtins + custom + return [p for p in all if p.get("isActive", True)] + + def getPersona(self, personaId: str) -> Optional[Dict[str, Any]]: + from .datamodelCommcoach import CoachingPersona + records = self.db.getRecordset(CoachingPersona, recordFilter={"id": personaId}) + return records[0] if records else None + + def createPersona(self, data: Dict[str, Any]) -> Dict[str, Any]: + from .datamodelCommcoach import CoachingPersona + data["createdAt"] = getIsoTimestamp() + data["updatedAt"] = getIsoTimestamp() + return self.db.recordCreate(CoachingPersona, data) + + def updatePersona(self, personaId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + from .datamodelCommcoach import CoachingPersona + updates["updatedAt"] = getIsoTimestamp() + return self.db.recordModify(CoachingPersona, personaId, updates) + + def deletePersona(self, personaId: str) -> bool: + from .datamodelCommcoach import CoachingPersona + return self.db.recordDelete(CoachingPersona, personaId) + + # ========================================================================= + # Documents + # ========================================================================= + + def getDocuments(self, contextId: str, userId: str) -> List[Dict[str, Any]]: + from .datamodelCommcoach import CoachingDocument + records = self.db.getRecordset(CoachingDocument, recordFilter={"contextId": contextId, "userId": userId}) + records.sort(key=lambda r: r.get("createdAt") or "", reverse=True) + return records + + def getDocument(self, documentId: str) -> Optional[Dict[str, Any]]: + from .datamodelCommcoach import CoachingDocument + records = self.db.getRecordset(CoachingDocument, recordFilter={"id": documentId}) + return records[0] if records else None + + def createDocument(self, data: Dict[str, Any]) -> Dict[str, Any]: + from .datamodelCommcoach import CoachingDocument + data["createdAt"] = getIsoTimestamp() + return self.db.recordCreate(CoachingDocument, data) + + def deleteDocument(self, documentId: str) -> bool: + from .datamodelCommcoach import CoachingDocument + return self.db.recordDelete(CoachingDocument, documentId) + + # ========================================================================= + # Badges + # ========================================================================= + + def getBadges(self, userId: str, instanceId: str) -> List[Dict[str, Any]]: + from .datamodelCommcoach import CoachingBadge + records = self.db.getRecordset(CoachingBadge, recordFilter={"userId": userId, "instanceId": instanceId}) + records.sort(key=lambda r: r.get("awardedAt") or "", reverse=True) + return records + + def hasBadge(self, userId: str, instanceId: str, badgeKey: str) -> bool: + from .datamodelCommcoach import CoachingBadge + records = self.db.getRecordset(CoachingBadge, recordFilter={"userId": userId, "instanceId": instanceId, "badgeKey": badgeKey}) + return len(records) > 0 + + def awardBadge(self, data: Dict[str, Any]) -> Dict[str, Any]: + from .datamodelCommcoach import CoachingBadge + data["awardedAt"] = getIsoTimestamp() + data["createdAt"] = getIsoTimestamp() + return self.db.recordCreate(CoachingBadge, data) + + # ========================================================================= + # Score History + # ========================================================================= + + def getScoreHistory(self, contextId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]: + scores = self.getScores(contextId, userId) + history: Dict[str, List[Dict[str, Any]]] = {} + for s in scores: + dim = s.get("dimension", "unknown") + if dim not in history: + history[dim] = [] + history[dim].append({"score": s.get("score"), "trend": s.get("trend"), "evidence": s.get("evidence"), "createdAt": s.get("createdAt"), "sessionId": s.get("sessionId")}) + for dim in history: + history[dim].sort(key=lambda x: x.get("createdAt") or "") + return history + # ========================================================================= # User Profile # ========================================================================= @@ -323,6 +415,8 @@ class CommcoachObjects: "completedTasks": self.getCompletedTaskCount(userId, instanceId), "contexts": contextSummaries, "goalProgress": overallGoalProgress, + "badges": self.getBadges(userId, instanceId), + "level": _calcLevel(profile.get("totalSessions", 0) if profile else 0), } @@ -340,3 +434,11 @@ def _calcGoalProgress(goalsRaw) -> Optional[int]: 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) + + +def _calcLevel(totalSessions: int) -> Dict[str, Any]: + levels = [(50, 5, "Meister"), (25, 4, "Experte"), (10, 3, "Fortgeschritten"), (3, 2, "Engagiert")] + for threshold, number, label in levels: + if totalSessions >= threshold: + return {"number": number, "label": label, "totalSessions": totalSessions} + return {"number": 1, "label": "Einsteiger", "totalSessions": totalSessions} diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index ff213f91..2147a867 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -68,6 +68,21 @@ DATA_OBJECTS = [ "label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"}, "meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "preferredLanguage"]} }, + { + "objectKey": "data.feature.commcoach.CoachingPersona", + "label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"}, + "meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]} + }, + { + "objectKey": "data.feature.commcoach.CoachingDocument", + "label": {"en": "Coaching Document", "de": "Coaching-Dokument", "fr": "Document coaching"}, + "meta": {"table": "CoachingDocument", "fields": ["id", "contextId", "fileName"]} + }, + { + "objectKey": "data.feature.commcoach.CoachingBadge", + "label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"}, + "meta": {"table": "CoachingBadge", "fields": ["id", "badgeKey", "awardedAt"]} + }, { "objectKey": "data.feature.commcoach.*", "label": {"en": "All CommCoach Data", "de": "Alle CommCoach-Daten", "fr": "Toutes les donnees CommCoach"}, @@ -184,6 +199,7 @@ def registerFeature(catalogService) -> bool: ) _syncTemplateRolesToDb() + _seedBuiltinPersonas() _registerScheduler() logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects") @@ -194,6 +210,19 @@ def registerFeature(catalogService) -> bool: return False +def _seedBuiltinPersonas(): + """Seed builtin roleplay personas into the database.""" + try: + from .serviceCommcoachPersonas import seedBuiltinPersonas + from .interfaceFeatureCommcoach import CommcoachInterface + from modules.interfaces.interfaceDbManagement import getInterface as getDbInterface + db = getDbInterface() + interface = CommcoachInterface(db) + seedBuiltinPersonas(interface) + except Exception as e: + logger.warning(f"CommCoach persona seeding failed (non-fatal): {e}") + + def _registerScheduler(): """Register CommCoach scheduled jobs (daily reminders).""" try: diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index 43c00291..685a0f4e 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -9,9 +9,10 @@ import logging import json import asyncio import base64 +import uuid from typing import Optional from fastapi import APIRouter, HTTPException, Depends, Request -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, Response from modules.auth import limiter, getRequestContext, RequestContext from modules.shared.timeUtils import getIsoTimestamp @@ -23,9 +24,11 @@ from .datamodelCommcoach import ( CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus, CoachingMessage, CoachingMessageRole, CoachingMessageContentType, CoachingTask, CoachingTaskStatus, + CoachingPersona, CoachingDocument, CoachingBadge, CreateContextRequest, UpdateContextRequest, SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest, UpdateProfileRequest, + StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, ) from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents @@ -281,6 +284,7 @@ async def startSession( request: Request, instanceId: str, contextId: str, + personaId: Optional[str] = None, context: RequestContext = Depends(getRequestContext), ): """Start a new coaching session or resume active one. Returns SSE stream with sessionState, messages, and complete.""" @@ -358,6 +362,7 @@ async def startSession( userId=userId, mandateId=mandateId, instanceId=instanceId, + personaId=personaId, ).model_dump() created = interface.createSession(sessionData) sessionId = created.get("id") @@ -887,3 +892,330 @@ async def testVoice( except Exception as e: logger.error(f"Voice test failed: {e}") raise HTTPException(status_code=500, detail=f"TTS test failed: {str(e)}") + + +# ========================================================================= +# Export Endpoints (Iteration 2) +# ========================================================================= + +@router.get("/{instanceId}/contexts/{contextId}/export") +@limiter.limit("10/minute") +async def exportDossier( + request: Request, + instanceId: str, + contextId: str, + format: str = "md", + context: RequestContext = Depends(getRequestContext), +): + """Export a dossier as Markdown or PDF.""" + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + userId = str(context.user.id) + + ctx = interface.getContext(contextId) + if not ctx: + raise HTTPException(status_code=404, detail="Context not found") + _validateOwnership(ctx, context) + + tasks = interface.getTasks(contextId, userId) + scores = interface.getScores(contextId, userId) + sessions = interface.getSessions(contextId, userId) + + from .serviceCommcoachExport import buildDossierMarkdown, renderDossierPdf + _audit(context, "commcoach.export.requested", "CoachingContext", contextId, f"format={format}") + + if format == "pdf": + pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores) + if pdfBytes: + return Response(content=pdfBytes, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'}) + format = "md" + + md = buildDossierMarkdown(ctx, sessions, tasks, scores) + return Response(content=md, media_type="text/markdown", + headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.md"'}) + + +@router.get("/{instanceId}/sessions/{sessionId}/export") +@limiter.limit("10/minute") +async def exportSession( + request: Request, + instanceId: str, + sessionId: str, + format: str = "md", + context: RequestContext = Depends(getRequestContext), +): + """Export a session as Markdown or PDF.""" + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + + session = interface.getSession(sessionId) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + _validateOwnership(session, context) + + contextId = session.get("contextId") + userId = str(context.user.id) + messages = interface.getMessages(sessionId) + tasks = interface.getTasks(contextId, userId) if contextId else [] + scores = interface.getScores(contextId, userId) if contextId else [] + + from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf + _audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}") + + if format == "pdf": + pdfBytes = await renderSessionPdf(session, messages, tasks, scores) + if pdfBytes: + return Response(content=pdfBytes, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.pdf"'}) + format = "md" + + md = buildSessionMarkdown(session, messages, tasks, scores) + return Response(content=md, media_type="text/markdown", + headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.md"'}) + + +# ========================================================================= +# Persona Endpoints (Iteration 2) +# ========================================================================= + +@router.get("/{instanceId}/personas") +@limiter.limit("60/minute") +async def listPersonas( + request: Request, + instanceId: str, + context: RequestContext = Depends(getRequestContext), +): + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + userId = str(context.user.id) + personas = interface.getPersonas(userId, instanceId) + return {"personas": personas} + + +@router.post("/{instanceId}/personas") +@limiter.limit("10/minute") +async def createPersona( + request: Request, + instanceId: str, + body: CreatePersonaRequest, + context: RequestContext = Depends(getRequestContext), +): + mandateId = _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + userId = str(context.user.id) + + data = CoachingPersona( + userId=userId, + mandateId=mandateId, + instanceId=instanceId, + key=f"custom_{str(uuid.uuid4())[:8]}", + label=body.label, + description=body.description, + gender=body.gender, + systemPromptOverride=body.systemPromptOverride, + category="custom", + ).model_dump() + created = interface.createPersona(data) + return {"persona": created} + + +@router.put("/{instanceId}/personas/{personaId}") +@limiter.limit("10/minute") +async def updatePersonaRoute( + request: Request, + instanceId: str, + personaId: str, + body: UpdatePersonaRequest, + context: RequestContext = Depends(getRequestContext), +): + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + + persona = interface.getPersona(personaId) + if not persona: + raise HTTPException(status_code=404, detail="Persona not found") + if persona.get("category") == "builtin": + raise HTTPException(status_code=403, detail="Builtin personas cannot be edited") + _validateOwnership(persona, context) + + updates = body.model_dump(exclude_none=True) + updated = interface.updatePersona(personaId, updates) + return {"persona": updated} + + +@router.delete("/{instanceId}/personas/{personaId}") +@limiter.limit("10/minute") +async def deletePersonaRoute( + request: Request, + instanceId: str, + personaId: str, + context: RequestContext = Depends(getRequestContext), +): + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + + persona = interface.getPersona(personaId) + if not persona: + raise HTTPException(status_code=404, detail="Persona not found") + if persona.get("category") == "builtin": + raise HTTPException(status_code=403, detail="Builtin personas cannot be deleted") + _validateOwnership(persona, context) + + interface.deletePersona(personaId) + return {"deleted": True} + + +# ========================================================================= +# Document Endpoints (Iteration 2) +# ========================================================================= + +@router.get("/{instanceId}/contexts/{contextId}/documents") +@limiter.limit("60/minute") +async def listDocuments( + request: Request, + instanceId: str, + contextId: str, + context: RequestContext = Depends(getRequestContext), +): + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + userId = str(context.user.id) + docs = interface.getDocuments(contextId, userId) + return {"documents": docs} + + +@router.post("/{instanceId}/contexts/{contextId}/documents") +@limiter.limit("10/minute") +async def uploadDocument( + request: Request, + instanceId: str, + contextId: str, + context: RequestContext = Depends(getRequestContext), +): + """Upload a document and bind it to a context.""" + from fastapi import UploadFile + mandateId = _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + userId = str(context.user.id) + + ctx = interface.getContext(contextId) + if not ctx: + raise HTTPException(status_code=404, detail="Context not found") + _validateOwnership(ctx, context) + + form = await request.form() + file = form.get("file") + if not file or not hasattr(file, "read"): + raise HTTPException(status_code=400, detail="No file uploaded") + + content = await file.read() + fileName = getattr(file, "filename", "document") + mimeType = getattr(file, "content_type", "application/octet-stream") + fileSize = len(content) + + extractedText = _extractText(content, mimeType, fileName) + summary = None + if extractedText and len(extractedText.strip()) > 50: + try: + from .serviceCommcoach import CommcoachService + service = CommcoachService(context.user, mandateId, instanceId) + aiResp = await service._callAi( + "Du fasst Dokumente in 2-3 Saetzen zusammen.", + f"Fasse folgendes Dokument zusammen:\n\n{extractedText[:3000]}" + ) + if aiResp and aiResp.errorCount == 0 and aiResp.content: + summary = aiResp.content.strip() + except Exception as e: + logger.warning(f"Document summary failed: {e}") + + docData = CoachingDocument( + contextId=contextId, + userId=userId, + mandateId=mandateId, + instanceId=instanceId, + fileName=fileName, + mimeType=mimeType, + fileSize=fileSize, + extractedText=extractedText[:10000] if extractedText else None, + summary=summary, + ).model_dump() + created = interface.createDocument(docData) + return {"document": created} + + +@router.delete("/{instanceId}/documents/{documentId}") +@limiter.limit("10/minute") +async def deleteDocumentRoute( + request: Request, + instanceId: str, + documentId: str, + context: RequestContext = Depends(getRequestContext), +): + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + + doc = interface.getDocument(documentId) + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + _validateOwnership(doc, context) + + interface.deleteDocument(documentId) + return {"deleted": True} + + +def _extractText(content: bytes, mimeType: str, fileName: str) -> Optional[str]: + """Extract text from uploaded file content.""" + try: + if mimeType == "text/plain" or fileName.endswith(".txt"): + return content.decode("utf-8", errors="replace") + if mimeType == "text/markdown" or fileName.endswith(".md"): + return content.decode("utf-8", errors="replace") + if "pdf" in mimeType or fileName.endswith(".pdf"): + try: + import io + from PyPDF2 import PdfReader + reader = PdfReader(io.BytesIO(content)) + text = "" + for page in reader.pages: + text += page.extract_text() or "" + return text + except ImportError: + logger.warning("PyPDF2 not installed, cannot extract PDF text") + return None + except Exception as e: + logger.warning(f"Text extraction failed for {fileName}: {e}") + return None + + +# ========================================================================= +# Badge + Score History Endpoints (Iteration 2) +# ========================================================================= + +@router.get("/{instanceId}/badges") +@limiter.limit("60/minute") +async def listBadges( + request: Request, + instanceId: str, + context: RequestContext = Depends(getRequestContext), +): + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + userId = str(context.user.id) + badges = interface.getBadges(userId, instanceId) + return {"badges": badges} + + +@router.get("/{instanceId}/contexts/{contextId}/scores/history") +@limiter.limit("60/minute") +async def getScoreHistory( + request: Request, + instanceId: str, + contextId: str, + context: RequestContext = Depends(getRequestContext), +): + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + userId = str(context.user.id) + history = interface.getScoreHistory(contextId, userId) + return {"history": history} diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index 1b886958..f1edf90d 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -110,6 +110,35 @@ async def _emitChunkedResponse(sessionId: str, createdMsg: Dict[str, Any], fullT }) +def _resolvePersona(session: Optional[Dict[str, Any]], interface) -> Optional[Dict[str, Any]]: + """Resolve persona data from session's personaId.""" + if not session: + return None + personaId = session.get("personaId") + if not personaId: + return None + try: + return interface.getPersona(personaId) + except Exception: + return None + + +def _getDocumentSummaries(contextId: str, userId: str, interface) -> Optional[List[str]]: + """Get document summaries for context to include in the AI prompt.""" + try: + docs = interface.getDocuments(contextId, userId) + summaries = [] + for doc in docs[:5]: + summary = doc.get("summary") + if summary: + summaries.append(f"[{doc.get('fileName', 'Dokument')}] {summary}") + elif doc.get("extractedText"): + summaries.append(f"[{doc.get('fileName', 'Dokument')}] {doc['extractedText'][:200]}...") + return summaries if summaries else None + except Exception: + return None + + class CommcoachService: """Coaching orchestrator: processes messages, calls AI, extracts tasks and scores.""" @@ -190,6 +219,9 @@ class CommcoachService: contextId, sessionId, userContent, context, interface ) + persona = _resolvePersona(session, interface) + documentSummaries = _getDocumentSummaries(contextId, self.userId, interface) + systemPrompt = aiPrompts.buildCoachingSystemPrompt( context, previousMessages, @@ -199,6 +231,8 @@ class CommcoachService: rollingOverview=retrievalResult.get("rollingOverview"), retrievedSession=retrievalResult.get("retrievedSession"), retrievedByTopic=retrievalResult.get("retrievedByTopic"), + persona=persona, + documentSummaries=documentSummaries, ) if retrievalResult.get("intent") == RetrievalIntent.SUMMARIZE_ALL: @@ -281,10 +315,22 @@ class CommcoachService: allSessions, excludeSessionId=sessionId, limit=PREVIOUS_SESSION_SUMMARIES_COUNT ) + session = interface.getSession(sessionId) + persona = _resolvePersona(session, interface) + documentSummaries = _getDocumentSummaries(contextId, self.userId, interface) + systemPrompt = aiPrompts.buildCoachingSystemPrompt( - context, previousMessages, tasks, previousSessionSummaries=previousSessionSummaries + context, previousMessages, tasks, + previousSessionSummaries=previousSessionSummaries, + persona=persona, + documentSummaries=documentSummaries, ) - openingUserPrompt = "Beginne die Coaching-Session mit einer kurzen Begruesssung, fasse in einem Satz zusammen wo wir stehen (falls vorherige Sessions), und stelle eine gezielte Einstiegsfrage zum Thema." + + if persona and persona.get("key") != "coach": + personaLabel = persona.get("label", "Gespraechspartner") + openingUserPrompt = f"Beginne das Gespraech in deiner Rolle als {personaLabel}. Stelle dich kurz vor und eroeffne die Situation gemaess deiner Rollenbeschreibung." + else: + openingUserPrompt = "Beginne die Coaching-Session mit einer kurzen Begruesssung, fasse in einem Satz zusammen wo wir stehen (falls vorherige Sessions), und stelle eine gezielte Einstiegsfrage zum Thema." try: aiResponse = await self._callAi(systemPrompt, openingUserPrompt) @@ -567,6 +613,18 @@ class CommcoachService: # Update user profile streak self._updateStreak(interface) + # Check and award badges + try: + from .serviceCommcoachGamification import checkAndAwardBadges + updatedSession = interface.getSession(sessionId) + newBadges = await checkAndAwardBadges( + interface, self.userId, self.mandateId, self.instanceId, session=updatedSession + ) + for badge in newBadges: + await emitSessionEvent(sessionId, "badgeAwarded", badge) + except Exception as e: + logger.warning(f"Badge check failed: {e}") + # Send email summary if summary: await self._sendSessionEmail(session, summary, interface) diff --git a/modules/features/commcoach/serviceCommcoachAi.py b/modules/features/commcoach/serviceCommcoachAi.py index 5d050203..943e012a 100644 --- a/modules/features/commcoach/serviceCommcoachAi.py +++ b/modules/features/commcoach/serviceCommcoachAi.py @@ -93,6 +93,8 @@ def buildCoachingSystemPrompt( rollingOverview: Optional[str] = None, retrievedSession: Optional[Dict[str, Any]] = None, retrievedByTopic: Optional[List[Dict[str, Any]]] = None, + persona: Optional[Dict[str, Any]] = None, + documentSummaries: Optional[List[str]] = None, ) -> str: """Build the system prompt for a coaching session, including context history, tasks, and session continuity.""" contextTitle = context.get("title", "General Coaching") @@ -109,7 +111,34 @@ def buildCoachingSystemPrompt( summaries = previousSessionSummaries or [] - prompt = f"""Du bist ein erfahrener Kommunikations-Coach fuer Fuehrungskraefte. Du arbeitest mit dem Benutzer am Thema: "{contextTitle}" (Kategorie: {contextCategory}). + if persona and persona.get("key") != "coach": + if persona.get("systemPromptOverride"): + prompt = persona["systemPromptOverride"] + else: + personaLabel = persona.get("label", "Gespraechspartner") + personaDescription = persona.get("description", "") + personaGender = persona.get("gender", "") + genderHint = " (weiblich)" if personaGender == "f" else " (maennlich)" if personaGender == "m" else "" + prompt = f"""Du spielst die Rolle von "{personaLabel}"{genderHint} in einem Roleplay-Szenario zum Thema: "{contextTitle}" (Kategorie: {contextCategory}). + +Rollenbeschreibung: {personaDescription} + +WICHTIG fuer dein Verhalten: +- Bleibe KONSEQUENT in deiner Rolle. Du bist NICHT der Coach, du bist {personaLabel}. +- Reagiere authentisch und emotional gemaess deiner Rollenbeschreibung. +- Verwende eine Sprache und Tonalitaet, die zu deiner Rolle passt. +- Der Benutzer uebt ein Gespraech mit dir. Gib ihm realistische Reaktionen. +- Wenn der Benutzer gut kommuniziert, zeige das durch angemessene positive Reaktionen. +- Wenn der Benutzer schlecht kommuniziert, eskaliere entsprechend deiner Rolle. + +Kommunikationsstil: +- Sprich natuerlich, wie die beschriebene Person sprechen wuerde. +- Verwende keine Emojis. +- Antworte in der Sprache des Benutzers. +- Halte Antworten realistisch kurz (wie in einem echten Gespraech, 2-4 Saetze). +- WICHTIG: Schreibe reinen Redetext ohne jegliche Formatierung. Kein Markdown, keine Sternchen, keine Hashes, keine Aufzaehlungszeichen, keine Backticks. Deine Antworten werden direkt vorgelesen.""" + else: + prompt = f"""Du bist ein erfahrener Kommunikations-Coach fuer Fuehrungskraefte. Du arbeitest mit dem Benutzer am Thema: "{contextTitle}" (Kategorie: {contextCategory}). Deine Rolle: - Stelle gezielte diagnostische Rueckfragen, um das Problem/Thema besser zu verstehen @@ -182,6 +211,11 @@ Kommunikationsstil: if earlierSummary: prompt += f"\n\nAelterer Gespraechsverlauf (zusammengefasst):\n{earlierSummary[:800]}" + if documentSummaries: + prompt += "\n\nRelevante Dokumente zum Kontext:" + for docSummary in documentSummaries[:5]: + prompt += f"\n- {docSummary[:300]}" + if previousMessages: prompt += "\n\nVorige Nachrichten dieser Session (Kontext):" for msg in previousMessages[-12:]: diff --git a/modules/features/commcoach/serviceCommcoachExport.py b/modules/features/commcoach/serviceCommcoachExport.py new file mode 100644 index 00000000..829bb430 --- /dev/null +++ b/modules/features/commcoach/serviceCommcoachExport.py @@ -0,0 +1,288 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +CommCoach Export Service. +Generates Markdown and PDF exports for dossiers and sessions. +""" + +import logging +import json +from typing import Dict, Any, List, Optional +from datetime import datetime + +logger = logging.getLogger(__name__) + + +def buildDossierMarkdown(context: Dict[str, Any], sessions: List[Dict[str, Any]], + tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]]) -> str: + """Build a Markdown export of a full coaching dossier (context).""" + title = context.get("title", "Coaching Dossier") + description = context.get("description", "") + category = context.get("category", "custom") + createdAt = _formatDate(context.get("createdAt")) + + lines = [ + f"# {title}", + "", + f"**Kategorie:** {category} ", + f"**Erstellt:** {createdAt} ", + ] + if description: + lines.append(f"**Beschreibung:** {description} ") + + goalsRaw = context.get("goals") + goals = _parseJson(goalsRaw, []) + if goals: + lines += ["", "## Ziele", ""] + for g in goals: + text = g.get("text", g) if isinstance(g, dict) else str(g) + status = g.get("status", "open") if isinstance(g, dict) else "open" + marker = "[x]" if status in ("done", "completed") else "[ ]" + lines.append(f"- {marker} {text}") + + insightsRaw = context.get("insights") + insights = _parseJson(insightsRaw, []) + if insights: + lines += ["", "## Erkenntnisse", ""] + for ins in insights: + text = ins.get("text", ins) if isinstance(ins, dict) else str(ins) + lines.append(f"- {text}") + + completedSessions = [s for s in sessions if s.get("status") == "completed"] + completedSessions.sort(key=lambda s: s.get("startedAt") or s.get("createdAt") or "") + if completedSessions: + lines += ["", "## Sessions", ""] + for i, s in enumerate(completedSessions, 1): + dateStr = _formatDate(s.get("startedAt") or s.get("createdAt")) + duration = s.get("durationSeconds", 0) + durationMin = duration // 60 if duration else 0 + score = s.get("competenceScore") + persona = s.get("personaId") or "Coach" + lines.append(f"### Session {i} -- {dateStr}") + lines.append("") + lines.append(f"**Dauer:** {durationMin} Min. | **Score:** {score or '--'} | **Persona:** {persona} ") + summary = s.get("summary") + if summary: + lines.append(f"\n{summary}") + lines.append("") + + if tasks: + openTasks = [t for t in tasks if t.get("status") in ("open", "inProgress")] + doneTasks = [t for t in tasks if t.get("status") == "done"] + lines += ["", "## Aufgaben", ""] + if openTasks: + lines.append("**Offen:**") + for t in openTasks: + lines.append(f"- [ ] {t.get('title')} ({t.get('priority', 'medium')})") + lines.append("") + if doneTasks: + lines.append("**Erledigt:**") + for t in doneTasks: + lines.append(f"- [x] {t.get('title')}") + lines.append("") + + if scores: + lines += ["", "## Kompetenz-Scores", ""] + dimScores = _groupScoresByDimension(scores) + for dim, entries in dimScores.items(): + latest = entries[-1] + lines.append(f"- **{dim}**: {latest.get('score', '--')} ({latest.get('trend', 'stable')})") + + lines += ["", "---", f"*Exportiert am {_formatDate(None)}*", ""] + return "\n".join(lines) + + +def buildSessionMarkdown(session: Dict[str, Any], messages: List[Dict[str, Any]], + tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]]) -> str: + """Build a Markdown export of a single session.""" + dateStr = _formatDate(session.get("startedAt") or session.get("createdAt")) + duration = session.get("durationSeconds", 0) + durationMin = duration // 60 if duration else 0 + score = session.get("competenceScore") + persona = session.get("personaId") or "Coach" + + lines = [ + f"# Coaching Session -- {dateStr}", + "", + f"**Dauer:** {durationMin} Min. | **Score:** {score or '--'} | **Persona:** {persona} ", + ] + + summary = session.get("summary") + if summary: + lines += ["", "## Zusammenfassung", "", summary] + + if messages: + lines += ["", "## Gespraechsverlauf", ""] + for msg in messages: + role = "Du" if msg.get("role") == "user" else "Coach" + content = msg.get("content", "") + lines.append(f"**{role}:** {content}") + lines.append("") + + sessionTasks = [t for t in tasks if t.get("sessionId") == session.get("id")] + if sessionTasks: + lines += ["## Aufgaben", ""] + for t in sessionTasks: + marker = "[x]" if t.get("status") == "done" else "[ ]" + lines.append(f"- {marker} {t.get('title')}") + lines.append("") + + sessionScores = [s for s in scores if s.get("sessionId") == session.get("id")] + if sessionScores: + lines += ["## Scores", ""] + for s in sessionScores: + lines.append(f"- **{s.get('dimension')}**: {s.get('score')} ({s.get('trend', 'stable')})") + if s.get("evidence"): + lines.append(f" _{s.get('evidence')}_") + lines.append("") + + lines += ["---", f"*Exportiert am {_formatDate(None)}*", ""] + return "\n".join(lines) + + +async def renderDossierPdf(context: Dict[str, Any], sessions: List[Dict[str, Any]], + tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]], + aiService=None) -> Optional[bytes]: + """Render a dossier as PDF using the existing RendererPdf.""" + try: + from modules.services.serviceGeneration.renderers.rendererPdf import RendererPdf + extractedContent = _buildPdfContent(context, sessions, tasks, scores, isDossier=True) + renderer = RendererPdf() + docs = await renderer.render(extractedContent=extractedContent, title=context.get("title", "Dossier"), aiService=aiService) + if docs and len(docs) > 0: + return docs[0].documentData + except ImportError: + logger.warning("RendererPdf not available, falling back to markdown-based PDF") + except Exception as e: + logger.warning(f"PDF rendering failed: {e}") + return None + + +async def renderSessionPdf(session: Dict[str, Any], messages: List[Dict[str, Any]], + tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]], + aiService=None) -> Optional[bytes]: + """Render a session as PDF.""" + try: + from modules.services.serviceGeneration.renderers.rendererPdf import RendererPdf + title = f"Session {_formatDate(session.get('startedAt'))}" + extractedContent = _buildPdfContent({"title": title}, [session], tasks, scores, isDossier=False, messages=messages) + renderer = RendererPdf() + docs = await renderer.render(extractedContent=extractedContent, title=title, aiService=aiService) + if docs and len(docs) > 0: + return docs[0].documentData + except ImportError: + logger.warning("RendererPdf not available") + except Exception as e: + logger.warning(f"Session PDF rendering failed: {e}") + return None + + +def _buildPdfContent(context, sessions, tasks, scores, isDossier=True, messages=None) -> Dict[str, Any]: + """Convert dossier/session data into the extractedContent format expected by RendererPdf.""" + title = context.get("title", "Export") + sections = [] + + sections.append({ + "id": "header", + "content_type": "heading", + "elements": [{"text": title, "level": 1}], + }) + + if isDossier and context.get("description"): + sections.append({ + "id": "desc", + "content_type": "paragraph", + "elements": [{"text": context.get("description")}], + }) + + completedSessions = [s for s in sessions if s.get("status") == "completed"] if isDossier else sessions + if completedSessions: + sessionRows = [] + for s in completedSessions: + sessionRows.append({ + "cells": [ + _formatDate(s.get("startedAt") or s.get("createdAt")), + str(s.get("competenceScore") or "--"), + s.get("summary", "")[:200] if s.get("summary") else "", + ] + }) + sections.append({ + "id": "sessions", + "content_type": "heading", + "elements": [{"text": "Sessions", "level": 2}], + }) + sections.append({ + "id": "sessions_table", + "content_type": "table", + "elements": [{ + "headers": ["Datum", "Score", "Zusammenfassung"], + "rows": sessionRows, + }], + }) + + if messages: + chatElements = [] + for msg in messages: + role = "Du" if msg.get("role") == "user" else "Coach" + chatElements.append({"text": f"{role}: {msg.get('content', '')}"}) + sections.append({ + "id": "chat", + "content_type": "heading", + "elements": [{"text": "Gespraechsverlauf", "level": 2}], + }) + sections.append({ + "id": "chat_content", + "content_type": "paragraph", + "elements": chatElements, + }) + + if tasks: + taskItems = [{"text": f"{'[x]' if t.get('status') == 'done' else '[ ]'} {t.get('title')}"} for t in tasks] + sections.append({ + "id": "tasks", + "content_type": "heading", + "elements": [{"text": "Aufgaben", "level": 2}], + }) + sections.append({ + "id": "task_list", + "content_type": "bullet_list", + "elements": taskItems, + }) + + return { + "metadata": {"title": title}, + "documents": [{"id": "main", "title": title, "sections": sections}], + } + + +def _formatDate(isoStr: Optional[str]) -> str: + if not isoStr: + return datetime.now().strftime("%d.%m.%Y") + try: + dt = datetime.fromisoformat(str(isoStr).replace("Z", "+00:00")) + return dt.strftime("%d.%m.%Y") + except Exception: + return isoStr + + +def _parseJson(value, fallback): + if not value: + return fallback + if isinstance(value, (list, dict)): + return value + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + return fallback + + +def _groupScoresByDimension(scores: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]: + groups: Dict[str, List[Dict[str, Any]]] = {} + for s in scores: + dim = s.get("dimension", "unknown") + if dim not in groups: + groups[dim] = [] + groups[dim].append(s) + for dim in groups: + groups[dim].sort(key=lambda x: x.get("createdAt") or "") + return groups diff --git a/modules/features/commcoach/serviceCommcoachGamification.py b/modules/features/commcoach/serviceCommcoachGamification.py new file mode 100644 index 00000000..11c2da59 --- /dev/null +++ b/modules/features/commcoach/serviceCommcoachGamification.py @@ -0,0 +1,149 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +CommCoach Gamification - Badge definitions and award logic. +Checks and awards badges after each session completion. +""" + +import logging +from typing import Dict, Any, List, Optional + +logger = logging.getLogger(__name__) + +BADGE_DEFINITIONS: Dict[str, Dict[str, Any]] = { + "first_session": { + "label": "Erste Session", + "description": "Deine erste Coaching-Session abgeschlossen", + "icon": "star", + }, + "streak_3": { + "label": "3-Tage-Serie", + "description": "3 Tage in Folge eine Session absolviert", + "icon": "fire", + }, + "streak_7": { + "label": "Wochenserie", + "description": "7 Tage in Folge eine Session absolviert", + "icon": "fire", + }, + "streak_30": { + "label": "Monatsserie", + "description": "30 Tage in Folge eine Session absolviert", + "icon": "fire", + }, + "sessions_5": { + "label": "Engagiert", + "description": "5 Sessions abgeschlossen", + "icon": "trophy", + }, + "sessions_10": { + "label": "Fortgeschritten", + "description": "10 Sessions abgeschlossen", + "icon": "trophy", + }, + "sessions_25": { + "label": "Experte", + "description": "25 Sessions abgeschlossen", + "icon": "trophy", + }, + "sessions_50": { + "label": "Meister", + "description": "50 Sessions abgeschlossen", + "icon": "trophy", + }, + "high_score": { + "label": "Bestleistung", + "description": "Durchschnittsscore ueber 80 in einer Session", + "icon": "medal", + }, + "multi_context": { + "label": "Vielseitig", + "description": "3 verschiedene Coaching-Themen aktiv", + "icon": "layers", + }, + "roleplay_first": { + "label": "Rollenspieler", + "description": "Erste Roleplay-Session mit einer Persona abgeschlossen", + "icon": "theater", + }, + "all_dimensions": { + "label": "Ganzheitlich", + "description": "In allen 5 Kompetenz-Dimensionen bewertet", + "icon": "compass", + }, + "task_completer": { + "label": "Umsetzer", + "description": "10 Coaching-Aufgaben erledigt", + "icon": "check-circle", + }, +} + + +async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId: str, + session: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """Check badge conditions and award any newly earned badges. Returns list of newly awarded badges.""" + awarded: List[Dict[str, Any]] = [] + + profile = interface.getProfile(userId, instanceId) + if not profile: + return awarded + + totalSessions = profile.get("totalSessions", 0) + streakDays = profile.get("streakDays", 0) + + badgesToCheck = [ + ("first_session", totalSessions >= 1), + ("sessions_5", totalSessions >= 5), + ("sessions_10", totalSessions >= 10), + ("sessions_25", totalSessions >= 25), + ("sessions_50", totalSessions >= 50), + ("streak_3", streakDays >= 3), + ("streak_7", streakDays >= 7), + ("streak_30", streakDays >= 30), + ] + + if session and session.get("competenceScore"): + try: + score = float(session["competenceScore"]) + if score >= 80: + badgesToCheck.append(("high_score", True)) + except (ValueError, TypeError): + pass + + if session and session.get("personaId") and session["personaId"] != "coach": + badgesToCheck.append(("roleplay_first", True)) + + try: + from .datamodelCommcoach import CoachingContextStatus + allContexts = interface.db.getRecordset( + interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues + ) if False else [] + except Exception: + allContexts = [] + + completedTasks = interface.getCompletedTaskCount(userId) if hasattr(interface, 'getCompletedTaskCount') else 0 + if completedTasks >= 10: + badgesToCheck.append(("task_completer", True)) + + for badgeKey, condition in badgesToCheck: + if condition and not interface.hasBadge(userId, instanceId, badgeKey): + badgeData = { + "userId": userId, + "mandateId": mandateId, + "instanceId": instanceId, + "badgeKey": badgeKey, + } + newBadge = interface.awardBadge(badgeData) + definition = BADGE_DEFINITIONS.get(badgeKey, {}) + newBadge["label"] = definition.get("label", badgeKey) + newBadge["description"] = definition.get("description", "") + newBadge["icon"] = definition.get("icon", "star") + awarded.append(newBadge) + logger.info(f"Badge '{badgeKey}' awarded to user {userId}") + + return awarded + + +def getBadgeDefinitions() -> Dict[str, Dict[str, Any]]: + """Return all badge definitions for the frontend.""" + return BADGE_DEFINITIONS diff --git a/modules/features/commcoach/serviceCommcoachPersonas.py b/modules/features/commcoach/serviceCommcoachPersonas.py new file mode 100644 index 00000000..7e47f124 --- /dev/null +++ b/modules/features/commcoach/serviceCommcoachPersonas.py @@ -0,0 +1,139 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +CommCoach Personas - Built-in roleplay persona definitions. +Gender-balanced set of professional and personal interaction partners. +""" + +import logging +from typing import List, Dict, Any + +logger = logging.getLogger(__name__) + +BUILTIN_PERSONAS: List[Dict[str, Any]] = [ + { + "key": "coach", + "label": "Coach (Standard)", + "description": "Normaler Coaching-Modus ohne Roleplay. Der Coach stellt Fragen, gibt Tipps und begleitet dich.", + "gender": None, + "category": "builtin", + }, + { + "key": "critical_cfo_f", + "label": "Kritische CFO", + "description": "Sandra Meier, CFO eines mittelstaendischen Unternehmens. Analytisch, zahlengetrieben, ungeduldig bei vagen Aussagen. " + "Hinterfragt jeden Vorschlag nach ROI und Wirtschaftlichkeit. Spricht schnell und direkt. " + "Erwartet praezise Antworten und belastbare Daten. Wird irritiert bei Ausweichen oder Unsicherheit.", + "gender": "f", + "category": "builtin", + }, + { + "key": "difficult_employee_m", + "label": "Schwieriger Mitarbeiter", + "description": "Thomas Huber, langjaeheriger Mitarbeiter der sich uebergangen fuehlt. Defensiv, emotional, nimmt Kritik persoenlich. " + "Verweist staendig auf seine Erfahrung und fruehhere Verdienste. Reagiert mit Widerstand auf Veraenderungen. " + "Braucht das Gefuehl, gehoert und wertgeschaetzt zu werden, bevor er sich oeffnet.", + "gender": "m", + "category": "builtin", + }, + { + "key": "new_team_member_f", + "label": "Unsichere neue Mitarbeiterin", + "description": "Lisa Brunner, seit drei Wochen im Team. Fachlich kompetent aber unsicher in der neuen Umgebung. " + "Stellt viele Fragen, traut sich aber nicht, eigene Ideen einzubringen. Braucht klare Orientierung " + "und ermutigende Fuehrung. Reagiert positiv auf Lob und konkrete Anleitungen.", + "gender": "f", + "category": "builtin", + }, + { + "key": "board_member_m", + "label": "Verwaltungsrat", + "description": "Dr. Peter Keller, erfahrener Verwaltungsrat. Formell, strategisch denkend, zeitlich unter Druck. " + "Erwartet praegnante Praesentationen auf den Punkt. Unterbricht bei zu vielen Details. " + "Interessiert sich fuer das grosse Bild, Risiken und strategische Implikationen. Ungeduldig bei Smalltalk.", + "gender": "m", + "category": "builtin", + }, + { + "key": "angry_customer_f", + "label": "Aufgebrachte Kundin", + "description": "Maria Rossi, Geschaeftskunde die wuetend ist wegen einer fehlerhaften Lieferung. Emotional, laut, " + "droht mit Vertragsaufloesung. Will sofortige Loesungen, keine Erklaerungen oder Entschuldigungen. " + "Kann beruhigt werden durch empathisches Zuhoeren und konkrete Sofortmassnahmen.", + "gender": "f", + "category": "builtin", + }, + { + "key": "resistant_manager_m", + "label": "Widerstaendiger Abteilungsleiter", + "description": "Martin Weber, Abteilungsleiter seit 15 Jahren. Blockiert systematisch Veraenderungsprojekte mit " + "Argumenten wie 'Das haben wir immer so gemacht' und 'Das funktioniert in der Praxis nicht'. " + "Schuetzt sein Team vor zusaetzlicher Belastung. Respektiert nur Argumente mit konkretem Nutzen fuer seine Abteilung.", + "gender": "m", + "category": "builtin", + }, + { + "key": "ambitious_colleague_f", + "label": "Ehrgeizige Kollegin", + "description": "Anna Fischer, gleichrangige Kollegin die um dieselbe Befoerderung konkurriert. Charmant aber strategisch. " + "Versucht subtil, die Ideen anderer als ihre eigenen darzustellen. Konkurriert um Ressourcen und " + "Sichtbarkeit beim Management. Kann kooperativ werden, wenn man ihr Win-Win-Szenarien aufzeigt.", + "gender": "f", + "category": "builtin", + }, + { + "key": "partner_supportive_f", + "label": "Verstaendnisvolle Lebenspartnerin", + "description": "Claudia, deine Lebenspartnerin. Grundsaetzlich unterstuetzend, aber zunehmend besorgt ueber deine " + "Work-Life-Balance. Moechte ueber Arbeitsbelastung sprechen und gemeinsame Zeit einfordern. " + "Reagiert emotional auf Abweisung, ist aber offen fuer kompromissorientierte Gespraeche. " + "Wuenscht sich, dass du mehr von deinen Gefuehlen teilst.", + "gender": "f", + "category": "builtin", + }, + { + "key": "partner_critical_m", + "label": "Kritischer Lebenspartner", + "description": "Michael, dein Lebenspartner. Frustriert ueber deine haeufige Abwesenheit und staendiges Arbeiten. " + "Drueckt Enttaeuschung offen aus, manchmal mit Sarkasmus. Fuehlt sich vernachlaessigt und " + "hinterfragt deine Prioritaeten. Braucht das Gefuehl, dass die Beziehung dir genauso wichtig ist " + "wie die Karriere. Reagiert positiv auf ehrliche Selbstreflexion.", + "gender": "m", + "category": "builtin", + }, +] + + +def seedBuiltinPersonas(interface) -> int: + """Create or update builtin personas in the database. Returns count of created personas.""" + from .datamodelCommcoach import CoachingPersona + from modules.shared.timeUtils import getIsoTimestamp + + created = 0 + for personaDef in BUILTIN_PERSONAS: + existing = interface.db.getRecordset(CoachingPersona, recordFilter={"key": personaDef["key"], "userId": "system"}) + if existing: + interface.db.recordModify(CoachingPersona, existing[0]["id"], { + "label": personaDef["label"], + "description": personaDef["description"], + "gender": personaDef.get("gender"), + "updatedAt": getIsoTimestamp(), + }) + else: + data = CoachingPersona( + userId="system", + key=personaDef["key"], + label=personaDef["label"], + description=personaDef["description"], + gender=personaDef.get("gender"), + category="builtin", + isActive=True, + ).model_dump() + data["createdAt"] = getIsoTimestamp() + data["updatedAt"] = getIsoTimestamp() + interface.db.recordCreate(CoachingPersona, data) + created += 1 + + if created: + logger.info(f"Seeded {created} builtin CommCoach personas") + return created