From cfd303792ff2ad74b3ca886ffd394a71d54259d8 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Wed, 6 May 2026 23:28:22 +0200
Subject: [PATCH 1/2] refactored comcoach und teamsbot
---
.../features/commcoach/datamodelCommcoach.py | 82 ++--
.../commcoach/interfaceFeatureCommcoach.py | 180 +++++----
modules/features/commcoach/mainCommcoach.py | 233 +++++++++--
.../commcoach/routeFeatureCommcoach.py | 367 +++++++++++-------
.../features/commcoach/serviceCommcoach.py | 46 ++-
.../commcoach/serviceCommcoachGamification.py | 2 +-
.../commcoach/serviceCommcoachPersonas.py | 51 +++
.../commcoach/serviceCommcoachScheduler.py | 8 +-
.../features/teamsbot/datamodelTeamsbot.py | 53 +++
.../teamsbot/interfaceFeatureTeamsbot.py | 46 ++-
modules/features/teamsbot/mainTeamsbot.py | 131 ++++++-
.../features/teamsbot/routeFeatureTeamsbot.py | 102 ++++-
modules/features/teamsbot/service.py | 2 +
.../migration/seedData/ui_language_seed.json | 20 +
.../serviceAgent/actionToolAdapter.py | 111 ++++--
.../coreTools/_dataSourceTools.py | 15 +-
.../serviceAgent/coreTools/_helpers.py | 30 +-
.../serviceAgent/coreTools/_mediaTools.py | 38 +-
.../services/serviceAgent/mainServiceAgent.py | 3 +-
.../services/serviceAi/subAiCallLooping.py | 7 +
.../services/serviceAi/subStructureFilling.py | 65 +---
.../services/serviceChat/mainServiceChat.py | 2 +-
.../renderers/documentRendererBaseTemplate.py | 12 +
.../renderers/rendererPdf.py | 20 +-
.../serviceGeneration/styleDefaults.py | 8 +-
.../executors/actionNodeExecutor.py | 21 +-
.../methods/methodAi/actions/process.py | 6 +-
27 files changed, 1244 insertions(+), 417 deletions(-)
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/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 37116ee5..cb815734 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/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py
index adfc4d8a..528cfd0d 100644
--- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py
+++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py
@@ -433,6 +433,13 @@ class AiCallLooper:
try:
extracted = extractJsonString(contexts.completePart)
parsed, parseErr, _ = tryParseJson(extracted)
+ if parseErr is not None:
+ from modules.shared.jsonUtils import repairBrokenJson
+ repaired = repairBrokenJson(extracted)
+ if repaired:
+ parsed = repaired
+ parseErr = None
+ logger.info(f"Iteration {iteration}: repairBrokenJson succeeded for completePart")
if parseErr is None and parsed:
normalized = self._normalizeJsonStructure(parsed, useCase)
result = json.dumps(normalized, indent=2, ensure_ascii=False)
diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py
index b31bc32d..1ebb7d0c 100644
--- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py
+++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py
@@ -933,19 +933,10 @@ class StructureFiller:
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE
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,
@@ -956,8 +947,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"
@@ -1036,7 +1025,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 = []
@@ -1115,19 +1104,10 @@ class StructureFiller:
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE
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,
@@ -1137,8 +1117,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"
@@ -1197,7 +1175,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 = []
@@ -1374,19 +1352,10 @@ class StructureFiller:
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE
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,
@@ -1396,8 +1365,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"
@@ -1457,7 +1424,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 = []
@@ -2166,6 +2133,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 0e69344a..8b2ea564 100644
--- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py
+++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
@@ -23,7 +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)
+ 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 163ed3b2..0aa0fc42 100644
--- a/modules/workflows/automation2/executors/actionNodeExecutor.py
+++ b/modules/workflows/automation2/executors/actionNodeExecutor.py
@@ -307,16 +307,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 2af480e7..6faa12d5 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:
From e9c39f8e316fbe4b60acbd004ad320b7b7a12077 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 7 May 2026 11:01:37 +0200
Subject: [PATCH 2/2] fixes redmine
---
modules/features/redmine/datamodelRedmine.py | 2 +
modules/features/redmine/serviceRedmine.py | 1 +
.../features/redmine/serviceRedmineSync.py | 1 +
.../services/serviceAgent/sandboxExecutor.py | 27 ++++++-
.../methodRedmine/actions/listRelations.py | 73 +++++++++++++++++++
.../methodRedmine/actions/listTickets.py | 18 +++--
.../methods/methodRedmine/methodRedmine.py | 35 +++++++++
7 files changed, 149 insertions(+), 8 deletions(-)
create mode 100644 modules/workflows/methods/methodRedmine/actions/listRelations.py
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/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/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__)