Merge pull request #154 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
This commit is contained in:
commit
513d879ae8
34 changed files with 1407 additions and 441 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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=[
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
})
|
||||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
Loading…
Reference in a new issue