iteration 2 done

This commit is contained in:
patrick-motsch 2026-03-03 23:02:53 +01:00
parent 5486a87b9a
commit f4940cf9e1
9 changed files with 1211 additions and 4 deletions

View file

@ -103,6 +103,7 @@ class CoachingSession(BaseModel):
mandateId: str = Field(description="Mandate ID") mandateId: str = Field(description="Mandate ID")
instanceId: str = Field(description="Feature instance ID") instanceId: str = Field(description="Feature instance ID")
status: CoachingSessionStatus = Field(default=CoachingSessionStatus.ACTIVE) 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") summary: Optional[str] = Field(default=None, description="AI-generated session summary")
coachNotes: Optional[str] = Field(default=None, description="JSON: AI internal notes for continuity") 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") 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) 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 # API Request/Response Models
# ============================================================================ # ============================================================================
@ -232,6 +289,25 @@ class UpdateProfileRequest(BaseModel):
emailSummaryEnabled: Optional[bool] = None 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): class DashboardData(BaseModel):
"""Aggregated dashboard data for the user.""" """Aggregated dashboard data for the user."""
totalContexts: int = 0 totalContexts: int = 0

View file

@ -238,6 +238,98 @@ class CommcoachObjects:
count += 1 count += 1
return count 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 # User Profile
# ========================================================================= # =========================================================================
@ -323,6 +415,8 @@ class CommcoachObjects:
"completedTasks": self.getCompletedTaskCount(userId, instanceId), "completedTasks": self.getCompletedTaskCount(userId, instanceId),
"contexts": contextSummaries, "contexts": contextSummaries,
"goalProgress": overallGoalProgress, "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 return None
done = sum(1 for g in goals if isinstance(g, dict) and g.get("status") in ("done", "completed")) done = sum(1 for g in goals if isinstance(g, dict) and g.get("status") in ("done", "completed"))
return round(done / len(goals) * 100) 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}

View file

@ -68,6 +68,21 @@ DATA_OBJECTS = [
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"}, "label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"},
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "preferredLanguage"]} "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.*", "objectKey": "data.feature.commcoach.*",
"label": {"en": "All CommCoach Data", "de": "Alle CommCoach-Daten", "fr": "Toutes les donnees CommCoach"}, "label": {"en": "All CommCoach Data", "de": "Alle CommCoach-Daten", "fr": "Toutes les donnees CommCoach"},
@ -184,6 +199,7 @@ def registerFeature(catalogService) -> bool:
) )
_syncTemplateRolesToDb() _syncTemplateRolesToDb()
_seedBuiltinPersonas()
_registerScheduler() _registerScheduler()
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects") logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
@ -194,6 +210,19 @@ def registerFeature(catalogService) -> bool:
return False 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(): def _registerScheduler():
"""Register CommCoach scheduled jobs (daily reminders).""" """Register CommCoach scheduled jobs (daily reminders)."""
try: try:

View file

