From f4940cf9e1712f377212abd875c4bb97e449d519 Mon Sep 17 00:00:00 2001
From: patrick-motsch
Date: Tue, 3 Mar 2026 23:02:53 +0100
Subject: [PATCH] iteration 2 done
---
.../features/commcoach/datamodelCommcoach.py | 76 ++++
.../commcoach/interfaceFeatureCommcoach.py | 102 ++++++
modules/features/commcoach/mainCommcoach.py | 29 ++
.../commcoach/routeFeatureCommcoach.py | 334 +++++++++++++++++-
.../features/commcoach/serviceCommcoach.py | 62 +++-
.../features/commcoach/serviceCommcoachAi.py | 36 +-
.../commcoach/serviceCommcoachExport.py | 288 +++++++++++++++
.../commcoach/serviceCommcoachGamification.py | 149 ++++++++
.../commcoach/serviceCommcoachPersonas.py | 139 ++++++++
9 files changed, 1211 insertions(+), 4 deletions(-)
create mode 100644 modules/features/commcoach/serviceCommcoachExport.py
create mode 100644 modules/features/commcoach/serviceCommcoachGamification.py
create mode 100644 modules/features/commcoach/serviceCommcoachPersonas.py
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