iteration 2 done
This commit is contained in:
parent
5486a87b9a
commit
f4940cf9e1
9 changed files with 1211 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:]:
|
||||
|
|
|
|||
288
modules/features/commcoach/serviceCommcoachExport.py
Normal file
288
modules/features/commcoach/serviceCommcoachExport.py
Normal 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
|
||||
149
modules/features/commcoach/serviceCommcoachGamification.py
Normal file
149
modules/features/commcoach/serviceCommcoachGamification.py
Normal 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
|
||||
139
modules/features/commcoach/serviceCommcoachPersonas.py
Normal file
139
modules/features/commcoach/serviceCommcoachPersonas.py
Normal 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
|
||||
Loading…
Reference in a new issue