@ -9,9 +9,10 @@ import logging
import json import json
import asyncio import asyncio
import base64 import base64
import uuid
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Request 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.auth import limiter, getRequestContext, RequestContext
from modules.shared.timeUtils import getIsoTimestamp from modules.shared.timeUtils import getIsoTimestamp
@ -23,9 +24,11 @@ from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus, CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
CoachingMessage, CoachingMessageRole, CoachingMessageContentType, CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
CoachingTask, CoachingTaskStatus, CoachingTask, CoachingTaskStatus,
CoachingPersona, CoachingDocument, CoachingBadge,
CreateContextRequest, UpdateContextRequest, CreateContextRequest, UpdateContextRequest,
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest, SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
UpdateProfileRequest, UpdateProfileRequest,
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest,
) )
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
@ -281,6 +284,7 @@ async def startSession(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, contextId: str,
personaId: Optional[str] = None,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Start a new coaching session or resume active one. Returns SSE stream with sessionState, messages, and complete.""" """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, userId=userId,
mandateId=mandateId, mandateId=mandateId,
instanceId=instanceId, instanceId=instanceId,
personaId=personaId,
).model_dump() ).model_dump()
created = interface.createSession(sessionData) created = interface.createSession(sessionData)
sessionId = created.get("id") sessionId = created.get("id")
@ -887,3 +892,330 @@ async def testVoice(
except Exception as e: except Exception as e:
logger.error(f"Voice test failed: {e}") logger.error(f"Voice test failed: {e}")
raise HTTPException(status_code=500, detail=f"TTS test failed: {str(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}

View file

@ -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: class CommcoachService:
"""Coaching orchestrator: processes messages, calls AI, extracts tasks and scores.""" """Coaching orchestrator: processes messages, calls AI, extracts tasks and scores."""
@ -190,6 +219,9 @@ class CommcoachService:
contextId, sessionId, userContent, context, interface contextId, sessionId, userContent, context, interface
) )
persona = _resolvePersona(session, interface)
documentSummaries = _getDocumentSummaries(contextId, self.userId, interface)
systemPrompt = aiPrompts.buildCoachingSystemPrompt( systemPrompt = aiPrompts.buildCoachingSystemPrompt(
context, context,
previousMessages, previousMessages,
@ -199,6 +231,8 @@ class CommcoachService:
rollingOverview=retrievalResult.get("rollingOverview"), rollingOverview=retrievalResult.get("rollingOverview"),
retrievedSession=retrievalResult.get("retrievedSession"), retrievedSession=retrievalResult.get("retrievedSession"),
retrievedByTopic=retrievalResult.get("retrievedByTopic"), retrievedByTopic=retrievalResult.get("retrievedByTopic"),
persona=persona,
documentSummaries=documentSummaries,
) )
if retrievalResult.get("intent") == RetrievalIntent.SUMMARIZE_ALL: if retrievalResult.get("intent") == RetrievalIntent.SUMMARIZE_ALL:
@ -281,10 +315,22 @@ class CommcoachService:
allSessions, excludeSessionId=sessionId, limit=PREVIOUS_SESSION_SUMMARIES_COUNT 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( 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: try:
aiResponse = await self._callAi(systemPrompt, openingUserPrompt) aiResponse = await self._callAi(systemPrompt, openingUserPrompt)
@ -567,6 +613,18 @@ class CommcoachService:
# Update user profile streak # Update user profile streak
self._updateStreak(interface) 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 # Send email summary
if summary: if summary:
await self._sendSessionEmail(session, summary, interface) await self._sendSessionEmail(session, summary, interface)

View file

@ -93,6 +93,8 @@ def buildCoachingSystemPrompt(
rollingOverview: Optional[str] = None, rollingOverview: Optional[str] = None,
retrievedSession: Optional[Dict[str, Any]] = None, retrievedSession: Optional[Dict[str, Any]] = None,
retrievedByTopic: Optional[List[Dict[str, Any]]] = None, retrievedByTopic: Optional[List[Dict[str, Any]]] = None,
persona: Optional[Dict[str, Any]] = None,
documentSummaries: Optional[List[str]] = None,
) -> str: ) -> str:
"""Build the system prompt for a coaching session, including context history, tasks, and session continuity.""" """Build the system prompt for a coaching session, including context history, tasks, and session continuity."""
contextTitle = context.get("title", "General Coaching") contextTitle = context.get("title", "General Coaching")
@ -109,7 +111,34 @@ def buildCoachingSystemPrompt(
summaries = previousSessionSummaries or [] 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: Deine Rolle:
- Stelle gezielte diagnostische Rueckfragen, um das Problem/Thema besser zu verstehen - Stelle gezielte diagnostische Rueckfragen, um das Problem/Thema besser zu verstehen
@ -182,6 +211,11 @@ Kommunikationsstil:
if earlierSummary: if earlierSummary:
prompt += f"\n\nAelterer Gespraechsverlauf (zusammengefasst):\n{earlierSummary[:800]}" 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: if previousMessages:
prompt += "\n\nVorige Nachrichten dieser Session (Kontext):" prompt += "\n\nVorige Nachrichten dieser Session (Kontext):"
for msg in previousMessages[-12:]: for msg in previousMessages[-12:]:

View file

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

View file

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

View file

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