refactored comcoach und teamsbot

This commit is contained in:
ValueOn AG 2026-05-06 23:28:22 +02:00
parent e07ac24fd8
commit cfd303792f
27 changed files with 1244 additions and 417 deletions

View file

@ -2,7 +2,7 @@
# All rights reserved. # All rights reserved.
""" """
CommCoach Feature - Data Models. CommCoach Feature - Data Models.
Pydantic models for coaching contexts, sessions, messages, tasks, scores, and user profiles. Pydantic models for training modules, sessions, messages, tasks, scores, and user profiles.
""" """
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -16,22 +16,18 @@ import uuid
# Enums # Enums
# ============================================================================ # ============================================================================
class CoachingContextStatus(str, Enum): class TrainingModuleStatus(str, Enum):
ACTIVE = "active" ACTIVE = "active"
PAUSED = "paused" PAUSED = "paused"
ARCHIVED = "archived" ARCHIVED = "archived"
COMPLETED = "completed" COMPLETED = "completed"
class CoachingContextCategory(str, Enum): class TrainingModuleType(str, Enum):
LEADERSHIP = "leadership" COACHING = "coaching"
CONFLICT = "conflict" TRAINING = "training"
NEGOTIATION = "negotiation" EXAM = "exam"
PRESENTATION = "presentation" ELEARNING = "elearning"
FEEDBACK = "feedback"
DELEGATION = "delegation"
CHANGE_MANAGEMENT = "changeManagement"
CUSTOM = "custom"
class CoachingSessionStatus(str, Enum): class CoachingSessionStatus(str, Enum):
@ -75,19 +71,21 @@ class CoachingScoreTrend(str, Enum):
# Database Models # Database Models
# ============================================================================ # ============================================================================
class CoachingContext(PowerOnModel): class TrainingModule(PowerOnModel):
"""A coaching context/dossier representing a topic the user is working on.""" """A training module representing a topic the user is working on."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str = Field(description="Owner user ID (strict ownership)") userId: str = Field(description="Owner user ID (strict ownership)")
mandateId: str = Field(description="Mandate ID") mandateId: str = Field(description="Mandate ID")
instanceId: str = Field(description="Feature instance ID") instanceId: str = Field(description="Feature instance ID")
title: str = Field(description="Context title, e.g. 'Conflict with team lead'") title: str = Field(description="Module title, e.g. 'Conflict with team lead'")
description: Optional[str] = Field(default=None, description="Short description") description: Optional[str] = Field(default=None, description="Short description")
category: CoachingContextCategory = Field(default=CoachingContextCategory.CUSTOM) moduleType: TrainingModuleType = Field(default=TrainingModuleType.COACHING)
status: CoachingContextStatus = Field(default=CoachingContextStatus.ACTIVE) status: TrainingModuleStatus = Field(default=TrainingModuleStatus.ACTIVE)
goals: Optional[str] = Field(default=None, description="JSON array of goals [{id, text, status, createdAt}]") goals: Optional[str] = Field(default=None, description="Free-text goal description")
insights: Optional[str] = Field(default=None, description="JSON array of AI insights [{id, text, sessionId, createdAt}]") insights: Optional[str] = Field(default=None, description="JSON array of AI insights [{id, text, sessionId, createdAt}]")
metadata: Optional[str] = Field(default=None, description="JSON object with flexible metadata") metadata: Optional[str] = Field(default=None, description="JSON object with flexible metadata")
personaId: Optional[str] = Field(default=None, description="Default persona for sessions")
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
sessionCount: int = Field(default=0) sessionCount: int = Field(default=0)
taskCount: int = Field(default=0) taskCount: int = Field(default=0)
lastSessionAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"}) lastSessionAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
@ -96,9 +94,9 @@ class CoachingContext(PowerOnModel):
class CoachingSession(PowerOnModel): class CoachingSession(PowerOnModel):
"""A single coaching conversation session within a context.""" """A single coaching conversation session within a module."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext") moduleId: str = Field(description="FK to TrainingModule")
userId: str = Field(description="Owner user ID") userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID") mandateId: str = Field(description="Mandate ID")
instanceId: str = Field(description="Feature instance ID") instanceId: str = Field(description="Feature instance ID")
@ -121,7 +119,7 @@ class CoachingMessage(PowerOnModel):
"""A single message in a coaching session.""" """A single message in a coaching session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
sessionId: str = Field(description="FK to CoachingSession") sessionId: str = Field(description="FK to CoachingSession")
contextId: str = Field(description="FK to CoachingContext") moduleId: str = Field(description="FK to TrainingModule")
userId: str = Field(description="Owner user ID") userId: str = Field(description="Owner user ID")
role: CoachingMessageRole = Field(description="Message author role") role: CoachingMessageRole = Field(description="Message author role")
content: str = Field(description="Message content (Markdown)") content: str = Field(description="Message content (Markdown)")
@ -131,9 +129,9 @@ class CoachingMessage(PowerOnModel):
class CoachingTask(PowerOnModel): class CoachingTask(PowerOnModel):
"""A task/checklist item assigned within a coaching context.""" """A task/checklist item assigned within a training module."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext") moduleId: str = Field(description="FK to TrainingModule")
sessionId: Optional[str] = Field(default=None, description="FK to originating session") sessionId: Optional[str] = Field(default=None, description="FK to originating session")
userId: str = Field(description="Owner user ID") userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID") mandateId: str = Field(description="Mandate ID")
@ -148,7 +146,7 @@ class CoachingTask(PowerOnModel):
class CoachingScore(PowerOnModel): class CoachingScore(PowerOnModel):
"""A competence score for a dimension, recorded after a session.""" """A competence score for a dimension, recorded after a session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext") moduleId: str = Field(description="FK to TrainingModule")
sessionId: str = Field(description="FK to CoachingSession") sessionId: str = Field(description="FK to CoachingSession")
userId: str = Field(description="Owner user ID") userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID") mandateId: str = Field(description="Mandate ID")
@ -193,6 +191,22 @@ class CoachingPersona(PowerOnModel):
isActive: bool = Field(default=True) isActive: bool = Field(default=True)
# ============================================================================
# Module-Persona Mapping (M:N)
# ============================================================================
class ModulePersonaMapping(PowerOnModel):
"""Maps which personas are available for a specific training module."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
moduleId: str = Field(description="FK to TrainingModule")
personaId: str = Field(description="FK to CoachingPersona")
instanceId: str = Field(description="Feature instance ID")
class SetModulePersonasRequest(BaseModel):
personaIds: List[str] = Field(description="List of persona IDs to assign to this module")
# ============================================================================ # ============================================================================
# Iteration 2: Badges / Gamification # Iteration 2: Badges / Gamification
# ============================================================================ # ============================================================================
@ -211,18 +225,22 @@ class CoachingBadge(PowerOnModel):
# API Request/Response Models # API Request/Response Models
# ============================================================================ # ============================================================================
class CreateContextRequest(BaseModel): class CreateModuleRequest(BaseModel):
title: str = Field(description="Context title") title: str = Field(description="Module title")
description: Optional[str] = None description: Optional[str] = None
category: Optional[CoachingContextCategory] = CoachingContextCategory.CUSTOM moduleType: Optional[TrainingModuleType] = TrainingModuleType.COACHING
goals: Optional[List[str]] = None goals: Optional[str] = None
personaId: Optional[str] = None
kpiTargets: Optional[str] = None
class UpdateContextRequest(BaseModel): class UpdateModuleRequest(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
category: Optional[CoachingContextCategory] = None moduleType: Optional[TrainingModuleType] = None
goals: Optional[str] = None goals: Optional[str] = None
personaId: Optional[str] = None
kpiTargets: Optional[str] = None
class SendMessageRequest(BaseModel): class SendMessageRequest(BaseModel):
@ -279,8 +297,8 @@ class UpdatePersonaRequest(BaseModel):
class DashboardData(BaseModel): class DashboardData(BaseModel):
"""Aggregated dashboard data for the user.""" """Aggregated dashboard data for the user."""
totalContexts: int = 0 totalModules: int = 0
activeContexts: int = 0 activeModules: int = 0
totalSessions: int = 0 totalSessions: int = 0
totalMinutes: int = 0 totalMinutes: int = 0
streakDays: int = 0 streakDays: int = 0
@ -289,4 +307,4 @@ class DashboardData(BaseModel):
recentScores: List[Dict[str, Any]] = Field(default_factory=list) recentScores: List[Dict[str, Any]] = Field(default_factory=list)
openTasks: int = 0 openTasks: int = 0
completedTasks: int = 0 completedTasks: int = 0
contexts: List[Dict[str, Any]] = Field(default_factory=list) modules: List[Dict[str, Any]] = Field(default_factory=list)

View file

@ -17,7 +17,7 @@ from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import resolveText, t from modules.shared.i18nRegistry import resolveText, t
from .datamodelCommcoach import ( from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus, TrainingModule, TrainingModuleStatus,
CoachingSession, CoachingSessionStatus, CoachingSession, CoachingSessionStatus,
CoachingMessage, CoachingMessage,
CoachingTask, CoachingTaskStatus, CoachingTask, CoachingTaskStatus,
@ -70,47 +70,60 @@ class CommcoachObjects:
) )
# ========================================================================= # =========================================================================
# Contexts # Modules (formerly Contexts)
# ========================================================================= # =========================================================================
def getContexts(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]: def getModules(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]:
"""Get all coaching contexts for a user. Strict ownership.""" """Get all training modules for a user. Enriches with live sessionCount from sessions table."""
records = self.db.getRecordset( records = self.db.getRecordset(
CoachingContext, TrainingModule,
recordFilter={"instanceId": instanceId, "userId": userId}, recordFilter={"instanceId": instanceId, "userId": userId},
) )
if not includeArchived: if not includeArchived:
records = [r for r in records if r.get("status") != CoachingContextStatus.ARCHIVED.value] records = [r for r in records if r.get("status") != TrainingModuleStatus.ARCHIVED.value]
allSessions = self.db.getRecordset(
CoachingSession,
recordFilter={"instanceId": instanceId, "userId": userId},
)
countByModule: Dict[str, int] = {}
for s in allSessions:
mid = s.get("moduleId")
if mid:
countByModule[mid] = countByModule.get(mid, 0) + 1
for r in records:
r["sessionCount"] = countByModule.get(r.get("id", ""), 0)
records.sort(key=lambda r: r.get("updatedAt") or r.get("createdAt") or "", reverse=True) records.sort(key=lambda r: r.get("updatedAt") or r.get("createdAt") or "", reverse=True)
return records return records
def getContext(self, contextId: str) -> Optional[Dict[str, Any]]: def getModule(self, moduleId: str) -> Optional[Dict[str, Any]]:
records = self.db.getRecordset(CoachingContext, recordFilter={"id": contextId}) records = self.db.getRecordset(TrainingModule, recordFilter={"id": moduleId})
return records[0] if records else None return records[0] if records else None
def createContext(self, data: Dict[str, Any]) -> Dict[str, Any]: def createModule(self, data: Dict[str, Any]) -> Dict[str, Any]:
data["createdAt"] = getIsoTimestamp() data["createdAt"] = getIsoTimestamp()
data["updatedAt"] = getIsoTimestamp() data["updatedAt"] = getIsoTimestamp()
return self.db.recordCreate(CoachingContext, data) return self.db.recordCreate(TrainingModule, data)
def updateContext(self, contextId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateModule(self, moduleId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
updates["updatedAt"] = getIsoTimestamp() updates["updatedAt"] = getIsoTimestamp()
return self.db.recordModify(CoachingContext, contextId, updates) return self.db.recordModify(TrainingModule, moduleId, updates)
def deleteContext(self, contextId: str) -> bool: def deleteModule(self, moduleId: str) -> bool:
self._deleteSessionsByContext(contextId) self._deleteSessionsByModule(moduleId)
self._deleteTasksByContext(contextId) self._deleteTasksByModule(moduleId)
self._deleteScoresByContext(contextId) self._deleteScoresByModule(moduleId)
return self.db.recordDelete(CoachingContext, contextId) return self.db.recordDelete(TrainingModule, moduleId)
# ========================================================================= # =========================================================================
# Sessions # Sessions
# ========================================================================= # =========================================================================
def getSessions(self, contextId: str, userId: str) -> List[Dict[str, Any]]: def getSessions(self, moduleId: str, userId: str) -> List[Dict[str, Any]]:
records = self.db.getRecordset( records = self.db.getRecordset(
CoachingSession, CoachingSession,
recordFilter={"contextId": contextId, "userId": userId}, recordFilter={"moduleId": moduleId, "userId": userId},
) )
records.sort(key=lambda r: r.get("startedAt") or 0, reverse=True) records.sort(key=lambda r: r.get("startedAt") or 0, reverse=True)
return records return records
@ -119,10 +132,10 @@ class CommcoachObjects:
records = self.db.getRecordset(CoachingSession, recordFilter={"id": sessionId}) records = self.db.getRecordset(CoachingSession, recordFilter={"id": sessionId})
return records[0] if records else None return records[0] if records else None
def getActiveSession(self, contextId: str, userId: str) -> Optional[Dict[str, Any]]: def getActiveSession(self, moduleId: str, userId: str) -> Optional[Dict[str, Any]]:
records = self.db.getRecordset( records = self.db.getRecordset(
CoachingSession, CoachingSession,
recordFilter={"contextId": contextId, "userId": userId, "status": CoachingSessionStatus.ACTIVE.value}, recordFilter={"moduleId": moduleId, "userId": userId, "status": CoachingSessionStatus.ACTIVE.value},
) )
return records[0] if records else None return records[0] if records else None
@ -136,8 +149,8 @@ class CommcoachObjects:
updates["updatedAt"] = getIsoTimestamp() updates["updatedAt"] = getIsoTimestamp()
return self.db.recordModify(CoachingSession, sessionId, updates) return self.db.recordModify(CoachingSession, sessionId, updates)
def _deleteSessionsByContext(self, contextId: str) -> int: def _deleteSessionsByModule(self, moduleId: str) -> int:
records = self.db.getRecordset(CoachingSession, recordFilter={"contextId": contextId}) records = self.db.getRecordset(CoachingSession, recordFilter={"moduleId": moduleId})
count = 0 count = 0
for record in records: for record in records:
self._deleteMessagesBySession(record.get("id")) self._deleteMessagesBySession(record.get("id"))
@ -174,10 +187,10 @@ class CommcoachObjects:
# Tasks # Tasks
# ========================================================================= # =========================================================================
def getTasks(self, contextId: str, userId: str) -> List[Dict[str, Any]]: def getTasks(self, moduleId: str, userId: str) -> List[Dict[str, Any]]:
records = self.db.getRecordset( records = self.db.getRecordset(
CoachingTask, CoachingTask,
recordFilter={"contextId": contextId, "userId": userId}, recordFilter={"moduleId": moduleId, "userId": userId},
) )
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True) records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
return records return records
@ -198,8 +211,8 @@ class CommcoachObjects:
def deleteTask(self, taskId: str) -> bool: def deleteTask(self, taskId: str) -> bool:
return self.db.recordDelete(CoachingTask, taskId) return self.db.recordDelete(CoachingTask, taskId)
def _deleteTasksByContext(self, contextId: str) -> int: def _deleteTasksByModule(self, moduleId: str) -> int:
records = self.db.getRecordset(CoachingTask, recordFilter={"contextId": contextId}) records = self.db.getRecordset(CoachingTask, recordFilter={"moduleId": moduleId})
count = 0 count = 0
for record in records: for record in records:
self.db.recordDelete(CoachingTask, record.get("id")) self.db.recordDelete(CoachingTask, record.get("id"))
@ -218,10 +231,10 @@ class CommcoachObjects:
# Scores # Scores
# ========================================================================= # =========================================================================
def getScores(self, contextId: str, userId: str) -> List[Dict[str, Any]]: def getScores(self, moduleId: str, userId: str) -> List[Dict[str, Any]]:
records = self.db.getRecordset( records = self.db.getRecordset(
CoachingScore, CoachingScore,
recordFilter={"contextId": contextId, "userId": userId}, recordFilter={"moduleId": moduleId, "userId": userId},
) )
records.sort(key=lambda r: r.get("createdAt") or "") records.sort(key=lambda r: r.get("createdAt") or "")
return records return records
@ -235,8 +248,8 @@ class CommcoachObjects:
data["createdAt"] = getIsoTimestamp() data["createdAt"] = getIsoTimestamp()
return self.db.recordCreate(CoachingScore, data) return self.db.recordCreate(CoachingScore, data)
def _deleteScoresByContext(self, contextId: str) -> int: def _deleteScoresByModule(self, moduleId: str) -> int:
records = self.db.getRecordset(CoachingScore, recordFilter={"contextId": contextId}) records = self.db.getRecordset(CoachingScore, recordFilter={"moduleId": moduleId})
count = 0 count = 0
for record in records: for record in records:
self.db.recordDelete(CoachingScore, record.get("id")) self.db.recordDelete(CoachingScore, record.get("id"))
@ -274,6 +287,39 @@ class CommcoachObjects:
from .datamodelCommcoach import CoachingPersona from .datamodelCommcoach import CoachingPersona
return self.db.recordDelete(CoachingPersona, personaId) return self.db.recordDelete(CoachingPersona, personaId)
def getAllPersonas(self, instanceId: str) -> List[Dict[str, Any]]:
"""All personas (builtin + custom for this instance), including inactive."""
from .datamodelCommcoach import CoachingPersona
builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"})
custom = self.db.getRecordset(CoachingPersona, recordFilter={"instanceId": instanceId})
custom = [p for p in custom if p.get("userId") != "system"]
return builtins + custom
# =========================================================================
# Module-Persona Mapping
# =========================================================================
def getModulePersonas(self, moduleId: str) -> List[Dict[str, Any]]:
from .datamodelCommcoach import ModulePersonaMapping
return self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId})
def setModulePersonas(self, moduleId: str, personaIds: List[str], instanceId: str) -> List[Dict[str, Any]]:
from .datamodelCommcoach import ModulePersonaMapping
existing = self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId})
for rec in existing:
self.db.recordDelete(ModulePersonaMapping, rec["id"])
created = []
for pId in personaIds:
data = ModulePersonaMapping(
moduleId=moduleId,
personaId=pId,
instanceId=instanceId,
).model_dump()
data["createdAt"] = getIsoTimestamp()
data["updatedAt"] = getIsoTimestamp()
created.append(self.db.recordCreate(ModulePersonaMapping, data))
return created
# ========================================================================= # =========================================================================
# Badges # Badges
# ========================================================================= # =========================================================================
@ -299,8 +345,8 @@ class CommcoachObjects:
# Score History # Score History
# ========================================================================= # =========================================================================
def getScoreHistory(self, contextId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]: def getScoreHistory(self, moduleId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]:
scores = self.getScores(contextId, userId) scores = self.getScores(moduleId, userId)
history: Dict[str, List[Dict[str, Any]]] = {} history: Dict[str, List[Dict[str, Any]]] = {}
for s in scores: for s in scores:
dim = s.get("dimension", "unknown") dim = s.get("dimension", "unknown")
@ -344,16 +390,15 @@ class CommcoachObjects:
# ========================================================================= # =========================================================================
def getDashboardData(self, userId: str, instanceId: str) -> Dict[str, Any]: def getDashboardData(self, userId: str, instanceId: str) -> Dict[str, Any]:
contexts = self.db.getRecordset(CoachingContext, recordFilter={"userId": userId, "instanceId": instanceId}) modules = self.db.getRecordset(TrainingModule, recordFilter={"userId": userId, "instanceId": instanceId})
sessions = self.db.getRecordset(CoachingSession, recordFilter={"userId": userId, "instanceId": instanceId}) sessions = self.db.getRecordset(CoachingSession, recordFilter={"userId": userId, "instanceId": instanceId})
profile = self.getProfile(userId, instanceId) profile = self.getProfile(userId, instanceId)
activeContexts = [c for c in contexts if c.get("status") == CoachingContextStatus.ACTIVE.value] activeModules = [m for m in modules if m.get("status") == TrainingModuleStatus.ACTIVE.value]
completedSessions = [s for s in sessions if s.get("status") == CoachingSessionStatus.COMPLETED.value]
totalMinutes = sum(s.get("durationSeconds", 0) for s in completedSessions) // 60 totalMinutes = sum(s.get("durationSeconds", 0) for s in sessions) // 60
scores = [] scores = []
for s in completedSessions: for s in sessions:
raw = s.get("competenceScore") raw = s.get("competenceScore")
if raw is not None: if raw is not None:
try: try:
@ -364,29 +409,27 @@ class CommcoachObjects:
recentScores = self.getRecentScores(userId, limit=10) recentScores = self.getRecentScores(userId, limit=10)
contextSummaries = [] countByModule: Dict[str, int] = {}
for ctx in activeContexts: for s in sessions:
goalProgress = _calcGoalProgress(ctx.get("goals")) mid = s.get("moduleId")
contextSummaries.append({ if mid:
"id": ctx.get("id"), countByModule[mid] = countByModule.get(mid, 0) + 1
"title": ctx.get("title"),
"category": ctx.get("category"), moduleSummaries = []
"sessionCount": ctx.get("sessionCount", 0), for mod in activeModules:
"lastSessionAt": ctx.get("lastSessionAt"), modId = mod.get("id", "")
"goalProgress": goalProgress, moduleSummaries.append({
"id": modId,
"title": mod.get("title"),
"moduleType": mod.get("moduleType"),
"sessionCount": countByModule.get(modId, 0),
"lastSessionAt": mod.get("lastSessionAt"),
}) })
allGoalProgress = []
for ctx in activeContexts:
gp = _calcGoalProgress(ctx.get("goals"))
if gp is not None:
allGoalProgress.append(gp)
overallGoalProgress = round(sum(allGoalProgress) / len(allGoalProgress)) if allGoalProgress else None
return { return {
"totalContexts": len(contexts), "totalModules": len(modules),
"activeContexts": len(activeContexts), "activeModules": len(activeModules),
"totalSessions": len(completedSessions), "totalSessions": len(sessions),
"totalMinutes": totalMinutes, "totalMinutes": totalMinutes,
"streakDays": profile.get("streakDays", 0) if profile else 0, "streakDays": profile.get("streakDays", 0) if profile else 0,
"longestStreak": profile.get("longestStreak", 0) if profile else 0, "longestStreak": profile.get("longestStreak", 0) if profile else 0,
@ -394,29 +437,12 @@ class CommcoachObjects:
"recentScores": recentScores, "recentScores": recentScores,
"openTasks": self.getOpenTaskCount(userId, instanceId), "openTasks": self.getOpenTaskCount(userId, instanceId),
"completedTasks": self.getCompletedTaskCount(userId, instanceId), "completedTasks": self.getCompletedTaskCount(userId, instanceId),
"contexts": contextSummaries, "modules": moduleSummaries,
"goalProgress": overallGoalProgress,
"badges": self.getBadges(userId, instanceId), "badges": self.getBadges(userId, instanceId),
"level": _calcLevel(profile.get("totalSessions", 0) if profile else 0), "level": _calcLevel(len(sessions)),
} }
def _calcGoalProgress(goalsRaw) -> Optional[int]:
"""Calculate goal completion percentage from a context's goals JSON field."""
if not goalsRaw:
return None
goals = goalsRaw
if isinstance(goalsRaw, str):
try:
goals = json.loads(goalsRaw)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(goals, list) or len(goals) == 0:
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)
_LEVELS = [ _LEVELS = [
(50, 5, "master", "Meister"), (50, 5, "master", "Meister"),
(25, 4, "expert", "Experte"), (25, 4, "expert", "Experte"),

View file

@ -23,9 +23,24 @@ UI_OBJECTS = [
"meta": {"area": "dashboard"} "meta": {"area": "dashboard"}
}, },
{ {
"objectKey": "ui.feature.commcoach.coaching", "objectKey": "ui.feature.commcoach.assistant",
"label": t("Arbeitsthemen", context="UI"), "label": t("Assistent", context="UI"),
"meta": {"area": "coaching"} "meta": {"area": "assistant"}
},
{
"objectKey": "ui.feature.commcoach.modules",
"label": t("Module", context="UI"),
"meta": {"area": "modules"}
},
{
"objectKey": "ui.feature.commcoach.session",
"label": t("Session", context="UI"),
"meta": {"area": "session"}
},
{
"objectKey": "ui.feature.commcoach.dossier",
"label": t("Dossier", context="UI"),
"meta": {"area": "dossier"}
}, },
{ {
"objectKey": "ui.feature.commcoach.settings", "objectKey": "ui.feature.commcoach.settings",
@ -35,15 +50,15 @@ UI_OBJECTS = [
] ]
DATA_OBJECTS = [ DATA_OBJECTS = [
# ── Record-Hierarchie: Context → Session → Message/Score, Context → Task ── # ── Record-Hierarchie: TrainingModule → Session → Message/Score, TrainingModule → Task ──
{ {
"objectKey": "data.feature.commcoach.CoachingContext", "objectKey": "data.feature.commcoach.TrainingModule",
"label": t("Coaching-Kontext", context="UI"), "label": t("Trainings-Modul", context="UI"),
"meta": { "meta": {
"table": "CoachingContext", "table": "TrainingModule",
"fields": ["id", "title", "category", "status", "lastSessionAt"], "fields": ["id", "title", "moduleType", "status", "lastSessionAt"],
"isParent": True, "isParent": True,
"displayFields": ["title", "category", "status"], "displayFields": ["title", "moduleType", "status"],
} }
}, },
{ {
@ -51,10 +66,10 @@ DATA_OBJECTS = [
"label": t("Coaching-Session", context="UI"), "label": t("Coaching-Session", context="UI"),
"meta": { "meta": {
"table": "CoachingSession", "table": "CoachingSession",
"fields": ["id", "contextId", "status", "summary", "startedAt", "endedAt", "competenceScore"], "fields": ["id", "moduleId", "status", "summary", "startedAt", "endedAt", "competenceScore"],
"isParent": True, "isParent": True,
"parentTable": "CoachingContext", "parentTable": "TrainingModule",
"parentKey": "contextId", "parentKey": "moduleId",
"displayFields": ["startedAt", "status"], "displayFields": ["startedAt", "status"],
} }
}, },
@ -63,7 +78,7 @@ DATA_OBJECTS = [
"label": t("Coaching-Nachricht", context="UI"), "label": t("Coaching-Nachricht", context="UI"),
"meta": { "meta": {
"table": "CoachingMessage", "table": "CoachingMessage",
"fields": ["id", "sessionId", "contextId", "role", "content", "contentType"], "fields": ["id", "sessionId", "moduleId", "role", "content", "contentType"],
"parentTable": "CoachingSession", "parentTable": "CoachingSession",
"parentKey": "sessionId", "parentKey": "sessionId",
} }
@ -73,7 +88,7 @@ DATA_OBJECTS = [
"label": t("Coaching-Score", context="UI"), "label": t("Coaching-Score", context="UI"),
"meta": { "meta": {
"table": "CoachingScore", "table": "CoachingScore",
"fields": ["id", "sessionId", "contextId", "dimension", "score", "trend"], "fields": ["id", "sessionId", "moduleId", "dimension", "score", "trend"],
"parentTable": "CoachingSession", "parentTable": "CoachingSession",
"parentKey": "sessionId", "parentKey": "sessionId",
} }
@ -83,9 +98,9 @@ DATA_OBJECTS = [
"label": t("Coaching-Aufgabe", context="UI"), "label": t("Coaching-Aufgabe", context="UI"),
"meta": { "meta": {
"table": "CoachingTask", "table": "CoachingTask",
"fields": ["id", "contextId", "title", "status", "priority", "dueDate"], "fields": ["id", "moduleId", "title", "status", "priority", "dueDate"],
"parentTable": "CoachingContext", "parentTable": "TrainingModule",
"parentKey": "contextId", "parentKey": "moduleId",
} }
}, },
# ── Stammdaten (sessionübergreifend, scoped per userId) ────────────────── # ── Stammdaten (sessionübergreifend, scoped per userId) ──────────────────
@ -112,6 +127,15 @@ DATA_OBJECTS = [
"fields": ["id", "key", "label", "gender", "category"], "fields": ["id", "key", "label", "gender", "category"],
} }
}, },
{
"objectKey": "data.feature.commcoach.ModulePersonaMapping",
"label": t("Modul-Persona-Zuordnung", context="UI"),
"meta": {
"table": "ModulePersonaMapping",
"group": "data.feature.commcoach.userData",
"fields": ["id", "moduleId", "personaId", "instanceId"],
}
},
{ {
"objectKey": "data.feature.commcoach.CoachingBadge", "objectKey": "data.feature.commcoach.CoachingBadge",
"label": t("Coaching-Auszeichnung", context="UI"), "label": t("Coaching-Auszeichnung", context="UI"),
@ -130,19 +154,19 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [ RESOURCE_OBJECTS = [
{ {
"objectKey": "resource.feature.commcoach.context.create", "objectKey": "resource.feature.commcoach.module.create",
"label": t("Kontext erstellen", context="UI"), "label": t("Modul erstellen", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"} "meta": {"endpoint": "/api/commcoach/{instanceId}/modules", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.commcoach.context.archive", "objectKey": "resource.feature.commcoach.module.archive",
"label": t("Kontext archivieren", context="UI"), "label": t("Modul archivieren", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"} "meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/archive", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.commcoach.session.start", "objectKey": "resource.feature.commcoach.session.start",
"label": t("Session starten", context="UI"), "label": t("Session starten", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/sessions/start", "method": "POST"} "meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/sessions/start", "method": "POST"}
}, },
{ {
"objectKey": "resource.feature.commcoach.session.complete", "objectKey": "resource.feature.commcoach.session.complete",
@ -152,7 +176,17 @@ RESOURCE_OBJECTS = [
{ {
"objectKey": "resource.feature.commcoach.task.manage", "objectKey": "resource.feature.commcoach.task.manage",
"label": t("Aufgaben verwalten", context="UI"), "label": t("Aufgaben verwalten", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/tasks", "method": "POST"} "meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/tasks", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.persona.manage",
"label": t("Persona verwalten", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/personas", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.modulePersonas.manage",
"label": t("Modul-Persona-Zuordnung verwalten", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/personas", "method": "PUT"}
}, },
] ]
@ -162,28 +196,33 @@ TEMPLATE_ROLES = [
"description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)", "description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.modules", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.session", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
# Viewer: keine RESOURCE-Endpunkte (Mutationen); Regel explizit fuer konsistente Kontext-Matrix
{"context": "RESOURCE", "item": None, "view": False}, {"context": "RESOURCE", "item": None, "view": False},
], ],
}, },
{ {
"roleLabel": "commcoach-user", "roleLabel": "commcoach-user",
"description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten", "description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Module und Sessions verwalten",
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.modules", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.session", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
{"context": "DATA", "item": "data.feature.commcoach.CoachingContext", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, {"context": "DATA", "item": "data.feature.commcoach.TrainingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingMessage", "view": True, "read": "m", "create": "m", "update": "n", "delete": "n"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingMessage", "view": True, "read": "m", "create": "m", "update": "n", "delete": "n"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingTask", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingTask", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingScore", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingScore", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingUserProfile", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingUserProfile", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "RESOURCE", "item": "resource.feature.commcoach.context.create", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.module.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.context.archive", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.module.archive", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True},
@ -252,6 +291,7 @@ def registerFeature(catalogService) -> bool:
meta=dataObj.get("meta") meta=dataObj.get("meta")
) )
_runMigrations()
_syncTemplateRolesToDb() _syncTemplateRolesToDb()
_seedBuiltinPersonas() _seedBuiltinPersonas()
_registerScheduler() _registerScheduler()
@ -264,6 +304,135 @@ def registerFeature(catalogService) -> bool:
return False return False
def _runMigrations():
"""Idempotent DB migrations for CommCoach feature.
Runs on every bootstrap; each step checks preconditions before executing.
"""
try:
from .interfaceFeatureCommcoach import commcoachDatabase
from modules.shared.configuration import APP_CONFIG
import psycopg2
from psycopg2.extras import RealDictCursor
conn = psycopg2.connect(
host=APP_CONFIG.get("DB_HOST", "localhost"),
database=commcoachDatabase,
user=APP_CONFIG.get("DB_USER"),
password=APP_CONFIG.get("DB_PASSWORD_SECRET"),
port=int(APP_CONFIG.get("DB_PORT", 5432)),
cursor_factory=RealDictCursor,
)
conn.autocommit = False
cur = conn.cursor()
def _tableExists(name):
cur.execute(
"SELECT 1 FROM information_schema.tables WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
(name,),
)
return cur.fetchone() is not None
def _columnExists(table, column):
cur.execute(
"SELECT 1 FROM information_schema.columns WHERE LOWER(table_name) = LOWER(%s) AND LOWER(column_name) = LOWER(%s) AND table_schema = 'public'",
(table, column),
)
return cur.fetchone() is not None
migrated = False
# M1: Rename table CoachingContext -> TrainingModule
if _tableExists("CoachingContext") and not _tableExists("TrainingModule"):
cur.execute('ALTER TABLE "CoachingContext" RENAME TO "TrainingModule"')
logger.info("Migration M1: Renamed table CoachingContext -> TrainingModule")
migrated = True
# M2: Rename contextId -> moduleId on child tables
for childTable in ["CoachingSession", "CoachingMessage", "CoachingTask", "CoachingScore"]:
if _tableExists(childTable) and _columnExists(childTable, "contextId") and not _columnExists(childTable, "moduleId"):
cur.execute(f'ALTER TABLE "{childTable}" RENAME COLUMN "contextId" TO "moduleId"')
logger.info(f"Migration M2: Renamed contextId -> moduleId on {childTable}")
migrated = True
# M3: Add moduleType column with default 'coaching'
if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "moduleType"):
cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "moduleType" TEXT DEFAULT \'coaching\'')
cur.execute('UPDATE "TrainingModule" SET "moduleType" = \'coaching\' WHERE "moduleType" IS NULL')
logger.info("Migration M3: Added moduleType column to TrainingModule")
migrated = True
# M4: Add personaId column
if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "personaId"):
cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "personaId" TEXT')
logger.info("Migration M4: Added personaId column to TrainingModule")
migrated = True
# M5: Add kpiTargets column
if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "kpiTargets"):
cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "kpiTargets" TEXT')
logger.info("Migration M5: Added kpiTargets column to TrainingModule")
migrated = True
# M6: Drop category column (replaced by moduleType)
if _tableExists("TrainingModule") and _columnExists("TrainingModule", "category"):
cur.execute('ALTER TABLE "TrainingModule" DROP COLUMN "category"')
logger.info("Migration M6: Dropped category column from TrainingModule")
migrated = True
# M7: Convert goals from JSON array to plain text
if _tableExists("TrainingModule") and _columnExists("TrainingModule", "goals"):
cur.execute("""
UPDATE "TrainingModule"
SET "goals" = subq.plainText
FROM (
SELECT id,
string_agg(elem->>'text', E'\\n') AS plainText
FROM "TrainingModule",
LATERAL jsonb_array_elements("goals"::jsonb) AS elem
WHERE "goals" IS NOT NULL
AND "goals" LIKE '[%'
GROUP BY id
) subq
WHERE "TrainingModule".id = subq.id
""")
rowCount = cur.rowcount
if rowCount > 0:
logger.info(f"Migration M7: Converted {rowCount} goals fields from JSON to plain text")
migrated = True
# M8: Create ModulePersonaMapping table
if not _tableExists("ModulePersonaMapping"):
cur.execute("""
CREATE TABLE "ModulePersonaMapping" (
id TEXT PRIMARY KEY,
"moduleId" TEXT NOT NULL,
"personaId" TEXT NOT NULL,
"instanceId" TEXT NOT NULL,
"createdAt" TEXT,
"updatedAt" TEXT,
UNIQUE("moduleId", "personaId")
)
""")
cur.execute('CREATE INDEX IF NOT EXISTS idx_mpm_module ON "ModulePersonaMapping" ("moduleId")')
cur.execute('CREATE INDEX IF NOT EXISTS idx_mpm_persona ON "ModulePersonaMapping" ("personaId")')
logger.info("Migration M8: Created ModulePersonaMapping table")
migrated = True
if migrated:
conn.commit()
logger.info("CommCoach DB migrations committed")
else:
conn.rollback()
cur.close()
conn.close()
except ImportError:
logger.debug("psycopg2 not available, skipping CommCoach DB migrations")
except Exception as e:
logger.warning(f"CommCoach DB migration failed (non-fatal): {e}")
def _seedBuiltinPersonas(): def _seedBuiltinPersonas():
"""Seed builtin roleplay personas into the database.""" """Seed builtin roleplay personas into the database."""
try: try:

View file

@ -2,7 +2,7 @@
# All rights reserved. # All rights reserved.
""" """
CommCoach routes for the backend API. CommCoach routes for the backend API.
Implements coaching context management, session streaming, tasks, and dashboard. Implements training module management, session streaming, tasks, and dashboard.
""" """
import logging import logging
@ -23,14 +23,14 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface
from . import interfaceFeatureCommcoach as interfaceDb from . import interfaceFeatureCommcoach as interfaceDb
from .datamodelCommcoach import ( from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus, TrainingModule, TrainingModuleStatus, CoachingSession, CoachingSessionStatus,
CoachingMessage, CoachingMessageRole, CoachingMessageContentType, CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
CoachingTask, CoachingTaskStatus, CoachingTask, CoachingTaskStatus,
CoachingPersona, CoachingBadge, CoachingPersona, CoachingBadge, ModulePersonaMapping,
CreateContextRequest, UpdateContextRequest, CreateModuleRequest, UpdateModuleRequest,
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest, SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
UpdateProfileRequest, UpdateProfileRequest,
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, SetModulePersonasRequest,
) )
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext
@ -91,204 +91,200 @@ def _validateOwnership(record: dict, context: RequestContext, fieldName: str = "
# ========================================================================= # =========================================================================
# Context Endpoints # Module Endpoints (formerly Context)
# ========================================================================= # =========================================================================
@router.get("/{instanceId}/contexts") @router.get("/{instanceId}/modules")
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def listContexts( async def listModules(
request: Request, request: Request,
instanceId: str, instanceId: str,
includeArchived: bool = False, includeArchived: bool = False,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""List all coaching contexts for the current user.""" """List all training modules for the current user."""
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
contexts = interface.getContexts(instanceId, userId, includeArchived=includeArchived) modules = interface.getModules(instanceId, userId, includeArchived=includeArchived)
return {"contexts": contexts} return {"modules": modules}
@router.post("/{instanceId}/contexts") @router.post("/{instanceId}/modules")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def createContext( async def createModule(
request: Request, request: Request,
instanceId: str, instanceId: str,
body: CreateContextRequest, body: CreateModuleRequest,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Create a new coaching context/dossier.""" """Create a new training module."""
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
goalsJson = None moduleData = TrainingModule(
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, userId=userId,
mandateId=mandateId, mandateId=mandateId,
instanceId=instanceId, instanceId=instanceId,
title=body.title, title=body.title,
description=body.description, description=body.description,
category=body.category, moduleType=body.moduleType,
goals=goalsJson, goals=body.goals,
personaId=body.personaId,
kpiTargets=body.kpiTargets,
).model_dump() ).model_dump()
created = interface.createContext(contextData) created = interface.createModule(moduleData)
logger.info(f"CommCoach context created: {created.get('id')} for user {userId}") logger.info(f"CommCoach module created: {created.get('id')} for user {userId}")
_audit(context, "commcoach.context.created", "CoachingContext", created.get("id"), f"Title: {body.title}") _audit(context, "commcoach.module.created", "TrainingModule", created.get("id"), f"Title: {body.title}")
return {"context": created} return {"module": created}
@router.get("/{instanceId}/contexts/{contextId}") @router.get("/{instanceId}/modules/{moduleId}")
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getContext( async def getModuleDetail(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Get a coaching context with tasks and score summary.""" """Get a training module with tasks and score summary."""
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
ctx = interface.getContext(contextId) mod = interface.getModule(moduleId)
if not ctx: if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(ctx, context) _validateOwnership(mod, context)
tasks = interface.getTasks(contextId, userId) tasks = interface.getTasks(moduleId, userId)
scores = interface.getScores(contextId, userId) scores = interface.getScores(moduleId, userId)
sessions = interface.getSessions(contextId, userId) sessions = interface.getSessions(moduleId, userId)
return { return {
"context": ctx, "module": mod,
"tasks": tasks, "tasks": tasks,
"scores": scores, "scores": scores,
"sessions": sessions, "sessions": sessions,
} }
@router.put("/{instanceId}/contexts/{contextId}") @router.put("/{instanceId}/modules/{moduleId}")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def updateContext( async def updateModuleFields(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
body: UpdateContextRequest, body: UpdateModuleRequest,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
ctx = interface.getContext(contextId) mod = interface.getModule(moduleId)
if not ctx: if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(ctx, context) _validateOwnership(mod, context)
updates = body.model_dump(exclude_none=True) updates = body.model_dump(exclude_none=True)
updated = interface.updateContext(contextId, updates) updated = interface.updateModule(moduleId, updates)
return {"context": updated} return {"module": updated}
@router.delete("/{instanceId}/contexts/{contextId}") @router.delete("/{instanceId}/modules/{moduleId}")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def deleteContext( async def deleteModuleAndData(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
ctx = interface.getContext(contextId) mod = interface.getModule(moduleId)
if not ctx: if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(ctx, context) _validateOwnership(mod, context)
interface.deleteContext(contextId) interface.deleteModule(moduleId)
return {"deleted": True} return {"deleted": True}
@router.post("/{instanceId}/contexts/{contextId}/archive") @router.post("/{instanceId}/modules/{moduleId}/archive")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def archiveContext( async def archiveModule(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
ctx = interface.getContext(contextId) mod = interface.getModule(moduleId)
if not ctx: if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(ctx, context) _validateOwnership(mod, context)
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value}) updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ARCHIVED.value})
_audit(context, "commcoach.context.archived", "CoachingContext", contextId) _audit(context, "commcoach.module.archived", "TrainingModule", moduleId)
return {"context": updated} return {"module": updated}
@router.post("/{instanceId}/contexts/{contextId}/activate") @router.post("/{instanceId}/modules/{moduleId}/activate")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def activateContext( async def activateModule(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
ctx = interface.getContext(contextId) mod = interface.getModule(moduleId)
if not ctx: if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(ctx, context) _validateOwnership(mod, context)
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value}) updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ACTIVE.value})
return {"context": updated} return {"module": updated}
# ========================================================================= # =========================================================================
# Session Endpoints # Session Endpoints
# ========================================================================= # =========================================================================
@router.get("/{instanceId}/contexts/{contextId}/sessions") @router.get("/{instanceId}/modules/{moduleId}/sessions")
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def listSessions( async def listSessions(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
ctx = interface.getContext(contextId) mod = interface.getModule(moduleId)
if not ctx: if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(ctx, context) _validateOwnership(mod, context)
sessions = interface.getSessions(contextId, userId) sessions = interface.getSessions(moduleId, userId)
return {"sessions": sessions} return {"sessions": sessions}
@router.post("/{instanceId}/contexts/{contextId}/sessions/start") @router.post("/{instanceId}/modules/{moduleId}/sessions/start")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def startSession( async def startSession(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
personaId: Optional[str] = None, personaId: Optional[str] = None,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
@ -297,22 +293,22 @@ async def startSession(
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
ctx = interface.getContext(contextId) mod = interface.getModule(moduleId)
if not ctx: if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(ctx, context) _validateOwnership(mod, context)
activeSession = interface.getActiveSession(contextId, userId) activeSession = interface.getActiveSession(moduleId, userId)
if activeSession: if activeSession:
sessionId = activeSession.get("id") sessionId = activeSession.get("id")
messages = interface.getMessages(sessionId) messages = interface.getMessages(sessionId)
async def _resumedEventGenerator(): async def _resumedEventGenerator():
service = CommcoachService(context.user, mandateId, instanceId) service = CommcoachService(context.user, mandateId, instanceId)
greetingText = await service.generateResumeGreeting(sessionId, contextId, messages, interface) greetingText = await service.generateResumeGreeting(sessionId, moduleId, messages, interface)
assistantMsg = CoachingMessage( assistantMsg = CoachingMessage(
sessionId=sessionId, sessionId=sessionId,
contextId=contextId, moduleId=moduleId,
userId=userId, userId=userId,
role=CoachingMessageRole.ASSISTANT, role=CoachingMessageRole.ASSISTANT,
content=greetingText, content=greetingText,
@ -323,7 +319,7 @@ async def startSession(
greetingForFrontend = { greetingForFrontend = {
"id": createdGreeting.get("id"), "id": createdGreeting.get("id"),
"sessionId": sessionId, "sessionId": sessionId,
"contextId": contextId, "moduleId": moduleId,
"role": "assistant", "role": "assistant",
"content": greetingText, "content": greetingText,
"contentType": "text", "contentType": "text",
@ -365,7 +361,7 @@ async def startSession(
) )
sessionData = CoachingSession( sessionData = CoachingSession(
contextId=contextId, moduleId=moduleId,
userId=userId, userId=userId,
mandateId=mandateId, mandateId=mandateId,
instanceId=instanceId, instanceId=instanceId,
@ -378,7 +374,7 @@ async def startSession(
await emitSessionEvent(sessionId, "sessionState", {"session": created, "resumed": False}) await emitSessionEvent(sessionId, "sessionState", {"session": created, "resumed": False})
service = CommcoachService(context.user, mandateId, instanceId) service = CommcoachService(context.user, mandateId, instanceId)
asyncio.create_task(service.processSessionOpening(sessionId, contextId, interface)) asyncio.create_task(service.processSessionOpening(sessionId, moduleId, interface))
async def _newSessionEventGenerator(): async def _newSessionEventGenerator():
from modules.shared.timeUtils import getIsoTimestamp from modules.shared.timeUtils import getIsoTimestamp
@ -399,8 +395,8 @@ async def startSession(
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}") logger.info(f"CommCoach session started (streaming): {sessionId} for module {moduleId}")
_audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Context: {contextId}") _audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Module: {moduleId}")
return StreamingResponse( return StreamingResponse(
_newSessionEventGenerator(), _newSessionEventGenerator(),
media_type="text/event-stream", media_type="text/event-stream",
@ -504,7 +500,7 @@ async def sendMessageStream(
if session.get("status") != CoachingSessionStatus.ACTIVE.value: if session.get("status") != CoachingSessionStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active")) raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active"))
contextId = session.get("contextId") moduleId = session.get("moduleId")
service = CommcoachService(context.user, mandateId, instanceId) service = CommcoachService(context.user, mandateId, instanceId)
existingTask = _activeProcessTasks.get(sessionId) existingTask = _activeProcessTasks.get(sessionId)
@ -517,7 +513,7 @@ async def sendMessageStream(
task = asyncio.create_task( task = asyncio.create_task(
service.processMessage( service.processMessage(
sessionId, contextId, body.content, interface, sessionId, moduleId, body.content, interface,
fileIds=body.fileIds, fileIds=body.fileIds,
dataSourceIds=body.dataSourceIds, dataSourceIds=body.dataSourceIds,
featureDataSourceIds=body.featureDataSourceIds, featureDataSourceIds=body.featureDataSourceIds,
@ -587,11 +583,11 @@ async def sendAudioStream(
from .serviceCommcoach import getUserVoicePrefs from .serviceCommcoach import getUserVoicePrefs
language, _ = getUserVoicePrefs(str(context.user.id), mandateId) language, _ = getUserVoicePrefs(str(context.user.id), mandateId)
contextId = session.get("contextId") moduleId = session.get("moduleId")
service = CommcoachService(context.user, mandateId, instanceId) service = CommcoachService(context.user, mandateId, instanceId)
asyncio.create_task( asyncio.create_task(
service.processAudioMessage(sessionId, contextId, audioBody, language, interface) service.processAudioMessage(sessionId, moduleId, audioBody, language, interface)
) )
async def _eventGenerator(): async def _eventGenerator():
@ -680,27 +676,27 @@ async def streamSession(
# Task Endpoints # Task Endpoints
# ========================================================================= # =========================================================================
@router.get("/{instanceId}/contexts/{contextId}/tasks") @router.get("/{instanceId}/modules/{moduleId}/tasks")
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def listTasks( async def listTasks(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
tasks = interface.getTasks(contextId, userId) tasks = interface.getTasks(moduleId, userId)
return {"tasks": tasks} return {"tasks": tasks}
@router.post("/{instanceId}/contexts/{contextId}/tasks") @router.post("/{instanceId}/modules/{moduleId}/tasks")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def createTask( async def createTask(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
body: CreateTaskRequest, body: CreateTaskRequest,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
@ -708,13 +704,13 @@ async def createTask(
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
ctx = interface.getContext(contextId) mod = interface.getModule(moduleId)
if not ctx: if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(ctx, context) _validateOwnership(mod, context)
taskData = CoachingTask( taskData = CoachingTask(
contextId=contextId, moduleId=moduleId,
userId=userId, userId=userId,
mandateId=mandateId, mandateId=mandateId,
title=body.title, title=body.title,
@ -853,12 +849,12 @@ async def updateProfile(
# Export Endpoints (Iteration 2) # Export Endpoints (Iteration 2)
# ========================================================================= # =========================================================================
@router.get("/{instanceId}/contexts/{contextId}/export") @router.get("/{instanceId}/modules/{moduleId}/export")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def exportDossier( async def exportDossier(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
format: str = "md", format: str = "md",
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
@ -867,26 +863,26 @@ async def exportDossier(
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
ctx = interface.getContext(contextId) mod = interface.getModule(moduleId)
if not ctx: if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(ctx, context) _validateOwnership(mod, context)
tasks = interface.getTasks(contextId, userId) tasks = interface.getTasks(moduleId, userId)
scores = interface.getScores(contextId, userId) scores = interface.getScores(moduleId, userId)
sessions = interface.getSessions(contextId, userId) sessions = interface.getSessions(moduleId, userId)
from .serviceCommcoachExport import buildDossierMarkdown, renderDossierPdf from .serviceCommcoachExport import buildDossierMarkdown, renderDossierPdf
_audit(context, "commcoach.export.requested", "CoachingContext", contextId, f"format={format}") _audit(context, "commcoach.export.requested", "TrainingModule", moduleId, f"format={format}")
if format == "pdf": if format == "pdf":
pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores) pdfBytes = await renderDossierPdf(mod, sessions, tasks, scores)
return Response(content=pdfBytes, media_type="application/pdf", return Response(content=pdfBytes, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'}) headers={"Content-Disposition": f'attachment; filename="dossier_{moduleId[:8]}.pdf"'})
md = buildDossierMarkdown(ctx, sessions, tasks, scores) md = buildDossierMarkdown(mod, sessions, tasks, scores)
return Response(content=md, media_type="text/markdown", return Response(content=md, media_type="text/markdown",
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.md"'}) headers={"Content-Disposition": f'attachment; filename="dossier_{moduleId[:8]}.md"'})
@router.get("/{instanceId}/sessions/{sessionId}/export") @router.get("/{instanceId}/sessions/{sessionId}/export")
@ -907,11 +903,11 @@ async def exportSession(
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
_validateOwnership(session, context) _validateOwnership(session, context)
contextId = session.get("contextId") moduleId = session.get("moduleId")
userId = str(context.user.id) userId = str(context.user.id)
messages = interface.getMessages(sessionId) messages = interface.getMessages(sessionId)
tasks = interface.getTasks(contextId, userId) if contextId else [] tasks = interface.getTasks(moduleId, userId) if moduleId else []
scores = interface.getScores(contextId, userId) if contextId else [] scores = interface.getScores(moduleId, userId) if moduleId else []
from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf
_audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}") _audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}")
@ -935,13 +931,47 @@ async def exportSession(
async def listPersonas( async def listPersonas(
request: Request, request: Request,
instanceId: str, instanceId: str,
pagination: Optional[str] = Query(None),
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
column: Optional[str] = Query(None, description="Column key for mode=filterValues"),
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
userId = str(context.user.id) allPersonas = interface.getAllPersonas(instanceId)
personas = interface.getPersonas(userId, instanceId)
return {"personas": personas} if mode == "filterValues":
from modules.routes.routeHelpers import handleFilterValuesInMemory
if not column:
raise HTTPException(status_code=400, detail=routeApiMsg("column parameter required"))
return handleFilterValuesInMemory(allPersonas, column, pagination)
if mode == "ids":
from modules.routes.routeHelpers import handleIdsInMemory
return handleIdsInMemory(allPersonas, pagination)
if pagination:
import json as _json
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeHelpers import applyFiltersAndSort, paginateInMemory
paginationDict = _json.loads(pagination)
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
filtered = applyFiltersAndSort(allPersonas, paginationParams)
pageItems, totalItems = paginateInMemory(filtered, paginationParams)
import math
return {
"items": pageItems,
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0,
sort=[s.model_dump() for s in paginationParams.sort] if paginationParams.sort else [],
filters=paginationParams.filters,
).model_dump(),
}
return {"items": allPersonas, "pagination": None}
@router.post("/{instanceId}/personas") @router.post("/{instanceId}/personas")
@ -1017,6 +1047,43 @@ async def deletePersonaRoute(
return {"deleted": True} return {"deleted": True}
# =========================================================================
# Module-Persona Mapping Endpoints
# =========================================================================
@router.get("/{instanceId}/modules/{moduleId}/personas")
@limiter.limit("60/minute")
async def getModulePersonas(
request: Request,
instanceId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
mappings = interface.getModulePersonas(moduleId)
personaIds = [m["personaId"] for m in mappings]
return {"personaIds": personaIds}
@router.put("/{instanceId}/modules/{moduleId}/personas")
@limiter.limit("20/minute")
async def setModulePersonas(
request: Request,
instanceId: str,
moduleId: str,
body: SetModulePersonasRequest,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
module = interface.getModule(moduleId)
if not module:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
interface.setModulePersonas(moduleId, body.personaIds, instanceId)
return {"personaIds": body.personaIds}
# ========================================================================= # =========================================================================
# Badge + Score History Endpoints (Iteration 2) # Badge + Score History Endpoints (Iteration 2)
# ========================================================================= # =========================================================================
@ -1035,16 +1102,46 @@ async def listBadges(
return {"badges": badges} return {"badges": badges}
@router.get("/{instanceId}/contexts/{contextId}/scores/history") @router.get("/{instanceId}/modules/{moduleId}/scores/history")
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getScoreHistory( async def getScoreHistory(
request: Request, request: Request,
instanceId: str, instanceId: str,
contextId: str, moduleId: str,
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId) interface = _getInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
history = interface.getScoreHistory(contextId, userId) history = interface.getScoreHistory(moduleId, userId)
return {"history": history} return {"history": history}
# =========================================================================
# Backward-Compatibility Redirects (old /contexts/ paths → /modules/)
# =========================================================================
@router.get("/{instanceId}/contexts")
async def _redirectListContexts(instanceId: str, request: Request):
qs = f"?{request.query_params}" if request.query_params else ""
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules{qs}"})
@router.post("/{instanceId}/contexts")
async def _redirectCreateContext(instanceId: str, request: Request):
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules"})
@router.get("/{instanceId}/contexts/{contextId}")
async def _redirectGetContext(instanceId: str, contextId: str, request: Request):
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"})
@router.put("/{instanceId}/contexts/{contextId}")
async def _redirectUpdateContext(instanceId: str, contextId: str, request: Request):
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"})
@router.delete("/{instanceId}/contexts/{contextId}")
async def _redirectDeleteContext(instanceId: str, contextId: str, request: Request):
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"})

View file

@ -420,7 +420,7 @@ async def _saveOrUpdateDocument(doc: Dict[str, Any], contextId: str, userId: str
logger.info(f"Document saved as platform FileItem: {fileItem.id} ({title})") logger.info(f"Document saved as platform FileItem: {fileItem.id} ({title})")
except Exception as e: except Exception as e:
logger.warning(f"Failed to save document as FileItem: {e}") logger.error(f"Failed to save document as FileItem: {e}", exc_info=True)
@ -483,12 +483,12 @@ def _loadDocumentContents(docIds: List[str], interface, mandateId: str = None, i
content = "" content = ""
try: try:
from modules.datamodels.datamodelKnowledge import FileContentIndex from modules.datamodels.datamodelKnowledge import FileContentIndex
idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"fileId": fId}) idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"id": fId})
if idxRecords: if idxRecords:
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump() idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS] content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS]
except Exception: except Exception as e:
pass logger.warning(f"Failed to load FileContentIndex for {fId}: {e}")
results.append({ results.append({
"id": fId, "id": fId,
"title": f.get("fileName") or f.get("name") or "Dokument", "title": f.get("fileName") or f.get("name") or "Dokument",
@ -557,13 +557,13 @@ def _getDocumentSummaries(contextId: str, userId: str, interface,
try: try:
from modules.datamodels.datamodelKnowledge import FileContentIndex from modules.datamodels.datamodelKnowledge import FileContentIndex
idxRecords = mgmtIf.db.getRecordset( idxRecords = mgmtIf.db.getRecordset(
FileContentIndex, recordFilter={"fileId": fId} FileContentIndex, recordFilter={"id": fId}
) )
if idxRecords: if idxRecords:
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump() idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
snippet = (idx.get("extractedText") or "")[:200] snippet = (idx.get("extractedText") or "")[:200]
except Exception: except Exception as e:
pass logger.warning(f"Failed to load FileContentIndex for {fId}: {e}")
if snippet: if snippet:
summaries.append(f"[{name}] {snippet}...") summaries.append(f"[{name}] {snippet}...")
else: else:
@ -748,7 +748,7 @@ class CommcoachService:
# Store user message # Store user message
userMsg = CoachingMessage( userMsg = CoachingMessage(
sessionId=sessionId, sessionId=sessionId,
contextId=contextId, moduleId=contextId,
userId=self.userId, userId=self.userId,
role=CoachingMessageRole.USER, role=CoachingMessageRole.USER,
content=userContent, content=userContent,
@ -764,7 +764,7 @@ class CommcoachService:
}) })
# Build context # Build context
context = interface.getContext(contextId) context = interface.getModule(contextId)
if not context: if not context:
logger.error(f"Context {contextId} not found") logger.error(f"Context {contextId} not found")
return createdUserMsg return createdUserMsg
@ -857,7 +857,7 @@ class CommcoachService:
assistantMsg = CoachingMessage( assistantMsg = CoachingMessage(
sessionId=sessionId, sessionId=sessionId,
contextId=contextId, moduleId=contextId,
userId=self.userId, userId=self.userId,
role=CoachingMessageRole.ASSISTANT, role=CoachingMessageRole.ASSISTANT,
content=textContent, content=textContent,
@ -946,6 +946,8 @@ class CommcoachService:
await emitSessionEvent(sessionId, "toolResult", event.data or {}) await emitSessionEvent(sessionId, "toolResult", event.data or {})
elif event.type == AgentEventTypeEnum.AGENT_PROGRESS: elif event.type == AgentEventTypeEnum.AGENT_PROGRESS:
await emitSessionEvent(sessionId, "agentProgress", event.data or {}) await emitSessionEvent(sessionId, "agentProgress", event.data or {})
elif event.type == AgentEventTypeEnum.FILE_CREATED:
await emitSessionEvent(sessionId, "documentCreated", event.data or {})
elif event.type == AgentEventTypeEnum.ERROR: elif event.type == AgentEventTypeEnum.ERROR:
await emitSessionEvent(sessionId, "error", {"message": event.content or "Agent error"}) await emitSessionEvent(sessionId, "error", {"message": event.content or "Agent error"})
@ -958,7 +960,7 @@ class CommcoachService:
""" """
await emitSessionEvent(sessionId, "status", {"label": "Coach bereitet sich vor..."}) await emitSessionEvent(sessionId, "status", {"label": "Coach bereitet sich vor..."})
context = interface.getContext(contextId) context = interface.getModule(contextId)
if not context: if not context:
logger.error(f"Context {contextId} not found") logger.error(f"Context {contextId} not found")
await emitSessionEvent(sessionId, "error", {"message": "Context not found"}) await emitSessionEvent(sessionId, "error", {"message": "Context not found"})
@ -1024,7 +1026,7 @@ class CommcoachService:
assistantMsg = CoachingMessage( assistantMsg = CoachingMessage(
sessionId=sessionId, sessionId=sessionId,
contextId=contextId, moduleId=contextId,
userId=self.userId, userId=self.userId,
role=CoachingMessageRole.ASSISTANT, role=CoachingMessageRole.ASSISTANT,
content=textContent, content=textContent,
@ -1046,7 +1048,7 @@ class CommcoachService:
async def generateResumeGreeting(self, sessionId: str, contextId: str, messages: list, interface) -> str: async def generateResumeGreeting(self, sessionId: str, contextId: str, messages: list, interface) -> str:
"""Generate a follow-up greeting when user returns to an active session.""" """Generate a follow-up greeting when user returns to an active session."""
context = interface.getContext(contextId) context = interface.getModule(contextId)
if not context: if not context:
raise ValueError(f"Context {contextId} not found for resume greeting") raise ValueError(f"Context {contextId} not found for resume greeting")
contextTitle = context.get("title", "Coaching") contextTitle = context.get("title", "Coaching")
@ -1100,8 +1102,10 @@ class CommcoachService:
if not session: if not session:
return {} return {}
contextId = session.get("contextId") contextId = session.get("moduleId")
context = interface.getContext(contextId) if contextId else None if not contextId:
logger.error(f"completeSession: session {sessionId} has no moduleId")
context = interface.getModule(contextId) if contextId else None
messages = interface.getMessages(sessionId) messages = interface.getMessages(sessionId)
if len(messages) < 2: if len(messages) < 2:
@ -1156,7 +1160,7 @@ class CommcoachService:
for taskData in extractedTasks[:3]: for taskData in extractedTasks[:3]:
if isinstance(taskData, dict) and taskData.get("title"): if isinstance(taskData, dict) and taskData.get("title"):
newTask = CoachingTask( newTask = CoachingTask(
contextId=contextId, moduleId=contextId,
sessionId=sessionId, sessionId=sessionId,
userId=self.userId, userId=self.userId,
mandateId=self.mandateId, mandateId=self.mandateId,
@ -1181,7 +1185,7 @@ class CommcoachService:
for scoreData in scores: for scoreData in scores:
if isinstance(scoreData, dict) and "dimension" in scoreData and "score" in scoreData: if isinstance(scoreData, dict) and "dimension" in scoreData and "score" in scoreData:
newScore = CoachingScore( newScore = CoachingScore(
contextId=contextId, moduleId=contextId,
sessionId=sessionId, sessionId=sessionId,
userId=self.userId, userId=self.userId,
mandateId=self.mandateId, mandateId=self.mandateId,
@ -1213,7 +1217,7 @@ class CommcoachService:
existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()}) existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()})
await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId}) await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId})
if contextId and existingInsights: if contextId and existingInsights:
interface.updateContext(contextId, {"insights": json.dumps(existingInsights[-10:])}) interface.updateModule(contextId, {"insights": json.dumps(existingInsights[-10:])})
except Exception as e: except Exception as e:
logger.warning(f"Insight generation failed: {e}") logger.warning(f"Insight generation failed: {e}")
@ -1280,7 +1284,7 @@ class CommcoachService:
if contextId: if contextId:
allSessions = interface.getSessions(contextId, self.userId) allSessions = interface.getSessions(contextId, self.userId)
completedCount = len([s for s in allSessions if s.get("status") == CoachingSessionStatus.COMPLETED.value]) completedCount = len([s for s in allSessions if s.get("status") == CoachingSessionStatus.COMPLETED.value])
interface.updateContext(contextId, { interface.updateModule(contextId, {
"sessionCount": completedCount, "sessionCount": completedCount,
"lastSessionAt": getUtcTimestamp(), "lastSessionAt": getUtcTimestamp(),
}) })
@ -1429,7 +1433,7 @@ class CommcoachService:
"sessionSummaries": [], "sessionSummaries": [],
} }
ctx = interface.getContext(contextId) ctx = interface.getModule(contextId)
rollingOverview = ctx.get("rollingOverview") if ctx else None rollingOverview = ctx.get("rollingOverview") if ctx else None
rollingUpTo = ctx.get("rollingOverviewUpToSessionCount") if ctx else None rollingUpTo = ctx.get("rollingOverviewUpToSessionCount") if ctx else None
@ -1506,7 +1510,7 @@ class CommcoachService:
) )
if overviewResponse and overviewResponse.errorCount == 0 and overviewResponse.content: if overviewResponse and overviewResponse.errorCount == 0 and overviewResponse.content:
newOverview = overviewResponse.content.strip() newOverview = overviewResponse.content.strip()
interface.updateContext(contextId, { interface.updateModule(contextId, {
"rollingOverview": newOverview, "rollingOverview": newOverview,
"rollingOverviewUpToSessionCount": len(completedSessions), "rollingOverviewUpToSessionCount": len(completedSessions),
}) })

View file

@ -143,7 +143,7 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
badgesToCheck.append(("roleplay_first", True)) badgesToCheck.append(("roleplay_first", True))
try: try:
from .datamodelCommcoach import CoachingContextStatus from .datamodelCommcoach import TrainingModuleStatus
allContexts = interface.db.getRecordset( allContexts = interface.db.getRecordset(
interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues
) if False else [] ) if False else []

View file

@ -146,6 +146,57 @@ BUILTIN_PERSONAS: List[Dict[str, Any]] = [
"gender": "m", "gender": "m",
"category": "builtin", "category": "builtin",
}, },
# --- Fachpersonen / Therapeutische & rechtliche Gesprächspartner ---
{
"key": "couples_therapist_f",
"label": "Paartherapeutin",
"description": "Dr. Eva Roth, erfahrene Paartherapeutin. Empathisch, strukturiert, stellt gezielte Fragen zu "
"Beziehungsdynamiken. Spiegelt Gefühle und Muster, ohne Partei zu ergreifen. Arbeitet mit der "
"Gewaltfreien Kommunikation und systemischen Methoden. Fragt nach Bedürfnissen hinter Vorwürfen "
"und lenkt das Gespräch auf konkrete Verhaltensänderungen statt Schuldzuweisungen.",
"gender": "f",
"category": "builtin",
},
{
"key": "psychologist_m",
"label": "Psychologe",
"description": "Dr. Markus Frei, klinischer Psychologe mit Schwerpunkt Stressbewältigung und Burnout-Prävention. "
"Ruhig, geduldig, stellt offene Fragen zur Selbstreflexion. Erkennt Denkmuster und benennt sie "
"behutsam. Arbeitet lösungsorientiert und hilft bei der Identifikation von Stressoren, Ressourcen "
"und Bewältigungsstrategien. Drängt nicht, lässt Raum für Stille und Nachdenken.",
"gender": "m",
"category": "builtin",
},
{
"key": "lawyer_m",
"label": "Rechtsanwalt",
"description": "lic. iur. Daniel Brandt, Wirtschaftsanwalt mit Fokus auf Vertragsrecht und Arbeitsrecht. Sachlich, "
"analytisch, prüft jede Aussage auf juristische Stichhaltigkeit. Fragt nach Fakten, Fristen und "
"Beweislage. Weist auf Risiken und Haftungsfragen hin. Formuliert präzise und erwartet dasselbe "
"vom Gegenüber. Kann unangenehme rechtliche Realitäten nüchtern kommunizieren.",
"gender": "m",
"category": "builtin",
},
{
"key": "mediator_f",
"label": "Mediatorin",
"description": "Sabine Lang, zertifizierte Wirtschaftsmediatorin. Strikt neutral, strukturiert den Dialog zwischen "
"Konfliktparteien. Stellt sicher, dass beide Seiten gehört werden. Arbeitet mit Ich-Botschaften und "
"Interessenklärung statt Positionsverhandlung. Unterbricht respektvoll bei Eskalation und lenkt "
"zurück auf Sachebene. Ziel ist immer eine tragfähige Vereinbarung, nicht Recht oder Unrecht.",
"gender": "f",
"category": "builtin",
},
{
"key": "hr_manager_f",
"label": "HR-Managerin",
"description": "Kathrin Vogt, Head of HR in einem Konzern. Kennt Arbeitsrecht, Feedbackkultur und Change-Prozesse. "
"Spricht diplomatisch aber klar. Achtet auf Compliance und Gleichbehandlung. Erwartet strukturierte "
"Argumentation bei Personalentscheiden. Reagiert sensibel auf Diskriminierungs- oder Mobbingthemen. "
"Kann sowohl Arbeitgeber- als auch Arbeitnehmerperspektive einnehmen.",
"gender": "f",
"category": "builtin",
},
] ]

View file

@ -62,7 +62,7 @@ async def _runDailyReminders():
try: try:
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from .datamodelCommcoach import CoachingUserProfile, CoachingContextStatus from .datamodelCommcoach import CoachingUserProfile, TrainingModuleStatus
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
@ -94,10 +94,10 @@ async def _runDailyReminders():
continue continue
# Check if user has active contexts # Check if user has active contexts
from .datamodelCommcoach import CoachingContext from .datamodelCommcoach import TrainingModule
contexts = db.getRecordset(CoachingContext, recordFilter={ contexts = db.getRecordset(TrainingModule, recordFilter={
"userId": userId, "userId": userId,
"status": CoachingContextStatus.ACTIVE.value, "status": TrainingModuleStatus.ACTIVE.value,
}) })
if not contexts: if not contexts:
continue continue

View file

@ -79,15 +79,47 @@ class TeamsbotTransferMode(str, Enum):
AUTO = "auto" # Automatic: anonymous → audio, authenticated → caption AUTO = "auto" # Automatic: anonymous → audio, authenticated → caption
class TeamsbotSeriesType(str, Enum):
"""Type of meeting series."""
WEEKLY = "weekly"
BIWEEKLY = "biweekly"
MONTHLY = "monthly"
ADHOC = "adhoc"
PROJECT = "project"
class TeamsbotModuleStatus(str, Enum):
"""Status of a meeting module."""
ACTIVE = "active"
ARCHIVED = "archived"
COMPLETED = "completed"
# ============================================================================ # ============================================================================
# Database Models (stored in PostgreSQL) # Database Models (stored in PostgreSQL)
# ============================================================================ # ============================================================================
class TeamsbotMeetingModule(PowerOnModel):
"""A meeting module groups related sessions (e.g. 'Weekly Standup')."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Module ID")
instanceId: str = Field(description="Feature instance ID (FK)")
mandateId: str = Field(description="Mandate ID (FK)")
ownerUserId: str = Field(description="Owner user ID")
title: str = Field(description="Module title, e.g. 'Weekly Standup'")
seriesType: TeamsbotSeriesType = Field(default=TeamsbotSeriesType.ADHOC)
defaultBotId: Optional[str] = Field(default=None, description="FK to TeamsbotSystemBot")
defaultDirectorPrompts: Optional[str] = Field(default=None, description="JSON list of default director prompts")
goals: Optional[str] = Field(default=None, description="Free-text goals")
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE)
class TeamsbotSession(PowerOnModel): class TeamsbotSession(PowerOnModel):
"""A Teams Bot meeting session.""" """A Teams Bot meeting session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
instanceId: str = Field(description="Feature instance ID (FK)") instanceId: str = Field(description="Feature instance ID (FK)")
mandateId: str = Field(description="Mandate ID (FK)") mandateId: str = Field(description="Mandate ID (FK)")
moduleId: Optional[str] = Field(default=None, description="FK to TeamsbotMeetingModule (nullable during transition)")
meetingLink: str = Field(description="Teams meeting join link") meetingLink: str = Field(description="Teams meeting join link")
botName: str = Field(default="AI Assistant", description="Display name of the bot in the meeting") botName: str = Field(default="AI Assistant", description="Display name of the bot in the meeting")
status: TeamsbotSessionStatus = Field(default=TeamsbotSessionStatus.PENDING, description="Current session status") status: TeamsbotSessionStatus = Field(default=TeamsbotSessionStatus.PENDING, description="Current session status")
@ -237,6 +269,27 @@ class TeamsbotSessionResponse(BaseModel):
botResponses: Optional[List[TeamsbotBotResponse]] = Field(default=None, description="Bot responses (if requested)") botResponses: Optional[List[TeamsbotBotResponse]] = Field(default=None, description="Bot responses (if requested)")
class CreateMeetingModuleRequest(BaseModel):
"""Request to create a new meeting module."""
title: str
seriesType: Optional[TeamsbotSeriesType] = TeamsbotSeriesType.ADHOC
defaultBotId: Optional[str] = None
defaultDirectorPrompts: Optional[str] = None
goals: Optional[str] = None
kpiTargets: Optional[str] = None
class UpdateMeetingModuleRequest(BaseModel):
"""Request to update an existing meeting module."""
title: Optional[str] = None
seriesType: Optional[TeamsbotSeriesType] = None
defaultBotId: Optional[str] = None
defaultDirectorPrompts: Optional[str] = None
goals: Optional[str] = None
kpiTargets: Optional[str] = None
status: Optional[TeamsbotModuleStatus] = None
class TeamsbotConfigUpdateRequest(BaseModel): class TeamsbotConfigUpdateRequest(BaseModel):
"""Request to update teamsbot configuration.""" """Request to update teamsbot configuration."""
botName: Optional[str] = None botName: Optional[str] = None

View file

@ -24,6 +24,7 @@ from .datamodelTeamsbot import (
TeamsbotDirectorPrompt, TeamsbotDirectorPrompt,
TeamsbotDirectorPromptStatus, TeamsbotDirectorPromptStatus,
TeamsbotDirectorPromptMode, TeamsbotDirectorPromptMode,
TeamsbotMeetingModule,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -330,6 +331,36 @@ class TeamsbotObjects:
count += 1 count += 1
return count return count
# =========================================================================
# Meeting Modules
# =========================================================================
def getModules(self, instanceId: str) -> List[Dict[str, Any]]:
"""Get all meeting modules for a feature instance."""
records = self.db.getRecordset(TeamsbotMeetingModule, recordFilter={"instanceId": instanceId})
records.sort(key=lambda r: r.get("sysCreatedAt") or "", reverse=True)
return records
def getModule(self, moduleId: str) -> Optional[Dict[str, Any]]:
"""Get a single meeting module by ID."""
records = self.db.getRecordset(TeamsbotMeetingModule, recordFilter={"id": moduleId})
return records[0] if records else None
def createModule(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new meeting module."""
return self.db.recordCreate(TeamsbotMeetingModule, data)
def updateModule(self, moduleId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update an existing meeting module."""
return self.db.recordModify(TeamsbotMeetingModule, moduleId, updates)
def deleteModule(self, moduleId: str) -> bool:
"""Delete a meeting module. Unlinks all sessions first."""
sessions = self.db.getRecordset(TeamsbotSession, recordFilter={"moduleId": moduleId})
for session in sessions:
self.db.recordModify(TeamsbotSession, session["id"], {"moduleId": None})
return self.db.recordDelete(TeamsbotMeetingModule, moduleId)
# ========================================================================= # =========================================================================
# Stats / Aggregation # Stats / Aggregation
# ========================================================================= # =========================================================================
@ -338,14 +369,23 @@ class TeamsbotObjects:
"""Get aggregated statistics for a session.""" """Get aggregated statistics for a session."""
transcripts = self.db.getRecordset(TeamsbotTranscript, recordFilter={"sessionId": sessionId}) transcripts = self.db.getRecordset(TeamsbotTranscript, recordFilter={"sessionId": sessionId})
responses = self.db.getRecordset(TeamsbotBotResponse, recordFilter={"sessionId": sessionId}) responses = self.db.getRecordset(TeamsbotBotResponse, recordFilter={"sessionId": sessionId})
prompts = self.db.getRecordset(TeamsbotDirectorPrompt, recordFilter={"sessionId": sessionId})
totalCost = sum(r.get("priceCHF", 0) for r in responses) totalCost = sum(r.get("priceCHF", 0) for r in responses)
totalProcessingTime = sum(r.get("processingTime", 0) for r in responses) totalProcessingTime = sum(r.get("processingTime", 0) for r in responses)
speakers = list(set(t.get("speaker") for t in transcripts if t.get("speaker")))
firstTimestamp = min((t.get("timestamp") or 0 for t in transcripts), default=0)
lastTimestamp = max((t.get("timestamp") or 0 for t in transcripts), default=0)
durationSeconds = round(lastTimestamp - firstTimestamp, 1) if firstTimestamp and lastTimestamp else 0
return { return {
"transcriptSegments": len(transcripts), "transcriptSegments": len(transcripts),
"botResponses": len(responses), "botResponses": len(responses),
"directorPrompts": len(prompts),
"totalCostCHF": round(totalCost, 4), "totalCostCHF": round(totalCost, 4),
"totalProcessingTime": round(totalProcessingTime, 2), "totalProcessingTime": round(totalProcessingTime, 2),
"speakers": list(set(t.get("speaker") for t in transcripts if t.get("speaker"))), "speakers": speakers,
"speakerCount": len(speakers),
"durationSeconds": durationSeconds,
} }

View file

@ -24,6 +24,16 @@ UI_OBJECTS = [
"label": t("Dashboard", context="UI"), "label": t("Dashboard", context="UI"),
"meta": {"area": "dashboard"} "meta": {"area": "dashboard"}
}, },
{
"objectKey": "ui.feature.teamsbot.assistant",
"label": t("Assistent", context="UI"),
"meta": {"area": "assistant"}
},
{
"objectKey": "ui.feature.teamsbot.modules",
"label": t("Module", context="UI"),
"meta": {"area": "modules"}
},
{ {
"objectKey": "ui.feature.teamsbot.sessions", "objectKey": "ui.feature.teamsbot.sessions",
"label": t("Sitzungen", context="UI"), "label": t("Sitzungen", context="UI"),
@ -38,13 +48,24 @@ UI_OBJECTS = [
# DATA Objects for RBAC catalog (tables/entities) # DATA Objects for RBAC catalog (tables/entities)
DATA_OBJECTS = [ DATA_OBJECTS = [
{
"objectKey": "data.feature.teamsbot.TeamsbotMeetingModule",
"label": t("Meeting-Modul", context="UI"),
"meta": {
"table": "TeamsbotMeetingModule",
"fields": ["id", "title", "seriesType", "status", "ownerUserId"],
"isParent": True,
"displayFields": ["title", "seriesType", "status"],
}
},
{ {
"objectKey": "data.feature.teamsbot.TeamsbotSession", "objectKey": "data.feature.teamsbot.TeamsbotSession",
"label": t("Sitzung", context="UI"), "label": t("Sitzung", context="UI"),
"meta": { "meta": {
"table": "TeamsbotSession", "table": "TeamsbotSession",
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"], "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
"isParent": True, "parentTable": "TeamsbotMeetingModule",
"parentKey": "moduleId",
"displayFields": ["botName", "status", "startedAt"], "displayFields": ["botName", "status", "startedAt"],
} }
}, },
@ -97,6 +118,16 @@ RESOURCE_OBJECTS = [
"label": t("Konfiguration bearbeiten", context="UI"), "label": t("Konfiguration bearbeiten", context="UI"),
"meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True} "meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True}
}, },
{
"objectKey": "resource.feature.teamsbot.module.create",
"label": t("Meeting-Modul erstellen", context="UI"),
"meta": {"endpoint": "/api/teamsbot/{instanceId}/modules", "method": "POST"}
},
{
"objectKey": "resource.feature.teamsbot.module.delete",
"label": t("Meeting-Modul loeschen", context="UI"),
"meta": {"endpoint": "/api/teamsbot/{instanceId}/modules/{moduleId}", "method": "DELETE"}
},
] ]
# Template roles for this feature with AccessRules # Template roles for this feature with AccessRules
@ -114,6 +145,8 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.delete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.delete", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.delete", "view": True},
] ]
}, },
{ {
@ -121,6 +154,8 @@ TEMPLATE_ROLES = [
"description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)", "description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.assistant", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.modules", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
], ],
@ -130,12 +165,16 @@ TEMPLATE_ROLES = [
"description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen", "description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.assistant", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.modules", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotMeetingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.create", "view": True},
], ],
}, },
] ]
@ -198,6 +237,7 @@ def registerFeature(catalogService) -> bool:
meta=dataObj.get("meta") meta=dataObj.get("meta")
) )
_runMigrations()
_syncTemplateRolesToDb() _syncTemplateRolesToDb()
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects") logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
@ -208,6 +248,95 @@ def registerFeature(catalogService) -> bool:
return False return False
def _runMigrations():
"""Idempotent DB migrations for TeamsBot feature.
Runs on every bootstrap; each step checks preconditions before executing.
The TeamsbotMeetingModule table and TeamsbotSession.moduleId column are
auto-created by the DB connector from the Pydantic model. This migration
handles data backfill: creating default Adhoc modules for existing sessions.
"""
try:
from .interfaceFeatureTeamsbot import teamsbotDatabase
from .datamodelTeamsbot import TeamsbotMeetingModule, TeamsbotSession
from modules.shared.configuration import APP_CONFIG
import psycopg2
from psycopg2.extras import RealDictCursor
import uuid
conn = psycopg2.connect(
host=APP_CONFIG.get("DB_HOST", "localhost"),
database=teamsbotDatabase,
user=APP_CONFIG.get("DB_USER"),
password=APP_CONFIG.get("DB_PASSWORD_SECRET"),
port=int(APP_CONFIG.get("DB_PORT", 5432)),
cursor_factory=RealDictCursor,
)
conn.autocommit = False
cur = conn.cursor()
def _tableExists(name):
cur.execute(
"SELECT 1 FROM information_schema.tables WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
(name,),
)
return cur.fetchone() is not None
def _columnExists(table, column):
cur.execute(
"SELECT 1 FROM information_schema.columns WHERE LOWER(table_name) = LOWER(%s) AND LOWER(column_name) = LOWER(%s) AND table_schema = 'public'",
(table, column),
)
return cur.fetchone() is not None
migrated = False
# M1: Create default Adhoc modules for orphaned sessions
# (only runs if TeamsbotSession table exists with moduleId column
# and there are sessions without a moduleId)
if _tableExists("TeamsbotSession") and _columnExists("TeamsbotSession", "moduleId"):
cur.execute("""
SELECT DISTINCT "instanceId", "mandateId"
FROM "TeamsbotSession"
WHERE "moduleId" IS NULL AND "instanceId" IS NOT NULL
""")
orphanGroups = cur.fetchall()
for group in orphanGroups:
instId = group["instanceId"]
mandId = group["mandateId"]
if not instId:
continue
adhocId = str(uuid.uuid4())
import time as _time
now = _time.time()
cur.execute("""
INSERT INTO "TeamsbotMeetingModule" (id, "instanceId", "mandateId", "ownerUserId", title, "seriesType", status, "sysCreatedAt")
VALUES (%s, %s, %s, 'system', 'Adhoc', 'adhoc', 'active', %s)
""", (adhocId, instId, mandId, now))
cur.execute("""
UPDATE "TeamsbotSession"
SET "moduleId" = %s
WHERE "instanceId" = %s AND "moduleId" IS NULL
""", (adhocId, instId))
sessionCount = cur.rowcount
logger.info(f"Migration M1: Created Adhoc module for instanceId={instId}, assigned {sessionCount} sessions")
migrated = True
if migrated:
conn.commit()
logger.info("TeamsBot DB migrations committed")
else:
conn.rollback()
cur.close()
conn.close()
except ImportError:
logger.debug("psycopg2 not available, skipping TeamsBot DB migrations")
except Exception as e:
logger.warning(f"TeamsBot DB migration failed (non-fatal): {e}")
def _syncTemplateRolesToDb() -> int: def _syncTemplateRolesToDb() -> int:
"""Sync template roles and their AccessRules to the database.""" """Sync template roles and their AccessRules to the database."""
try: try:

View file

@ -39,6 +39,9 @@ from .datamodelTeamsbot import (
TeamsbotDirectorPromptCreateRequest, TeamsbotDirectorPromptCreateRequest,
TeamsbotDirectorPromptMode, TeamsbotDirectorPromptMode,
TeamsbotDirectorPromptStatus, TeamsbotDirectorPromptStatus,
TeamsbotMeetingModule,
CreateMeetingModuleRequest,
UpdateMeetingModuleRequest,
DIRECTOR_PROMPT_FILE_LIMIT, DIRECTOR_PROMPT_FILE_LIMIT,
DIRECTOR_PROMPT_TEXT_LIMIT, DIRECTOR_PROMPT_TEXT_LIMIT,
) )
@ -167,6 +170,100 @@ def _getInstanceConfig(instanceId: str) -> TeamsbotConfig:
return TeamsbotConfig() return TeamsbotConfig()
# =========================================================================
# Meeting Module Endpoints
# =========================================================================
@router.get("/{instanceId}/modules")
@limiter.limit("60/minute")
async def listModules(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""List all meeting modules for a feature instance."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
modules = interface.getModules(instanceId)
return {"modules": modules}
@router.post("/{instanceId}/modules")
@limiter.limit("30/minute")
async def createModule(
request: Request,
instanceId: str,
body: CreateMeetingModuleRequest,
context: RequestContext = Depends(getRequestContext),
):
"""Create a new meeting module."""
interface = _getInterface(context, instanceId)
mandateId = _validateInstanceAccess(instanceId, context)
data = body.model_dump(exclude_none=True)
data["instanceId"] = instanceId
data["mandateId"] = mandateId
data["ownerUserId"] = str(context.user.id)
module = interface.createModule(data)
return {"module": module}
@router.get("/{instanceId}/modules/{moduleId}")
@limiter.limit("60/minute")
async def getModuleDetail(
request: Request,
instanceId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Get a single module with its sessions."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
module = interface.getModule(moduleId)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
sessions = interface.getSessions(instanceId)
moduleSessions = [s for s in sessions if s.get("moduleId") == moduleId]
return {"module": module, "sessions": moduleSessions}
@router.put("/{instanceId}/modules/{moduleId}")
@limiter.limit("30/minute")
async def updateModule(
request: Request,
instanceId: str,
moduleId: str,
body: UpdateMeetingModuleRequest,
context: RequestContext = Depends(getRequestContext),
):
"""Update an existing meeting module."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
module = interface.getModule(moduleId)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
updates = body.model_dump(exclude_none=True)
updated = interface.updateModule(moduleId, updates)
return {"module": updated}
@router.delete("/{instanceId}/modules/{moduleId}")
@limiter.limit("10/minute")
async def deleteModule(
request: Request,
instanceId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Delete a meeting module and unlink its sessions."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
module = interface.getModule(moduleId)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
interface.deleteModule(moduleId)
return {"success": True}
# ========================================================================= # =========================================================================
# Session Endpoints # Session Endpoints
# ========================================================================= # =========================================================================
@ -385,8 +482,9 @@ async def streamSession(
"""Generate SSE events from the session event queue.""" """Generate SSE events from the session event queue."""
from .service import sessionEvents from .service import sessionEvents
# Send initial session state # Send initial session state with stats
yield f"data: {json.dumps({'type': 'sessionState', 'data': session})}\n\n" stats = interface.getSessionStats(sessionId)
yield f"data: {json.dumps({'type': 'sessionState', 'data': session, 'stats': stats})}\n\n"
# Send current bot WebSocket connection state so the operator UI can # Send current bot WebSocket connection state so the operator UI can
# render the live indicator without waiting for the next connect/disconnect. # render the live indicator without waiting for the next connect/disconnect.

View file

@ -3409,6 +3409,8 @@ class TeamsbotService:
"status": "toolCall", "status": "toolCall",
"toolName": toolName, "toolName": toolName,
}) })
elif event.type == AgentEventTypeEnum.FILE_CREATED:
await _emitSessionEvent(sessionId, "documentCreated", event.data or {})
elif event.type == AgentEventTypeEnum.FINAL: elif event.type == AgentEventTypeEnum.FINAL:
finalText = (event.content or "").strip() finalText = (event.content or "").strip()
elif event.type == AgentEventTypeEnum.ERROR: elif event.type == AgentEventTypeEnum.ERROR:

View file

@ -658,6 +658,11 @@
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
"value": "" "value": ""
}, },
{
"context": "ui",
"key": "Dossiers",
"value": "UDB tab label for chat workflows / cases"
},
{ {
"context": "ui", "context": "ui",
"key": "Dokument", "key": "Dokument",
@ -4046,6 +4051,11 @@
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
"value": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden" "value": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden"
}, },
{
"context": "ui",
"key": "Dossiers",
"value": "Dossiers"
},
{ {
"context": "ui", "context": "ui",
"key": "Dokument", "key": "Dokument",
@ -7404,6 +7414,11 @@
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
"value": "This field is managed by {provider} and cannot be changed" "value": "This field is managed by {provider} and cannot be changed"
}, },
{
"context": "ui",
"key": "Dossiers",
"value": "Dossiers"
},
{ {
"context": "ui", "context": "ui",
"key": "Dokument", "key": "Dokument",
@ -10617,6 +10632,11 @@
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
"value": "Ce champ est géré par {provider} et ne peut pas être modifié" "value": "Ce champ est géré par {provider} et ne peut pas être modifié"
}, },
{
"context": "ui",
"key": "Dossiers",
"value": "Dossiers"
},
{ {
"context": "ui", "context": "ui",
"key": "Dokument", "key": "Dokument",

View file

@ -203,16 +203,40 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser
args["featureInstanceId"] = context["featureInstanceId"] args["featureInstanceId"] = context["featureInstanceId"]
if "mandateId" not in args and context.get("mandateId"): if "mandateId" not in args and context.get("mandateId"):
args["mandateId"] = context["mandateId"] args["mandateId"] = context["mandateId"]
if "parentOperationId" not in args:
import time as _time
toolOpId = f"agentTool_{methodName}_{actionName}_{int(_time.time())}"
chatSvc = getattr(services, "chat", None) if services else None
if chatSvc:
try:
chatSvc.progressLogStart(toolOpId, methodName.capitalize(), actionName, "Agent tool")
except Exception:
pass
args["parentOperationId"] = toolOpId
else:
toolOpId = None
chatSvc = None
result = await actionExecutor.executeAction(methodName, actionName, args) result = await actionExecutor.executeAction(methodName, actionName, args)
data = _formatActionResult(result, services, context) if toolOpId and chatSvc:
try:
chatSvc.progressLogFinish(toolOpId, result.success)
except Exception:
pass
data, sideEvents = _formatActionResult(result, services, context)
return ToolResult( return ToolResult(
toolCallId="", toolCallId="",
toolName=f"{methodName}_{actionName}", toolName=f"{methodName}_{actionName}",
success=result.success, success=result.success,
data=data, data=data,
error=result.error error=result.error,
sideEvents=sideEvents or None,
) )
except Exception as e: except Exception as e:
if toolOpId and chatSvc:
try:
chatSvc.progressLogFinish(toolOpId, False)
except Exception:
pass
logger.error(f"ActionToolAdapter dispatch failed for {methodName}_{actionName}: {e}") logger.error(f"ActionToolAdapter dispatch failed for {methodName}_{actionName}: {e}")
return ToolResult( return ToolResult(
toolCallId="", toolCallId="",
@ -226,11 +250,12 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser
_INLINE_CONTENT_LIMIT = 2000 _INLINE_CONTENT_LIMIT = 2000
def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[str]: def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Save an ActionDocument with large content as a workspace file. """Save an ActionDocument as a workspace file.
Returns a formatted result line (with file id + docItem ref) or None Handles both str and bytes documentData.
if persistence is not possible. Returns a dict with 'line' (formatted result) and 'fileInfo' (for sideEvents),
or None if persistence is not possible.
""" """
if not services: if not services:
return None return None
@ -238,49 +263,77 @@ def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[st
if not chatService: if not chatService:
return None return None
docData = getattr(doc, "documentData", None) docData = getattr(doc, "documentData", None)
if not docData or not isinstance(docData, str): if not docData:
return None
if isinstance(docData, bytes):
docBytes = docData
elif isinstance(docData, str):
docBytes = docData.encode("utf-8")
else:
return None return None
docName = getattr(doc, "documentName", "unnamed") docName = getattr(doc, "documentName", "unnamed")
docBytes = docData.encode("utf-8") docMime = getattr(doc, "mimeType", "application/octet-stream")
try: try:
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docBytes, docName) fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docBytes, docName)
fiId = context.get("featureInstanceId") or getattr(services, "featureInstanceId", "")
if fiId:
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
_attachFileAsChatDocument, _attachFileAsChatDocument,
_formatToolFileResult, _formatToolFileResult,
_getOrCreateTempFolder, _getOrCreateTempFolder,
) )
updateFields = {}
tempFolderId = _getOrCreateTempFolder(chatService) tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId: if tempFolderId:
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId}) updateFields["folderId"] = tempFolderId
fiId = context.get("featureInstanceId") or getattr(services, "featureInstanceId", "")
if fiId:
updateFields["featureInstanceId"] = fiId
updateFields["scope"] = "featureInstance"
mandateId = context.get("mandateId") or getattr(services, "mandateId", "")
if mandateId:
updateFields["mandateId"] = mandateId
if updateFields:
logger.debug("_persistLargeDocument: updating file %s with %s", fileItem.id, updateFields)
chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields)
else:
logger.warning("_persistLargeDocument: no updateFields for file %s (tempFolderId=%s, fiId=%s)", fileItem.id, tempFolderId, fiId)
chatDocId = _attachFileAsChatDocument( chatDocId = _attachFileAsChatDocument(
services, fileItem, services, fileItem,
label=f"action_doc:{docName}", label=f"action_doc:{docName}",
userMessage=f"Action document: {docName}", userMessage=f"Action document: {docName}",
) )
return _formatToolFileResult( line = _formatToolFileResult(
fileItem=fileItem, fileItem=fileItem,
chatDocId=chatDocId, chatDocId=chatDocId,
actionLabel="Produced", actionLabel="Produced",
extraInfo="Use readFile to read the content.", extraInfo="Use readFile to read the content.",
) )
return {
"line": line,
"fileInfo": {
"fileId": fileItem.id,
"fileName": docName,
"mimeType": docMime,
"fileSize": len(docBytes),
},
}
except Exception as e: except Exception as e:
logger.warning(f"_persistLargeDocument failed for {docName}: {e}") logger.error(f"_persistLargeDocument failed for {docName}: {e}", exc_info=True)
return None return None
def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] = None) -> str: def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] = None):
"""Format an ActionResult into a text representation for the agent. """Format an ActionResult into a text representation for the agent.
Documents whose content exceeds the inline limit are persisted as Documents whose content exceeds the inline limit (or is binary bytes)
workspace files so the agent can access them via readFile / are persisted as workspace files.
ai_process / searchInFileContent.
Returns (str, list) the formatted text and a list of sideEvent dicts.
""" """
parts = [] parts = []
sideEvents = []
ctx = context or {} ctx = context or {}
if result.resultLabel: if result.resultLabel:
@ -296,12 +349,22 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]]
docType = getattr(doc, "mimeType", "unknown") docType = getattr(doc, "mimeType", "unknown")
docData = getattr(doc, "documentData", None) docData = getattr(doc, "documentData", None)
isLarge = docData and isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT needsPersist = (
if isLarge: (isinstance(docData, bytes) and len(docData) > 0) or
persistedLine = _persistLargeDocument(doc, services, ctx) (isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT)
if persistedLine: )
if needsPersist:
persisted = _persistLargeDocument(doc, services, ctx)
if persisted:
parts.append(f" - {docName} ({docType})") parts.append(f" - {docName} ({docType})")
parts.append(f" {persistedLine}") parts.append(f" {persisted['line']}")
sideEvents.append({
"type": "fileCreated",
"data": persisted["fileInfo"],
})
continue
logger.error(f"Document '{docName}' ({docType}, {len(docData)} bytes) could not be persisted")
parts.append(f" - {docName} ({docType}) [ERROR: persistence failed]")
continue continue
parts.append(f" - {docName} ({docType})") parts.append(f" - {docName} ({docType})")
@ -311,4 +374,4 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]]
if not parts: if not parts:
parts.append("Action completed successfully." if result.success else "Action failed.") parts.append("Action completed successfully." if result.success else "Action failed.")
return "\n".join(parts) return "\n".join(parts), sideEvents

View file

@ -228,14 +228,17 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
fileName = f"{fileName}.zip" fileName = f"{fileName}.zip"
chatService = services.chat chatService = services.chat
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName) fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName)
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") updateFields = {}
if fiId:
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
if _sourceNeutralize:
chatService.interfaceDbComponent.updateFile(fileItem.id, {"neutralize": True})
tempFolderId = _getOrCreateTempFolder(chatService) tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId: if tempFolderId:
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId}) updateFields["folderId"] = tempFolderId
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
updateFields["featureInstanceId"] = fiId
if _sourceNeutralize:
updateFields["neutralize"] = True
if updateFields:
chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields)
chatDocId = _attachFileAsChatDocument( chatDocId = _attachFileAsChatDocument(
services, fileItem, services, fileItem,

View file

@ -47,12 +47,28 @@ def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool:
def _getOrCreateTempFolder(chatService) -> Optional[str]: def _getOrCreateTempFolder(chatService) -> Optional[str]:
"""Deprecated stub: folder-based organisation has been replaced by grouping. """Return the ID of the user's 'Temp' folder, creating it if it doesn't exist."""
ifc = getattr(chatService, "interfaceDbComponent", None)
Returns None unconditionally so callers skip the (now removed) folderId if not ifc:
assignment. Remove callers incrementally and delete this stub afterwards. logger.warning("_getOrCreateTempFolder: no interfaceDbComponent on chatService")
""" return None
logger.debug("_getOrCreateTempFolder called folder support removed, returning None") userId = getattr(ifc, "userId", None)
if not userId:
logger.warning("_getOrCreateTempFolder: userId is None on interfaceDbComponent")
return None
try:
ownFolders = ifc.getOwnFolderTree()
for f in ownFolders:
if f.get("name") == "Temp":
folderId = f.get("id")
logger.debug("_getOrCreateTempFolder: found existing Temp folder %s", folderId)
return str(folderId) if folderId else None
newFolder = ifc.createFolder("Temp")
folderId = newFolder.get("id") if isinstance(newFolder, dict) else getattr(newFolder, "id", None)
logger.info("_getOrCreateTempFolder: created Temp folder %s for user %s", folderId, userId)
return str(folderId) if folderId else None
except Exception as e:
logger.warning("_getOrCreateTempFolder failed: %s", e)
return None return None

View file

@ -222,12 +222,15 @@ def _registerMediaTools(registry: ToolRegistry, services):
if fileItem: if fileItem:
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") updateFields = {}
if fiId:
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
tempFolderId = _getOrCreateTempFolder(chatService) tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId: if tempFolderId:
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId}) updateFields["folderId"] = tempFolderId
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
updateFields["featureInstanceId"] = fiId
if updateFields:
chatService.interfaceDbComponent.updateFile(fid, updateFields)
chatDocId = _attachFileAsChatDocument( chatDocId = _attachFileAsChatDocument(
services, fileItem, services, fileItem,
label=f"renderDocument:{docName}", label=f"renderDocument:{docName}",
@ -517,12 +520,15 @@ def _registerMediaTools(registry: ToolRegistry, services):
if fileItem: if fileItem:
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") updateFields = {}
if fiId:
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
tempFolderId = _getOrCreateTempFolder(chatService) tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId: if tempFolderId:
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId}) updateFields["folderId"] = tempFolderId
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
updateFields["featureInstanceId"] = fiId
if updateFields:
chatService.interfaceDbComponent.updateFile(fid, updateFields)
chatDocId = _attachFileAsChatDocument( chatDocId = _attachFileAsChatDocument(
services, fileItem, services, fileItem,
label=f"generateImage:{docName}", label=f"generateImage:{docName}",
@ -679,12 +685,16 @@ def _registerMediaTools(registry: ToolRegistry, services):
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName) fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName)
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?" fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?"
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") if fid != "?":
if fiId and fid != "?": updateFields = {}
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
tempFolderId = _getOrCreateTempFolder(chatService) tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId and fid != "?": if tempFolderId:
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId}) updateFields["folderId"] = tempFolderId
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
updateFields["featureInstanceId"] = fiId
if updateFields:
chatService.interfaceDbComponent.updateFile(fid, updateFields)
chatDocId = _attachFileAsChatDocument( chatDocId = _attachFileAsChatDocument(
services, fileItem, services, fileItem,

View file

@ -203,7 +203,8 @@ class AgentService:
# ContentParts" symptom we see when the workspace route calls # ContentParts" symptom we see when the workspace route calls
# runAgent for an attached single-file data source. # runAgent for an attached single-file data source.
# Mirrors workflowManager._propagateWorkflowToContext. # Mirrors workflowManager._propagateWorkflowToContext.
if workflowId and workflowId != "unknown": isChatWorkflowId = workflowId and workflowId != "unknown" and ":" not in workflowId
if isChatWorkflowId:
try: try:
workflow = getattr(self.services, "workflow", None) workflow = getattr(self.services, "workflow", None)
if workflow is None or getattr(workflow, "id", None) != workflowId: if workflow is None or getattr(workflow, "id", None) != workflowId:

View file

@ -433,6 +433,13 @@ class AiCallLooper:
try: try:
extracted = extractJsonString(contexts.completePart) extracted = extractJsonString(contexts.completePart)
parsed, parseErr, _ = tryParseJson(extracted) parsed, parseErr, _ = tryParseJson(extracted)
if parseErr is not None:
from modules.shared.jsonUtils import repairBrokenJson
repaired = repairBrokenJson(extracted)
if repaired:
parsed = repaired
parseErr = None
logger.info(f"Iteration {iteration}: repairBrokenJson succeeded for completePart")
if parseErr is None and parsed: if parseErr is None and parsed:
normalized = self._normalizeJsonStructure(parsed, useCase) normalized = self._normalizeJsonStructure(parsed, useCase)
result = json.dumps(normalized, indent=2, ensure_ascii=False) result = json.dumps(normalized, indent=2, ensure_ascii=False)

View file

@ -933,19 +933,10 @@ class StructureFiller:
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE
if operationType == OperationTypeEnum.IMAGE_GENERATE: if operationType == OperationTypeEnum.IMAGE_GENERATE:
maxPromptLength = 4000 imagePrompt = self._buildImagePrompt(section, generationHint, language)
if len(generationPrompt) > maxPromptLength: self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
generationPrompt,
f"{chapterId}_section_{sectionId}_prompt"
)
request = AiCallRequest( request = AiCallRequest(
prompt=generationPrompt, prompt=imagePrompt,
contentParts=[], contentParts=[],
options=AiCallOptions( options=AiCallOptions(
operationType=operationType, operationType=operationType,
@ -956,8 +947,6 @@ class StructureFiller:
checkWorkflowStopped(self.services) checkWorkflowStopped(self.services)
aiResponse = await self.aiService.callAi(request) aiResponse = await self.aiService.callAi(request)
generatedElements = [] generatedElements = []
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile( self.services.utils.writeDebugFile(
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse), aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
f"{chapterId}_section_{sectionId}_response" f"{chapterId}_section_{sectionId}_response"
@ -1036,7 +1025,7 @@ class StructureFiller:
aiResponse = _AiResponseFallback(aiResponseJson) aiResponse = _AiResponseFallback(aiResponseJson)
except Exception as parseError: except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
aiResponse = _AiResponseFallback(aiResponseJson) aiResponse = _AiResponseFallback(aiResponseJson)
generatedElements = [] generatedElements = []
@ -1115,19 +1104,10 @@ class StructureFiller:
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE
if operationType == OperationTypeEnum.IMAGE_GENERATE: if operationType == OperationTypeEnum.IMAGE_GENERATE:
maxPromptLength = 4000 imagePrompt = self._buildImagePrompt(section, generationHint, language)
if len(generationPrompt) > maxPromptLength: self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
generationPrompt,
f"{chapterId}_section_{sectionId}_prompt"
)
request = AiCallRequest( request = AiCallRequest(
prompt=generationPrompt, prompt=imagePrompt,
contentParts=[], contentParts=[],
options=AiCallOptions( options=AiCallOptions(
operationType=operationType, operationType=operationType,
@ -1137,8 +1117,6 @@ class StructureFiller:
) )
aiResponse = await self.aiService.callAi(request) aiResponse = await self.aiService.callAi(request)
generatedElements = [] generatedElements = []
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile( self.services.utils.writeDebugFile(
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse), aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
f"{chapterId}_section_{sectionId}_response" f"{chapterId}_section_{sectionId}_response"
@ -1197,7 +1175,7 @@ class StructureFiller:
aiResponse = _AiResponseFallback(aiResponseJson) aiResponse = _AiResponseFallback(aiResponseJson)
except Exception as parseError: except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
aiResponse = _AiResponseFallback(aiResponseJson) aiResponse = _AiResponseFallback(aiResponseJson)
generatedElements = [] generatedElements = []
@ -1374,19 +1352,10 @@ class StructureFiller:
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE
if operationType == OperationTypeEnum.IMAGE_GENERATE: if operationType == OperationTypeEnum.IMAGE_GENERATE:
maxPromptLength = 4000 imagePrompt = self._buildImagePrompt(section, generationHint, language)
if len(generationPrompt) > maxPromptLength: self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
generationPrompt,
f"{chapterId}_section_{sectionId}_prompt"
)
request = AiCallRequest( request = AiCallRequest(
prompt=generationPrompt, prompt=imagePrompt,
contentParts=[], contentParts=[],
options=AiCallOptions( options=AiCallOptions(
operationType=operationType, operationType=operationType,
@ -1396,8 +1365,6 @@ class StructureFiller:
) )
aiResponse = await self.aiService.callAi(request) aiResponse = await self.aiService.callAi(request)
generatedElements = [] generatedElements = []
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile( self.services.utils.writeDebugFile(
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse), aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
f"{chapterId}_section_{sectionId}_response" f"{chapterId}_section_{sectionId}_response"
@ -1457,7 +1424,7 @@ class StructureFiller:
aiResponse = _AiResponseFallback(aiResponseJson) aiResponse = _AiResponseFallback(aiResponseJson)
except Exception as parseError: except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
aiResponse = _AiResponseFallback(aiResponseJson) aiResponse = _AiResponseFallback(aiResponseJson)
generatedElements = [] generatedElements = []
@ -2166,6 +2133,14 @@ Return only valid JSON. Do not include any explanatory text outside the JSON.
""" """
return prompt return prompt
def _buildImagePrompt(self, section: Dict[str, Any], generationHint: str, language: str = "de") -> str:
"""Build a concise image-generation prompt from generationHint only.
Image models need short, visual descriptions - not the full document
context or user prompt that can be hundreds of KB."""
sectionTitle = section.get("title", "")
description = generationHint or sectionTitle or "Generate an image"
return f"{description}\nLanguage for any text in the image: {language.upper()}"
def _getContentStructureExample(self, contentType: str) -> str: def _getContentStructureExample(self, contentType: str) -> str:
"""Get the JSON structure example for a specific content type.""" """Get the JSON structure example for a specific content type."""
structures = { structures = {

View file

@ -23,7 +23,7 @@ class ChatService:
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandate_id) self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandate_id)
self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandate_id) self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandate_id, featureInstanceId=context.feature_instance_id)
self.interfaceDbChat = getChatInterface( self.interfaceDbChat = getChatInterface(
context.user, context.user,
mandateId=context.mandate_id, mandateId=context.mandate_id,

View file

@ -122,26 +122,37 @@ class BaseRenderer(ABC):
"title": { "title": {
"font_size": h1["sizePt"], "color": h1["color"], "font_size": h1["sizePt"], "color": h1["color"],
"bold": h1.get("weight") == "bold", "align": "left", "bold": h1.get("weight") == "bold", "align": "left",
"space_before": 0,
"space_after": h1.get("spaceAfterPt", 8),
}, },
"heading1": { "heading1": {
"font_size": h1["sizePt"], "color": h1["color"], "font_size": h1["sizePt"], "color": h1["color"],
"bold": h1.get("weight") == "bold", "align": "left", "bold": h1.get("weight") == "bold", "align": "left",
"space_before": h1.get("spaceBeforePt", 24),
"space_after": h1.get("spaceAfterPt", 8),
}, },
"heading2": { "heading2": {
"font_size": h2["sizePt"], "color": h2["color"], "font_size": h2["sizePt"], "color": h2["color"],
"bold": h2.get("weight") == "bold", "align": "left", "bold": h2.get("weight") == "bold", "align": "left",
"space_before": h2.get("spaceBeforePt", 20),
"space_after": h2.get("spaceAfterPt", 6),
}, },
"heading3": { "heading3": {
"font_size": h3["sizePt"], "color": h3["color"], "font_size": h3["sizePt"], "color": h3["color"],
"bold": h3.get("weight") == "bold", "align": "left", "bold": h3.get("weight") == "bold", "align": "left",
"space_before": h3.get("spaceBeforePt", 16),
"space_after": h3.get("spaceAfterPt", 4),
}, },
"heading4": { "heading4": {
"font_size": h4["sizePt"], "color": h4["color"], "font_size": h4["sizePt"], "color": h4["color"],
"bold": h4.get("weight") == "bold", "align": "left", "bold": h4.get("weight") == "bold", "align": "left",
"space_before": h4.get("spaceBeforePt", 12),
"space_after": h4.get("spaceAfterPt", 3),
}, },
"paragraph": { "paragraph": {
"font_size": para["sizePt"], "color": para["color"], "font_size": para["sizePt"], "color": para["color"],
"bold": False, "align": "left", "bold": False, "align": "left",
"line_height": para.get("lineSpacing", 1.15),
}, },
"table_header": { "table_header": {
"background": tbl["headerBg"], "text_color": tbl["headerFg"], "background": tbl["headerBg"], "text_color": tbl["headerFg"],
@ -157,6 +168,7 @@ class BaseRenderer(ABC):
"bullet_list": { "bullet_list": {
"font_size": lst["sizePt"], "color": para["color"], "font_size": lst["sizePt"], "color": para["color"],
"indent": lst["indentPt"], "indent": lst["indentPt"],
"bullet_char": lst.get("bulletChar", "\u2022"),
}, },
"code_block": { "code_block": {
"font": style["fonts"]["monospace"], "font": style["fonts"]["monospace"],

View file

@ -851,25 +851,35 @@ class RendererPdf(BaseRenderer):
return [] return []
def _renderJsonBulletList(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: def _renderJsonBulletList(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]:
"""Render a JSON bullet list to PDF elements using AI-generated styles.""" """Render a JSON bullet list to PDF elements."""
try: try:
content = list_data.get("content", {}) content = list_data.get("content", {})
if not isinstance(content, dict): if not isinstance(content, dict):
return [] return []
items = content.get("items", []) items = content.get("items", [])
bulletStyleDef = styles.get("bullet_list", {}) bulletStyleDef = styles.get("bullet_list", {})
normalStyle = self._createNormalStyle(styles) indent = bulletStyleDef.get("indent", 18)
bulletStyle = ParagraphStyle(
"BulletItem",
fontSize=bulletStyleDef.get("font_size", 11),
textColor=self._hexToColor(bulletStyleDef.get("color", "#333333")),
leftIndent=indent,
firstLineIndent=-indent,
spaceAfter=2,
leading=bulletStyleDef.get("font_size", 11) * 1.25,
)
bulletChar = bulletStyleDef.get("bullet_char", "\u2022")
elements = [] elements = []
for item in items: for item in items:
runs = self._inlineRunsForListItem(item) runs = self._inlineRunsForListItem(item)
if isinstance(item, list): if isinstance(item, list):
xml = self._renderInlineRunsToPdfXml(runs) xml = self._renderInlineRunsToPdfXml(runs)
elements.append(Paragraph(f"\u2022 {_wrapEmojiSpansInXml(xml)}", normalStyle)) elements.append(Paragraph(f"{bulletChar} {_wrapEmojiSpansInXml(xml)}", bulletStyle))
elif isinstance(item, str): elif isinstance(item, str):
elements.append(Paragraph(f"\u2022 {self._markdownInlineToReportlabXml(item)}", normalStyle)) elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item)}", bulletStyle))
elif isinstance(item, dict) and "text" in item: elif isinstance(item, dict) and "text" in item:
elements.append(Paragraph(f"\u2022 {self._markdownInlineToReportlabXml(item['text'])}", normalStyle)) elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item['text'])}", bulletStyle))
if elements: if elements:
elements.append(Spacer(1, bulletStyleDef.get("space_after", 3))) elements.append(Spacer(1, bulletStyleDef.get("space_after", 3)))

View file

@ -17,10 +17,10 @@ DEFAULT_STYLE: Dict[str, Any] = {
"background": "#FFFFFF", "background": "#FFFFFF",
}, },
"headings": { "headings": {
"h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 12, "spaceAfterPt": 6}, "h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 24, "spaceAfterPt": 8},
"h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 10, "spaceAfterPt": 4}, "h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6},
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 8, "spaceAfterPt": 3}, "h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4},
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 6, "spaceAfterPt": 2}, "h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3},
}, },
"paragraph": {"sizePt": 11, "lineSpacing": 1.15, "color": "#333333"}, "paragraph": {"sizePt": 11, "lineSpacing": 1.15, "color": "#333333"},
"table": { "table": {

View file

@ -307,16 +307,35 @@ class ActionNodeExecutor:
resolvedParams.pop("subject", None) resolvedParams.pop("subject", None)
resolvedParams.pop("body", None) resolvedParams.pop("body", None)
# 8. Execute action # 8. Create progress parent so nested actions have a hierarchy
import time as _time
nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(_time.time())}"
chatService = getattr(self.services, "chat", None)
if chatService:
try:
chatService.progressLogStart(nodeOperationId, methodName.capitalize(), actionName, f"Node {nodeId}")
except Exception:
pass
resolvedParams["parentOperationId"] = nodeOperationId
# 9. Execute action
logger.info("ActionNodeExecutor node %s calling %s.%s with %d params", nodeId, methodName, actionName, len(resolvedParams)) logger.info("ActionNodeExecutor node %s calling %s.%s with %d params", nodeId, methodName, actionName, len(resolvedParams))
actionSuccess = False
try: try:
executor = ActionExecutor(self.services) executor = ActionExecutor(self.services)
result = await executor.executeAction(methodName, actionName, resolvedParams) result = await executor.executeAction(methodName, actionName, resolvedParams)
actionSuccess = True
except (_SubscriptionInactiveException, _BillingContextError): except (_SubscriptionInactiveException, _BillingContextError):
raise raise
except Exception as e: except Exception as e:
logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e) logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e)
return _normalizeError(e, outputSchema) return _normalizeError(e, outputSchema)
finally:
if chatService:
try:
chatService.progressLogFinish(nodeOperationId, actionSuccess)
except Exception:
pass
# 9. Persist generated documents as files and build JSON-safe output # 9. Persist generated documents as files and build JSON-safe output
docsList = [] docsList = []

View file

@ -93,7 +93,11 @@ def _resolve_file_refs_to_content_parts(services, fileIdRefs) -> List[ContentPar
fileId = ref.documentId fileId = ref.documentId
fileMeta = mgmt.getFile(fileId) fileMeta = mgmt.getFile(fileId)
if not fileMeta: if not fileMeta:
logger.warning(f"_resolve_file_refs_to_content_parts: file {fileId} not found") logger.warning("_resolve_file_refs_to_content_parts: file %s not found "
"(lookup scope: mandate=%s, featureInstanceId=%s, userId=%s)",
fileId, getattr(mgmt, "mandateId", "?"),
getattr(mgmt, "featureInstanceId", "?"),
getattr(mgmt, "userId", "?"))
continue continue
fileData = mgmt.getFileData(fileId) fileData = mgmt.getFileData(fileId)
if not fileData: if not fileData: