gateway/modules/features/commcoach/routeFeatureCommcoach.py

1247 lines
44 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
CommCoach routes for the backend API.
Implements coaching context management, session streaming, tasks, dashboard, and voice endpoints.
"""
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, 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, CoachingDocument, 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)
profile = interface.getProfile(userId, instanceId)
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
voiceName = profile.get("preferredVoice") if profile else None
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")
profile = interface.getProfile(str(context.user.id), instanceId)
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
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}
# =========================================================================
# Voice Endpoints
# =========================================================================
@router.get("/{instanceId}/voice/languages")
@limiter.limit("30/minute")
async def getVoiceLanguages(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
mandateId = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
languagesResult = await voiceInterface.getAvailableLanguages()
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
return {"languages": languageList}
@router.get("/{instanceId}/voice/voices")
@limiter.limit("30/minute")
async def getVoiceVoices(
request: Request,
instanceId: str,
language: str = "de-DE",
context: RequestContext = Depends(getRequestContext),
):
mandateId = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
voicesResult = await voiceInterface.getAvailableVoices(language)
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
return {"voices": voiceList}
@router.post("/{instanceId}/voice/tts")
@limiter.limit("10/minute")
async def testVoice(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""TTS preview / voice test."""
mandateId = _validateInstanceAccess(instanceId, context)
body = await request.json()
text = body.get("text", "Hallo, ich bin dein Coaching-Assistent.")
language = body.get("language", "de-DE")
voiceId = body.get("voiceId")
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
try:
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
if result and isinstance(result, dict):
audioContent = result.get("audioContent")
if audioContent:
audioB64 = base64.b64encode(
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
).decode()
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
return {"success": False, "error": "TTS returned no audio"}
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)
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}
# =========================================================================
# 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. Stores file in Management DB."""
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)
if not content:
raise HTTPException(status_code=400, detail="Leere Datei hochgeladen")
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
mgmtInterface = interfaceDbManagement.getInterface(currentUser=context.user)
fileItem, _dupType = mgmtInterface.saveUploadedFile(content, fileName)
fileRef = fileItem.id
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,
fileRef=fileRef,
).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),
):
mandateId = _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)
fileRef = doc.get("fileRef")
if fileRef:
try:
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
mgmtInterface = interfaceDbManagement.getInterface(
currentUser=context.user, mandateId=mandateId, featureInstanceId=instanceId
)
mgmtInterface.deleteFile(fileRef)
except Exception as e:
logger.warning(f"Failed to delete file {fileRef}: {e}")
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}