diff --git a/modules/features/commcoach/datamodelCommcoach.py b/modules/features/commcoach/datamodelCommcoach.py index afc14df5..250d4799 100644 --- a/modules/features/commcoach/datamodelCommcoach.py +++ b/modules/features/commcoach/datamodelCommcoach.py @@ -2,7 +2,7 @@ # All rights reserved. """ 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 pydantic import BaseModel, Field @@ -16,22 +16,18 @@ import uuid # Enums # ============================================================================ -class CoachingContextStatus(str, Enum): +class TrainingModuleStatus(str, Enum): ACTIVE = "active" PAUSED = "paused" ARCHIVED = "archived" COMPLETED = "completed" -class CoachingContextCategory(str, Enum): - LEADERSHIP = "leadership" - CONFLICT = "conflict" - NEGOTIATION = "negotiation" - PRESENTATION = "presentation" - FEEDBACK = "feedback" - DELEGATION = "delegation" - CHANGE_MANAGEMENT = "changeManagement" - CUSTOM = "custom" +class TrainingModuleType(str, Enum): + COACHING = "coaching" + TRAINING = "training" + EXAM = "exam" + ELEARNING = "elearning" class CoachingSessionStatus(str, Enum): @@ -75,19 +71,21 @@ class CoachingScoreTrend(str, Enum): # Database Models # ============================================================================ -class CoachingContext(PowerOnModel): - """A coaching context/dossier representing a topic the user is working on.""" +class TrainingModule(PowerOnModel): + """A training module representing a topic the user is working on.""" id: str = Field(default_factory=lambda: str(uuid.uuid4())) userId: str = Field(description="Owner user ID (strict ownership)") mandateId: str = Field(description="Mandate 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") - category: CoachingContextCategory = Field(default=CoachingContextCategory.CUSTOM) - status: CoachingContextStatus = Field(default=CoachingContextStatus.ACTIVE) - goals: Optional[str] = Field(default=None, description="JSON array of goals [{id, text, status, createdAt}]") + moduleType: TrainingModuleType = Field(default=TrainingModuleType.COACHING) + status: TrainingModuleStatus = Field(default=TrainingModuleStatus.ACTIVE) + 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}]") 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) taskCount: int = Field(default=0) lastSessionAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"}) @@ -96,9 +94,9 @@ class CoachingContext(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())) - contextId: str = Field(description="FK to CoachingContext") + moduleId: str = Field(description="FK to TrainingModule") userId: str = Field(description="Owner user ID") mandateId: str = Field(description="Mandate ID") instanceId: str = Field(description="Feature instance ID") @@ -121,7 +119,7 @@ class CoachingMessage(PowerOnModel): """A single message in a coaching session.""" id: str = Field(default_factory=lambda: str(uuid.uuid4())) 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") role: CoachingMessageRole = Field(description="Message author role") content: str = Field(description="Message content (Markdown)") @@ -131,9 +129,9 @@ class CoachingMessage(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())) - 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") userId: str = Field(description="Owner user ID") mandateId: str = Field(description="Mandate ID") @@ -148,7 +146,7 @@ class CoachingTask(PowerOnModel): class CoachingScore(PowerOnModel): """A competence score for a dimension, recorded after a session.""" 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") userId: str = Field(description="Owner user ID") mandateId: str = Field(description="Mandate ID") @@ -193,6 +191,22 @@ class CoachingPersona(PowerOnModel): 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 # ============================================================================ @@ -211,18 +225,22 @@ class CoachingBadge(PowerOnModel): # API Request/Response Models # ============================================================================ -class CreateContextRequest(BaseModel): - title: str = Field(description="Context title") +class CreateModuleRequest(BaseModel): + title: str = Field(description="Module title") description: Optional[str] = None - category: Optional[CoachingContextCategory] = CoachingContextCategory.CUSTOM - goals: Optional[List[str]] = None + moduleType: Optional[TrainingModuleType] = TrainingModuleType.COACHING + goals: Optional[str] = None + personaId: Optional[str] = None + kpiTargets: Optional[str] = None -class UpdateContextRequest(BaseModel): +class UpdateModuleRequest(BaseModel): title: Optional[str] = None description: Optional[str] = None - category: Optional[CoachingContextCategory] = None + moduleType: Optional[TrainingModuleType] = None goals: Optional[str] = None + personaId: Optional[str] = None + kpiTargets: Optional[str] = None class SendMessageRequest(BaseModel): @@ -279,8 +297,8 @@ class UpdatePersonaRequest(BaseModel): class DashboardData(BaseModel): """Aggregated dashboard data for the user.""" - totalContexts: int = 0 - activeContexts: int = 0 + totalModules: int = 0 + activeModules: int = 0 totalSessions: int = 0 totalMinutes: int = 0 streakDays: int = 0 @@ -289,4 +307,4 @@ class DashboardData(BaseModel): recentScores: List[Dict[str, Any]] = Field(default_factory=list) openTasks: int = 0 completedTasks: int = 0 - contexts: List[Dict[str, Any]] = Field(default_factory=list) + modules: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py index e4485591..a6fd41ec 100644 --- a/modules/features/commcoach/interfaceFeatureCommcoach.py +++ b/modules/features/commcoach/interfaceFeatureCommcoach.py @@ -17,7 +17,7 @@ from modules.shared.configuration import APP_CONFIG from modules.shared.i18nRegistry import resolveText, t from .datamodelCommcoach import ( - CoachingContext, CoachingContextStatus, + TrainingModule, TrainingModuleStatus, CoachingSession, CoachingSessionStatus, CoachingMessage, 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]]: - """Get all coaching contexts for a user. Strict ownership.""" + def getModules(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]: + """Get all training modules for a user. Enriches with live sessionCount from sessions table.""" records = self.db.getRecordset( - CoachingContext, + TrainingModule, recordFilter={"instanceId": instanceId, "userId": userId}, ) 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) return records - def getContext(self, contextId: str) -> Optional[Dict[str, Any]]: - records = self.db.getRecordset(CoachingContext, recordFilter={"id": contextId}) + def getModule(self, moduleId: str) -> Optional[Dict[str, Any]]: + records = self.db.getRecordset(TrainingModule, recordFilter={"id": moduleId}) 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["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() - return self.db.recordModify(CoachingContext, contextId, updates) + return self.db.recordModify(TrainingModule, moduleId, updates) - def deleteContext(self, contextId: str) -> bool: - self._deleteSessionsByContext(contextId) - self._deleteTasksByContext(contextId) - self._deleteScoresByContext(contextId) - return self.db.recordDelete(CoachingContext, contextId) + def deleteModule(self, moduleId: str) -> bool: + self._deleteSessionsByModule(moduleId) + self._deleteTasksByModule(moduleId) + self._deleteScoresByModule(moduleId) + return self.db.recordDelete(TrainingModule, moduleId) # ========================================================================= # 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( CoachingSession, - recordFilter={"contextId": contextId, "userId": userId}, + recordFilter={"moduleId": moduleId, "userId": userId}, ) records.sort(key=lambda r: r.get("startedAt") or 0, reverse=True) return records @@ -119,10 +132,10 @@ class CommcoachObjects: records = self.db.getRecordset(CoachingSession, recordFilter={"id": sessionId}) 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( 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 @@ -136,8 +149,8 @@ class CommcoachObjects: updates["updatedAt"] = getIsoTimestamp() return self.db.recordModify(CoachingSession, sessionId, updates) - def _deleteSessionsByContext(self, contextId: str) -> int: - records = self.db.getRecordset(CoachingSession, recordFilter={"contextId": contextId}) + def _deleteSessionsByModule(self, moduleId: str) -> int: + records = self.db.getRecordset(CoachingSession, recordFilter={"moduleId": moduleId}) count = 0 for record in records: self._deleteMessagesBySession(record.get("id")) @@ -174,10 +187,10 @@ class CommcoachObjects: # 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( CoachingTask, - recordFilter={"contextId": contextId, "userId": userId}, + recordFilter={"moduleId": moduleId, "userId": userId}, ) records.sort(key=lambda r: r.get("createdAt") or "", reverse=True) return records @@ -198,8 +211,8 @@ class CommcoachObjects: def deleteTask(self, taskId: str) -> bool: return self.db.recordDelete(CoachingTask, taskId) - def _deleteTasksByContext(self, contextId: str) -> int: - records = self.db.getRecordset(CoachingTask, recordFilter={"contextId": contextId}) + def _deleteTasksByModule(self, moduleId: str) -> int: + records = self.db.getRecordset(CoachingTask, recordFilter={"moduleId": moduleId}) count = 0 for record in records: self.db.recordDelete(CoachingTask, record.get("id")) @@ -218,10 +231,10 @@ class CommcoachObjects: # 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( CoachingScore, - recordFilter={"contextId": contextId, "userId": userId}, + recordFilter={"moduleId": moduleId, "userId": userId}, ) records.sort(key=lambda r: r.get("createdAt") or "") return records @@ -235,8 +248,8 @@ class CommcoachObjects: data["createdAt"] = getIsoTimestamp() return self.db.recordCreate(CoachingScore, data) - def _deleteScoresByContext(self, contextId: str) -> int: - records = self.db.getRecordset(CoachingScore, recordFilter={"contextId": contextId}) + def _deleteScoresByModule(self, moduleId: str) -> int: + records = self.db.getRecordset(CoachingScore, recordFilter={"moduleId": moduleId}) count = 0 for record in records: self.db.recordDelete(CoachingScore, record.get("id")) @@ -274,6 +287,39 @@ class CommcoachObjects: from .datamodelCommcoach import CoachingPersona 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 # ========================================================================= @@ -299,8 +345,8 @@ class CommcoachObjects: # Score History # ========================================================================= - def getScoreHistory(self, contextId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]: - scores = self.getScores(contextId, userId) + def getScoreHistory(self, moduleId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]: + scores = self.getScores(moduleId, userId) history: Dict[str, List[Dict[str, Any]]] = {} for s in scores: dim = s.get("dimension", "unknown") @@ -344,16 +390,15 @@ class CommcoachObjects: # ========================================================================= 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}) profile = self.getProfile(userId, instanceId) - activeContexts = [c for c in contexts if c.get("status") == CoachingContextStatus.ACTIVE.value] - completedSessions = [s for s in sessions if s.get("status") == CoachingSessionStatus.COMPLETED.value] + activeModules = [m for m in modules if m.get("status") == TrainingModuleStatus.ACTIVE.value] - totalMinutes = sum(s.get("durationSeconds", 0) for s in completedSessions) // 60 + totalMinutes = sum(s.get("durationSeconds", 0) for s in sessions) // 60 scores = [] - for s in completedSessions: + for s in sessions: raw = s.get("competenceScore") if raw is not None: try: @@ -364,29 +409,27 @@ class CommcoachObjects: recentScores = self.getRecentScores(userId, limit=10) - contextSummaries = [] - for ctx in activeContexts: - goalProgress = _calcGoalProgress(ctx.get("goals")) - contextSummaries.append({ - "id": ctx.get("id"), - "title": ctx.get("title"), - "category": ctx.get("category"), - "sessionCount": ctx.get("sessionCount", 0), - "lastSessionAt": ctx.get("lastSessionAt"), - "goalProgress": goalProgress, + countByModule: Dict[str, int] = {} + for s in sessions: + mid = s.get("moduleId") + if mid: + countByModule[mid] = countByModule.get(mid, 0) + 1 + + moduleSummaries = [] + for mod in activeModules: + modId = mod.get("id", "") + 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 { - "totalContexts": len(contexts), - "activeContexts": len(activeContexts), - "totalSessions": len(completedSessions), + "totalModules": len(modules), + "activeModules": len(activeModules), + "totalSessions": len(sessions), "totalMinutes": totalMinutes, "streakDays": profile.get("streakDays", 0) if profile else 0, "longestStreak": profile.get("longestStreak", 0) if profile else 0, @@ -394,29 +437,12 @@ class CommcoachObjects: "recentScores": recentScores, "openTasks": self.getOpenTaskCount(userId, instanceId), "completedTasks": self.getCompletedTaskCount(userId, instanceId), - "contexts": contextSummaries, - "goalProgress": overallGoalProgress, + "modules": moduleSummaries, "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 = [ (50, 5, "master", "Meister"), (25, 4, "expert", "Experte"), diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index 33469a62..6beede11 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -23,9 +23,24 @@ UI_OBJECTS = [ "meta": {"area": "dashboard"} }, { - "objectKey": "ui.feature.commcoach.coaching", - "label": t("Arbeitsthemen", context="UI"), - "meta": {"area": "coaching"} + "objectKey": "ui.feature.commcoach.assistant", + "label": t("Assistent", context="UI"), + "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", @@ -35,15 +50,15 @@ UI_OBJECTS = [ ] DATA_OBJECTS = [ - # ── Record-Hierarchie: Context → Session → Message/Score, Context → Task ── + # ── Record-Hierarchie: TrainingModule → Session → Message/Score, TrainingModule → Task ── { - "objectKey": "data.feature.commcoach.CoachingContext", - "label": t("Coaching-Kontext", context="UI"), + "objectKey": "data.feature.commcoach.TrainingModule", + "label": t("Trainings-Modul", context="UI"), "meta": { - "table": "CoachingContext", - "fields": ["id", "title", "category", "status", "lastSessionAt"], + "table": "TrainingModule", + "fields": ["id", "title", "moduleType", "status", "lastSessionAt"], "isParent": True, - "displayFields": ["title", "category", "status"], + "displayFields": ["title", "moduleType", "status"], } }, { @@ -51,10 +66,10 @@ DATA_OBJECTS = [ "label": t("Coaching-Session", context="UI"), "meta": { "table": "CoachingSession", - "fields": ["id", "contextId", "status", "summary", "startedAt", "endedAt", "competenceScore"], + "fields": ["id", "moduleId", "status", "summary", "startedAt", "endedAt", "competenceScore"], "isParent": True, - "parentTable": "CoachingContext", - "parentKey": "contextId", + "parentTable": "TrainingModule", + "parentKey": "moduleId", "displayFields": ["startedAt", "status"], } }, @@ -63,7 +78,7 @@ DATA_OBJECTS = [ "label": t("Coaching-Nachricht", context="UI"), "meta": { "table": "CoachingMessage", - "fields": ["id", "sessionId", "contextId", "role", "content", "contentType"], + "fields": ["id", "sessionId", "moduleId", "role", "content", "contentType"], "parentTable": "CoachingSession", "parentKey": "sessionId", } @@ -73,7 +88,7 @@ DATA_OBJECTS = [ "label": t("Coaching-Score", context="UI"), "meta": { "table": "CoachingScore", - "fields": ["id", "sessionId", "contextId", "dimension", "score", "trend"], + "fields": ["id", "sessionId", "moduleId", "dimension", "score", "trend"], "parentTable": "CoachingSession", "parentKey": "sessionId", } @@ -83,9 +98,9 @@ DATA_OBJECTS = [ "label": t("Coaching-Aufgabe", context="UI"), "meta": { "table": "CoachingTask", - "fields": ["id", "contextId", "title", "status", "priority", "dueDate"], - "parentTable": "CoachingContext", - "parentKey": "contextId", + "fields": ["id", "moduleId", "title", "status", "priority", "dueDate"], + "parentTable": "TrainingModule", + "parentKey": "moduleId", } }, # ── Stammdaten (sessionübergreifend, scoped per userId) ────────────────── @@ -112,6 +127,15 @@ DATA_OBJECTS = [ "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", "label": t("Coaching-Auszeichnung", context="UI"), @@ -130,19 +154,19 @@ DATA_OBJECTS = [ RESOURCE_OBJECTS = [ { - "objectKey": "resource.feature.commcoach.context.create", - "label": t("Kontext erstellen", context="UI"), - "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"} + "objectKey": "resource.feature.commcoach.module.create", + "label": t("Modul erstellen", context="UI"), + "meta": {"endpoint": "/api/commcoach/{instanceId}/modules", "method": "POST"} }, { - "objectKey": "resource.feature.commcoach.context.archive", - "label": t("Kontext archivieren", context="UI"), - "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"} + "objectKey": "resource.feature.commcoach.module.archive", + "label": t("Modul archivieren", context="UI"), + "meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/archive", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.session.start", "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", @@ -152,7 +176,17 @@ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.commcoach.task.manage", "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)", "accessRules": [ {"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": "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}, ], }, { "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": [ {"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": "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.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.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": "RESOURCE", "item": "resource.feature.commcoach.context.create", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.commcoach.context.archive", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.commcoach.module.create", "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.complete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True}, @@ -252,6 +291,7 @@ def registerFeature(catalogService) -> bool: meta=dataObj.get("meta") ) + _runMigrations() _syncTemplateRolesToDb() _seedBuiltinPersonas() _registerScheduler() @@ -264,6 +304,135 @@ def registerFeature(catalogService) -> bool: 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(): """Seed builtin roleplay personas into the database.""" try: diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index c308684a..45075ae9 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -2,7 +2,7 @@ # All rights reserved. """ 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 @@ -23,14 +23,14 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface from . import interfaceFeatureCommcoach as interfaceDb from .datamodelCommcoach import ( - CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus, + TrainingModule, TrainingModuleStatus, CoachingSession, CoachingSessionStatus, CoachingMessage, CoachingMessageRole, CoachingMessageContentType, CoachingTask, CoachingTaskStatus, - CoachingPersona, CoachingBadge, - CreateContextRequest, UpdateContextRequest, + CoachingPersona, CoachingBadge, ModulePersonaMapping, + CreateModuleRequest, UpdateModuleRequest, SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest, UpdateProfileRequest, - StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, + StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, SetModulePersonasRequest, ) from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents 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") -async def listContexts( +async def listModules( request: Request, instanceId: str, includeArchived: bool = False, context: RequestContext = Depends(getRequestContext), ): - """List all coaching contexts for the current user.""" + """List all training modules for the current user.""" mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - contexts = interface.getContexts(instanceId, userId, includeArchived=includeArchived) - return {"contexts": contexts} + modules = interface.getModules(instanceId, userId, includeArchived=includeArchived) + return {"modules": modules} -@router.post("/{instanceId}/contexts") +@router.post("/{instanceId}/modules") @limiter.limit("20/minute") -async def createContext( +async def createModule( request: Request, instanceId: str, - body: CreateContextRequest, + body: CreateModuleRequest, context: RequestContext = Depends(getRequestContext), ): - """Create a new coaching context/dossier.""" + """Create a new training module.""" mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - goalsJson = None - if body.goals: - import uuid as _uuid - goalsList = [{"id": str(_uuid.uuid4()), "text": g, "status": "open", "createdAt": ""} for g in body.goals] - goalsJson = json.dumps(goalsList) - - contextData = CoachingContext( + moduleData = TrainingModule( userId=userId, mandateId=mandateId, instanceId=instanceId, title=body.title, description=body.description, - category=body.category, - goals=goalsJson, + moduleType=body.moduleType, + goals=body.goals, + personaId=body.personaId, + kpiTargets=body.kpiTargets, ).model_dump() - created = interface.createContext(contextData) - logger.info(f"CommCoach context created: {created.get('id')} for user {userId}") - _audit(context, "commcoach.context.created", "CoachingContext", created.get("id"), f"Title: {body.title}") - return {"context": created} + created = interface.createModule(moduleData) + logger.info(f"CommCoach module created: {created.get('id')} for user {userId}") + _audit(context, "commcoach.module.created", "TrainingModule", created.get("id"), f"Title: {body.title}") + return {"module": created} -@router.get("/{instanceId}/contexts/{contextId}") +@router.get("/{instanceId}/modules/{moduleId}") @limiter.limit("60/minute") -async def getContext( +async def getModuleDetail( request: Request, instanceId: str, - contextId: str, + moduleId: str, 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) interface = _getInterface(context, instanceId) userId = str(context.user.id) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - tasks = interface.getTasks(contextId, userId) - scores = interface.getScores(contextId, userId) - sessions = interface.getSessions(contextId, userId) + tasks = interface.getTasks(moduleId, userId) + scores = interface.getScores(moduleId, userId) + sessions = interface.getSessions(moduleId, userId) return { - "context": ctx, + "module": mod, "tasks": tasks, "scores": scores, "sessions": sessions, } -@router.put("/{instanceId}/contexts/{contextId}") +@router.put("/{instanceId}/modules/{moduleId}") @limiter.limit("30/minute") -async def updateContext( +async def updateModuleFields( request: Request, instanceId: str, - contextId: str, - body: UpdateContextRequest, + moduleId: str, + body: UpdateModuleRequest, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) updates = body.model_dump(exclude_none=True) - updated = interface.updateContext(contextId, updates) - return {"context": updated} + updated = interface.updateModule(moduleId, updates) + return {"module": updated} -@router.delete("/{instanceId}/contexts/{contextId}") +@router.delete("/{instanceId}/modules/{moduleId}") @limiter.limit("10/minute") -async def deleteContext( +async def deleteModuleAndData( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - interface.deleteContext(contextId) + interface.deleteModule(moduleId) return {"deleted": True} -@router.post("/{instanceId}/contexts/{contextId}/archive") +@router.post("/{instanceId}/modules/{moduleId}/archive") @limiter.limit("10/minute") -async def archiveContext( +async def archiveModule( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value}) - _audit(context, "commcoach.context.archived", "CoachingContext", contextId) - return {"context": updated} + updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ARCHIVED.value}) + _audit(context, "commcoach.module.archived", "TrainingModule", moduleId) + return {"module": updated} -@router.post("/{instanceId}/contexts/{contextId}/activate") +@router.post("/{instanceId}/modules/{moduleId}/activate") @limiter.limit("10/minute") -async def activateContext( +async def activateModule( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value}) - return {"context": updated} + updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ACTIVE.value}) + return {"module": updated} # ========================================================================= # Session Endpoints # ========================================================================= -@router.get("/{instanceId}/contexts/{contextId}/sessions") +@router.get("/{instanceId}/modules/{moduleId}/sessions") @limiter.limit("60/minute") async def listSessions( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - sessions = interface.getSessions(contextId, userId) + sessions = interface.getSessions(moduleId, userId) return {"sessions": sessions} -@router.post("/{instanceId}/contexts/{contextId}/sessions/start") +@router.post("/{instanceId}/modules/{moduleId}/sessions/start") @limiter.limit("10/minute") async def startSession( request: Request, instanceId: str, - contextId: str, + moduleId: str, personaId: Optional[str] = None, context: RequestContext = Depends(getRequestContext), ): @@ -297,22 +293,22 @@ async def startSession( interface = _getInterface(context, instanceId) userId = str(context.user.id) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - activeSession = interface.getActiveSession(contextId, userId) + activeSession = interface.getActiveSession(moduleId, userId) if activeSession: sessionId = activeSession.get("id") messages = interface.getMessages(sessionId) async def _resumedEventGenerator(): service = CommcoachService(context.user, mandateId, instanceId) - greetingText = await service.generateResumeGreeting(sessionId, contextId, messages, interface) + greetingText = await service.generateResumeGreeting(sessionId, moduleId, messages, interface) assistantMsg = CoachingMessage( sessionId=sessionId, - contextId=contextId, + moduleId=moduleId, userId=userId, role=CoachingMessageRole.ASSISTANT, content=greetingText, @@ -323,7 +319,7 @@ async def startSession( greetingForFrontend = { "id": createdGreeting.get("id"), "sessionId": sessionId, - "contextId": contextId, + "moduleId": moduleId, "role": "assistant", "content": greetingText, "contentType": "text", @@ -365,7 +361,7 @@ async def startSession( ) sessionData = CoachingSession( - contextId=contextId, + moduleId=moduleId, userId=userId, mandateId=mandateId, instanceId=instanceId, @@ -378,7 +374,7 @@ async def startSession( await emitSessionEvent(sessionId, "sessionState", {"session": created, "resumed": False}) 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(): from modules.shared.timeUtils import getIsoTimestamp @@ -399,8 +395,8 @@ async def startSession( except asyncio.CancelledError: pass - logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}") - _audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Context: {contextId}") + logger.info(f"CommCoach session started (streaming): {sessionId} for module {moduleId}") + _audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Module: {moduleId}") return StreamingResponse( _newSessionEventGenerator(), media_type="text/event-stream", @@ -504,7 +500,7 @@ async def sendMessageStream( if session.get("status") != CoachingSessionStatus.ACTIVE.value: 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) existingTask = _activeProcessTasks.get(sessionId) @@ -517,7 +513,7 @@ async def sendMessageStream( task = asyncio.create_task( service.processMessage( - sessionId, contextId, body.content, interface, + sessionId, moduleId, body.content, interface, fileIds=body.fileIds, dataSourceIds=body.dataSourceIds, featureDataSourceIds=body.featureDataSourceIds, @@ -587,11 +583,11 @@ async def sendAudioStream( from .serviceCommcoach import getUserVoicePrefs language, _ = getUserVoicePrefs(str(context.user.id), mandateId) - contextId = session.get("contextId") + moduleId = session.get("moduleId") service = CommcoachService(context.user, mandateId, instanceId) asyncio.create_task( - service.processAudioMessage(sessionId, contextId, audioBody, language, interface) + service.processAudioMessage(sessionId, moduleId, audioBody, language, interface) ) async def _eventGenerator(): @@ -680,27 +676,27 @@ async def streamSession( # Task Endpoints # ========================================================================= -@router.get("/{instanceId}/contexts/{contextId}/tasks") +@router.get("/{instanceId}/modules/{moduleId}/tasks") @limiter.limit("60/minute") async def listTasks( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - tasks = interface.getTasks(contextId, userId) + tasks = interface.getTasks(moduleId, userId) return {"tasks": tasks} -@router.post("/{instanceId}/contexts/{contextId}/tasks") +@router.post("/{instanceId}/modules/{moduleId}/tasks") @limiter.limit("30/minute") async def createTask( request: Request, instanceId: str, - contextId: str, + moduleId: str, body: CreateTaskRequest, context: RequestContext = Depends(getRequestContext), ): @@ -708,13 +704,13 @@ async def createTask( interface = _getInterface(context, instanceId) userId = str(context.user.id) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) taskData = CoachingTask( - contextId=contextId, + moduleId=moduleId, userId=userId, mandateId=mandateId, title=body.title, @@ -853,12 +849,12 @@ async def updateProfile( # Export Endpoints (Iteration 2) # ========================================================================= -@router.get("/{instanceId}/contexts/{contextId}/export") +@router.get("/{instanceId}/modules/{moduleId}/export") @limiter.limit("10/minute") async def exportDossier( request: Request, instanceId: str, - contextId: str, + moduleId: str, format: str = "md", context: RequestContext = Depends(getRequestContext), ): @@ -867,26 +863,26 @@ async def exportDossier( interface = _getInterface(context, instanceId) userId = str(context.user.id) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - tasks = interface.getTasks(contextId, userId) - scores = interface.getScores(contextId, userId) - sessions = interface.getSessions(contextId, userId) + tasks = interface.getTasks(moduleId, userId) + scores = interface.getScores(moduleId, userId) + sessions = interface.getSessions(moduleId, userId) 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": - pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores) + pdfBytes = await renderDossierPdf(mod, sessions, tasks, scores) 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", - 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") @@ -907,11 +903,11 @@ async def exportSession( raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) _validateOwnership(session, context) - contextId = session.get("contextId") + moduleId = session.get("moduleId") userId = str(context.user.id) messages = interface.getMessages(sessionId) - tasks = interface.getTasks(contextId, userId) if contextId else [] - scores = interface.getScores(contextId, userId) if contextId else [] + tasks = interface.getTasks(moduleId, userId) if moduleId else [] + scores = interface.getScores(moduleId, userId) if moduleId else [] from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf _audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}") @@ -935,13 +931,47 @@ async def exportSession( async def listPersonas( request: Request, 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), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - userId = str(context.user.id) - personas = interface.getPersonas(userId, instanceId) - return {"personas": personas} + allPersonas = interface.getAllPersonas(instanceId) + + 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") @@ -1017,6 +1047,43 @@ async def deletePersonaRoute( 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) # ========================================================================= @@ -1035,16 +1102,46 @@ async def listBadges( return {"badges": badges} -@router.get("/{instanceId}/contexts/{contextId}/scores/history") +@router.get("/{instanceId}/modules/{moduleId}/scores/history") @limiter.limit("60/minute") async def getScoreHistory( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - history = interface.getScoreHistory(contextId, userId) + history = interface.getScoreHistory(moduleId, userId) 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}"}) diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index 4ebe84ff..821fb291 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -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})") 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 = "" try: from modules.datamodels.datamodelKnowledge import FileContentIndex - idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"fileId": fId}) + idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"id": fId}) if idxRecords: idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump() content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS] - except Exception: - pass + except Exception as e: + logger.warning(f"Failed to load FileContentIndex for {fId}: {e}") results.append({ "id": fId, "title": f.get("fileName") or f.get("name") or "Dokument", @@ -557,13 +557,13 @@ def _getDocumentSummaries(contextId: str, userId: str, interface, try: from modules.datamodels.datamodelKnowledge import FileContentIndex idxRecords = mgmtIf.db.getRecordset( - FileContentIndex, recordFilter={"fileId": fId} + FileContentIndex, recordFilter={"id": fId} ) if idxRecords: idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump() snippet = (idx.get("extractedText") or "")[:200] - except Exception: - pass + except Exception as e: + logger.warning(f"Failed to load FileContentIndex for {fId}: {e}") if snippet: summaries.append(f"[{name}] {snippet}...") else: @@ -748,7 +748,7 @@ class CommcoachService: # Store user message userMsg = CoachingMessage( sessionId=sessionId, - contextId=contextId, + moduleId=contextId, userId=self.userId, role=CoachingMessageRole.USER, content=userContent, @@ -764,7 +764,7 @@ class CommcoachService: }) # Build context - context = interface.getContext(contextId) + context = interface.getModule(contextId) if not context: logger.error(f"Context {contextId} not found") return createdUserMsg @@ -857,7 +857,7 @@ class CommcoachService: assistantMsg = CoachingMessage( sessionId=sessionId, - contextId=contextId, + moduleId=contextId, userId=self.userId, role=CoachingMessageRole.ASSISTANT, content=textContent, @@ -946,6 +946,8 @@ class CommcoachService: await emitSessionEvent(sessionId, "toolResult", event.data or {}) elif event.type == AgentEventTypeEnum.AGENT_PROGRESS: 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: 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..."}) - context = interface.getContext(contextId) + context = interface.getModule(contextId) if not context: logger.error(f"Context {contextId} not found") await emitSessionEvent(sessionId, "error", {"message": "Context not found"}) @@ -1024,7 +1026,7 @@ class CommcoachService: assistantMsg = CoachingMessage( sessionId=sessionId, - contextId=contextId, + moduleId=contextId, userId=self.userId, role=CoachingMessageRole.ASSISTANT, content=textContent, @@ -1046,7 +1048,7 @@ class CommcoachService: async def generateResumeGreeting(self, sessionId: str, contextId: str, messages: list, interface) -> str: """Generate a follow-up greeting when user returns to an active session.""" - context = interface.getContext(contextId) + context = interface.getModule(contextId) if not context: raise ValueError(f"Context {contextId} not found for resume greeting") contextTitle = context.get("title", "Coaching") @@ -1100,8 +1102,10 @@ class CommcoachService: if not session: return {} - contextId = session.get("contextId") - context = interface.getContext(contextId) if contextId else None + contextId = session.get("moduleId") + if not contextId: + logger.error(f"completeSession: session {sessionId} has no moduleId") + context = interface.getModule(contextId) if contextId else None messages = interface.getMessages(sessionId) if len(messages) < 2: @@ -1156,7 +1160,7 @@ class CommcoachService: for taskData in extractedTasks[:3]: if isinstance(taskData, dict) and taskData.get("title"): newTask = CoachingTask( - contextId=contextId, + moduleId=contextId, sessionId=sessionId, userId=self.userId, mandateId=self.mandateId, @@ -1181,7 +1185,7 @@ class CommcoachService: for scoreData in scores: if isinstance(scoreData, dict) and "dimension" in scoreData and "score" in scoreData: newScore = CoachingScore( - contextId=contextId, + moduleId=contextId, sessionId=sessionId, userId=self.userId, mandateId=self.mandateId, @@ -1213,7 +1217,7 @@ class CommcoachService: existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()}) await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId}) if contextId and existingInsights: - interface.updateContext(contextId, {"insights": json.dumps(existingInsights[-10:])}) + interface.updateModule(contextId, {"insights": json.dumps(existingInsights[-10:])}) except Exception as e: logger.warning(f"Insight generation failed: {e}") @@ -1280,7 +1284,7 @@ class CommcoachService: if contextId: allSessions = interface.getSessions(contextId, self.userId) completedCount = len([s for s in allSessions if s.get("status") == CoachingSessionStatus.COMPLETED.value]) - interface.updateContext(contextId, { + interface.updateModule(contextId, { "sessionCount": completedCount, "lastSessionAt": getUtcTimestamp(), }) @@ -1429,7 +1433,7 @@ class CommcoachService: "sessionSummaries": [], } - ctx = interface.getContext(contextId) + ctx = interface.getModule(contextId) rollingOverview = ctx.get("rollingOverview") 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: newOverview = overviewResponse.content.strip() - interface.updateContext(contextId, { + interface.updateModule(contextId, { "rollingOverview": newOverview, "rollingOverviewUpToSessionCount": len(completedSessions), }) diff --git a/modules/features/commcoach/serviceCommcoachGamification.py b/modules/features/commcoach/serviceCommcoachGamification.py index badf9761..331dd9b1 100644 --- a/modules/features/commcoach/serviceCommcoachGamification.py +++ b/modules/features/commcoach/serviceCommcoachGamification.py @@ -143,7 +143,7 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId badgesToCheck.append(("roleplay_first", True)) try: - from .datamodelCommcoach import CoachingContextStatus + from .datamodelCommcoach import TrainingModuleStatus allContexts = interface.db.getRecordset( interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues ) if False else [] diff --git a/modules/features/commcoach/serviceCommcoachPersonas.py b/modules/features/commcoach/serviceCommcoachPersonas.py index f5c8254e..867b51a0 100644 --- a/modules/features/commcoach/serviceCommcoachPersonas.py +++ b/modules/features/commcoach/serviceCommcoachPersonas.py @@ -146,6 +146,57 @@ BUILTIN_PERSONAS: List[Dict[str, Any]] = [ "gender": "m", "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", + }, ] diff --git a/modules/features/commcoach/serviceCommcoachScheduler.py b/modules/features/commcoach/serviceCommcoachScheduler.py index 00bc3b1e..72e253d6 100644 --- a/modules/features/commcoach/serviceCommcoachScheduler.py +++ b/modules/features/commcoach/serviceCommcoachScheduler.py @@ -62,7 +62,7 @@ async def _runDailyReminders(): try: from modules.shared.configuration import APP_CONFIG 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.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName @@ -94,10 +94,10 @@ async def _runDailyReminders(): continue # Check if user has active contexts - from .datamodelCommcoach import CoachingContext - contexts = db.getRecordset(CoachingContext, recordFilter={ + from .datamodelCommcoach import TrainingModule + contexts = db.getRecordset(TrainingModule, recordFilter={ "userId": userId, - "status": CoachingContextStatus.ACTIVE.value, + "status": TrainingModuleStatus.ACTIVE.value, }) if not contexts: continue diff --git a/modules/features/redmine/datamodelRedmine.py b/modules/features/redmine/datamodelRedmine.py index 61555826..e33ee407 100644 --- a/modules/features/redmine/datamodelRedmine.py +++ b/modules/features/redmine/datamodelRedmine.py @@ -223,6 +223,7 @@ class RedmineTicketMirror(PowerOnModel): fixedVersionName: Optional[str] = Field(default=None, json_schema_extra={"label": "Zielversion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) categoryId: Optional[int] = Field(default=None, json_schema_extra={"label": "Kategorie-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False}) categoryName: Optional[str] = Field(default=None, json_schema_extra={"label": "Kategorie", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + doneRatio: Optional[int] = Field(default=None, description="Redmine % done (0-100)", json_schema_extra={"label": "% erledigt", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False}) closedOnTs: Optional[float] = Field( default=None, description="Best-effort UTC epoch when the ticket transitioned to a closed status. Approximated as updatedOnTs for closed tickets at sync time; used by Stats to render the open-vs-total snapshot chart.", @@ -338,6 +339,7 @@ class RedmineTicketDto(BaseModel): fixedVersionName: Optional[str] = None categoryId: Optional[int] = None categoryName: Optional[str] = None + doneRatio: Optional[int] = None createdOn: Optional[str] = None updatedOn: Optional[str] = None customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list) diff --git a/modules/features/redmine/serviceRedmine.py b/modules/features/redmine/serviceRedmine.py index f0cfbfb4..2aea0918 100644 --- a/modules/features/redmine/serviceRedmine.py +++ b/modules/features/redmine/serviceRedmine.py @@ -222,6 +222,7 @@ def _mirroredRowToDto( fixedVersionName=row.get("fixedVersionName"), categoryId=row.get("categoryId"), categoryName=row.get("categoryName"), + doneRatio=row.get("doneRatio"), createdOn=row.get("createdOn"), updatedOn=row.get("updatedOn"), customFields=[ diff --git a/modules/features/redmine/serviceRedmineSync.py b/modules/features/redmine/serviceRedmineSync.py index 2fd269d1..32cd5a09 100644 --- a/modules/features/redmine/serviceRedmineSync.py +++ b/modules/features/redmine/serviceRedmineSync.py @@ -402,6 +402,7 @@ def _ticketRecordFromIssue( "fixedVersionName": fixed_version.get("name"), "categoryId": category.get("id"), "categoryName": category.get("name"), + "doneRatio": issue.get("done_ratio"), "createdOn": created_on, "updatedOn": updated_on, "createdOnTs": _parseRedmineDateToEpoch(created_on), diff --git a/modules/features/teamsbot/datamodelTeamsbot.py b/modules/features/teamsbot/datamodelTeamsbot.py index f7d12fda..8c8d61e7 100644 --- a/modules/features/teamsbot/datamodelTeamsbot.py +++ b/modules/features/teamsbot/datamodelTeamsbot.py @@ -79,15 +79,47 @@ class TeamsbotTransferMode(str, Enum): 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) # ============================================================================ +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): """A Teams Bot meeting session.""" id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID") instanceId: str = Field(description="Feature instance 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") 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") @@ -237,6 +269,27 @@ class TeamsbotSessionResponse(BaseModel): 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): """Request to update teamsbot configuration.""" botName: Optional[str] = None diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py index a7dedd6e..8491b3b9 100644 --- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py +++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py @@ -24,6 +24,7 @@ from .datamodelTeamsbot import ( TeamsbotDirectorPrompt, TeamsbotDirectorPromptStatus, TeamsbotDirectorPromptMode, + TeamsbotMeetingModule, ) logger = logging.getLogger(__name__) @@ -330,6 +331,36 @@ class TeamsbotObjects: count += 1 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 # ========================================================================= @@ -338,14 +369,23 @@ class TeamsbotObjects: """Get aggregated statistics for a session.""" transcripts = self.db.getRecordset(TeamsbotTranscript, 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) 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 { "transcriptSegments": len(transcripts), "botResponses": len(responses), + "directorPrompts": len(prompts), "totalCostCHF": round(totalCost, 4), "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, } diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py index c2c271e0..66bc9247 100644 --- a/modules/features/teamsbot/mainTeamsbot.py +++ b/modules/features/teamsbot/mainTeamsbot.py @@ -24,6 +24,16 @@ UI_OBJECTS = [ "label": t("Dashboard", context="UI"), "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", "label": t("Sitzungen", context="UI"), @@ -38,13 +48,24 @@ UI_OBJECTS = [ # DATA Objects for RBAC catalog (tables/entities) 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", "label": t("Sitzung", context="UI"), "meta": { "table": "TeamsbotSession", "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"], - "isParent": True, + "parentTable": "TeamsbotMeetingModule", + "parentKey": "moduleId", "displayFields": ["botName", "status", "startedAt"], } }, @@ -97,6 +118,16 @@ RESOURCE_OBJECTS = [ "label": t("Konfiguration bearbeiten", context="UI"), "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 @@ -114,6 +145,8 @@ TEMPLATE_ROLES = [ {"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.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)", "accessRules": [ {"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": "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", "accessRules": [ {"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": "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.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": "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.module.create", "view": True}, ], }, ] @@ -198,6 +237,7 @@ def registerFeature(catalogService) -> bool: meta=dataObj.get("meta") ) + _runMigrations() _syncTemplateRolesToDb() 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 +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: """Sync template roles and their AccessRules to the database.""" try: diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index 3368f9fc..ab42db22 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -39,6 +39,9 @@ from .datamodelTeamsbot import ( TeamsbotDirectorPromptCreateRequest, TeamsbotDirectorPromptMode, TeamsbotDirectorPromptStatus, + TeamsbotMeetingModule, + CreateMeetingModuleRequest, + UpdateMeetingModuleRequest, DIRECTOR_PROMPT_FILE_LIMIT, DIRECTOR_PROMPT_TEXT_LIMIT, ) @@ -167,6 +170,100 @@ def _getInstanceConfig(instanceId: str) -> 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 # ========================================================================= @@ -385,8 +482,9 @@ async def streamSession( """Generate SSE events from the session event queue.""" from .service import sessionEvents - # Send initial session state - yield f"data: {json.dumps({'type': 'sessionState', 'data': session})}\n\n" + # Send initial session state with stats + 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 # render the live indicator without waiting for the next connect/disconnect. diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index 1d3939ac..fe0d6c34 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -3409,6 +3409,8 @@ class TeamsbotService: "status": "toolCall", "toolName": toolName, }) + elif event.type == AgentEventTypeEnum.FILE_CREATED: + await _emitSessionEvent(sessionId, "documentCreated", event.data or {}) elif event.type == AgentEventTypeEnum.FINAL: finalText = (event.content or "").strip() elif event.type == AgentEventTypeEnum.ERROR: diff --git a/modules/migration/seedData/ui_language_seed.json b/modules/migration/seedData/ui_language_seed.json index 2d2193c8..0f769074 100644 --- a/modules/migration/seedData/ui_language_seed.json +++ b/modules/migration/seedData/ui_language_seed.json @@ -658,6 +658,11 @@ "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", "value": "" }, + { + "context": "ui", + "key": "Dossiers", + "value": "UDB tab label for chat workflows / cases" + }, { "context": "ui", "key": "Dokument", @@ -4046,6 +4051,11 @@ "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" }, + { + "context": "ui", + "key": "Dossiers", + "value": "Dossiers" + }, { "context": "ui", "key": "Dokument", @@ -7404,6 +7414,11 @@ "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", "value": "This field is managed by {provider} and cannot be changed" }, + { + "context": "ui", + "key": "Dossiers", + "value": "Dossiers" + }, { "context": "ui", "key": "Dokument", @@ -10617,6 +10632,11 @@ "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é" }, + { + "context": "ui", + "key": "Dossiers", + "value": "Dossiers" + }, { "context": "ui", "key": "Dokument", diff --git a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py index 56ba791a..a23688e5 100644 --- a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py +++ b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py @@ -203,16 +203,40 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser args["featureInstanceId"] = context["featureInstanceId"] if "mandateId" not in args and context.get("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) - 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( toolCallId="", toolName=f"{methodName}_{actionName}", success=result.success, data=data, - error=result.error + error=result.error, + sideEvents=sideEvents or None, ) 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}") return ToolResult( toolCallId="", @@ -226,11 +250,12 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser _INLINE_CONTENT_LIMIT = 2000 -def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[str]: - """Save an ActionDocument with large content as a workspace file. +def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Save an ActionDocument as a workspace file. - Returns a formatted result line (with file id + docItem ref) or None - if persistence is not possible. + Handles both str and bytes documentData. + Returns a dict with 'line' (formatted result) and 'fileInfo' (for sideEvents), + or None if persistence is not possible. """ if not services: return None @@ -238,49 +263,77 @@ def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[st if not chatService: return 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 docName = getattr(doc, "documentName", "unnamed") - docBytes = docData.encode("utf-8") + docMime = getattr(doc, "mimeType", "application/octet-stream") try: 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 ( _attachFileAsChatDocument, _formatToolFileResult, _getOrCreateTempFolder, ) + + updateFields = {} tempFolderId = _getOrCreateTempFolder(chatService) 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( services, fileItem, label=f"action_doc:{docName}", userMessage=f"Action document: {docName}", ) - return _formatToolFileResult( + line = _formatToolFileResult( fileItem=fileItem, chatDocId=chatDocId, actionLabel="Produced", 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: - logger.warning(f"_persistLargeDocument failed for {docName}: {e}") + logger.error(f"_persistLargeDocument failed for {docName}: {e}", exc_info=True) 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. - Documents whose content exceeds the inline limit are persisted as - workspace files so the agent can access them via readFile / - ai_process / searchInFileContent. + Documents whose content exceeds the inline limit (or is binary bytes) + are persisted as workspace files. + + Returns (str, list) – the formatted text and a list of sideEvent dicts. """ parts = [] + sideEvents = [] ctx = context or {} if result.resultLabel: @@ -296,13 +349,23 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] docType = getattr(doc, "mimeType", "unknown") docData = getattr(doc, "documentData", None) - isLarge = docData and isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT - if isLarge: - persistedLine = _persistLargeDocument(doc, services, ctx) - if persistedLine: + needsPersist = ( + (isinstance(docData, bytes) and len(docData) > 0) or + (isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT) + ) + if needsPersist: + persisted = _persistLargeDocument(doc, services, ctx) + if persisted: 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 parts.append(f" - {docName} ({docType})") if docData and isinstance(docData, str) and len(docData) < _INLINE_CONTENT_LIMIT: @@ -311,4 +374,4 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] if not parts: parts.append("Action completed successfully." if result.success else "Action failed.") - return "\n".join(parts) + return "\n".join(parts), sideEvents diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py index c1191c1f..fff1bcb3 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py @@ -228,14 +228,17 @@ def _registerDataSourceTools(registry: ToolRegistry, services): fileName = f"{fileName}.zip" chatService = services.chat fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName) - fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") - if fiId: - chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId}) - if _sourceNeutralize: - chatService.interfaceDbComponent.updateFile(fileItem.id, {"neutralize": True}) + updateFields = {} tempFolderId = _getOrCreateTempFolder(chatService) 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( services, fileItem, diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py index ceb6e025..ea80fdc7 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py @@ -47,13 +47,29 @@ def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool: def _getOrCreateTempFolder(chatService) -> Optional[str]: - """Deprecated stub: folder-based organisation has been replaced by grouping. - - Returns None unconditionally so callers skip the (now removed) folderId - assignment. Remove callers incrementally and delete this stub afterwards. - """ - logger.debug("_getOrCreateTempFolder called – folder support removed, returning None") - return None + """Return the ID of the user's 'Temp' folder, creating it if it doesn't exist.""" + ifc = getattr(chatService, "interfaceDbComponent", None) + if not ifc: + logger.warning("_getOrCreateTempFolder: no interfaceDbComponent on chatService") + return 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 async def _getOrCreateInstanceGroup( diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py index adb79ecf..a3fbb3ed 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py @@ -222,12 +222,15 @@ def _registerMediaTools(registry: ToolRegistry, services): if fileItem: fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") - fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") - if fiId: - chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) + updateFields = {} tempFolderId = _getOrCreateTempFolder(chatService) 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( services, fileItem, label=f"renderDocument:{docName}", @@ -517,12 +520,15 @@ def _registerMediaTools(registry: ToolRegistry, services): if fileItem: fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") - fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") - if fiId: - chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) + updateFields = {} tempFolderId = _getOrCreateTempFolder(chatService) 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( services, fileItem, label=f"generateImage:{docName}", @@ -679,12 +685,16 @@ def _registerMediaTools(registry: ToolRegistry, services): fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName) 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 fiId and fid != "?": - chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) - tempFolderId = _getOrCreateTempFolder(chatService) - if tempFolderId and fid != "?": - chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId}) + if fid != "?": + updateFields = {} + tempFolderId = _getOrCreateTempFolder(chatService) + if 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( services, fileItem, diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 17eb83e4..83f9de41 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -203,7 +203,8 @@ class AgentService: # ContentParts" symptom we see when the workspace route calls # runAgent for an attached single-file data source. # Mirrors workflowManager._propagateWorkflowToContext. - if workflowId and workflowId != "unknown": + isChatWorkflowId = workflowId and workflowId != "unknown" and ":" not in workflowId + if isChatWorkflowId: try: workflow = getattr(self.services, "workflow", None) if workflow is None or getattr(workflow, "id", None) != workflowId: diff --git a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py index e4671a70..c2e16506 100644 --- a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py +++ b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py @@ -17,7 +17,7 @@ SANDBOX_ALLOWED_MODULES = { } _PYTHON_BLOCKED_BUILTINS = { - "open", "exec", "eval", "compile", "__import__", "globals", "locals", + "exec", "eval", "compile", "__import__", "globals", "locals", "getattr", "setattr", "delattr", "breakpoint", "exit", "quit", "input", "memoryview", } @@ -73,6 +73,29 @@ def _buildRestrictedGlobals() -> Dict[str, Any]: return {"__builtins__": safeBuiltins} +class _VirtualFS: + """In-memory virtual filesystem for sandbox open() calls.""" + + def __init__(self): + self.files: Dict[str, str] = {} + + def open(self, name, mode="r", **_kwargs): + if "r" in mode and "+" not in mode: + if name not in self.files: + raise FileNotFoundError(f"Virtual file '{name}' not found") + buf = io.StringIO(self.files[name]) + buf.name = name + return buf + buf = io.StringIO() + buf.name = name + realClose = buf.close + def _flushingClose(): + self.files[name] = buf.getvalue() + realClose() + buf.close = _flushingClose + return buf + + def _makeReadFile(services): """Create a readFile(fileId) closure bound to the current services context.""" def readFile(fileId: str) -> str: @@ -92,6 +115,8 @@ async def executePython(code: str, *, services=None) -> Dict[str, Any]: def _run(): restrictedGlobals = _buildRestrictedGlobals() + vfs = _VirtualFS() + restrictedGlobals["__builtins__"]["open"] = vfs.open if services: restrictedGlobals["__builtins__"]["readFile"] = _makeReadFile(services) capturedOutput = io.StringIO() diff --git a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py index de746483..4285de51 100644 --- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py +++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py @@ -444,19 +444,28 @@ class AiCallLooper: lastValidCompletePart = contexts.completePart try: - parsed, parseErr, extracted = tryParseJson(contexts.completePart) + extracted = extractJsonString(contexts.completePart) + parsed, parseErr, _ = tryParseJson(extracted) if parseErr is not None: - raise ValueError(str(parseErr)) - normalized = self._normalizeJsonStructure(parsed, useCase) - result = json.dumps(normalized, indent=2, ensure_ascii=False) - jsonBase = contexts.completePart - - if iterationOperationId: - self.services.chat.progressLogFinish(iterationOperationId, True) - - if not useCase.finalResultHandler: - raise ValueError( - f"Use case '{useCaseId}' is missing required 'finalResultHandler' callback." + 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: + normalized = self._normalizeJsonStructure(parsed, useCase) + result = json.dumps(normalized, indent=2, ensure_ascii=False) + + if iterationOperationId: + self.services.chat.progressLogFinish(iterationOperationId, True) + + if not useCase.finalResultHandler: + raise ValueError( + f"Use case '{useCaseId}' is missing required 'finalResultHandler' callback." + ) + return useCase.finalResultHandler( + result, normalized, extracted, debugPrefix, self.services ) return useCase.finalResultHandler( result, normalized, extracted, debugPrefix, self.services diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py index 33398b64..5682fbb2 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py +++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py @@ -932,19 +932,10 @@ class StructureFiller: operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE if operationType == OperationTypeEnum.IMAGE_GENERATE: - maxPromptLength = 4000 - if len(generationPrompt) > maxPromptLength: - 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" - ) - + imagePrompt = self._buildImagePrompt(section, generationHint, language) + self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt") request = AiCallRequest( - prompt=generationPrompt, + prompt=imagePrompt, contentParts=[], options=AiCallOptions( operationType=operationType, @@ -955,8 +946,6 @@ class StructureFiller: checkWorkflowStopped(self.services) aiResponse = await self.aiService.callAi(request) generatedElements = [] - - # Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping) self.services.utils.writeDebugFile( aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse), f"{chapterId}_section_{sectionId}_response" @@ -1008,7 +997,7 @@ class StructureFiller: aiResponse = _AiResponseFallback(aiResponseJson) 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) generatedElements = [] @@ -1087,19 +1076,10 @@ class StructureFiller: operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE if operationType == OperationTypeEnum.IMAGE_GENERATE: - maxPromptLength = 4000 - if len(generationPrompt) > maxPromptLength: - 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" - ) - + imagePrompt = self._buildImagePrompt(section, generationHint, language) + self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt") request = AiCallRequest( - prompt=generationPrompt, + prompt=imagePrompt, contentParts=[], options=AiCallOptions( operationType=operationType, @@ -1110,8 +1090,6 @@ class StructureFiller: checkWorkflowStopped(self.services) aiResponse = await self.aiService.callAi(request) generatedElements = [] - - # Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping) self.services.utils.writeDebugFile( aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse), f"{chapterId}_section_{sectionId}_response" @@ -1164,7 +1142,7 @@ class StructureFiller: generatedElements = _elements_from_section_content_ai_json(parsedResponse) aiResponse = _AiResponseFallback(aiResponseJson) 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) generatedElements = [] @@ -1341,19 +1319,10 @@ class StructureFiller: operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE if operationType == OperationTypeEnum.IMAGE_GENERATE: - maxPromptLength = 4000 - if len(generationPrompt) > maxPromptLength: - 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" - ) - + imagePrompt = self._buildImagePrompt(section, generationHint, language) + self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt") request = AiCallRequest( - prompt=generationPrompt, + prompt=imagePrompt, contentParts=[], options=AiCallOptions( operationType=operationType, @@ -1363,8 +1332,6 @@ class StructureFiller: ) aiResponse = await self.aiService.callAi(request) generatedElements = [] - - # Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping) self.services.utils.writeDebugFile( aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse), f"{chapterId}_section_{sectionId}_response" @@ -1418,7 +1385,7 @@ class StructureFiller: generatedElements = _elements_from_section_content_ai_json(parsedResponse) aiResponse = _AiResponseFallback(aiResponseJson) 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) generatedElements = [] @@ -2127,6 +2094,14 @@ Return only valid JSON. Do not include any explanatory text outside the JSON. """ 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: """Get the JSON structure example for a specific content type.""" structures = { diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 1e2d19f1..7852360c 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -23,11 +23,7 @@ class ChatService: from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface from modules.interfaces.interfaceDbChat import getInterface as getChatInterface self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandate_id) - self.interfaceDbComponent = getComponentInterface( - context.user, - mandateId=context.mandate_id, - featureInstanceId=context.feature_instance_id, - ) + self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandate_id, featureInstanceId=context.feature_instance_id) self.interfaceDbChat = getChatInterface( context.user, mandateId=context.mandate_id, diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py index 583c423c..d7c237fa 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py @@ -122,26 +122,37 @@ class BaseRenderer(ABC): "title": { "font_size": h1["sizePt"], "color": h1["color"], "bold": h1.get("weight") == "bold", "align": "left", + "space_before": 0, + "space_after": h1.get("spaceAfterPt", 8), }, "heading1": { "font_size": h1["sizePt"], "color": h1["color"], "bold": h1.get("weight") == "bold", "align": "left", + "space_before": h1.get("spaceBeforePt", 24), + "space_after": h1.get("spaceAfterPt", 8), }, "heading2": { "font_size": h2["sizePt"], "color": h2["color"], "bold": h2.get("weight") == "bold", "align": "left", + "space_before": h2.get("spaceBeforePt", 20), + "space_after": h2.get("spaceAfterPt", 6), }, "heading3": { "font_size": h3["sizePt"], "color": h3["color"], "bold": h3.get("weight") == "bold", "align": "left", + "space_before": h3.get("spaceBeforePt", 16), + "space_after": h3.get("spaceAfterPt", 4), }, "heading4": { "font_size": h4["sizePt"], "color": h4["color"], "bold": h4.get("weight") == "bold", "align": "left", + "space_before": h4.get("spaceBeforePt", 12), + "space_after": h4.get("spaceAfterPt", 3), }, "paragraph": { "font_size": para["sizePt"], "color": para["color"], "bold": False, "align": "left", + "line_height": para.get("lineSpacing", 1.15), }, "table_header": { "background": tbl["headerBg"], "text_color": tbl["headerFg"], @@ -157,6 +168,7 @@ class BaseRenderer(ABC): "bullet_list": { "font_size": lst["sizePt"], "color": para["color"], "indent": lst["indentPt"], + "bullet_char": lst.get("bulletChar", "\u2022"), }, "code_block": { "font": style["fonts"]["monospace"], diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py index 7913a246..8ba20c6a 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py @@ -851,25 +851,35 @@ class RendererPdf(BaseRenderer): return [] 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: content = list_data.get("content", {}) if not isinstance(content, dict): return [] items = content.get("items", []) 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 = [] for item in items: runs = self._inlineRunsForListItem(item) if isinstance(item, list): xml = self._renderInlineRunsToPdfXml(runs) - elements.append(Paragraph(f"\u2022 {_wrapEmojiSpansInXml(xml)}", normalStyle)) + elements.append(Paragraph(f"{bulletChar} {_wrapEmojiSpansInXml(xml)}", bulletStyle)) 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: - elements.append(Paragraph(f"\u2022 {self._markdownInlineToReportlabXml(item['text'])}", normalStyle)) + elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item['text'])}", bulletStyle)) if elements: elements.append(Spacer(1, bulletStyleDef.get("space_after", 3))) diff --git a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py index b5a92641..1984f18d 100644 --- a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py +++ b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py @@ -17,10 +17,10 @@ DEFAULT_STYLE: Dict[str, Any] = { "background": "#FFFFFF", }, "headings": { - "h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 12, "spaceAfterPt": 6}, - "h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 10, "spaceAfterPt": 4}, - "h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 8, "spaceAfterPt": 3}, - "h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 6, "spaceAfterPt": 2}, + "h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 24, "spaceAfterPt": 8}, + "h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6}, + "h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4}, + "h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3}, }, "paragraph": {"sizePt": 11, "lineSpacing": 1.15, "color": "#333333"}, "table": { diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index 2806bd4c..fe686ba2 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -410,16 +410,35 @@ class ActionNodeExecutor: resolvedParams.pop("subject", 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)) + actionSuccess = False try: executor = ActionExecutor(self.services) result = await executor.executeAction(methodName, actionName, resolvedParams) + actionSuccess = True except (_SubscriptionInactiveException, _BillingContextError): raise except Exception as e: logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e) 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 docsList = [] diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index fee57c2e..f4380ae0 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -93,7 +93,11 @@ def _resolve_file_refs_to_content_parts(services, fileIdRefs) -> List[ContentPar fileId = ref.documentId fileMeta = mgmt.getFile(fileId) 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 fileData = mgmt.getFileData(fileId) if not fileData: diff --git a/modules/workflows/methods/methodRedmine/actions/listRelations.py b/modules/workflows/methods/methodRedmine/actions/listRelations.py new file mode 100644 index 00000000..90f44594 --- /dev/null +++ b/modules/workflows/methods/methodRedmine/actions/listRelations.py @@ -0,0 +1,73 @@ +# Copyright (c) 2026 Patrick Motsch +# All rights reserved. +"""Workflow action: list Redmine relations from the mirror.""" + +import logging +from typing import Any, Dict, List, Optional + +from modules.datamodels.datamodelChat import ActionResult +from modules.features.redmine.interfaceFeatureRedmine import getInterface + +from ._shared import resolveInstanceContext + +logger = logging.getLogger(__name__) + + +async def listRelationsAction(self, parameters: Dict[str, Any]) -> ActionResult: + """List all mirrored relations, optionally filtered by issueId or relationType.""" + try: + user, mandateId, featureInstanceId = resolveInstanceContext(self.services, parameters) + except ValueError as exc: + return ActionResult.isFailure(error=str(exc)) + + iface = getInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId) + rows = iface.listMirroredRelations(featureInstanceId) + + issueId: Optional[int] = None + rawIssueId = parameters.get("issueId") + if rawIssueId not in (None, ""): + try: + issueId = int(rawIssueId) + except (TypeError, ValueError): + return ActionResult.isFailure(error="issueId must be an int") + + relationType = parameters.get("relationType") or None + + if issueId is not None: + rows = [ + r for r in rows + if int(r.get("issueId") or 0) == issueId + or int(r.get("issueToId") or 0) == issueId + ] + if relationType: + rows = [r for r in rows if r.get("relationType") == relationType] + + limit = 1000 + try: + limit = max(1, min(5000, int(parameters.get("limit") or 1000))) + except (TypeError, ValueError): + limit = 1000 + + offset = 0 + try: + offset = max(0, int(parameters.get("offset") or 0)) + except (TypeError, ValueError): + offset = 0 + + page = rows[offset:offset + limit] + return ActionResult.isSuccess(data={ + "count": len(page), + "totalMatched": len(rows), + "offset": offset, + "hasMore": (offset + limit) < len(rows), + "relations": [ + { + "redmineRelationId": r.get("redmineRelationId"), + "issueId": r.get("issueId"), + "issueToId": r.get("issueToId"), + "relationType": r.get("relationType"), + "delay": r.get("delay"), + } + for r in page + ], + }) diff --git a/modules/workflows/methods/methodRedmine/actions/listTickets.py b/modules/workflows/methods/methodRedmine/actions/listTickets.py index d1867b86..8573237a 100644 --- a/modules/workflows/methods/methodRedmine/actions/listTickets.py +++ b/modules/workflows/methods/methodRedmine/actions/listTickets.py @@ -64,19 +64,23 @@ async def listTicketsAction(self, parameters: Dict[str, Any]) -> ActionResult: logger.exception("redmine.listTickets failed") return ActionResult.isFailure(error=f"List tickets failed: {exc}") - # AI-friendly pagination: always capped so we don't accidentally feed a - # 20k-ticket dump into a context window. Callers that need more must - # paginate via filters. limit = 100 try: limit = max(1, min(500, int(parameters.get("limit") or 100))) except (TypeError, ValueError): limit = 100 - truncated = tickets[:limit] + offset = 0 + try: + offset = max(0, int(parameters.get("offset") or 0)) + except (TypeError, ValueError): + offset = 0 + + page = tickets[offset:offset + limit] return ActionResult.isSuccess(data={ - "count": len(truncated), + "count": len(page), "totalMatched": len(tickets), - "truncated": len(tickets) > limit, - "tickets": [ticketToDict(t) for t in truncated], + "offset": offset, + "hasMore": (offset + limit) < len(tickets), + "tickets": [ticketToDict(t) for t in page], }) diff --git a/modules/workflows/methods/methodRedmine/methodRedmine.py b/modules/workflows/methods/methodRedmine/methodRedmine.py index 6c40c951..700375cd 100644 --- a/modules/workflows/methods/methodRedmine/methodRedmine.py +++ b/modules/workflows/methods/methodRedmine/methodRedmine.py @@ -22,6 +22,7 @@ from modules.workflows.methods.methodBase import MethodBase from .actions.createTicket import createTicketAction from .actions.getStats import getStatsAction +from .actions.listRelations import listRelationsAction from .actions.listTickets import listTicketsAction from .actions.readTicket import readTicket from .actions.runSync import runSyncAction @@ -90,9 +91,42 @@ class MethodRedmine(MethodBase): name="limit", type="int", frontendType=FrontendType.TEXT, required=False, description="Max tickets in the result (1-500, default 100).", ), + "offset": WorkflowActionParameter( + name="offset", type="int", frontendType=FrontendType.TEXT, + required=False, description="Skip first N matched tickets (for pagination, default 0).", + ), }, execute=listTicketsAction.__get__(self, self.__class__), ), + "listRelations": WorkflowActionDefinition( + actionId="redmine.listRelations", + description="List all mirrored relations. Optional filters: issueId, relationType. Returns issueId<->issueToId pairs with relationType.", + dynamicMode=False, + outputType="RedmineRelationList", + parameters={ + "featureInstanceId": WorkflowActionParameter( + name="featureInstanceId", type="FeatureInstanceRef", frontendType=FrontendType.TEXT, + required=True, description="Redmine feature instance", + ), + "issueId": WorkflowActionParameter( + name="issueId", type="int", frontendType=FrontendType.TEXT, + required=False, description="Filter relations involving this Redmine issue id (as source or target).", + ), + "relationType": WorkflowActionParameter( + name="relationType", type="str", frontendType=FrontendType.TEXT, + required=False, description="Filter by relation type: relates | precedes | follows | blocks | blocked | duplicates | duplicated | copied_to | copied_from.", + ), + "limit": WorkflowActionParameter( + name="limit", type="int", frontendType=FrontendType.TEXT, + required=False, description="Max relations in the result (1-5000, default 1000).", + ), + "offset": WorkflowActionParameter( + name="offset", type="int", frontendType=FrontendType.TEXT, + required=False, description="Skip first N relations (for pagination, default 0).", + ), + }, + execute=listRelationsAction.__get__(self, self.__class__), + ), "createTicket": WorkflowActionDefinition( actionId="redmine.createTicket", description="Create a new Redmine ticket. Requires subject and trackerId.", @@ -253,6 +287,7 @@ class MethodRedmine(MethodBase): # rather than through the action dict also work. self.readTicket = readTicket.__get__(self, self.__class__) self.listTickets = listTicketsAction.__get__(self, self.__class__) + self.listRelations = listRelationsAction.__get__(self, self.__class__) self.createTicket = createTicketAction.__get__(self, self.__class__) self.updateTicket = updateTicketAction.__get__(self, self.__class__) self.getStats = getStatsAction.__get__(self, self.__class__)