Merge pull request #154 from valueonag/feat/demo-system-readieness

Feat/demo system readieness
This commit is contained in:
Patrick Motsch 2026-05-08 00:14:09 +02:00 committed by GitHub
commit 513d879ae8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1407 additions and 441 deletions

View file

@ -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)

View file

@ -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"),

View file

@ -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:

View file

@ -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}"})

View file

@ -420,7 +420,7 @@ async def _saveOrUpdateDocument(doc: Dict[str, Any], contextId: str, userId: str
logger.info(f"Document saved as platform FileItem: {fileItem.id} ({title})")
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),
})

View file

@ -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 []

View file

@ -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",
},
]

View file

@ -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

View file

@ -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)

View file

@ -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=[

View file

@ -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),

View file

@ -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

View file

@ -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,
}

View file

@ -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:

View file

@ -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.

View file

@ -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:

View file

@ -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",

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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,

View file

@ -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:

View file

@ -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()

View file

@ -444,19 +444,28 @@ class AiCallLooper:
lastValidCompletePart = contexts.completePart
try:
parsed, parseErr, extracted = tryParseJson(contexts.completePart)
extracted = extractJsonString(contexts.completePart)
parsed, parseErr, _ = tryParseJson(extracted)
if parseErr is not None:
raise ValueError(str(parseErr))
normalized = self._normalizeJsonStructure(parsed, useCase)
result = json.dumps(normalized, indent=2, ensure_ascii=False)
jsonBase = contexts.completePart
if iterationOperationId:
self.services.chat.progressLogFinish(iterationOperationId, True)
if not useCase.finalResultHandler:
raise ValueError(
f"Use case '{useCaseId}' is missing required 'finalResultHandler' callback."
from modules.shared.jsonUtils import repairBrokenJson
repaired = repairBrokenJson(extracted)
if repaired:
parsed = repaired
parseErr = None
logger.info(f"Iteration {iteration}: repairBrokenJson succeeded for completePart")
if parseErr is None and parsed:
normalized = self._normalizeJsonStructure(parsed, useCase)
result = json.dumps(normalized, indent=2, ensure_ascii=False)
if iterationOperationId:
self.services.chat.progressLogFinish(iterationOperationId, True)
if not useCase.finalResultHandler:
raise ValueError(
f"Use case '{useCaseId}' is missing required 'finalResultHandler' callback."
)
return useCase.finalResultHandler(
result, normalized, extracted, debugPrefix, self.services
)
return useCase.finalResultHandler(
result, normalized, extracted, debugPrefix, self.services

View file

@ -932,19 +932,10 @@ class StructureFiller:
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
if operationType == OperationTypeEnum.IMAGE_GENERATE:
maxPromptLength = 4000
if len(generationPrompt) > maxPromptLength:
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
generationPrompt,
f"{chapterId}_section_{sectionId}_prompt"
)
imagePrompt = self._buildImagePrompt(section, generationHint, language)
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
request = AiCallRequest(
prompt=generationPrompt,
prompt=imagePrompt,
contentParts=[],
options=AiCallOptions(
operationType=operationType,
@ -955,8 +946,6 @@ class StructureFiller:
checkWorkflowStopped(self.services)
aiResponse = await self.aiService.callAi(request)
generatedElements = []
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
f"{chapterId}_section_{sectionId}_response"
@ -1008,7 +997,7 @@ class StructureFiller:
aiResponse = _AiResponseFallback(aiResponseJson)
except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
aiResponse = _AiResponseFallback(aiResponseJson)
generatedElements = []
@ -1087,19 +1076,10 @@ class StructureFiller:
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
if operationType == OperationTypeEnum.IMAGE_GENERATE:
maxPromptLength = 4000
if len(generationPrompt) > maxPromptLength:
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
generationPrompt,
f"{chapterId}_section_{sectionId}_prompt"
)
imagePrompt = self._buildImagePrompt(section, generationHint, language)
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
request = AiCallRequest(
prompt=generationPrompt,
prompt=imagePrompt,
contentParts=[],
options=AiCallOptions(
operationType=operationType,
@ -1110,8 +1090,6 @@ class StructureFiller:
checkWorkflowStopped(self.services)
aiResponse = await self.aiService.callAi(request)
generatedElements = []
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
f"{chapterId}_section_{sectionId}_response"
@ -1164,7 +1142,7 @@ class StructureFiller:
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
aiResponse = _AiResponseFallback(aiResponseJson)
except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
aiResponse = _AiResponseFallback(aiResponseJson)
generatedElements = []
@ -1341,19 +1319,10 @@ class StructureFiller:
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
if operationType == OperationTypeEnum.IMAGE_GENERATE:
maxPromptLength = 4000
if len(generationPrompt) > maxPromptLength:
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
generationPrompt,
f"{chapterId}_section_{sectionId}_prompt"
)
imagePrompt = self._buildImagePrompt(section, generationHint, language)
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
request = AiCallRequest(
prompt=generationPrompt,
prompt=imagePrompt,
contentParts=[],
options=AiCallOptions(
operationType=operationType,
@ -1363,8 +1332,6 @@ class StructureFiller:
)
aiResponse = await self.aiService.callAi(request)
generatedElements = []
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
f"{chapterId}_section_{sectionId}_response"
@ -1418,7 +1385,7 @@ class StructureFiller:
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
aiResponse = _AiResponseFallback(aiResponseJson)
except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
aiResponse = _AiResponseFallback(aiResponseJson)
generatedElements = []
@ -2127,6 +2094,14 @@ Return only valid JSON. Do not include any explanatory text outside the JSON.
"""
return prompt
def _buildImagePrompt(self, section: Dict[str, Any], generationHint: str, language: str = "de") -> str:
"""Build a concise image-generation prompt from generationHint only.
Image models need short, visual descriptions - not the full document
context or user prompt that can be hundreds of KB."""
sectionTitle = section.get("title", "")
description = generationHint or sectionTitle or "Generate an image"
return f"{description}\nLanguage for any text in the image: {language.upper()}"
def _getContentStructureExample(self, contentType: str) -> str:
"""Get the JSON structure example for a specific content type."""
structures = {

View file

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

View file

@ -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"],

View file

@ -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)))

View file

@ -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": {

View file

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

View file

@ -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:

View file

@ -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
],
})

View file

@ -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],
})

View file

@ -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__)