# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ CommCoach routes for the backend API. Implements coaching context management, session streaming, tasks, and dashboard. """ import logging import json import asyncio import base64 import uuid from typing import Optional from fastapi import APIRouter, HTTPException, Depends, Request, WebSocket, WebSocketDisconnect, Query from fastapi.responses import StreamingResponse, Response from modules.auth import limiter, getRequestContext, RequestContext from modules.shared.timeUtils import getIsoTimestamp from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface from . import interfaceFeatureCommcoach as interfaceDb from .datamodelCommcoach import ( CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus, CoachingMessage, CoachingMessageRole, CoachingMessageContentType, CoachingTask, CoachingTaskStatus, CoachingPersona, CoachingBadge, CreateContextRequest, UpdateContextRequest, SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest, UpdateProfileRequest, StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, ) from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents logger = logging.getLogger(__name__) _activeProcessTasks: dict = {} def _audit(context: RequestContext, action: str, resourceType: str = None, resourceId: str = None, details: str = ""): """Log an audit event for CommCoach. Non-blocking, best-effort.""" try: from modules.shared.auditLogger import audit_logger audit_logger.logEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else None, category="commcoach", action=action, resourceType=resourceType, resourceId=resourceId, details=details, ) except Exception: pass router = APIRouter( prefix="/api/commcoach", tags=["CommCoach"], responses={404: {"description": "Not found"}} ) # ========================================================================= # Helpers # ========================================================================= def _getInterface(context: RequestContext, instanceId: Optional[str] = None): mandateId = str(context.mandateId) if context.mandateId else None return interfaceDb.getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException(status_code=404, detail=f"Feature instance '{instanceId}' not found") mandateId = instance.get("mandateId") if isinstance(instance, dict) else getattr(instance, "mandateId", None) if not mandateId: raise HTTPException(status_code=500, detail="Feature instance has no mandateId") return str(mandateId) def _validateOwnership(record: dict, context: RequestContext, fieldName: str = "userId") -> None: """Strict ownership check. SysAdmin does NOT bypass for content access.""" if record.get(fieldName) != str(context.user.id): raise HTTPException(status_code=404, detail="Not found") # ========================================================================= # Context Endpoints # ========================================================================= @router.get("/{instanceId}/contexts") @limiter.limit("60/minute") async def listContexts( request: Request, instanceId: str, includeArchived: bool = False, context: RequestContext = Depends(getRequestContext), ): """List all coaching contexts for the current user.""" mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) contexts = interface.getContexts(instanceId, userId, includeArchived=includeArchived) return {"contexts": contexts} @router.post("/{instanceId}/contexts") @limiter.limit("20/minute") async def createContext( request: Request, instanceId: str, body: CreateContextRequest, context: RequestContext = Depends(getRequestContext), ): """Create a new coaching context/dossier.""" mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) goalsJson = None if body.goals: import uuid as _uuid goalsList = [{"id": str(_uuid.uuid4()), "text": g, "status": "open", "createdAt": ""} for g in body.goals] goalsJson = json.dumps(goalsList) contextData = CoachingContext( userId=userId, mandateId=mandateId, instanceId=instanceId, title=body.title, description=body.description, category=body.category, goals=goalsJson, ).model_dump() created = interface.createContext(contextData) logger.info(f"CommCoach context created: {created.get('id')} for user {userId}") _audit(context, "commcoach.context.created", "CoachingContext", created.get("id"), f"Title: {body.title}") return {"context": created} @router.get("/{instanceId}/contexts/{contextId}") @limiter.limit("60/minute") async def getContext( request: Request, instanceId: str, contextId: str, context: RequestContext = Depends(getRequestContext), ): """Get a coaching context with tasks and score summary.""" _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) return { "context": ctx, "tasks": tasks, "scores": scores, "sessions": sessions, } @router.put("/{instanceId}/contexts/{contextId}") @limiter.limit("30/minute") async def updateContext( request: Request, instanceId: str, contextId: str, body: UpdateContextRequest, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) ctx = interface.getContext(contextId) if not ctx: raise HTTPException(status_code=404, detail="Context not found") _validateOwnership(ctx, context) updates = body.model_dump(exclude_none=True) updated = interface.updateContext(contextId, updates) return {"context": updated} @router.delete("/{instanceId}/contexts/{contextId}") @limiter.limit("10/minute") async def deleteContext( request: Request, instanceId: str, contextId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) ctx = interface.getContext(contextId) if not ctx: raise HTTPException(status_code=404, detail="Context not found") _validateOwnership(ctx, context) interface.deleteContext(contextId) return {"deleted": True} @router.post("/{instanceId}/contexts/{contextId}/archive") @limiter.limit("10/minute") async def archiveContext( request: Request, instanceId: str, contextId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) ctx = interface.getContext(contextId) if not ctx: raise HTTPException(status_code=404, detail="Context not found") _validateOwnership(ctx, context) updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value}) _audit(context, "commcoach.context.archived", "CoachingContext", contextId) return {"context": updated} @router.post("/{instanceId}/contexts/{contextId}/activate") @limiter.limit("10/minute") async def activateContext( request: Request, instanceId: str, contextId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) ctx = interface.getContext(contextId) if not ctx: raise HTTPException(status_code=404, detail="Context not found") _validateOwnership(ctx, context) updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value}) return {"context": updated} # ========================================================================= # Session Endpoints # ========================================================================= @router.get("/{instanceId}/contexts/{contextId}/sessions") @limiter.limit("60/minute") async def listSessions( request: Request, instanceId: str, contextId: str, context: RequestContext = Depends(getRequestContext), ): _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) sessions = interface.getSessions(contextId, userId) return {"sessions": sessions} @router.post("/{instanceId}/contexts/{contextId}/sessions/start") @limiter.limit("10/minute") 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.""" 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) activeSession = interface.getActiveSession(contextId, userId) if activeSession: sessionId = activeSession.get("id") messages = interface.getMessages(sessionId) async def _resumedEventGenerator(): service = CommcoachService(context.user, mandateId, instanceId) greetingText = await service.generateResumeGreeting(sessionId, contextId, messages, interface) assistantMsg = CoachingMessage( sessionId=sessionId, contextId=contextId, userId=userId, role=CoachingMessageRole.ASSISTANT, content=greetingText, contentType=CoachingMessageContentType.TEXT, ).model_dump() createdGreeting = interface.createMessage(assistantMsg) interface.updateSession(sessionId, {"messageCount": len(messages) + 1}) greetingForFrontend = { "id": createdGreeting.get("id"), "sessionId": sessionId, "contextId": contextId, "role": "assistant", "content": greetingText, "contentType": "text", "createdAt": createdGreeting.get("createdAt"), } messagesWithGreeting = messages + [greetingForFrontend] sessionWithCount = {**activeSession, "messageCount": len(messagesWithGreeting)} yield f"data: {json.dumps({'type': 'sessionState', 'data': {'session': sessionWithCount, 'resumed': True, 'messages': messagesWithGreeting}})}\n\n" if greetingText: try: from modules.interfaces.interfaceVoiceObjects import getVoiceInterface voiceInterface = getVoiceInterface(context.user, mandateId) from .serviceCommcoach import _getUserVoicePrefs language, voiceName = _getUserVoicePrefs(userId, mandateId) from .serviceCommcoach import _stripMarkdownForTts ttsResult = await voiceInterface.textToSpeech( text=_stripMarkdownForTts(greetingText), languageCode=language, voiceName=voiceName, ) if ttsResult and isinstance(ttsResult, dict): audioBytes = ttsResult.get("audioContent") if audioBytes: audioB64 = base64.b64encode( audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode() ).decode() yield f"data: {json.dumps({'type': 'ttsAudio', 'data': {'audio': audioB64, 'format': 'mp3'}})}\n\n" except Exception as e: logger.warning(f"TTS failed for resumed session: {e}") yield f"data: {json.dumps({'type': 'complete', 'data': {}, 'timestamp': getIsoTimestamp()})}\n\n" return StreamingResponse( _resumedEventGenerator(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, ) sessionData = CoachingSession( contextId=contextId, userId=userId, mandateId=mandateId, instanceId=instanceId, personaId=personaId, ).model_dump() created = interface.createSession(sessionData) sessionId = created.get("id") eventQueue = getSessionEventQueue(sessionId) await emitSessionEvent(sessionId, "sessionState", {"session": created, "resumed": False}) service = CommcoachService(context.user, mandateId, instanceId) asyncio.create_task(service.processSessionOpening(sessionId, contextId, interface)) async def _newSessionEventGenerator(): from modules.shared.timeUtils import getIsoTimestamp timeoutCount = 0 try: while True: try: event = await asyncio.wait_for(eventQueue.get(), timeout=30.0) yield f"data: {json.dumps(event)}\n\n" timeoutCount = 0 if event.get("type") in ("complete", "error"): break except asyncio.TimeoutError: yield f"data: {json.dumps({'type': 'ping', 'timestamp': getIsoTimestamp()})}\n\n" timeoutCount += 1 if timeoutCount > 10: break except asyncio.CancelledError: pass logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}") _audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Context: {contextId}") return StreamingResponse( _newSessionEventGenerator(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, ) @router.get("/{instanceId}/sessions/{sessionId}") @limiter.limit("60/minute") async def getSession( request: Request, instanceId: str, sessionId: str, context: RequestContext = Depends(getRequestContext), ): _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) messages = interface.getMessages(sessionId) return {"session": session, "messages": messages} @router.post("/{instanceId}/sessions/{sessionId}/complete") @limiter.limit("10/minute") async def completeSession( request: Request, instanceId: str, sessionId: str, context: RequestContext = Depends(getRequestContext), ): """Complete a coaching session. Triggers summary, scoring, task extraction, email.""" mandateId = _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) if session.get("status") != CoachingSessionStatus.ACTIVE.value: raise HTTPException(status_code=400, detail=f"Session is already {session.get('status')}") service = CommcoachService(context.user, mandateId, instanceId) result = await service.completeSession(sessionId, interface) _audit(context, "commcoach.session.completed", "CoachingSession", sessionId) return {"session": result} @router.post("/{instanceId}/sessions/{sessionId}/cancel") @limiter.limit("10/minute") async def cancelSession( request: Request, instanceId: str, sessionId: str, context: RequestContext = Depends(getRequestContext), ): _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) from modules.shared.timeUtils import getIsoTimestamp interface.updateSession(sessionId, { "status": CoachingSessionStatus.CANCELLED.value, "endedAt": getIsoTimestamp(), }) return {"cancelled": True} # ========================================================================= # Chat Streaming Endpoints # ========================================================================= @router.post("/{instanceId}/sessions/{sessionId}/message/stream") @limiter.limit("30/minute") async def sendMessageStream( request: Request, instanceId: str, sessionId: str, body: SendMessageRequest, context: RequestContext = Depends(getRequestContext), ): """Send a text message and stream the coaching response via SSE.""" mandateId = _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) if session.get("status") != CoachingSessionStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Session is not active") contextId = session.get("contextId") service = CommcoachService(context.user, mandateId, instanceId) existingTask = _activeProcessTasks.get(sessionId) if existingTask and not existingTask.done(): existingTask.cancel() logger.info(f"Cancelled previous processMessage task for session {sessionId}") def _onTaskDone(task): _activeProcessTasks.pop(sessionId, None) task = asyncio.create_task( service.processMessage(sessionId, contextId, body.content, interface) ) task.add_done_callback(_onTaskDone) _activeProcessTasks[sessionId] = task # Stream events async def _eventGenerator(): eventQueue = getSessionEventQueue(sessionId) try: timeout_count = 0 while True: try: event = await asyncio.wait_for(eventQueue.get(), timeout=30.0) yield f"data: {json.dumps(event)}\n\n" timeout_count = 0 eventType = event.get("type") if eventType in ("complete", "error"): break except asyncio.TimeoutError: yield f"data: {json.dumps({'type': 'ping'})}\n\n" timeout_count += 1 if timeout_count > 10: break except asyncio.CancelledError: pass return StreamingResponse( _eventGenerator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", } ) @router.post("/{instanceId}/sessions/{sessionId}/audio/stream") @limiter.limit("20/minute") async def sendAudioStream( request: Request, instanceId: str, sessionId: str, context: RequestContext = Depends(getRequestContext), ): """Send audio, get STT -> coaching response -> TTS via SSE.""" mandateId = _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) if session.get("status") != CoachingSessionStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Session is not active") audioBody = await request.body() if not audioBody: raise HTTPException(status_code=400, detail="No audio data received") from .serviceCommcoach import _getUserVoicePrefs language, _ = _getUserVoicePrefs(str(context.user.id), mandateId) contextId = session.get("contextId") service = CommcoachService(context.user, mandateId, instanceId) asyncio.create_task( service.processAudioMessage(sessionId, contextId, audioBody, language, interface) ) async def _eventGenerator(): eventQueue = getSessionEventQueue(sessionId) try: timeout_count = 0 while True: try: event = await asyncio.wait_for(eventQueue.get(), timeout=30.0) yield f"data: {json.dumps(event)}\n\n" timeout_count = 0 eventType = event.get("type") if eventType in ("complete", "error"): break if eventType == "message" and event.get("data", {}).get("role") == "assistant": break except asyncio.TimeoutError: yield f"data: {json.dumps({'type': 'ping'})}\n\n" timeout_count += 1 if timeout_count > 10: break except asyncio.CancelledError: pass return StreamingResponse( _eventGenerator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", } ) @router.get("/{instanceId}/sessions/{sessionId}/stream") @limiter.limit("60/minute") async def streamSession( request: Request, instanceId: str, sessionId: str, context: RequestContext = Depends(getRequestContext), ): """Reconnect to an active session's SSE stream.""" _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) async def _eventGenerator(): yield f"data: {json.dumps({'type': 'sessionState', 'data': session})}\n\n" messages = interface.getMessages(sessionId) for msg in messages: yield f"data: {json.dumps({'type': 'message', 'data': msg})}\n\n" eventQueue = getSessionEventQueue(sessionId) try: while True: try: event = await asyncio.wait_for(eventQueue.get(), timeout=30.0) yield f"data: {json.dumps(event)}\n\n" if event.get("type") in ("complete", "error"): break except asyncio.TimeoutError: yield f"data: {json.dumps({'type': 'ping'})}\n\n" except asyncio.CancelledError: pass return StreamingResponse( _eventGenerator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", } ) # ========================================================================= # Task Endpoints # ========================================================================= @router.get("/{instanceId}/contexts/{contextId}/tasks") @limiter.limit("60/minute") async def listTasks( request: Request, instanceId: str, contextId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) tasks = interface.getTasks(contextId, userId) return {"tasks": tasks} @router.post("/{instanceId}/contexts/{contextId}/tasks") @limiter.limit("30/minute") async def createTask( request: Request, instanceId: str, contextId: str, body: CreateTaskRequest, context: RequestContext = Depends(getRequestContext), ): 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) taskData = CoachingTask( contextId=contextId, userId=userId, mandateId=mandateId, title=body.title, description=body.description, priority=body.priority, dueDate=body.dueDate, ).model_dump() created = interface.createTask(taskData) return {"task": created} @router.put("/{instanceId}/tasks/{taskId}") @limiter.limit("30/minute") async def updateTask( request: Request, instanceId: str, taskId: str, body: UpdateTaskRequest, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) task = interface.getTask(taskId) if not task: raise HTTPException(status_code=404, detail="Task not found") _validateOwnership(task, context) updates = body.model_dump(exclude_none=True) updated = interface.updateTask(taskId, updates) return {"task": updated} @router.put("/{instanceId}/tasks/{taskId}/status") @limiter.limit("30/minute") async def updateTaskStatus( request: Request, instanceId: str, taskId: str, body: UpdateTaskStatusRequest, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) task = interface.getTask(taskId) if not task: raise HTTPException(status_code=404, detail="Task not found") _validateOwnership(task, context) updates = {"status": body.status.value} if body.status == CoachingTaskStatus.DONE: from modules.shared.timeUtils import getIsoTimestamp updates["completedAt"] = getIsoTimestamp() updated = interface.updateTask(taskId, updates) return {"task": updated} @router.delete("/{instanceId}/tasks/{taskId}") @limiter.limit("10/minute") async def deleteTask( request: Request, instanceId: str, taskId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) task = interface.getTask(taskId) if not task: raise HTTPException(status_code=404, detail="Task not found") _validateOwnership(task, context) interface.deleteTask(taskId) return {"deleted": True} # ========================================================================= # Dashboard # ========================================================================= @router.get("/{instanceId}/dashboard") @limiter.limit("60/minute") async def getDashboard( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) data = interface.getDashboardData(userId, instanceId) return {"dashboard": data} # ========================================================================= # User Profile # ========================================================================= @router.get("/{instanceId}/profile") @limiter.limit("60/minute") async def getProfile( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext), ): mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) profile = interface.getOrCreateProfile(userId, mandateId, instanceId) return {"profile": profile} @router.put("/{instanceId}/profile") @limiter.limit("10/minute") async def updateProfile( request: Request, instanceId: str, body: UpdateProfileRequest, context: RequestContext = Depends(getRequestContext), ): mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) profile = interface.getOrCreateProfile(userId, mandateId, instanceId) updates = body.model_dump(exclude_none=True) updated = interface.updateProfile(profile.get("id"), updates) return {"profile": updated} # ========================================================================= # 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) return Response(content=pdfBytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'}) 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) return Response(content=pdfBytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.pdf"'}) 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} # ========================================================================= # 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}