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.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
CommCoach Feature - Data Models.
|
CommCoach Feature - Data Models.
|
||||||
Pydantic models for coaching contexts, sessions, messages, tasks, scores, and user profiles.
|
Pydantic models for training modules, sessions, messages, tasks, scores, and user profiles.
|
||||||
"""
|
"""
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -16,22 +16,18 @@ import uuid
|
||||||
# Enums
|
# Enums
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class CoachingContextStatus(str, Enum):
|
class TrainingModuleStatus(str, Enum):
|
||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
PAUSED = "paused"
|
PAUSED = "paused"
|
||||||
ARCHIVED = "archived"
|
ARCHIVED = "archived"
|
||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
|
|
||||||
|
|
||||||
class CoachingContextCategory(str, Enum):
|
class TrainingModuleType(str, Enum):
|
||||||
LEADERSHIP = "leadership"
|
COACHING = "coaching"
|
||||||
CONFLICT = "conflict"
|
TRAINING = "training"
|
||||||
NEGOTIATION = "negotiation"
|
EXAM = "exam"
|
||||||
PRESENTATION = "presentation"
|
ELEARNING = "elearning"
|
||||||
FEEDBACK = "feedback"
|
|
||||||
DELEGATION = "delegation"
|
|
||||||
CHANGE_MANAGEMENT = "changeManagement"
|
|
||||||
CUSTOM = "custom"
|
|
||||||
|
|
||||||
|
|
||||||
class CoachingSessionStatus(str, Enum):
|
class CoachingSessionStatus(str, Enum):
|
||||||
|
|
@ -75,19 +71,21 @@ class CoachingScoreTrend(str, Enum):
|
||||||
# Database Models
|
# Database Models
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class CoachingContext(PowerOnModel):
|
class TrainingModule(PowerOnModel):
|
||||||
"""A coaching context/dossier representing a topic the user is working on."""
|
"""A training module representing a topic the user is working on."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str = Field(description="Owner user ID (strict ownership)")
|
userId: str = Field(description="Owner user ID (strict ownership)")
|
||||||
mandateId: str = Field(description="Mandate ID")
|
mandateId: str = Field(description="Mandate ID")
|
||||||
instanceId: str = Field(description="Feature instance ID")
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
title: str = Field(description="Context title, e.g. 'Conflict with team lead'")
|
title: str = Field(description="Module title, e.g. 'Conflict with team lead'")
|
||||||
description: Optional[str] = Field(default=None, description="Short description")
|
description: Optional[str] = Field(default=None, description="Short description")
|
||||||
category: CoachingContextCategory = Field(default=CoachingContextCategory.CUSTOM)
|
moduleType: TrainingModuleType = Field(default=TrainingModuleType.COACHING)
|
||||||
status: CoachingContextStatus = Field(default=CoachingContextStatus.ACTIVE)
|
status: TrainingModuleStatus = Field(default=TrainingModuleStatus.ACTIVE)
|
||||||
goals: Optional[str] = Field(default=None, description="JSON array of goals [{id, text, status, createdAt}]")
|
goals: Optional[str] = Field(default=None, description="Free-text goal description")
|
||||||
insights: Optional[str] = Field(default=None, description="JSON array of AI insights [{id, text, sessionId, createdAt}]")
|
insights: Optional[str] = Field(default=None, description="JSON array of AI insights [{id, text, sessionId, createdAt}]")
|
||||||
metadata: Optional[str] = Field(default=None, description="JSON object with flexible metadata")
|
metadata: Optional[str] = Field(default=None, description="JSON object with flexible metadata")
|
||||||
|
personaId: Optional[str] = Field(default=None, description="Default persona for sessions")
|
||||||
|
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
|
||||||
sessionCount: int = Field(default=0)
|
sessionCount: int = Field(default=0)
|
||||||
taskCount: int = Field(default=0)
|
taskCount: int = Field(default=0)
|
||||||
lastSessionAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
|
lastSessionAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
|
||||||
|
|
@ -96,9 +94,9 @@ class CoachingContext(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
class CoachingSession(PowerOnModel):
|
class CoachingSession(PowerOnModel):
|
||||||
"""A single coaching conversation session within a context."""
|
"""A single coaching conversation session within a module."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
contextId: str = Field(description="FK to CoachingContext")
|
moduleId: str = Field(description="FK to TrainingModule")
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(description="Owner user ID")
|
||||||
mandateId: str = Field(description="Mandate ID")
|
mandateId: str = Field(description="Mandate ID")
|
||||||
instanceId: str = Field(description="Feature instance ID")
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
|
|
@ -121,7 +119,7 @@ class CoachingMessage(PowerOnModel):
|
||||||
"""A single message in a coaching session."""
|
"""A single message in a coaching session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
sessionId: str = Field(description="FK to CoachingSession")
|
sessionId: str = Field(description="FK to CoachingSession")
|
||||||
contextId: str = Field(description="FK to CoachingContext")
|
moduleId: str = Field(description="FK to TrainingModule")
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(description="Owner user ID")
|
||||||
role: CoachingMessageRole = Field(description="Message author role")
|
role: CoachingMessageRole = Field(description="Message author role")
|
||||||
content: str = Field(description="Message content (Markdown)")
|
content: str = Field(description="Message content (Markdown)")
|
||||||
|
|
@ -131,9 +129,9 @@ class CoachingMessage(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
class CoachingTask(PowerOnModel):
|
class CoachingTask(PowerOnModel):
|
||||||
"""A task/checklist item assigned within a coaching context."""
|
"""A task/checklist item assigned within a training module."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
contextId: str = Field(description="FK to CoachingContext")
|
moduleId: str = Field(description="FK to TrainingModule")
|
||||||
sessionId: Optional[str] = Field(default=None, description="FK to originating session")
|
sessionId: Optional[str] = Field(default=None, description="FK to originating session")
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(description="Owner user ID")
|
||||||
mandateId: str = Field(description="Mandate ID")
|
mandateId: str = Field(description="Mandate ID")
|
||||||
|
|
@ -148,7 +146,7 @@ class CoachingTask(PowerOnModel):
|
||||||
class CoachingScore(PowerOnModel):
|
class CoachingScore(PowerOnModel):
|
||||||
"""A competence score for a dimension, recorded after a session."""
|
"""A competence score for a dimension, recorded after a session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
contextId: str = Field(description="FK to CoachingContext")
|
moduleId: str = Field(description="FK to TrainingModule")
|
||||||
sessionId: str = Field(description="FK to CoachingSession")
|
sessionId: str = Field(description="FK to CoachingSession")
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(description="Owner user ID")
|
||||||
mandateId: str = Field(description="Mandate ID")
|
mandateId: str = Field(description="Mandate ID")
|
||||||
|
|
@ -193,6 +191,22 @@ class CoachingPersona(PowerOnModel):
|
||||||
isActive: bool = Field(default=True)
|
isActive: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Module-Persona Mapping (M:N)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class ModulePersonaMapping(PowerOnModel):
|
||||||
|
"""Maps which personas are available for a specific training module."""
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
moduleId: str = Field(description="FK to TrainingModule")
|
||||||
|
personaId: str = Field(description="FK to CoachingPersona")
|
||||||
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
|
|
||||||
|
|
||||||
|
class SetModulePersonasRequest(BaseModel):
|
||||||
|
personaIds: List[str] = Field(description="List of persona IDs to assign to this module")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Iteration 2: Badges / Gamification
|
# Iteration 2: Badges / Gamification
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -211,18 +225,22 @@ class CoachingBadge(PowerOnModel):
|
||||||
# API Request/Response Models
|
# API Request/Response Models
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class CreateContextRequest(BaseModel):
|
class CreateModuleRequest(BaseModel):
|
||||||
title: str = Field(description="Context title")
|
title: str = Field(description="Module title")
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
category: Optional[CoachingContextCategory] = CoachingContextCategory.CUSTOM
|
moduleType: Optional[TrainingModuleType] = TrainingModuleType.COACHING
|
||||||
goals: Optional[List[str]] = None
|
goals: Optional[str] = None
|
||||||
|
personaId: Optional[str] = None
|
||||||
|
kpiTargets: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateContextRequest(BaseModel):
|
class UpdateModuleRequest(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
category: Optional[CoachingContextCategory] = None
|
moduleType: Optional[TrainingModuleType] = None
|
||||||
goals: Optional[str] = None
|
goals: Optional[str] = None
|
||||||
|
personaId: Optional[str] = None
|
||||||
|
kpiTargets: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
class SendMessageRequest(BaseModel):
|
||||||
|
|
@ -279,8 +297,8 @@ class UpdatePersonaRequest(BaseModel):
|
||||||
|
|
||||||
class DashboardData(BaseModel):
|
class DashboardData(BaseModel):
|
||||||
"""Aggregated dashboard data for the user."""
|
"""Aggregated dashboard data for the user."""
|
||||||
totalContexts: int = 0
|
totalModules: int = 0
|
||||||
activeContexts: int = 0
|
activeModules: int = 0
|
||||||
totalSessions: int = 0
|
totalSessions: int = 0
|
||||||
totalMinutes: int = 0
|
totalMinutes: int = 0
|
||||||
streakDays: int = 0
|
streakDays: int = 0
|
||||||
|
|
@ -289,4 +307,4 @@ class DashboardData(BaseModel):
|
||||||
recentScores: List[Dict[str, Any]] = Field(default_factory=list)
|
recentScores: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
openTasks: int = 0
|
openTasks: int = 0
|
||||||
completedTasks: int = 0
|
completedTasks: int = 0
|
||||||
contexts: List[Dict[str, Any]] = Field(default_factory=list)
|
modules: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.i18nRegistry import resolveText, t
|
from modules.shared.i18nRegistry import resolveText, t
|
||||||
|
|
||||||
from .datamodelCommcoach import (
|
from .datamodelCommcoach import (
|
||||||
CoachingContext, CoachingContextStatus,
|
TrainingModule, TrainingModuleStatus,
|
||||||
CoachingSession, CoachingSessionStatus,
|
CoachingSession, CoachingSessionStatus,
|
||||||
CoachingMessage,
|
CoachingMessage,
|
||||||
CoachingTask, CoachingTaskStatus,
|
CoachingTask, CoachingTaskStatus,
|
||||||
|
|
@ -70,47 +70,60 @@ class CommcoachObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Contexts
|
# Modules (formerly Contexts)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def getContexts(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]:
|
def getModules(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]:
|
||||||
"""Get all coaching contexts for a user. Strict ownership."""
|
"""Get all training modules for a user. Enriches with live sessionCount from sessions table."""
|
||||||
records = self.db.getRecordset(
|
records = self.db.getRecordset(
|
||||||
CoachingContext,
|
TrainingModule,
|
||||||
recordFilter={"instanceId": instanceId, "userId": userId},
|
recordFilter={"instanceId": instanceId, "userId": userId},
|
||||||
)
|
)
|
||||||
if not includeArchived:
|
if not includeArchived:
|
||||||
records = [r for r in records if r.get("status") != CoachingContextStatus.ARCHIVED.value]
|
records = [r for r in records if r.get("status") != TrainingModuleStatus.ARCHIVED.value]
|
||||||
|
|
||||||
|
allSessions = self.db.getRecordset(
|
||||||
|
CoachingSession,
|
||||||
|
recordFilter={"instanceId": instanceId, "userId": userId},
|
||||||
|
)
|
||||||
|
countByModule: Dict[str, int] = {}
|
||||||
|
for s in allSessions:
|
||||||
|
mid = s.get("moduleId")
|
||||||
|
if mid:
|
||||||
|
countByModule[mid] = countByModule.get(mid, 0) + 1
|
||||||
|
for r in records:
|
||||||
|
r["sessionCount"] = countByModule.get(r.get("id", ""), 0)
|
||||||
|
|
||||||
records.sort(key=lambda r: r.get("updatedAt") or r.get("createdAt") or "", reverse=True)
|
records.sort(key=lambda r: r.get("updatedAt") or r.get("createdAt") or "", reverse=True)
|
||||||
return records
|
return records
|
||||||
|
|
||||||
def getContext(self, contextId: str) -> Optional[Dict[str, Any]]:
|
def getModule(self, moduleId: str) -> Optional[Dict[str, Any]]:
|
||||||
records = self.db.getRecordset(CoachingContext, recordFilter={"id": contextId})
|
records = self.db.getRecordset(TrainingModule, recordFilter={"id": moduleId})
|
||||||
return records[0] if records else None
|
return records[0] if records else None
|
||||||
|
|
||||||
def createContext(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
def createModule(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
data["createdAt"] = getIsoTimestamp()
|
data["createdAt"] = getIsoTimestamp()
|
||||||
data["updatedAt"] = getIsoTimestamp()
|
data["updatedAt"] = getIsoTimestamp()
|
||||||
return self.db.recordCreate(CoachingContext, data)
|
return self.db.recordCreate(TrainingModule, data)
|
||||||
|
|
||||||
def updateContext(self, contextId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def updateModule(self, moduleId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
updates["updatedAt"] = getIsoTimestamp()
|
updates["updatedAt"] = getIsoTimestamp()
|
||||||
return self.db.recordModify(CoachingContext, contextId, updates)
|
return self.db.recordModify(TrainingModule, moduleId, updates)
|
||||||
|
|
||||||
def deleteContext(self, contextId: str) -> bool:
|
def deleteModule(self, moduleId: str) -> bool:
|
||||||
self._deleteSessionsByContext(contextId)
|
self._deleteSessionsByModule(moduleId)
|
||||||
self._deleteTasksByContext(contextId)
|
self._deleteTasksByModule(moduleId)
|
||||||
self._deleteScoresByContext(contextId)
|
self._deleteScoresByModule(moduleId)
|
||||||
return self.db.recordDelete(CoachingContext, contextId)
|
return self.db.recordDelete(TrainingModule, moduleId)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Sessions
|
# Sessions
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def getSessions(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
|
def getSessions(self, moduleId: str, userId: str) -> List[Dict[str, Any]]:
|
||||||
records = self.db.getRecordset(
|
records = self.db.getRecordset(
|
||||||
CoachingSession,
|
CoachingSession,
|
||||||
recordFilter={"contextId": contextId, "userId": userId},
|
recordFilter={"moduleId": moduleId, "userId": userId},
|
||||||
)
|
)
|
||||||
records.sort(key=lambda r: r.get("startedAt") or 0, reverse=True)
|
records.sort(key=lambda r: r.get("startedAt") or 0, reverse=True)
|
||||||
return records
|
return records
|
||||||
|
|
@ -119,10 +132,10 @@ class CommcoachObjects:
|
||||||
records = self.db.getRecordset(CoachingSession, recordFilter={"id": sessionId})
|
records = self.db.getRecordset(CoachingSession, recordFilter={"id": sessionId})
|
||||||
return records[0] if records else None
|
return records[0] if records else None
|
||||||
|
|
||||||
def getActiveSession(self, contextId: str, userId: str) -> Optional[Dict[str, Any]]:
|
def getActiveSession(self, moduleId: str, userId: str) -> Optional[Dict[str, Any]]:
|
||||||
records = self.db.getRecordset(
|
records = self.db.getRecordset(
|
||||||
CoachingSession,
|
CoachingSession,
|
||||||
recordFilter={"contextId": contextId, "userId": userId, "status": CoachingSessionStatus.ACTIVE.value},
|
recordFilter={"moduleId": moduleId, "userId": userId, "status": CoachingSessionStatus.ACTIVE.value},
|
||||||
)
|
)
|
||||||
return records[0] if records else None
|
return records[0] if records else None
|
||||||
|
|
||||||
|
|
@ -136,8 +149,8 @@ class CommcoachObjects:
|
||||||
updates["updatedAt"] = getIsoTimestamp()
|
updates["updatedAt"] = getIsoTimestamp()
|
||||||
return self.db.recordModify(CoachingSession, sessionId, updates)
|
return self.db.recordModify(CoachingSession, sessionId, updates)
|
||||||
|
|
||||||
def _deleteSessionsByContext(self, contextId: str) -> int:
|
def _deleteSessionsByModule(self, moduleId: str) -> int:
|
||||||
records = self.db.getRecordset(CoachingSession, recordFilter={"contextId": contextId})
|
records = self.db.getRecordset(CoachingSession, recordFilter={"moduleId": moduleId})
|
||||||
count = 0
|
count = 0
|
||||||
for record in records:
|
for record in records:
|
||||||
self._deleteMessagesBySession(record.get("id"))
|
self._deleteMessagesBySession(record.get("id"))
|
||||||
|
|
@ -174,10 +187,10 @@ class CommcoachObjects:
|
||||||
# Tasks
|
# Tasks
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def getTasks(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
|
def getTasks(self, moduleId: str, userId: str) -> List[Dict[str, Any]]:
|
||||||
records = self.db.getRecordset(
|
records = self.db.getRecordset(
|
||||||
CoachingTask,
|
CoachingTask,
|
||||||
recordFilter={"contextId": contextId, "userId": userId},
|
recordFilter={"moduleId": moduleId, "userId": userId},
|
||||||
)
|
)
|
||||||
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
|
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
|
||||||
return records
|
return records
|
||||||
|
|
@ -198,8 +211,8 @@ class CommcoachObjects:
|
||||||
def deleteTask(self, taskId: str) -> bool:
|
def deleteTask(self, taskId: str) -> bool:
|
||||||
return self.db.recordDelete(CoachingTask, taskId)
|
return self.db.recordDelete(CoachingTask, taskId)
|
||||||
|
|
||||||
def _deleteTasksByContext(self, contextId: str) -> int:
|
def _deleteTasksByModule(self, moduleId: str) -> int:
|
||||||
records = self.db.getRecordset(CoachingTask, recordFilter={"contextId": contextId})
|
records = self.db.getRecordset(CoachingTask, recordFilter={"moduleId": moduleId})
|
||||||
count = 0
|
count = 0
|
||||||
for record in records:
|
for record in records:
|
||||||
self.db.recordDelete(CoachingTask, record.get("id"))
|
self.db.recordDelete(CoachingTask, record.get("id"))
|
||||||
|
|
@ -218,10 +231,10 @@ class CommcoachObjects:
|
||||||
# Scores
|
# Scores
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def getScores(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
|
def getScores(self, moduleId: str, userId: str) -> List[Dict[str, Any]]:
|
||||||
records = self.db.getRecordset(
|
records = self.db.getRecordset(
|
||||||
CoachingScore,
|
CoachingScore,
|
||||||
recordFilter={"contextId": contextId, "userId": userId},
|
recordFilter={"moduleId": moduleId, "userId": userId},
|
||||||
)
|
)
|
||||||
records.sort(key=lambda r: r.get("createdAt") or "")
|
records.sort(key=lambda r: r.get("createdAt") or "")
|
||||||
return records
|
return records
|
||||||
|
|
@ -235,8 +248,8 @@ class CommcoachObjects:
|
||||||
data["createdAt"] = getIsoTimestamp()
|
data["createdAt"] = getIsoTimestamp()
|
||||||
return self.db.recordCreate(CoachingScore, data)
|
return self.db.recordCreate(CoachingScore, data)
|
||||||
|
|
||||||
def _deleteScoresByContext(self, contextId: str) -> int:
|
def _deleteScoresByModule(self, moduleId: str) -> int:
|
||||||
records = self.db.getRecordset(CoachingScore, recordFilter={"contextId": contextId})
|
records = self.db.getRecordset(CoachingScore, recordFilter={"moduleId": moduleId})
|
||||||
count = 0
|
count = 0
|
||||||
for record in records:
|
for record in records:
|
||||||
self.db.recordDelete(CoachingScore, record.get("id"))
|
self.db.recordDelete(CoachingScore, record.get("id"))
|
||||||
|
|
@ -274,6 +287,39 @@ class CommcoachObjects:
|
||||||
from .datamodelCommcoach import CoachingPersona
|
from .datamodelCommcoach import CoachingPersona
|
||||||
return self.db.recordDelete(CoachingPersona, personaId)
|
return self.db.recordDelete(CoachingPersona, personaId)
|
||||||
|
|
||||||
|
def getAllPersonas(self, instanceId: str) -> List[Dict[str, Any]]:
|
||||||
|
"""All personas (builtin + custom for this instance), including inactive."""
|
||||||
|
from .datamodelCommcoach import CoachingPersona
|
||||||
|
builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"})
|
||||||
|
custom = self.db.getRecordset(CoachingPersona, recordFilter={"instanceId": instanceId})
|
||||||
|
custom = [p for p in custom if p.get("userId") != "system"]
|
||||||
|
return builtins + custom
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Module-Persona Mapping
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getModulePersonas(self, moduleId: str) -> List[Dict[str, Any]]:
|
||||||
|
from .datamodelCommcoach import ModulePersonaMapping
|
||||||
|
return self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId})
|
||||||
|
|
||||||
|
def setModulePersonas(self, moduleId: str, personaIds: List[str], instanceId: str) -> List[Dict[str, Any]]:
|
||||||
|
from .datamodelCommcoach import ModulePersonaMapping
|
||||||
|
existing = self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId})
|
||||||
|
for rec in existing:
|
||||||
|
self.db.recordDelete(ModulePersonaMapping, rec["id"])
|
||||||
|
created = []
|
||||||
|
for pId in personaIds:
|
||||||
|
data = ModulePersonaMapping(
|
||||||
|
moduleId=moduleId,
|
||||||
|
personaId=pId,
|
||||||
|
instanceId=instanceId,
|
||||||
|
).model_dump()
|
||||||
|
data["createdAt"] = getIsoTimestamp()
|
||||||
|
data["updatedAt"] = getIsoTimestamp()
|
||||||
|
created.append(self.db.recordCreate(ModulePersonaMapping, data))
|
||||||
|
return created
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Badges
|
# Badges
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -299,8 +345,8 @@ class CommcoachObjects:
|
||||||
# Score History
|
# Score History
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def getScoreHistory(self, contextId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]:
|
def getScoreHistory(self, moduleId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
scores = self.getScores(contextId, userId)
|
scores = self.getScores(moduleId, userId)
|
||||||
history: Dict[str, List[Dict[str, Any]]] = {}
|
history: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
for s in scores:
|
for s in scores:
|
||||||
dim = s.get("dimension", "unknown")
|
dim = s.get("dimension", "unknown")
|
||||||
|
|
@ -344,16 +390,15 @@ class CommcoachObjects:
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def getDashboardData(self, userId: str, instanceId: str) -> Dict[str, Any]:
|
def getDashboardData(self, userId: str, instanceId: str) -> Dict[str, Any]:
|
||||||
contexts = self.db.getRecordset(CoachingContext, recordFilter={"userId": userId, "instanceId": instanceId})
|
modules = self.db.getRecordset(TrainingModule, recordFilter={"userId": userId, "instanceId": instanceId})
|
||||||
sessions = self.db.getRecordset(CoachingSession, recordFilter={"userId": userId, "instanceId": instanceId})
|
sessions = self.db.getRecordset(CoachingSession, recordFilter={"userId": userId, "instanceId": instanceId})
|
||||||
profile = self.getProfile(userId, instanceId)
|
profile = self.getProfile(userId, instanceId)
|
||||||
|
|
||||||
activeContexts = [c for c in contexts if c.get("status") == CoachingContextStatus.ACTIVE.value]
|
activeModules = [m for m in modules if m.get("status") == TrainingModuleStatus.ACTIVE.value]
|
||||||
completedSessions = [s for s in sessions if s.get("status") == CoachingSessionStatus.COMPLETED.value]
|
|
||||||
|
|
||||||
totalMinutes = sum(s.get("durationSeconds", 0) for s in completedSessions) // 60
|
totalMinutes = sum(s.get("durationSeconds", 0) for s in sessions) // 60
|
||||||
scores = []
|
scores = []
|
||||||
for s in completedSessions:
|
for s in sessions:
|
||||||
raw = s.get("competenceScore")
|
raw = s.get("competenceScore")
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -364,29 +409,27 @@ class CommcoachObjects:
|
||||||
|
|
||||||
recentScores = self.getRecentScores(userId, limit=10)
|
recentScores = self.getRecentScores(userId, limit=10)
|
||||||
|
|
||||||
contextSummaries = []
|
countByModule: Dict[str, int] = {}
|
||||||
for ctx in activeContexts:
|
for s in sessions:
|
||||||
goalProgress = _calcGoalProgress(ctx.get("goals"))
|
mid = s.get("moduleId")
|
||||||
contextSummaries.append({
|
if mid:
|
||||||
"id": ctx.get("id"),
|
countByModule[mid] = countByModule.get(mid, 0) + 1
|
||||||
"title": ctx.get("title"),
|
|
||||||
"category": ctx.get("category"),
|
moduleSummaries = []
|
||||||
"sessionCount": ctx.get("sessionCount", 0),
|
for mod in activeModules:
|
||||||
"lastSessionAt": ctx.get("lastSessionAt"),
|
modId = mod.get("id", "")
|
||||||
"goalProgress": goalProgress,
|
moduleSummaries.append({
|
||||||
|
"id": modId,
|
||||||
|
"title": mod.get("title"),
|
||||||
|
"moduleType": mod.get("moduleType"),
|
||||||
|
"sessionCount": countByModule.get(modId, 0),
|
||||||
|
"lastSessionAt": mod.get("lastSessionAt"),
|
||||||
})
|
})
|
||||||
|
|
||||||
allGoalProgress = []
|
|
||||||
for ctx in activeContexts:
|
|
||||||
gp = _calcGoalProgress(ctx.get("goals"))
|
|
||||||
if gp is not None:
|
|
||||||
allGoalProgress.append(gp)
|
|
||||||
overallGoalProgress = round(sum(allGoalProgress) / len(allGoalProgress)) if allGoalProgress else None
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"totalContexts": len(contexts),
|
"totalModules": len(modules),
|
||||||
"activeContexts": len(activeContexts),
|
"activeModules": len(activeModules),
|
||||||
"totalSessions": len(completedSessions),
|
"totalSessions": len(sessions),
|
||||||
"totalMinutes": totalMinutes,
|
"totalMinutes": totalMinutes,
|
||||||
"streakDays": profile.get("streakDays", 0) if profile else 0,
|
"streakDays": profile.get("streakDays", 0) if profile else 0,
|
||||||
"longestStreak": profile.get("longestStreak", 0) if profile else 0,
|
"longestStreak": profile.get("longestStreak", 0) if profile else 0,
|
||||||
|
|
@ -394,29 +437,12 @@ class CommcoachObjects:
|
||||||
"recentScores": recentScores,
|
"recentScores": recentScores,
|
||||||
"openTasks": self.getOpenTaskCount(userId, instanceId),
|
"openTasks": self.getOpenTaskCount(userId, instanceId),
|
||||||
"completedTasks": self.getCompletedTaskCount(userId, instanceId),
|
"completedTasks": self.getCompletedTaskCount(userId, instanceId),
|
||||||
"contexts": contextSummaries,
|
"modules": moduleSummaries,
|
||||||
"goalProgress": overallGoalProgress,
|
|
||||||
"badges": self.getBadges(userId, instanceId),
|
"badges": self.getBadges(userId, instanceId),
|
||||||
"level": _calcLevel(profile.get("totalSessions", 0) if profile else 0),
|
"level": _calcLevel(len(sessions)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _calcGoalProgress(goalsRaw) -> Optional[int]:
|
|
||||||
"""Calculate goal completion percentage from a context's goals JSON field."""
|
|
||||||
if not goalsRaw:
|
|
||||||
return None
|
|
||||||
goals = goalsRaw
|
|
||||||
if isinstance(goalsRaw, str):
|
|
||||||
try:
|
|
||||||
goals = json.loads(goalsRaw)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
return None
|
|
||||||
if not isinstance(goals, list) or len(goals) == 0:
|
|
||||||
return None
|
|
||||||
done = sum(1 for g in goals if isinstance(g, dict) and g.get("status") in ("done", "completed"))
|
|
||||||
return round(done / len(goals) * 100)
|
|
||||||
|
|
||||||
|
|
||||||
_LEVELS = [
|
_LEVELS = [
|
||||||
(50, 5, "master", "Meister"),
|
(50, 5, "master", "Meister"),
|
||||||
(25, 4, "expert", "Experte"),
|
(25, 4, "expert", "Experte"),
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,24 @@ UI_OBJECTS = [
|
||||||
"meta": {"area": "dashboard"}
|
"meta": {"area": "dashboard"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.commcoach.coaching",
|
"objectKey": "ui.feature.commcoach.assistant",
|
||||||
"label": t("Arbeitsthemen", context="UI"),
|
"label": t("Assistent", context="UI"),
|
||||||
"meta": {"area": "coaching"}
|
"meta": {"area": "assistant"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "ui.feature.commcoach.modules",
|
||||||
|
"label": t("Module", context="UI"),
|
||||||
|
"meta": {"area": "modules"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "ui.feature.commcoach.session",
|
||||||
|
"label": t("Session", context="UI"),
|
||||||
|
"meta": {"area": "session"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "ui.feature.commcoach.dossier",
|
||||||
|
"label": t("Dossier", context="UI"),
|
||||||
|
"meta": {"area": "dossier"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.commcoach.settings",
|
"objectKey": "ui.feature.commcoach.settings",
|
||||||
|
|
@ -35,15 +50,15 @@ UI_OBJECTS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
DATA_OBJECTS = [
|
DATA_OBJECTS = [
|
||||||
# ── Record-Hierarchie: Context → Session → Message/Score, Context → Task ──
|
# ── Record-Hierarchie: TrainingModule → Session → Message/Score, TrainingModule → Task ──
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingContext",
|
"objectKey": "data.feature.commcoach.TrainingModule",
|
||||||
"label": t("Coaching-Kontext", context="UI"),
|
"label": t("Trainings-Modul", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingContext",
|
"table": "TrainingModule",
|
||||||
"fields": ["id", "title", "category", "status", "lastSessionAt"],
|
"fields": ["id", "title", "moduleType", "status", "lastSessionAt"],
|
||||||
"isParent": True,
|
"isParent": True,
|
||||||
"displayFields": ["title", "category", "status"],
|
"displayFields": ["title", "moduleType", "status"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -51,10 +66,10 @@ DATA_OBJECTS = [
|
||||||
"label": t("Coaching-Session", context="UI"),
|
"label": t("Coaching-Session", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingSession",
|
"table": "CoachingSession",
|
||||||
"fields": ["id", "contextId", "status", "summary", "startedAt", "endedAt", "competenceScore"],
|
"fields": ["id", "moduleId", "status", "summary", "startedAt", "endedAt", "competenceScore"],
|
||||||
"isParent": True,
|
"isParent": True,
|
||||||
"parentTable": "CoachingContext",
|
"parentTable": "TrainingModule",
|
||||||
"parentKey": "contextId",
|
"parentKey": "moduleId",
|
||||||
"displayFields": ["startedAt", "status"],
|
"displayFields": ["startedAt", "status"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -63,7 +78,7 @@ DATA_OBJECTS = [
|
||||||
"label": t("Coaching-Nachricht", context="UI"),
|
"label": t("Coaching-Nachricht", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingMessage",
|
"table": "CoachingMessage",
|
||||||
"fields": ["id", "sessionId", "contextId", "role", "content", "contentType"],
|
"fields": ["id", "sessionId", "moduleId", "role", "content", "contentType"],
|
||||||
"parentTable": "CoachingSession",
|
"parentTable": "CoachingSession",
|
||||||
"parentKey": "sessionId",
|
"parentKey": "sessionId",
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +88,7 @@ DATA_OBJECTS = [
|
||||||
"label": t("Coaching-Score", context="UI"),
|
"label": t("Coaching-Score", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingScore",
|
"table": "CoachingScore",
|
||||||
"fields": ["id", "sessionId", "contextId", "dimension", "score", "trend"],
|
"fields": ["id", "sessionId", "moduleId", "dimension", "score", "trend"],
|
||||||
"parentTable": "CoachingSession",
|
"parentTable": "CoachingSession",
|
||||||
"parentKey": "sessionId",
|
"parentKey": "sessionId",
|
||||||
}
|
}
|
||||||
|
|
@ -83,9 +98,9 @@ DATA_OBJECTS = [
|
||||||
"label": t("Coaching-Aufgabe", context="UI"),
|
"label": t("Coaching-Aufgabe", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingTask",
|
"table": "CoachingTask",
|
||||||
"fields": ["id", "contextId", "title", "status", "priority", "dueDate"],
|
"fields": ["id", "moduleId", "title", "status", "priority", "dueDate"],
|
||||||
"parentTable": "CoachingContext",
|
"parentTable": "TrainingModule",
|
||||||
"parentKey": "contextId",
|
"parentKey": "moduleId",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
# ── Stammdaten (sessionübergreifend, scoped per userId) ──────────────────
|
# ── Stammdaten (sessionübergreifend, scoped per userId) ──────────────────
|
||||||
|
|
@ -112,6 +127,15 @@ DATA_OBJECTS = [
|
||||||
"fields": ["id", "key", "label", "gender", "category"],
|
"fields": ["id", "key", "label", "gender", "category"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "data.feature.commcoach.ModulePersonaMapping",
|
||||||
|
"label": t("Modul-Persona-Zuordnung", context="UI"),
|
||||||
|
"meta": {
|
||||||
|
"table": "ModulePersonaMapping",
|
||||||
|
"group": "data.feature.commcoach.userData",
|
||||||
|
"fields": ["id", "moduleId", "personaId", "instanceId"],
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingBadge",
|
"objectKey": "data.feature.commcoach.CoachingBadge",
|
||||||
"label": t("Coaching-Auszeichnung", context="UI"),
|
"label": t("Coaching-Auszeichnung", context="UI"),
|
||||||
|
|
@ -130,19 +154,19 @@ DATA_OBJECTS = [
|
||||||
|
|
||||||
RESOURCE_OBJECTS = [
|
RESOURCE_OBJECTS = [
|
||||||
{
|
{
|
||||||
"objectKey": "resource.feature.commcoach.context.create",
|
"objectKey": "resource.feature.commcoach.module.create",
|
||||||
"label": t("Kontext erstellen", context="UI"),
|
"label": t("Modul erstellen", context="UI"),
|
||||||
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"}
|
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules", "method": "POST"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "resource.feature.commcoach.context.archive",
|
"objectKey": "resource.feature.commcoach.module.archive",
|
||||||
"label": t("Kontext archivieren", context="UI"),
|
"label": t("Modul archivieren", context="UI"),
|
||||||
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"}
|
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/archive", "method": "POST"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "resource.feature.commcoach.session.start",
|
"objectKey": "resource.feature.commcoach.session.start",
|
||||||
"label": t("Session starten", context="UI"),
|
"label": t("Session starten", context="UI"),
|
||||||
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/sessions/start", "method": "POST"}
|
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/sessions/start", "method": "POST"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "resource.feature.commcoach.session.complete",
|
"objectKey": "resource.feature.commcoach.session.complete",
|
||||||
|
|
@ -152,7 +176,17 @@ RESOURCE_OBJECTS = [
|
||||||
{
|
{
|
||||||
"objectKey": "resource.feature.commcoach.task.manage",
|
"objectKey": "resource.feature.commcoach.task.manage",
|
||||||
"label": t("Aufgaben verwalten", context="UI"),
|
"label": t("Aufgaben verwalten", context="UI"),
|
||||||
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/tasks", "method": "POST"}
|
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/tasks", "method": "POST"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "resource.feature.commcoach.persona.manage",
|
||||||
|
"label": t("Persona verwalten", context="UI"),
|
||||||
|
"meta": {"endpoint": "/api/commcoach/{instanceId}/personas", "method": "POST"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "resource.feature.commcoach.modulePersonas.manage",
|
||||||
|
"label": t("Modul-Persona-Zuordnung verwalten", context="UI"),
|
||||||
|
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/personas", "method": "PUT"}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -162,28 +196,33 @@ TEMPLATE_ROLES = [
|
||||||
"description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
|
"description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
|
{"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.commcoach.modules", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.commcoach.session", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
|
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
# Viewer: keine RESOURCE-Endpunkte (Mutationen); Regel explizit fuer konsistente Kontext-Matrix
|
|
||||||
{"context": "RESOURCE", "item": None, "view": False},
|
{"context": "RESOURCE", "item": None, "view": False},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "commcoach-user",
|
"roleLabel": "commcoach-user",
|
||||||
"description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
|
"description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Module und Sessions verwalten",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
|
{"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.commcoach.modules", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.commcoach.session", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
|
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
|
||||||
{"context": "DATA", "item": "data.feature.commcoach.CoachingContext", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
{"context": "DATA", "item": "data.feature.commcoach.TrainingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
||||||
{"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
{"context": "DATA", "item": "data.feature.commcoach.CoachingMessage", "view": True, "read": "m", "create": "m", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.commcoach.CoachingMessage", "view": True, "read": "m", "create": "m", "update": "n", "delete": "n"},
|
||||||
{"context": "DATA", "item": "data.feature.commcoach.CoachingTask", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
{"context": "DATA", "item": "data.feature.commcoach.CoachingTask", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
||||||
{"context": "DATA", "item": "data.feature.commcoach.CoachingScore", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.commcoach.CoachingScore", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
{"context": "DATA", "item": "data.feature.commcoach.CoachingUserProfile", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.commcoach.CoachingUserProfile", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.context.create", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.commcoach.module.create", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.context.archive", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.commcoach.module.archive", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True},
|
||||||
|
|
@ -252,6 +291,7 @@ def registerFeature(catalogService) -> bool:
|
||||||
meta=dataObj.get("meta")
|
meta=dataObj.get("meta")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_runMigrations()
|
||||||
_syncTemplateRolesToDb()
|
_syncTemplateRolesToDb()
|
||||||
_seedBuiltinPersonas()
|
_seedBuiltinPersonas()
|
||||||
_registerScheduler()
|
_registerScheduler()
|
||||||
|
|
@ -264,6 +304,135 @@ def registerFeature(catalogService) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _runMigrations():
|
||||||
|
"""Idempotent DB migrations for CommCoach feature.
|
||||||
|
Runs on every bootstrap; each step checks preconditions before executing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .interfaceFeatureCommcoach import commcoachDatabase
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
database=commcoachDatabase,
|
||||||
|
user=APP_CONFIG.get("DB_USER"),
|
||||||
|
password=APP_CONFIG.get("DB_PASSWORD_SECRET"),
|
||||||
|
port=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
)
|
||||||
|
conn.autocommit = False
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
def _tableExists(name):
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM information_schema.tables WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
|
||||||
|
(name,),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
def _columnExists(table, column):
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM information_schema.columns WHERE LOWER(table_name) = LOWER(%s) AND LOWER(column_name) = LOWER(%s) AND table_schema = 'public'",
|
||||||
|
(table, column),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
migrated = False
|
||||||
|
|
||||||
|
# M1: Rename table CoachingContext -> TrainingModule
|
||||||
|
if _tableExists("CoachingContext") and not _tableExists("TrainingModule"):
|
||||||
|
cur.execute('ALTER TABLE "CoachingContext" RENAME TO "TrainingModule"')
|
||||||
|
logger.info("Migration M1: Renamed table CoachingContext -> TrainingModule")
|
||||||
|
migrated = True
|
||||||
|
|
||||||
|
# M2: Rename contextId -> moduleId on child tables
|
||||||
|
for childTable in ["CoachingSession", "CoachingMessage", "CoachingTask", "CoachingScore"]:
|
||||||
|
if _tableExists(childTable) and _columnExists(childTable, "contextId") and not _columnExists(childTable, "moduleId"):
|
||||||
|
cur.execute(f'ALTER TABLE "{childTable}" RENAME COLUMN "contextId" TO "moduleId"')
|
||||||
|
logger.info(f"Migration M2: Renamed contextId -> moduleId on {childTable}")
|
||||||
|
migrated = True
|
||||||
|
|
||||||
|
# M3: Add moduleType column with default 'coaching'
|
||||||
|
if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "moduleType"):
|
||||||
|
cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "moduleType" TEXT DEFAULT \'coaching\'')
|
||||||
|
cur.execute('UPDATE "TrainingModule" SET "moduleType" = \'coaching\' WHERE "moduleType" IS NULL')
|
||||||
|
logger.info("Migration M3: Added moduleType column to TrainingModule")
|
||||||
|
migrated = True
|
||||||
|
|
||||||
|
# M4: Add personaId column
|
||||||
|
if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "personaId"):
|
||||||
|
cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "personaId" TEXT')
|
||||||
|
logger.info("Migration M4: Added personaId column to TrainingModule")
|
||||||
|
migrated = True
|
||||||
|
|
||||||
|
# M5: Add kpiTargets column
|
||||||
|
if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "kpiTargets"):
|
||||||
|
cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "kpiTargets" TEXT')
|
||||||
|
logger.info("Migration M5: Added kpiTargets column to TrainingModule")
|
||||||
|
migrated = True
|
||||||
|
|
||||||
|
# M6: Drop category column (replaced by moduleType)
|
||||||
|
if _tableExists("TrainingModule") and _columnExists("TrainingModule", "category"):
|
||||||
|
cur.execute('ALTER TABLE "TrainingModule" DROP COLUMN "category"')
|
||||||
|
logger.info("Migration M6: Dropped category column from TrainingModule")
|
||||||
|
migrated = True
|
||||||
|
|
||||||
|
# M7: Convert goals from JSON array to plain text
|
||||||
|
if _tableExists("TrainingModule") and _columnExists("TrainingModule", "goals"):
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE "TrainingModule"
|
||||||
|
SET "goals" = subq.plainText
|
||||||
|
FROM (
|
||||||
|
SELECT id,
|
||||||
|
string_agg(elem->>'text', E'\\n') AS plainText
|
||||||
|
FROM "TrainingModule",
|
||||||
|
LATERAL jsonb_array_elements("goals"::jsonb) AS elem
|
||||||
|
WHERE "goals" IS NOT NULL
|
||||||
|
AND "goals" LIKE '[%'
|
||||||
|
GROUP BY id
|
||||||
|
) subq
|
||||||
|
WHERE "TrainingModule".id = subq.id
|
||||||
|
""")
|
||||||
|
rowCount = cur.rowcount
|
||||||
|
if rowCount > 0:
|
||||||
|
logger.info(f"Migration M7: Converted {rowCount} goals fields from JSON to plain text")
|
||||||
|
migrated = True
|
||||||
|
|
||||||
|
# M8: Create ModulePersonaMapping table
|
||||||
|
if not _tableExists("ModulePersonaMapping"):
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE "ModulePersonaMapping" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
"moduleId" TEXT NOT NULL,
|
||||||
|
"personaId" TEXT NOT NULL,
|
||||||
|
"instanceId" TEXT NOT NULL,
|
||||||
|
"createdAt" TEXT,
|
||||||
|
"updatedAt" TEXT,
|
||||||
|
UNIQUE("moduleId", "personaId")
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute('CREATE INDEX IF NOT EXISTS idx_mpm_module ON "ModulePersonaMapping" ("moduleId")')
|
||||||
|
cur.execute('CREATE INDEX IF NOT EXISTS idx_mpm_persona ON "ModulePersonaMapping" ("personaId")')
|
||||||
|
logger.info("Migration M8: Created ModulePersonaMapping table")
|
||||||
|
migrated = True
|
||||||
|
|
||||||
|
if migrated:
|
||||||
|
conn.commit()
|
||||||
|
logger.info("CommCoach DB migrations committed")
|
||||||
|
else:
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("psycopg2 not available, skipping CommCoach DB migrations")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"CommCoach DB migration failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
|
||||||
def _seedBuiltinPersonas():
|
def _seedBuiltinPersonas():
|
||||||
"""Seed builtin roleplay personas into the database."""
|
"""Seed builtin roleplay personas into the database."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
CommCoach routes for the backend API.
|
CommCoach routes for the backend API.
|
||||||
Implements coaching context management, session streaming, tasks, and dashboard.
|
Implements training module management, session streaming, tasks, and dashboard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -23,14 +23,14 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
|
|
||||||
from . import interfaceFeatureCommcoach as interfaceDb
|
from . import interfaceFeatureCommcoach as interfaceDb
|
||||||
from .datamodelCommcoach import (
|
from .datamodelCommcoach import (
|
||||||
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
|
TrainingModule, TrainingModuleStatus, CoachingSession, CoachingSessionStatus,
|
||||||
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
|
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
|
||||||
CoachingTask, CoachingTaskStatus,
|
CoachingTask, CoachingTaskStatus,
|
||||||
CoachingPersona, CoachingBadge,
|
CoachingPersona, CoachingBadge, ModulePersonaMapping,
|
||||||
CreateContextRequest, UpdateContextRequest,
|
CreateModuleRequest, UpdateModuleRequest,
|
||||||
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
|
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest,
|
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, SetModulePersonasRequest,
|
||||||
)
|
)
|
||||||
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
|
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
@ -91,204 +91,200 @@ def _validateOwnership(record: dict, context: RequestContext, fieldName: str = "
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Context Endpoints
|
# Module Endpoints (formerly Context)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts")
|
@router.get("/{instanceId}/modules")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def listContexts(
|
async def listModules(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
includeArchived: bool = False,
|
includeArchived: bool = False,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""List all coaching contexts for the current user."""
|
"""List all training modules for the current user."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
contexts = interface.getContexts(instanceId, userId, includeArchived=includeArchived)
|
modules = interface.getModules(instanceId, userId, includeArchived=includeArchived)
|
||||||
return {"contexts": contexts}
|
return {"modules": modules}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts")
|
@router.post("/{instanceId}/modules")
|
||||||
@limiter.limit("20/minute")
|
@limiter.limit("20/minute")
|
||||||
async def createContext(
|
async def createModule(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
body: CreateContextRequest,
|
body: CreateModuleRequest,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""Create a new coaching context/dossier."""
|
"""Create a new training module."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
goalsJson = None
|
moduleData = TrainingModule(
|
||||||
if body.goals:
|
|
||||||
import uuid as _uuid
|
|
||||||
goalsList = [{"id": str(_uuid.uuid4()), "text": g, "status": "open", "createdAt": ""} for g in body.goals]
|
|
||||||
goalsJson = json.dumps(goalsList)
|
|
||||||
|
|
||||||
contextData = CoachingContext(
|
|
||||||
userId=userId,
|
userId=userId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
instanceId=instanceId,
|
instanceId=instanceId,
|
||||||
title=body.title,
|
title=body.title,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
category=body.category,
|
moduleType=body.moduleType,
|
||||||
goals=goalsJson,
|
goals=body.goals,
|
||||||
|
personaId=body.personaId,
|
||||||
|
kpiTargets=body.kpiTargets,
|
||||||
).model_dump()
|
).model_dump()
|
||||||
|
|
||||||
created = interface.createContext(contextData)
|
created = interface.createModule(moduleData)
|
||||||
logger.info(f"CommCoach context created: {created.get('id')} for user {userId}")
|
logger.info(f"CommCoach module created: {created.get('id')} for user {userId}")
|
||||||
_audit(context, "commcoach.context.created", "CoachingContext", created.get("id"), f"Title: {body.title}")
|
_audit(context, "commcoach.module.created", "TrainingModule", created.get("id"), f"Title: {body.title}")
|
||||||
return {"context": created}
|
return {"module": created}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}")
|
@router.get("/{instanceId}/modules/{moduleId}")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def getContext(
|
async def getModuleDetail(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""Get a coaching context with tasks and score summary."""
|
"""Get a training module with tasks and score summary."""
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
tasks = interface.getTasks(contextId, userId)
|
tasks = interface.getTasks(moduleId, userId)
|
||||||
scores = interface.getScores(contextId, userId)
|
scores = interface.getScores(moduleId, userId)
|
||||||
sessions = interface.getSessions(contextId, userId)
|
sessions = interface.getSessions(moduleId, userId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"context": ctx,
|
"module": mod,
|
||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
"scores": scores,
|
"scores": scores,
|
||||||
"sessions": sessions,
|
"sessions": sessions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{instanceId}/contexts/{contextId}")
|
@router.put("/{instanceId}/modules/{moduleId}")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def updateContext(
|
async def updateModuleFields(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
body: UpdateContextRequest,
|
body: UpdateModuleRequest,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
updates = body.model_dump(exclude_none=True)
|
updates = body.model_dump(exclude_none=True)
|
||||||
updated = interface.updateContext(contextId, updates)
|
updated = interface.updateModule(moduleId, updates)
|
||||||
return {"context": updated}
|
return {"module": updated}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{instanceId}/contexts/{contextId}")
|
@router.delete("/{instanceId}/modules/{moduleId}")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def deleteContext(
|
async def deleteModuleAndData(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
interface.deleteContext(contextId)
|
interface.deleteModule(moduleId)
|
||||||
return {"deleted": True}
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts/{contextId}/archive")
|
@router.post("/{instanceId}/modules/{moduleId}/archive")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def archiveContext(
|
async def archiveModule(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value})
|
updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ARCHIVED.value})
|
||||||
_audit(context, "commcoach.context.archived", "CoachingContext", contextId)
|
_audit(context, "commcoach.module.archived", "TrainingModule", moduleId)
|
||||||
return {"context": updated}
|
return {"module": updated}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts/{contextId}/activate")
|
@router.post("/{instanceId}/modules/{moduleId}/activate")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def activateContext(
|
async def activateModule(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value})
|
updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ACTIVE.value})
|
||||||
return {"context": updated}
|
return {"module": updated}
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Session Endpoints
|
# Session Endpoints
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}/sessions")
|
@router.get("/{instanceId}/modules/{moduleId}/sessions")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def listSessions(
|
async def listSessions(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
sessions = interface.getSessions(contextId, userId)
|
sessions = interface.getSessions(moduleId, userId)
|
||||||
return {"sessions": sessions}
|
return {"sessions": sessions}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts/{contextId}/sessions/start")
|
@router.post("/{instanceId}/modules/{moduleId}/sessions/start")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def startSession(
|
async def startSession(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
personaId: Optional[str] = None,
|
personaId: Optional[str] = None,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
|
|
@ -297,22 +293,22 @@ async def startSession(
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
activeSession = interface.getActiveSession(contextId, userId)
|
activeSession = interface.getActiveSession(moduleId, userId)
|
||||||
if activeSession:
|
if activeSession:
|
||||||
sessionId = activeSession.get("id")
|
sessionId = activeSession.get("id")
|
||||||
messages = interface.getMessages(sessionId)
|
messages = interface.getMessages(sessionId)
|
||||||
|
|
||||||
async def _resumedEventGenerator():
|
async def _resumedEventGenerator():
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
greetingText = await service.generateResumeGreeting(sessionId, contextId, messages, interface)
|
greetingText = await service.generateResumeGreeting(sessionId, moduleId, messages, interface)
|
||||||
assistantMsg = CoachingMessage(
|
assistantMsg = CoachingMessage(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
contextId=contextId,
|
moduleId=moduleId,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
role=CoachingMessageRole.ASSISTANT,
|
role=CoachingMessageRole.ASSISTANT,
|
||||||
content=greetingText,
|
content=greetingText,
|
||||||
|
|
@ -323,7 +319,7 @@ async def startSession(
|
||||||
greetingForFrontend = {
|
greetingForFrontend = {
|
||||||
"id": createdGreeting.get("id"),
|
"id": createdGreeting.get("id"),
|
||||||
"sessionId": sessionId,
|
"sessionId": sessionId,
|
||||||
"contextId": contextId,
|
"moduleId": moduleId,
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": greetingText,
|
"content": greetingText,
|
||||||
"contentType": "text",
|
"contentType": "text",
|
||||||
|
|
@ -365,7 +361,7 @@ async def startSession(
|
||||||
)
|
)
|
||||||
|
|
||||||
sessionData = CoachingSession(
|
sessionData = CoachingSession(
|
||||||
contextId=contextId,
|
moduleId=moduleId,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
instanceId=instanceId,
|
instanceId=instanceId,
|
||||||
|
|
@ -378,7 +374,7 @@ async def startSession(
|
||||||
await emitSessionEvent(sessionId, "sessionState", {"session": created, "resumed": False})
|
await emitSessionEvent(sessionId, "sessionState", {"session": created, "resumed": False})
|
||||||
|
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
asyncio.create_task(service.processSessionOpening(sessionId, contextId, interface))
|
asyncio.create_task(service.processSessionOpening(sessionId, moduleId, interface))
|
||||||
|
|
||||||
async def _newSessionEventGenerator():
|
async def _newSessionEventGenerator():
|
||||||
from modules.shared.timeUtils import getIsoTimestamp
|
from modules.shared.timeUtils import getIsoTimestamp
|
||||||
|
|
@ -399,8 +395,8 @@ async def startSession(
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}")
|
logger.info(f"CommCoach session started (streaming): {sessionId} for module {moduleId}")
|
||||||
_audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Context: {contextId}")
|
_audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Module: {moduleId}")
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_newSessionEventGenerator(),
|
_newSessionEventGenerator(),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
|
|
@ -504,7 +500,7 @@ async def sendMessageStream(
|
||||||
if session.get("status") != CoachingSessionStatus.ACTIVE.value:
|
if session.get("status") != CoachingSessionStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active"))
|
raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active"))
|
||||||
|
|
||||||
contextId = session.get("contextId")
|
moduleId = session.get("moduleId")
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
|
|
||||||
existingTask = _activeProcessTasks.get(sessionId)
|
existingTask = _activeProcessTasks.get(sessionId)
|
||||||
|
|
@ -517,7 +513,7 @@ async def sendMessageStream(
|
||||||
|
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
service.processMessage(
|
service.processMessage(
|
||||||
sessionId, contextId, body.content, interface,
|
sessionId, moduleId, body.content, interface,
|
||||||
fileIds=body.fileIds,
|
fileIds=body.fileIds,
|
||||||
dataSourceIds=body.dataSourceIds,
|
dataSourceIds=body.dataSourceIds,
|
||||||
featureDataSourceIds=body.featureDataSourceIds,
|
featureDataSourceIds=body.featureDataSourceIds,
|
||||||
|
|
@ -587,11 +583,11 @@ async def sendAudioStream(
|
||||||
from .serviceCommcoach import getUserVoicePrefs
|
from .serviceCommcoach import getUserVoicePrefs
|
||||||
language, _ = getUserVoicePrefs(str(context.user.id), mandateId)
|
language, _ = getUserVoicePrefs(str(context.user.id), mandateId)
|
||||||
|
|
||||||
contextId = session.get("contextId")
|
moduleId = session.get("moduleId")
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
service.processAudioMessage(sessionId, contextId, audioBody, language, interface)
|
service.processAudioMessage(sessionId, moduleId, audioBody, language, interface)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _eventGenerator():
|
async def _eventGenerator():
|
||||||
|
|
@ -680,27 +676,27 @@ async def streamSession(
|
||||||
# Task Endpoints
|
# Task Endpoints
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}/tasks")
|
@router.get("/{instanceId}/modules/{moduleId}/tasks")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def listTasks(
|
async def listTasks(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
tasks = interface.getTasks(contextId, userId)
|
tasks = interface.getTasks(moduleId, userId)
|
||||||
return {"tasks": tasks}
|
return {"tasks": tasks}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts/{contextId}/tasks")
|
@router.post("/{instanceId}/modules/{moduleId}/tasks")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def createTask(
|
async def createTask(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
body: CreateTaskRequest,
|
body: CreateTaskRequest,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
|
|
@ -708,13 +704,13 @@ async def createTask(
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
taskData = CoachingTask(
|
taskData = CoachingTask(
|
||||||
contextId=contextId,
|
moduleId=moduleId,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
title=body.title,
|
title=body.title,
|
||||||
|
|
@ -853,12 +849,12 @@ async def updateProfile(
|
||||||
# Export Endpoints (Iteration 2)
|
# Export Endpoints (Iteration 2)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}/export")
|
@router.get("/{instanceId}/modules/{moduleId}/export")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def exportDossier(
|
async def exportDossier(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
format: str = "md",
|
format: str = "md",
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
|
|
@ -867,26 +863,26 @@ async def exportDossier(
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
tasks = interface.getTasks(contextId, userId)
|
tasks = interface.getTasks(moduleId, userId)
|
||||||
scores = interface.getScores(contextId, userId)
|
scores = interface.getScores(moduleId, userId)
|
||||||
sessions = interface.getSessions(contextId, userId)
|
sessions = interface.getSessions(moduleId, userId)
|
||||||
|
|
||||||
from .serviceCommcoachExport import buildDossierMarkdown, renderDossierPdf
|
from .serviceCommcoachExport import buildDossierMarkdown, renderDossierPdf
|
||||||
_audit(context, "commcoach.export.requested", "CoachingContext", contextId, f"format={format}")
|
_audit(context, "commcoach.export.requested", "TrainingModule", moduleId, f"format={format}")
|
||||||
|
|
||||||
if format == "pdf":
|
if format == "pdf":
|
||||||
pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores)
|
pdfBytes = await renderDossierPdf(mod, sessions, tasks, scores)
|
||||||
return Response(content=pdfBytes, media_type="application/pdf",
|
return Response(content=pdfBytes, media_type="application/pdf",
|
||||||
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'})
|
headers={"Content-Disposition": f'attachment; filename="dossier_{moduleId[:8]}.pdf"'})
|
||||||
|
|
||||||
md = buildDossierMarkdown(ctx, sessions, tasks, scores)
|
md = buildDossierMarkdown(mod, sessions, tasks, scores)
|
||||||
return Response(content=md, media_type="text/markdown",
|
return Response(content=md, media_type="text/markdown",
|
||||||
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.md"'})
|
headers={"Content-Disposition": f'attachment; filename="dossier_{moduleId[:8]}.md"'})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/sessions/{sessionId}/export")
|
@router.get("/{instanceId}/sessions/{sessionId}/export")
|
||||||
|
|
@ -907,11 +903,11 @@ async def exportSession(
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
|
||||||
_validateOwnership(session, context)
|
_validateOwnership(session, context)
|
||||||
|
|
||||||
contextId = session.get("contextId")
|
moduleId = session.get("moduleId")
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
messages = interface.getMessages(sessionId)
|
messages = interface.getMessages(sessionId)
|
||||||
tasks = interface.getTasks(contextId, userId) if contextId else []
|
tasks = interface.getTasks(moduleId, userId) if moduleId else []
|
||||||
scores = interface.getScores(contextId, userId) if contextId else []
|
scores = interface.getScores(moduleId, userId) if moduleId else []
|
||||||
|
|
||||||
from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf
|
from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf
|
||||||
_audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}")
|
_audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}")
|
||||||
|
|
@ -935,13 +931,47 @@ async def exportSession(
|
||||||
async def listPersonas(
|
async def listPersonas(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
|
pagination: Optional[str] = Query(None),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key for mode=filterValues"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
allPersonas = interface.getAllPersonas(instanceId)
|
||||||
personas = interface.getPersonas(userId, instanceId)
|
|
||||||
return {"personas": personas}
|
if mode == "filterValues":
|
||||||
|
from modules.routes.routeHelpers import handleFilterValuesInMemory
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail=routeApiMsg("column parameter required"))
|
||||||
|
return handleFilterValuesInMemory(allPersonas, column, pagination)
|
||||||
|
if mode == "ids":
|
||||||
|
from modules.routes.routeHelpers import handleIdsInMemory
|
||||||
|
return handleIdsInMemory(allPersonas, pagination)
|
||||||
|
|
||||||
|
if pagination:
|
||||||
|
import json as _json
|
||||||
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
||||||
|
from modules.routes.routeHelpers import applyFiltersAndSort, paginateInMemory
|
||||||
|
paginationDict = _json.loads(pagination)
|
||||||
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
|
paginationParams = PaginationParams(**paginationDict)
|
||||||
|
filtered = applyFiltersAndSort(allPersonas, paginationParams)
|
||||||
|
pageItems, totalItems = paginateInMemory(filtered, paginationParams)
|
||||||
|
import math
|
||||||
|
return {
|
||||||
|
"items": pageItems,
|
||||||
|
"pagination": PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=totalItems,
|
||||||
|
totalPages=math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0,
|
||||||
|
sort=[s.model_dump() for s in paginationParams.sort] if paginationParams.sort else [],
|
||||||
|
filters=paginationParams.filters,
|
||||||
|
).model_dump(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"items": allPersonas, "pagination": None}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/personas")
|
@router.post("/{instanceId}/personas")
|
||||||
|
|
@ -1017,6 +1047,43 @@ async def deletePersonaRoute(
|
||||||
return {"deleted": True}
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Module-Persona Mapping Endpoints
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/modules/{moduleId}/personas")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getModulePersonas(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
moduleId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
mappings = interface.getModulePersonas(moduleId)
|
||||||
|
personaIds = [m["personaId"] for m in mappings]
|
||||||
|
return {"personaIds": personaIds}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{instanceId}/modules/{moduleId}/personas")
|
||||||
|
@limiter.limit("20/minute")
|
||||||
|
async def setModulePersonas(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
moduleId: str,
|
||||||
|
body: SetModulePersonasRequest,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
module = interface.getModule(moduleId)
|
||||||
|
if not module:
|
||||||
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
|
interface.setModulePersonas(moduleId, body.personaIds, instanceId)
|
||||||
|
return {"personaIds": body.personaIds}
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Badge + Score History Endpoints (Iteration 2)
|
# Badge + Score History Endpoints (Iteration 2)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -1035,16 +1102,46 @@ async def listBadges(
|
||||||
return {"badges": badges}
|
return {"badges": badges}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}/scores/history")
|
@router.get("/{instanceId}/modules/{moduleId}/scores/history")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def getScoreHistory(
|
async def getScoreHistory(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
history = interface.getScoreHistory(contextId, userId)
|
history = interface.getScoreHistory(moduleId, userId)
|
||||||
return {"history": history}
|
return {"history": history}
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Backward-Compatibility Redirects (old /contexts/ paths → /modules/)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/contexts")
|
||||||
|
async def _redirectListContexts(instanceId: str, request: Request):
|
||||||
|
qs = f"?{request.query_params}" if request.query_params else ""
|
||||||
|
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules{qs}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instanceId}/contexts")
|
||||||
|
async def _redirectCreateContext(instanceId: str, request: Request):
|
||||||
|
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/contexts/{contextId}")
|
||||||
|
async def _redirectGetContext(instanceId: str, contextId: str, request: Request):
|
||||||
|
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{instanceId}/contexts/{contextId}")
|
||||||
|
async def _redirectUpdateContext(instanceId: str, contextId: str, request: Request):
|
||||||
|
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{instanceId}/contexts/{contextId}")
|
||||||
|
async def _redirectDeleteContext(instanceId: str, contextId: str, request: Request):
|
||||||
|
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"})
|
||||||
|
|
|
||||||
|
|
@ -420,7 +420,7 @@ async def _saveOrUpdateDocument(doc: Dict[str, Any], contextId: str, userId: str
|
||||||
logger.info(f"Document saved as platform FileItem: {fileItem.id} ({title})")
|
logger.info(f"Document saved as platform FileItem: {fileItem.id} ({title})")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to save document as FileItem: {e}")
|
logger.error(f"Failed to save document as FileItem: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -483,12 +483,12 @@ def _loadDocumentContents(docIds: List[str], interface, mandateId: str = None, i
|
||||||
content = ""
|
content = ""
|
||||||
try:
|
try:
|
||||||
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"fileId": fId})
|
idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"id": fId})
|
||||||
if idxRecords:
|
if idxRecords:
|
||||||
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
|
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
|
||||||
content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS]
|
content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS]
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.warning(f"Failed to load FileContentIndex for {fId}: {e}")
|
||||||
results.append({
|
results.append({
|
||||||
"id": fId,
|
"id": fId,
|
||||||
"title": f.get("fileName") or f.get("name") or "Dokument",
|
"title": f.get("fileName") or f.get("name") or "Dokument",
|
||||||
|
|
@ -557,13 +557,13 @@ def _getDocumentSummaries(contextId: str, userId: str, interface,
|
||||||
try:
|
try:
|
||||||
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
idxRecords = mgmtIf.db.getRecordset(
|
idxRecords = mgmtIf.db.getRecordset(
|
||||||
FileContentIndex, recordFilter={"fileId": fId}
|
FileContentIndex, recordFilter={"id": fId}
|
||||||
)
|
)
|
||||||
if idxRecords:
|
if idxRecords:
|
||||||
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
|
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
|
||||||
snippet = (idx.get("extractedText") or "")[:200]
|
snippet = (idx.get("extractedText") or "")[:200]
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.warning(f"Failed to load FileContentIndex for {fId}: {e}")
|
||||||
if snippet:
|
if snippet:
|
||||||
summaries.append(f"[{name}] {snippet}...")
|
summaries.append(f"[{name}] {snippet}...")
|
||||||
else:
|
else:
|
||||||
|
|
@ -748,7 +748,7 @@ class CommcoachService:
|
||||||
# Store user message
|
# Store user message
|
||||||
userMsg = CoachingMessage(
|
userMsg = CoachingMessage(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
contextId=contextId,
|
moduleId=contextId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
role=CoachingMessageRole.USER,
|
role=CoachingMessageRole.USER,
|
||||||
content=userContent,
|
content=userContent,
|
||||||
|
|
@ -764,7 +764,7 @@ class CommcoachService:
|
||||||
})
|
})
|
||||||
|
|
||||||
# Build context
|
# Build context
|
||||||
context = interface.getContext(contextId)
|
context = interface.getModule(contextId)
|
||||||
if not context:
|
if not context:
|
||||||
logger.error(f"Context {contextId} not found")
|
logger.error(f"Context {contextId} not found")
|
||||||
return createdUserMsg
|
return createdUserMsg
|
||||||
|
|
@ -857,7 +857,7 @@ class CommcoachService:
|
||||||
|
|
||||||
assistantMsg = CoachingMessage(
|
assistantMsg = CoachingMessage(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
contextId=contextId,
|
moduleId=contextId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
role=CoachingMessageRole.ASSISTANT,
|
role=CoachingMessageRole.ASSISTANT,
|
||||||
content=textContent,
|
content=textContent,
|
||||||
|
|
@ -946,6 +946,8 @@ class CommcoachService:
|
||||||
await emitSessionEvent(sessionId, "toolResult", event.data or {})
|
await emitSessionEvent(sessionId, "toolResult", event.data or {})
|
||||||
elif event.type == AgentEventTypeEnum.AGENT_PROGRESS:
|
elif event.type == AgentEventTypeEnum.AGENT_PROGRESS:
|
||||||
await emitSessionEvent(sessionId, "agentProgress", event.data or {})
|
await emitSessionEvent(sessionId, "agentProgress", event.data or {})
|
||||||
|
elif event.type == AgentEventTypeEnum.FILE_CREATED:
|
||||||
|
await emitSessionEvent(sessionId, "documentCreated", event.data or {})
|
||||||
elif event.type == AgentEventTypeEnum.ERROR:
|
elif event.type == AgentEventTypeEnum.ERROR:
|
||||||
await emitSessionEvent(sessionId, "error", {"message": event.content or "Agent error"})
|
await emitSessionEvent(sessionId, "error", {"message": event.content or "Agent error"})
|
||||||
|
|
||||||
|
|
@ -958,7 +960,7 @@ class CommcoachService:
|
||||||
"""
|
"""
|
||||||
await emitSessionEvent(sessionId, "status", {"label": "Coach bereitet sich vor..."})
|
await emitSessionEvent(sessionId, "status", {"label": "Coach bereitet sich vor..."})
|
||||||
|
|
||||||
context = interface.getContext(contextId)
|
context = interface.getModule(contextId)
|
||||||
if not context:
|
if not context:
|
||||||
logger.error(f"Context {contextId} not found")
|
logger.error(f"Context {contextId} not found")
|
||||||
await emitSessionEvent(sessionId, "error", {"message": "Context not found"})
|
await emitSessionEvent(sessionId, "error", {"message": "Context not found"})
|
||||||
|
|
@ -1024,7 +1026,7 @@ class CommcoachService:
|
||||||
|
|
||||||
assistantMsg = CoachingMessage(
|
assistantMsg = CoachingMessage(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
contextId=contextId,
|
moduleId=contextId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
role=CoachingMessageRole.ASSISTANT,
|
role=CoachingMessageRole.ASSISTANT,
|
||||||
content=textContent,
|
content=textContent,
|
||||||
|
|
@ -1046,7 +1048,7 @@ class CommcoachService:
|
||||||
|
|
||||||
async def generateResumeGreeting(self, sessionId: str, contextId: str, messages: list, interface) -> str:
|
async def generateResumeGreeting(self, sessionId: str, contextId: str, messages: list, interface) -> str:
|
||||||
"""Generate a follow-up greeting when user returns to an active session."""
|
"""Generate a follow-up greeting when user returns to an active session."""
|
||||||
context = interface.getContext(contextId)
|
context = interface.getModule(contextId)
|
||||||
if not context:
|
if not context:
|
||||||
raise ValueError(f"Context {contextId} not found for resume greeting")
|
raise ValueError(f"Context {contextId} not found for resume greeting")
|
||||||
contextTitle = context.get("title", "Coaching")
|
contextTitle = context.get("title", "Coaching")
|
||||||
|
|
@ -1100,8 +1102,10 @@ class CommcoachService:
|
||||||
if not session:
|
if not session:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
contextId = session.get("contextId")
|
contextId = session.get("moduleId")
|
||||||
context = interface.getContext(contextId) if contextId else None
|
if not contextId:
|
||||||
|
logger.error(f"completeSession: session {sessionId} has no moduleId")
|
||||||
|
context = interface.getModule(contextId) if contextId else None
|
||||||
messages = interface.getMessages(sessionId)
|
messages = interface.getMessages(sessionId)
|
||||||
|
|
||||||
if len(messages) < 2:
|
if len(messages) < 2:
|
||||||
|
|
@ -1156,7 +1160,7 @@ class CommcoachService:
|
||||||
for taskData in extractedTasks[:3]:
|
for taskData in extractedTasks[:3]:
|
||||||
if isinstance(taskData, dict) and taskData.get("title"):
|
if isinstance(taskData, dict) and taskData.get("title"):
|
||||||
newTask = CoachingTask(
|
newTask = CoachingTask(
|
||||||
contextId=contextId,
|
moduleId=contextId,
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
|
|
@ -1181,7 +1185,7 @@ class CommcoachService:
|
||||||
for scoreData in scores:
|
for scoreData in scores:
|
||||||
if isinstance(scoreData, dict) and "dimension" in scoreData and "score" in scoreData:
|
if isinstance(scoreData, dict) and "dimension" in scoreData and "score" in scoreData:
|
||||||
newScore = CoachingScore(
|
newScore = CoachingScore(
|
||||||
contextId=contextId,
|
moduleId=contextId,
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
|
|
@ -1213,7 +1217,7 @@ class CommcoachService:
|
||||||
existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()})
|
existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()})
|
||||||
await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId})
|
await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId})
|
||||||
if contextId and existingInsights:
|
if contextId and existingInsights:
|
||||||
interface.updateContext(contextId, {"insights": json.dumps(existingInsights[-10:])})
|
interface.updateModule(contextId, {"insights": json.dumps(existingInsights[-10:])})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Insight generation failed: {e}")
|
logger.warning(f"Insight generation failed: {e}")
|
||||||
|
|
||||||
|
|
@ -1280,7 +1284,7 @@ class CommcoachService:
|
||||||
if contextId:
|
if contextId:
|
||||||
allSessions = interface.getSessions(contextId, self.userId)
|
allSessions = interface.getSessions(contextId, self.userId)
|
||||||
completedCount = len([s for s in allSessions if s.get("status") == CoachingSessionStatus.COMPLETED.value])
|
completedCount = len([s for s in allSessions if s.get("status") == CoachingSessionStatus.COMPLETED.value])
|
||||||
interface.updateContext(contextId, {
|
interface.updateModule(contextId, {
|
||||||
"sessionCount": completedCount,
|
"sessionCount": completedCount,
|
||||||
"lastSessionAt": getUtcTimestamp(),
|
"lastSessionAt": getUtcTimestamp(),
|
||||||
})
|
})
|
||||||
|
|
@ -1429,7 +1433,7 @@ class CommcoachService:
|
||||||
"sessionSummaries": [],
|
"sessionSummaries": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
ctx = interface.getModule(contextId)
|
||||||
rollingOverview = ctx.get("rollingOverview") if ctx else None
|
rollingOverview = ctx.get("rollingOverview") if ctx else None
|
||||||
rollingUpTo = ctx.get("rollingOverviewUpToSessionCount") if ctx else None
|
rollingUpTo = ctx.get("rollingOverviewUpToSessionCount") if ctx else None
|
||||||
|
|
||||||
|
|
@ -1506,7 +1510,7 @@ class CommcoachService:
|
||||||
)
|
)
|
||||||
if overviewResponse and overviewResponse.errorCount == 0 and overviewResponse.content:
|
if overviewResponse and overviewResponse.errorCount == 0 and overviewResponse.content:
|
||||||
newOverview = overviewResponse.content.strip()
|
newOverview = overviewResponse.content.strip()
|
||||||
interface.updateContext(contextId, {
|
interface.updateModule(contextId, {
|
||||||
"rollingOverview": newOverview,
|
"rollingOverview": newOverview,
|
||||||
"rollingOverviewUpToSessionCount": len(completedSessions),
|
"rollingOverviewUpToSessionCount": len(completedSessions),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
|
||||||
badgesToCheck.append(("roleplay_first", True))
|
badgesToCheck.append(("roleplay_first", True))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .datamodelCommcoach import CoachingContextStatus
|
from .datamodelCommcoach import TrainingModuleStatus
|
||||||
allContexts = interface.db.getRecordset(
|
allContexts = interface.db.getRecordset(
|
||||||
interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues
|
interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues
|
||||||
) if False else []
|
) if False else []
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,57 @@ BUILTIN_PERSONAS: List[Dict[str, Any]] = [
|
||||||
"gender": "m",
|
"gender": "m",
|
||||||
"category": "builtin",
|
"category": "builtin",
|
||||||
},
|
},
|
||||||
|
# --- Fachpersonen / Therapeutische & rechtliche Gesprächspartner ---
|
||||||
|
{
|
||||||
|
"key": "couples_therapist_f",
|
||||||
|
"label": "Paartherapeutin",
|
||||||
|
"description": "Dr. Eva Roth, erfahrene Paartherapeutin. Empathisch, strukturiert, stellt gezielte Fragen zu "
|
||||||
|
"Beziehungsdynamiken. Spiegelt Gefühle und Muster, ohne Partei zu ergreifen. Arbeitet mit der "
|
||||||
|
"Gewaltfreien Kommunikation und systemischen Methoden. Fragt nach Bedürfnissen hinter Vorwürfen "
|
||||||
|
"und lenkt das Gespräch auf konkrete Verhaltensänderungen statt Schuldzuweisungen.",
|
||||||
|
"gender": "f",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "psychologist_m",
|
||||||
|
"label": "Psychologe",
|
||||||
|
"description": "Dr. Markus Frei, klinischer Psychologe mit Schwerpunkt Stressbewältigung und Burnout-Prävention. "
|
||||||
|
"Ruhig, geduldig, stellt offene Fragen zur Selbstreflexion. Erkennt Denkmuster und benennt sie "
|
||||||
|
"behutsam. Arbeitet lösungsorientiert und hilft bei der Identifikation von Stressoren, Ressourcen "
|
||||||
|
"und Bewältigungsstrategien. Drängt nicht, lässt Raum für Stille und Nachdenken.",
|
||||||
|
"gender": "m",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "lawyer_m",
|
||||||
|
"label": "Rechtsanwalt",
|
||||||
|
"description": "lic. iur. Daniel Brandt, Wirtschaftsanwalt mit Fokus auf Vertragsrecht und Arbeitsrecht. Sachlich, "
|
||||||
|
"analytisch, prüft jede Aussage auf juristische Stichhaltigkeit. Fragt nach Fakten, Fristen und "
|
||||||
|
"Beweislage. Weist auf Risiken und Haftungsfragen hin. Formuliert präzise und erwartet dasselbe "
|
||||||
|
"vom Gegenüber. Kann unangenehme rechtliche Realitäten nüchtern kommunizieren.",
|
||||||
|
"gender": "m",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "mediator_f",
|
||||||
|
"label": "Mediatorin",
|
||||||
|
"description": "Sabine Lang, zertifizierte Wirtschaftsmediatorin. Strikt neutral, strukturiert den Dialog zwischen "
|
||||||
|
"Konfliktparteien. Stellt sicher, dass beide Seiten gehört werden. Arbeitet mit Ich-Botschaften und "
|
||||||
|
"Interessenklärung statt Positionsverhandlung. Unterbricht respektvoll bei Eskalation und lenkt "
|
||||||
|
"zurück auf Sachebene. Ziel ist immer eine tragfähige Vereinbarung, nicht Recht oder Unrecht.",
|
||||||
|
"gender": "f",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hr_manager_f",
|
||||||
|
"label": "HR-Managerin",
|
||||||
|
"description": "Kathrin Vogt, Head of HR in einem Konzern. Kennt Arbeitsrecht, Feedbackkultur und Change-Prozesse. "
|
||||||
|
"Spricht diplomatisch aber klar. Achtet auf Compliance und Gleichbehandlung. Erwartet strukturierte "
|
||||||
|
"Argumentation bei Personalentscheiden. Reagiert sensibel auf Diskriminierungs- oder Mobbingthemen. "
|
||||||
|
"Kann sowohl Arbeitgeber- als auch Arbeitnehmerperspektive einnehmen.",
|
||||||
|
"gender": "f",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ async def _runDailyReminders():
|
||||||
try:
|
try:
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from .datamodelCommcoach import CoachingUserProfile, CoachingContextStatus
|
from .datamodelCommcoach import CoachingUserProfile, TrainingModuleStatus
|
||||||
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
||||||
from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
|
from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
|
||||||
|
|
||||||
|
|
@ -94,10 +94,10 @@ async def _runDailyReminders():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if user has active contexts
|
# Check if user has active contexts
|
||||||
from .datamodelCommcoach import CoachingContext
|
from .datamodelCommcoach import TrainingModule
|
||||||
contexts = db.getRecordset(CoachingContext, recordFilter={
|
contexts = db.getRecordset(TrainingModule, recordFilter={
|
||||||
"userId": userId,
|
"userId": userId,
|
||||||
"status": CoachingContextStatus.ACTIVE.value,
|
"status": TrainingModuleStatus.ACTIVE.value,
|
||||||
})
|
})
|
||||||
if not contexts:
|
if not contexts:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -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})
|
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})
|
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})
|
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(
|
closedOnTs: Optional[float] = Field(
|
||||||
default=None,
|
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.",
|
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
|
fixedVersionName: Optional[str] = None
|
||||||
categoryId: Optional[int] = None
|
categoryId: Optional[int] = None
|
||||||
categoryName: Optional[str] = None
|
categoryName: Optional[str] = None
|
||||||
|
doneRatio: Optional[int] = None
|
||||||
createdOn: Optional[str] = None
|
createdOn: Optional[str] = None
|
||||||
updatedOn: Optional[str] = None
|
updatedOn: Optional[str] = None
|
||||||
customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list)
|
customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list)
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,7 @@ def _mirroredRowToDto(
|
||||||
fixedVersionName=row.get("fixedVersionName"),
|
fixedVersionName=row.get("fixedVersionName"),
|
||||||
categoryId=row.get("categoryId"),
|
categoryId=row.get("categoryId"),
|
||||||
categoryName=row.get("categoryName"),
|
categoryName=row.get("categoryName"),
|
||||||
|
doneRatio=row.get("doneRatio"),
|
||||||
createdOn=row.get("createdOn"),
|
createdOn=row.get("createdOn"),
|
||||||
updatedOn=row.get("updatedOn"),
|
updatedOn=row.get("updatedOn"),
|
||||||
customFields=[
|
customFields=[
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,7 @@ def _ticketRecordFromIssue(
|
||||||
"fixedVersionName": fixed_version.get("name"),
|
"fixedVersionName": fixed_version.get("name"),
|
||||||
"categoryId": category.get("id"),
|
"categoryId": category.get("id"),
|
||||||
"categoryName": category.get("name"),
|
"categoryName": category.get("name"),
|
||||||
|
"doneRatio": issue.get("done_ratio"),
|
||||||
"createdOn": created_on,
|
"createdOn": created_on,
|
||||||
"updatedOn": updated_on,
|
"updatedOn": updated_on,
|
||||||
"createdOnTs": _parseRedmineDateToEpoch(created_on),
|
"createdOnTs": _parseRedmineDateToEpoch(created_on),
|
||||||
|
|
|
||||||
|
|
@ -79,15 +79,47 @@ class TeamsbotTransferMode(str, Enum):
|
||||||
AUTO = "auto" # Automatic: anonymous → audio, authenticated → caption
|
AUTO = "auto" # Automatic: anonymous → audio, authenticated → caption
|
||||||
|
|
||||||
|
|
||||||
|
class TeamsbotSeriesType(str, Enum):
|
||||||
|
"""Type of meeting series."""
|
||||||
|
WEEKLY = "weekly"
|
||||||
|
BIWEEKLY = "biweekly"
|
||||||
|
MONTHLY = "monthly"
|
||||||
|
ADHOC = "adhoc"
|
||||||
|
PROJECT = "project"
|
||||||
|
|
||||||
|
|
||||||
|
class TeamsbotModuleStatus(str, Enum):
|
||||||
|
"""Status of a meeting module."""
|
||||||
|
ACTIVE = "active"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Database Models (stored in PostgreSQL)
|
# Database Models (stored in PostgreSQL)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
class TeamsbotMeetingModule(PowerOnModel):
|
||||||
|
"""A meeting module groups related sessions (e.g. 'Weekly Standup')."""
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Module ID")
|
||||||
|
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||||
|
mandateId: str = Field(description="Mandate ID (FK)")
|
||||||
|
ownerUserId: str = Field(description="Owner user ID")
|
||||||
|
title: str = Field(description="Module title, e.g. 'Weekly Standup'")
|
||||||
|
seriesType: TeamsbotSeriesType = Field(default=TeamsbotSeriesType.ADHOC)
|
||||||
|
defaultBotId: Optional[str] = Field(default=None, description="FK to TeamsbotSystemBot")
|
||||||
|
defaultDirectorPrompts: Optional[str] = Field(default=None, description="JSON list of default director prompts")
|
||||||
|
goals: Optional[str] = Field(default=None, description="Free-text goals")
|
||||||
|
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
|
||||||
|
status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE)
|
||||||
|
|
||||||
|
|
||||||
class TeamsbotSession(PowerOnModel):
|
class TeamsbotSession(PowerOnModel):
|
||||||
"""A Teams Bot meeting session."""
|
"""A Teams Bot meeting session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
|
||||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||||
mandateId: str = Field(description="Mandate ID (FK)")
|
mandateId: str = Field(description="Mandate ID (FK)")
|
||||||
|
moduleId: Optional[str] = Field(default=None, description="FK to TeamsbotMeetingModule (nullable during transition)")
|
||||||
meetingLink: str = Field(description="Teams meeting join link")
|
meetingLink: str = Field(description="Teams meeting join link")
|
||||||
botName: str = Field(default="AI Assistant", description="Display name of the bot in the meeting")
|
botName: str = Field(default="AI Assistant", description="Display name of the bot in the meeting")
|
||||||
status: TeamsbotSessionStatus = Field(default=TeamsbotSessionStatus.PENDING, description="Current session status")
|
status: TeamsbotSessionStatus = Field(default=TeamsbotSessionStatus.PENDING, description="Current session status")
|
||||||
|
|
@ -237,6 +269,27 @@ class TeamsbotSessionResponse(BaseModel):
|
||||||
botResponses: Optional[List[TeamsbotBotResponse]] = Field(default=None, description="Bot responses (if requested)")
|
botResponses: Optional[List[TeamsbotBotResponse]] = Field(default=None, description="Bot responses (if requested)")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateMeetingModuleRequest(BaseModel):
|
||||||
|
"""Request to create a new meeting module."""
|
||||||
|
title: str
|
||||||
|
seriesType: Optional[TeamsbotSeriesType] = TeamsbotSeriesType.ADHOC
|
||||||
|
defaultBotId: Optional[str] = None
|
||||||
|
defaultDirectorPrompts: Optional[str] = None
|
||||||
|
goals: Optional[str] = None
|
||||||
|
kpiTargets: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateMeetingModuleRequest(BaseModel):
|
||||||
|
"""Request to update an existing meeting module."""
|
||||||
|
title: Optional[str] = None
|
||||||
|
seriesType: Optional[TeamsbotSeriesType] = None
|
||||||
|
defaultBotId: Optional[str] = None
|
||||||
|
defaultDirectorPrompts: Optional[str] = None
|
||||||
|
goals: Optional[str] = None
|
||||||
|
kpiTargets: Optional[str] = None
|
||||||
|
status: Optional[TeamsbotModuleStatus] = None
|
||||||
|
|
||||||
|
|
||||||
class TeamsbotConfigUpdateRequest(BaseModel):
|
class TeamsbotConfigUpdateRequest(BaseModel):
|
||||||
"""Request to update teamsbot configuration."""
|
"""Request to update teamsbot configuration."""
|
||||||
botName: Optional[str] = None
|
botName: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from .datamodelTeamsbot import (
|
||||||
TeamsbotDirectorPrompt,
|
TeamsbotDirectorPrompt,
|
||||||
TeamsbotDirectorPromptStatus,
|
TeamsbotDirectorPromptStatus,
|
||||||
TeamsbotDirectorPromptMode,
|
TeamsbotDirectorPromptMode,
|
||||||
|
TeamsbotMeetingModule,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -330,6 +331,36 @@ class TeamsbotObjects:
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Meeting Modules
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getModules(self, instanceId: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all meeting modules for a feature instance."""
|
||||||
|
records = self.db.getRecordset(TeamsbotMeetingModule, recordFilter={"instanceId": instanceId})
|
||||||
|
records.sort(key=lambda r: r.get("sysCreatedAt") or "", reverse=True)
|
||||||
|
return records
|
||||||
|
|
||||||
|
def getModule(self, moduleId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get a single meeting module by ID."""
|
||||||
|
records = self.db.getRecordset(TeamsbotMeetingModule, recordFilter={"id": moduleId})
|
||||||
|
return records[0] if records else None
|
||||||
|
|
||||||
|
def createModule(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Create a new meeting module."""
|
||||||
|
return self.db.recordCreate(TeamsbotMeetingModule, data)
|
||||||
|
|
||||||
|
def updateModule(self, moduleId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Update an existing meeting module."""
|
||||||
|
return self.db.recordModify(TeamsbotMeetingModule, moduleId, updates)
|
||||||
|
|
||||||
|
def deleteModule(self, moduleId: str) -> bool:
|
||||||
|
"""Delete a meeting module. Unlinks all sessions first."""
|
||||||
|
sessions = self.db.getRecordset(TeamsbotSession, recordFilter={"moduleId": moduleId})
|
||||||
|
for session in sessions:
|
||||||
|
self.db.recordModify(TeamsbotSession, session["id"], {"moduleId": None})
|
||||||
|
return self.db.recordDelete(TeamsbotMeetingModule, moduleId)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Stats / Aggregation
|
# Stats / Aggregation
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -338,14 +369,23 @@ class TeamsbotObjects:
|
||||||
"""Get aggregated statistics for a session."""
|
"""Get aggregated statistics for a session."""
|
||||||
transcripts = self.db.getRecordset(TeamsbotTranscript, recordFilter={"sessionId": sessionId})
|
transcripts = self.db.getRecordset(TeamsbotTranscript, recordFilter={"sessionId": sessionId})
|
||||||
responses = self.db.getRecordset(TeamsbotBotResponse, recordFilter={"sessionId": sessionId})
|
responses = self.db.getRecordset(TeamsbotBotResponse, recordFilter={"sessionId": sessionId})
|
||||||
|
prompts = self.db.getRecordset(TeamsbotDirectorPrompt, recordFilter={"sessionId": sessionId})
|
||||||
|
|
||||||
totalCost = sum(r.get("priceCHF", 0) for r in responses)
|
totalCost = sum(r.get("priceCHF", 0) for r in responses)
|
||||||
totalProcessingTime = sum(r.get("processingTime", 0) for r in responses)
|
totalProcessingTime = sum(r.get("processingTime", 0) for r in responses)
|
||||||
|
speakers = list(set(t.get("speaker") for t in transcripts if t.get("speaker")))
|
||||||
|
|
||||||
|
firstTimestamp = min((t.get("timestamp") or 0 for t in transcripts), default=0)
|
||||||
|
lastTimestamp = max((t.get("timestamp") or 0 for t in transcripts), default=0)
|
||||||
|
durationSeconds = round(lastTimestamp - firstTimestamp, 1) if firstTimestamp and lastTimestamp else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"transcriptSegments": len(transcripts),
|
"transcriptSegments": len(transcripts),
|
||||||
"botResponses": len(responses),
|
"botResponses": len(responses),
|
||||||
|
"directorPrompts": len(prompts),
|
||||||
"totalCostCHF": round(totalCost, 4),
|
"totalCostCHF": round(totalCost, 4),
|
||||||
"totalProcessingTime": round(totalProcessingTime, 2),
|
"totalProcessingTime": round(totalProcessingTime, 2),
|
||||||
"speakers": list(set(t.get("speaker") for t in transcripts if t.get("speaker"))),
|
"speakers": speakers,
|
||||||
|
"speakerCount": len(speakers),
|
||||||
|
"durationSeconds": durationSeconds,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,16 @@ UI_OBJECTS = [
|
||||||
"label": t("Dashboard", context="UI"),
|
"label": t("Dashboard", context="UI"),
|
||||||
"meta": {"area": "dashboard"}
|
"meta": {"area": "dashboard"}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "ui.feature.teamsbot.assistant",
|
||||||
|
"label": t("Assistent", context="UI"),
|
||||||
|
"meta": {"area": "assistant"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "ui.feature.teamsbot.modules",
|
||||||
|
"label": t("Module", context="UI"),
|
||||||
|
"meta": {"area": "modules"}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.teamsbot.sessions",
|
"objectKey": "ui.feature.teamsbot.sessions",
|
||||||
"label": t("Sitzungen", context="UI"),
|
"label": t("Sitzungen", context="UI"),
|
||||||
|
|
@ -38,13 +48,24 @@ UI_OBJECTS = [
|
||||||
|
|
||||||
# DATA Objects for RBAC catalog (tables/entities)
|
# DATA Objects for RBAC catalog (tables/entities)
|
||||||
DATA_OBJECTS = [
|
DATA_OBJECTS = [
|
||||||
|
{
|
||||||
|
"objectKey": "data.feature.teamsbot.TeamsbotMeetingModule",
|
||||||
|
"label": t("Meeting-Modul", context="UI"),
|
||||||
|
"meta": {
|
||||||
|
"table": "TeamsbotMeetingModule",
|
||||||
|
"fields": ["id", "title", "seriesType", "status", "ownerUserId"],
|
||||||
|
"isParent": True,
|
||||||
|
"displayFields": ["title", "seriesType", "status"],
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.teamsbot.TeamsbotSession",
|
"objectKey": "data.feature.teamsbot.TeamsbotSession",
|
||||||
"label": t("Sitzung", context="UI"),
|
"label": t("Sitzung", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "TeamsbotSession",
|
"table": "TeamsbotSession",
|
||||||
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
|
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
|
||||||
"isParent": True,
|
"parentTable": "TeamsbotMeetingModule",
|
||||||
|
"parentKey": "moduleId",
|
||||||
"displayFields": ["botName", "status", "startedAt"],
|
"displayFields": ["botName", "status", "startedAt"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -97,6 +118,16 @@ RESOURCE_OBJECTS = [
|
||||||
"label": t("Konfiguration bearbeiten", context="UI"),
|
"label": t("Konfiguration bearbeiten", context="UI"),
|
||||||
"meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True}
|
"meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "resource.feature.teamsbot.module.create",
|
||||||
|
"label": t("Meeting-Modul erstellen", context="UI"),
|
||||||
|
"meta": {"endpoint": "/api/teamsbot/{instanceId}/modules", "method": "POST"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "resource.feature.teamsbot.module.delete",
|
||||||
|
"label": t("Meeting-Modul loeschen", context="UI"),
|
||||||
|
"meta": {"endpoint": "/api/teamsbot/{instanceId}/modules/{moduleId}", "method": "DELETE"}
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Template roles for this feature with AccessRules
|
# Template roles for this feature with AccessRules
|
||||||
|
|
@ -114,6 +145,8 @@ TEMPLATE_ROLES = [
|
||||||
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.delete", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.delete", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True},
|
||||||
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.create", "view": True},
|
||||||
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.delete", "view": True},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -121,6 +154,8 @@ TEMPLATE_ROLES = [
|
||||||
"description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
|
"description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.teamsbot.assistant", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.teamsbot.modules", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
|
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
],
|
],
|
||||||
|
|
@ -130,12 +165,16 @@ TEMPLATE_ROLES = [
|
||||||
"description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
|
"description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.teamsbot.assistant", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.teamsbot.modules", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
|
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
|
||||||
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotMeetingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
|
||||||
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.create", "view": True},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -198,6 +237,7 @@ def registerFeature(catalogService) -> bool:
|
||||||
meta=dataObj.get("meta")
|
meta=dataObj.get("meta")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_runMigrations()
|
||||||
_syncTemplateRolesToDb()
|
_syncTemplateRolesToDb()
|
||||||
|
|
||||||
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
|
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
|
||||||
|
|
@ -208,6 +248,95 @@ def registerFeature(catalogService) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _runMigrations():
|
||||||
|
"""Idempotent DB migrations for TeamsBot feature.
|
||||||
|
Runs on every bootstrap; each step checks preconditions before executing.
|
||||||
|
The TeamsbotMeetingModule table and TeamsbotSession.moduleId column are
|
||||||
|
auto-created by the DB connector from the Pydantic model. This migration
|
||||||
|
handles data backfill: creating default Adhoc modules for existing sessions.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .interfaceFeatureTeamsbot import teamsbotDatabase
|
||||||
|
from .datamodelTeamsbot import TeamsbotMeetingModule, TeamsbotSession
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
database=teamsbotDatabase,
|
||||||
|
user=APP_CONFIG.get("DB_USER"),
|
||||||
|
password=APP_CONFIG.get("DB_PASSWORD_SECRET"),
|
||||||
|
port=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
|
cursor_factory=RealDictCursor,
|
||||||
|
)
|
||||||
|
conn.autocommit = False
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
def _tableExists(name):
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM information_schema.tables WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
|
||||||
|
(name,),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
def _columnExists(table, column):
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM information_schema.columns WHERE LOWER(table_name) = LOWER(%s) AND LOWER(column_name) = LOWER(%s) AND table_schema = 'public'",
|
||||||
|
(table, column),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
migrated = False
|
||||||
|
|
||||||
|
# M1: Create default Adhoc modules for orphaned sessions
|
||||||
|
# (only runs if TeamsbotSession table exists with moduleId column
|
||||||
|
# and there are sessions without a moduleId)
|
||||||
|
if _tableExists("TeamsbotSession") and _columnExists("TeamsbotSession", "moduleId"):
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT "instanceId", "mandateId"
|
||||||
|
FROM "TeamsbotSession"
|
||||||
|
WHERE "moduleId" IS NULL AND "instanceId" IS NOT NULL
|
||||||
|
""")
|
||||||
|
orphanGroups = cur.fetchall()
|
||||||
|
for group in orphanGroups:
|
||||||
|
instId = group["instanceId"]
|
||||||
|
mandId = group["mandateId"]
|
||||||
|
if not instId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
adhocId = str(uuid.uuid4())
|
||||||
|
import time as _time
|
||||||
|
now = _time.time()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO "TeamsbotMeetingModule" (id, "instanceId", "mandateId", "ownerUserId", title, "seriesType", status, "sysCreatedAt")
|
||||||
|
VALUES (%s, %s, %s, 'system', 'Adhoc', 'adhoc', 'active', %s)
|
||||||
|
""", (adhocId, instId, mandId, now))
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE "TeamsbotSession"
|
||||||
|
SET "moduleId" = %s
|
||||||
|
WHERE "instanceId" = %s AND "moduleId" IS NULL
|
||||||
|
""", (adhocId, instId))
|
||||||
|
sessionCount = cur.rowcount
|
||||||
|
logger.info(f"Migration M1: Created Adhoc module for instanceId={instId}, assigned {sessionCount} sessions")
|
||||||
|
migrated = True
|
||||||
|
|
||||||
|
if migrated:
|
||||||
|
conn.commit()
|
||||||
|
logger.info("TeamsBot DB migrations committed")
|
||||||
|
else:
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.debug("psycopg2 not available, skipping TeamsBot DB migrations")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"TeamsBot DB migration failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
|
||||||
def _syncTemplateRolesToDb() -> int:
|
def _syncTemplateRolesToDb() -> int:
|
||||||
"""Sync template roles and their AccessRules to the database."""
|
"""Sync template roles and their AccessRules to the database."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ from .datamodelTeamsbot import (
|
||||||
TeamsbotDirectorPromptCreateRequest,
|
TeamsbotDirectorPromptCreateRequest,
|
||||||
TeamsbotDirectorPromptMode,
|
TeamsbotDirectorPromptMode,
|
||||||
TeamsbotDirectorPromptStatus,
|
TeamsbotDirectorPromptStatus,
|
||||||
|
TeamsbotMeetingModule,
|
||||||
|
CreateMeetingModuleRequest,
|
||||||
|
UpdateMeetingModuleRequest,
|
||||||
DIRECTOR_PROMPT_FILE_LIMIT,
|
DIRECTOR_PROMPT_FILE_LIMIT,
|
||||||
DIRECTOR_PROMPT_TEXT_LIMIT,
|
DIRECTOR_PROMPT_TEXT_LIMIT,
|
||||||
)
|
)
|
||||||
|
|
@ -167,6 +170,100 @@ def _getInstanceConfig(instanceId: str) -> TeamsbotConfig:
|
||||||
return TeamsbotConfig()
|
return TeamsbotConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Meeting Module Endpoints
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/modules")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def listModules(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""List all meeting modules for a feature instance."""
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
modules = interface.getModules(instanceId)
|
||||||
|
return {"modules": modules}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instanceId}/modules")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def createModule(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
body: CreateMeetingModuleRequest,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Create a new meeting module."""
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
data = body.model_dump(exclude_none=True)
|
||||||
|
data["instanceId"] = instanceId
|
||||||
|
data["mandateId"] = mandateId
|
||||||
|
data["ownerUserId"] = str(context.user.id)
|
||||||
|
module = interface.createModule(data)
|
||||||
|
return {"module": module}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/modules/{moduleId}")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getModuleDetail(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
moduleId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Get a single module with its sessions."""
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
module = interface.getModule(moduleId)
|
||||||
|
if not module:
|
||||||
|
raise HTTPException(status_code=404, detail="Module not found")
|
||||||
|
sessions = interface.getSessions(instanceId)
|
||||||
|
moduleSessions = [s for s in sessions if s.get("moduleId") == moduleId]
|
||||||
|
return {"module": module, "sessions": moduleSessions}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{instanceId}/modules/{moduleId}")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def updateModule(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
moduleId: str,
|
||||||
|
body: UpdateMeetingModuleRequest,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Update an existing meeting module."""
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
module = interface.getModule(moduleId)
|
||||||
|
if not module:
|
||||||
|
raise HTTPException(status_code=404, detail="Module not found")
|
||||||
|
updates = body.model_dump(exclude_none=True)
|
||||||
|
updated = interface.updateModule(moduleId, updates)
|
||||||
|
return {"module": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{instanceId}/modules/{moduleId}")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def deleteModule(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
moduleId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Delete a meeting module and unlink its sessions."""
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
module = interface.getModule(moduleId)
|
||||||
|
if not module:
|
||||||
|
raise HTTPException(status_code=404, detail="Module not found")
|
||||||
|
interface.deleteModule(moduleId)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Session Endpoints
|
# Session Endpoints
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -385,8 +482,9 @@ async def streamSession(
|
||||||
"""Generate SSE events from the session event queue."""
|
"""Generate SSE events from the session event queue."""
|
||||||
from .service import sessionEvents
|
from .service import sessionEvents
|
||||||
|
|
||||||
# Send initial session state
|
# Send initial session state with stats
|
||||||
yield f"data: {json.dumps({'type': 'sessionState', 'data': session})}\n\n"
|
stats = interface.getSessionStats(sessionId)
|
||||||
|
yield f"data: {json.dumps({'type': 'sessionState', 'data': session, 'stats': stats})}\n\n"
|
||||||
|
|
||||||
# Send current bot WebSocket connection state so the operator UI can
|
# Send current bot WebSocket connection state so the operator UI can
|
||||||
# render the live indicator without waiting for the next connect/disconnect.
|
# render the live indicator without waiting for the next connect/disconnect.
|
||||||
|
|
|
||||||
|
|
@ -3409,6 +3409,8 @@ class TeamsbotService:
|
||||||
"status": "toolCall",
|
"status": "toolCall",
|
||||||
"toolName": toolName,
|
"toolName": toolName,
|
||||||
})
|
})
|
||||||
|
elif event.type == AgentEventTypeEnum.FILE_CREATED:
|
||||||
|
await _emitSessionEvent(sessionId, "documentCreated", event.data or {})
|
||||||
elif event.type == AgentEventTypeEnum.FINAL:
|
elif event.type == AgentEventTypeEnum.FINAL:
|
||||||
finalText = (event.content or "").strip()
|
finalText = (event.content or "").strip()
|
||||||
elif event.type == AgentEventTypeEnum.ERROR:
|
elif event.type == AgentEventTypeEnum.ERROR:
|
||||||
|
|
|
||||||
|
|
@ -658,6 +658,11 @@
|
||||||
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Dossiers",
|
||||||
|
"value": "UDB tab label for chat workflows / cases"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "Dokument",
|
"key": "Dokument",
|
||||||
|
|
@ -4046,6 +4051,11 @@
|
||||||
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
||||||
"value": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden"
|
"value": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Dossiers",
|
||||||
|
"value": "Dossiers"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "Dokument",
|
"key": "Dokument",
|
||||||
|
|
@ -7404,6 +7414,11 @@
|
||||||
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
||||||
"value": "This field is managed by {provider} and cannot be changed"
|
"value": "This field is managed by {provider} and cannot be changed"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Dossiers",
|
||||||
|
"value": "Dossiers"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "Dokument",
|
"key": "Dokument",
|
||||||
|
|
@ -10617,6 +10632,11 @@
|
||||||
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
||||||
"value": "Ce champ est géré par {provider} et ne peut pas être modifié"
|
"value": "Ce champ est géré par {provider} et ne peut pas être modifié"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Dossiers",
|
||||||
|
"value": "Dossiers"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "Dokument",
|
"key": "Dokument",
|
||||||
|
|
|
||||||
|
|
@ -203,16 +203,40 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser
|
||||||
args["featureInstanceId"] = context["featureInstanceId"]
|
args["featureInstanceId"] = context["featureInstanceId"]
|
||||||
if "mandateId" not in args and context.get("mandateId"):
|
if "mandateId" not in args and context.get("mandateId"):
|
||||||
args["mandateId"] = context["mandateId"]
|
args["mandateId"] = context["mandateId"]
|
||||||
|
if "parentOperationId" not in args:
|
||||||
|
import time as _time
|
||||||
|
toolOpId = f"agentTool_{methodName}_{actionName}_{int(_time.time())}"
|
||||||
|
chatSvc = getattr(services, "chat", None) if services else None
|
||||||
|
if chatSvc:
|
||||||
|
try:
|
||||||
|
chatSvc.progressLogStart(toolOpId, methodName.capitalize(), actionName, "Agent tool")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
args["parentOperationId"] = toolOpId
|
||||||
|
else:
|
||||||
|
toolOpId = None
|
||||||
|
chatSvc = None
|
||||||
result = await actionExecutor.executeAction(methodName, actionName, args)
|
result = await actionExecutor.executeAction(methodName, actionName, args)
|
||||||
data = _formatActionResult(result, services, context)
|
if toolOpId and chatSvc:
|
||||||
|
try:
|
||||||
|
chatSvc.progressLogFinish(toolOpId, result.success)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data, sideEvents = _formatActionResult(result, services, context)
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
toolCallId="",
|
toolCallId="",
|
||||||
toolName=f"{methodName}_{actionName}",
|
toolName=f"{methodName}_{actionName}",
|
||||||
success=result.success,
|
success=result.success,
|
||||||
data=data,
|
data=data,
|
||||||
error=result.error
|
error=result.error,
|
||||||
|
sideEvents=sideEvents or None,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if toolOpId and chatSvc:
|
||||||
|
try:
|
||||||
|
chatSvc.progressLogFinish(toolOpId, False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.error(f"ActionToolAdapter dispatch failed for {methodName}_{actionName}: {e}")
|
logger.error(f"ActionToolAdapter dispatch failed for {methodName}_{actionName}: {e}")
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
toolCallId="",
|
toolCallId="",
|
||||||
|
|
@ -226,11 +250,12 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser
|
||||||
_INLINE_CONTENT_LIMIT = 2000
|
_INLINE_CONTENT_LIMIT = 2000
|
||||||
|
|
||||||
|
|
||||||
def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[str]:
|
def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Save an ActionDocument with large content as a workspace file.
|
"""Save an ActionDocument as a workspace file.
|
||||||
|
|
||||||
Returns a formatted result line (with file id + docItem ref) or None
|
Handles both str and bytes documentData.
|
||||||
if persistence is not possible.
|
Returns a dict with 'line' (formatted result) and 'fileInfo' (for sideEvents),
|
||||||
|
or None if persistence is not possible.
|
||||||
"""
|
"""
|
||||||
if not services:
|
if not services:
|
||||||
return None
|
return None
|
||||||
|
|
@ -238,49 +263,77 @@ def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[st
|
||||||
if not chatService:
|
if not chatService:
|
||||||
return None
|
return None
|
||||||
docData = getattr(doc, "documentData", None)
|
docData = getattr(doc, "documentData", None)
|
||||||
if not docData or not isinstance(docData, str):
|
if not docData:
|
||||||
|
return None
|
||||||
|
if isinstance(docData, bytes):
|
||||||
|
docBytes = docData
|
||||||
|
elif isinstance(docData, str):
|
||||||
|
docBytes = docData.encode("utf-8")
|
||||||
|
else:
|
||||||
return None
|
return None
|
||||||
docName = getattr(doc, "documentName", "unnamed")
|
docName = getattr(doc, "documentName", "unnamed")
|
||||||
docBytes = docData.encode("utf-8")
|
docMime = getattr(doc, "mimeType", "application/octet-stream")
|
||||||
try:
|
try:
|
||||||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docBytes, docName)
|
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docBytes, docName)
|
||||||
fiId = context.get("featureInstanceId") or getattr(services, "featureInstanceId", "")
|
|
||||||
if fiId:
|
|
||||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
|
||||||
|
|
||||||
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
|
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
|
||||||
_attachFileAsChatDocument,
|
_attachFileAsChatDocument,
|
||||||
_formatToolFileResult,
|
_formatToolFileResult,
|
||||||
_getOrCreateTempFolder,
|
_getOrCreateTempFolder,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
updateFields = {}
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
if tempFolderId:
|
if tempFolderId:
|
||||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId})
|
updateFields["folderId"] = tempFolderId
|
||||||
|
fiId = context.get("featureInstanceId") or getattr(services, "featureInstanceId", "")
|
||||||
|
if fiId:
|
||||||
|
updateFields["featureInstanceId"] = fiId
|
||||||
|
updateFields["scope"] = "featureInstance"
|
||||||
|
mandateId = context.get("mandateId") or getattr(services, "mandateId", "")
|
||||||
|
if mandateId:
|
||||||
|
updateFields["mandateId"] = mandateId
|
||||||
|
if updateFields:
|
||||||
|
logger.debug("_persistLargeDocument: updating file %s with %s", fileItem.id, updateFields)
|
||||||
|
chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields)
|
||||||
|
else:
|
||||||
|
logger.warning("_persistLargeDocument: no updateFields for file %s (tempFolderId=%s, fiId=%s)", fileItem.id, tempFolderId, fiId)
|
||||||
|
|
||||||
chatDocId = _attachFileAsChatDocument(
|
chatDocId = _attachFileAsChatDocument(
|
||||||
services, fileItem,
|
services, fileItem,
|
||||||
label=f"action_doc:{docName}",
|
label=f"action_doc:{docName}",
|
||||||
userMessage=f"Action document: {docName}",
|
userMessage=f"Action document: {docName}",
|
||||||
)
|
)
|
||||||
return _formatToolFileResult(
|
line = _formatToolFileResult(
|
||||||
fileItem=fileItem,
|
fileItem=fileItem,
|
||||||
chatDocId=chatDocId,
|
chatDocId=chatDocId,
|
||||||
actionLabel="Produced",
|
actionLabel="Produced",
|
||||||
extraInfo="Use readFile to read the content.",
|
extraInfo="Use readFile to read the content.",
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
"line": line,
|
||||||
|
"fileInfo": {
|
||||||
|
"fileId": fileItem.id,
|
||||||
|
"fileName": docName,
|
||||||
|
"mimeType": docMime,
|
||||||
|
"fileSize": len(docBytes),
|
||||||
|
},
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"_persistLargeDocument failed for {docName}: {e}")
|
logger.error(f"_persistLargeDocument failed for {docName}: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] = None) -> str:
|
def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] = None):
|
||||||
"""Format an ActionResult into a text representation for the agent.
|
"""Format an ActionResult into a text representation for the agent.
|
||||||
|
|
||||||
Documents whose content exceeds the inline limit are persisted as
|
Documents whose content exceeds the inline limit (or is binary bytes)
|
||||||
workspace files so the agent can access them via readFile /
|
are persisted as workspace files.
|
||||||
ai_process / searchInFileContent.
|
|
||||||
|
Returns (str, list) – the formatted text and a list of sideEvent dicts.
|
||||||
"""
|
"""
|
||||||
parts = []
|
parts = []
|
||||||
|
sideEvents = []
|
||||||
ctx = context or {}
|
ctx = context or {}
|
||||||
|
|
||||||
if result.resultLabel:
|
if result.resultLabel:
|
||||||
|
|
@ -296,13 +349,23 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]]
|
||||||
docType = getattr(doc, "mimeType", "unknown")
|
docType = getattr(doc, "mimeType", "unknown")
|
||||||
docData = getattr(doc, "documentData", None)
|
docData = getattr(doc, "documentData", None)
|
||||||
|
|
||||||
isLarge = docData and isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT
|
needsPersist = (
|
||||||
if isLarge:
|
(isinstance(docData, bytes) and len(docData) > 0) or
|
||||||
persistedLine = _persistLargeDocument(doc, services, ctx)
|
(isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT)
|
||||||
if persistedLine:
|
)
|
||||||
|
if needsPersist:
|
||||||
|
persisted = _persistLargeDocument(doc, services, ctx)
|
||||||
|
if persisted:
|
||||||
parts.append(f" - {docName} ({docType})")
|
parts.append(f" - {docName} ({docType})")
|
||||||
parts.append(f" {persistedLine}")
|
parts.append(f" {persisted['line']}")
|
||||||
|
sideEvents.append({
|
||||||
|
"type": "fileCreated",
|
||||||
|
"data": persisted["fileInfo"],
|
||||||
|
})
|
||||||
continue
|
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})")
|
parts.append(f" - {docName} ({docType})")
|
||||||
if docData and isinstance(docData, str) and len(docData) < _INLINE_CONTENT_LIMIT:
|
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:
|
if not parts:
|
||||||
parts.append("Action completed successfully." if result.success else "Action failed.")
|
parts.append("Action completed successfully." if result.success else "Action failed.")
|
||||||
|
|
||||||
return "\n".join(parts)
|
return "\n".join(parts), sideEvents
|
||||||
|
|
|
||||||
|
|
@ -228,14 +228,17 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
fileName = f"{fileName}.zip"
|
fileName = f"{fileName}.zip"
|
||||||
chatService = services.chat
|
chatService = services.chat
|
||||||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName)
|
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName)
|
||||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
updateFields = {}
|
||||||
if fiId:
|
|
||||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
|
||||||
if _sourceNeutralize:
|
|
||||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"neutralize": True})
|
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
if tempFolderId:
|
if tempFolderId:
|
||||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId})
|
updateFields["folderId"] = tempFolderId
|
||||||
|
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||||||
|
if fiId:
|
||||||
|
updateFields["featureInstanceId"] = fiId
|
||||||
|
if _sourceNeutralize:
|
||||||
|
updateFields["neutralize"] = True
|
||||||
|
if updateFields:
|
||||||
|
chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields)
|
||||||
|
|
||||||
chatDocId = _attachFileAsChatDocument(
|
chatDocId = _attachFileAsChatDocument(
|
||||||
services, fileItem,
|
services, fileItem,
|
||||||
|
|
|
||||||
|
|
@ -47,13 +47,29 @@ def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def _getOrCreateTempFolder(chatService) -> Optional[str]:
|
def _getOrCreateTempFolder(chatService) -> Optional[str]:
|
||||||
"""Deprecated stub: folder-based organisation has been replaced by grouping.
|
"""Return the ID of the user's 'Temp' folder, creating it if it doesn't exist."""
|
||||||
|
ifc = getattr(chatService, "interfaceDbComponent", None)
|
||||||
Returns None unconditionally so callers skip the (now removed) folderId
|
if not ifc:
|
||||||
assignment. Remove callers incrementally and delete this stub afterwards.
|
logger.warning("_getOrCreateTempFolder: no interfaceDbComponent on chatService")
|
||||||
"""
|
return None
|
||||||
logger.debug("_getOrCreateTempFolder called – folder support removed, returning None")
|
userId = getattr(ifc, "userId", None)
|
||||||
return 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(
|
async def _getOrCreateInstanceGroup(
|
||||||
|
|
|
||||||
|
|
@ -222,12 +222,15 @@ def _registerMediaTools(registry: ToolRegistry, services):
|
||||||
|
|
||||||
if fileItem:
|
if fileItem:
|
||||||
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
|
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
|
||||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
updateFields = {}
|
||||||
if fiId:
|
|
||||||
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
|
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
if tempFolderId:
|
if tempFolderId:
|
||||||
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})
|
updateFields["folderId"] = tempFolderId
|
||||||
|
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||||||
|
if fiId:
|
||||||
|
updateFields["featureInstanceId"] = fiId
|
||||||
|
if updateFields:
|
||||||
|
chatService.interfaceDbComponent.updateFile(fid, updateFields)
|
||||||
chatDocId = _attachFileAsChatDocument(
|
chatDocId = _attachFileAsChatDocument(
|
||||||
services, fileItem,
|
services, fileItem,
|
||||||
label=f"renderDocument:{docName}",
|
label=f"renderDocument:{docName}",
|
||||||
|
|
@ -517,12 +520,15 @@ def _registerMediaTools(registry: ToolRegistry, services):
|
||||||
|
|
||||||
if fileItem:
|
if fileItem:
|
||||||
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
|
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
|
||||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
updateFields = {}
|
||||||
if fiId:
|
|
||||||
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
|
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
if tempFolderId:
|
if tempFolderId:
|
||||||
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})
|
updateFields["folderId"] = tempFolderId
|
||||||
|
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||||||
|
if fiId:
|
||||||
|
updateFields["featureInstanceId"] = fiId
|
||||||
|
if updateFields:
|
||||||
|
chatService.interfaceDbComponent.updateFile(fid, updateFields)
|
||||||
chatDocId = _attachFileAsChatDocument(
|
chatDocId = _attachFileAsChatDocument(
|
||||||
services, fileItem,
|
services, fileItem,
|
||||||
label=f"generateImage:{docName}",
|
label=f"generateImage:{docName}",
|
||||||
|
|
@ -679,12 +685,16 @@ def _registerMediaTools(registry: ToolRegistry, services):
|
||||||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName)
|
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName)
|
||||||
|
|
||||||
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?"
|
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?"
|
||||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
if fid != "?":
|
||||||
if fiId and fid != "?":
|
updateFields = {}
|
||||||
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
if tempFolderId:
|
||||||
if tempFolderId and fid != "?":
|
updateFields["folderId"] = tempFolderId
|
||||||
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})
|
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||||||
|
if fiId:
|
||||||
|
updateFields["featureInstanceId"] = fiId
|
||||||
|
if updateFields:
|
||||||
|
chatService.interfaceDbComponent.updateFile(fid, updateFields)
|
||||||
|
|
||||||
chatDocId = _attachFileAsChatDocument(
|
chatDocId = _attachFileAsChatDocument(
|
||||||
services, fileItem,
|
services, fileItem,
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,8 @@ class AgentService:
|
||||||
# ContentParts" symptom we see when the workspace route calls
|
# ContentParts" symptom we see when the workspace route calls
|
||||||
# runAgent for an attached single-file data source.
|
# runAgent for an attached single-file data source.
|
||||||
# Mirrors workflowManager._propagateWorkflowToContext.
|
# Mirrors workflowManager._propagateWorkflowToContext.
|
||||||
if workflowId and workflowId != "unknown":
|
isChatWorkflowId = workflowId and workflowId != "unknown" and ":" not in workflowId
|
||||||
|
if isChatWorkflowId:
|
||||||
try:
|
try:
|
||||||
workflow = getattr(self.services, "workflow", None)
|
workflow = getattr(self.services, "workflow", None)
|
||||||
if workflow is None or getattr(workflow, "id", None) != workflowId:
|
if workflow is None or getattr(workflow, "id", None) != workflowId:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ SANDBOX_ALLOWED_MODULES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
_PYTHON_BLOCKED_BUILTINS = {
|
_PYTHON_BLOCKED_BUILTINS = {
|
||||||
"open", "exec", "eval", "compile", "__import__", "globals", "locals",
|
"exec", "eval", "compile", "__import__", "globals", "locals",
|
||||||
"getattr", "setattr", "delattr", "breakpoint", "exit", "quit",
|
"getattr", "setattr", "delattr", "breakpoint", "exit", "quit",
|
||||||
"input", "memoryview",
|
"input", "memoryview",
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +73,29 @@ def _buildRestrictedGlobals() -> Dict[str, Any]:
|
||||||
return {"__builtins__": safeBuiltins}
|
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):
|
def _makeReadFile(services):
|
||||||
"""Create a readFile(fileId) closure bound to the current services context."""
|
"""Create a readFile(fileId) closure bound to the current services context."""
|
||||||
def readFile(fileId: str) -> str:
|
def readFile(fileId: str) -> str:
|
||||||
|
|
@ -92,6 +115,8 @@ async def executePython(code: str, *, services=None) -> Dict[str, Any]:
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
restrictedGlobals = _buildRestrictedGlobals()
|
restrictedGlobals = _buildRestrictedGlobals()
|
||||||
|
vfs = _VirtualFS()
|
||||||
|
restrictedGlobals["__builtins__"]["open"] = vfs.open
|
||||||
if services:
|
if services:
|
||||||
restrictedGlobals["__builtins__"]["readFile"] = _makeReadFile(services)
|
restrictedGlobals["__builtins__"]["readFile"] = _makeReadFile(services)
|
||||||
capturedOutput = io.StringIO()
|
capturedOutput = io.StringIO()
|
||||||
|
|
|
||||||
|
|
@ -444,19 +444,28 @@ class AiCallLooper:
|
||||||
lastValidCompletePart = contexts.completePart
|
lastValidCompletePart = contexts.completePart
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed, parseErr, extracted = tryParseJson(contexts.completePart)
|
extracted = extractJsonString(contexts.completePart)
|
||||||
|
parsed, parseErr, _ = tryParseJson(extracted)
|
||||||
if parseErr is not None:
|
if parseErr is not None:
|
||||||
raise ValueError(str(parseErr))
|
from modules.shared.jsonUtils import repairBrokenJson
|
||||||
normalized = self._normalizeJsonStructure(parsed, useCase)
|
repaired = repairBrokenJson(extracted)
|
||||||
result = json.dumps(normalized, indent=2, ensure_ascii=False)
|
if repaired:
|
||||||
jsonBase = contexts.completePart
|
parsed = repaired
|
||||||
|
parseErr = None
|
||||||
if iterationOperationId:
|
logger.info(f"Iteration {iteration}: repairBrokenJson succeeded for completePart")
|
||||||
self.services.chat.progressLogFinish(iterationOperationId, True)
|
if parseErr is None and parsed:
|
||||||
|
normalized = self._normalizeJsonStructure(parsed, useCase)
|
||||||
if not useCase.finalResultHandler:
|
result = json.dumps(normalized, indent=2, ensure_ascii=False)
|
||||||
raise ValueError(
|
|
||||||
f"Use case '{useCaseId}' is missing required 'finalResultHandler' callback."
|
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(
|
return useCase.finalResultHandler(
|
||||||
result, normalized, extracted, debugPrefix, self.services
|
result, normalized, extracted, debugPrefix, self.services
|
||||||
|
|
|
||||||
|
|
@ -932,19 +932,10 @@ class StructureFiller:
|
||||||
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
|
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
|
||||||
|
|
||||||
if operationType == OperationTypeEnum.IMAGE_GENERATE:
|
if operationType == OperationTypeEnum.IMAGE_GENERATE:
|
||||||
maxPromptLength = 4000
|
imagePrompt = self._buildImagePrompt(section, generationHint, language)
|
||||||
if len(generationPrompt) > maxPromptLength:
|
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
|
||||||
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
|
|
||||||
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
|
|
||||||
|
|
||||||
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
|
|
||||||
self.services.utils.writeDebugFile(
|
|
||||||
generationPrompt,
|
|
||||||
f"{chapterId}_section_{sectionId}_prompt"
|
|
||||||
)
|
|
||||||
|
|
||||||
request = AiCallRequest(
|
request = AiCallRequest(
|
||||||
prompt=generationPrompt,
|
prompt=imagePrompt,
|
||||||
contentParts=[],
|
contentParts=[],
|
||||||
options=AiCallOptions(
|
options=AiCallOptions(
|
||||||
operationType=operationType,
|
operationType=operationType,
|
||||||
|
|
@ -955,8 +946,6 @@ class StructureFiller:
|
||||||
checkWorkflowStopped(self.services)
|
checkWorkflowStopped(self.services)
|
||||||
aiResponse = await self.aiService.callAi(request)
|
aiResponse = await self.aiService.callAi(request)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
|
|
||||||
self.services.utils.writeDebugFile(
|
self.services.utils.writeDebugFile(
|
||||||
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
||||||
f"{chapterId}_section_{sectionId}_response"
|
f"{chapterId}_section_{sectionId}_response"
|
||||||
|
|
@ -1008,7 +997,7 @@ class StructureFiller:
|
||||||
|
|
||||||
aiResponse = _AiResponseFallback(aiResponseJson)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
except Exception as parseError:
|
except Exception as parseError:
|
||||||
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
|
logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
|
||||||
aiResponse = _AiResponseFallback(aiResponseJson)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
|
|
@ -1087,19 +1076,10 @@ class StructureFiller:
|
||||||
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
|
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
|
||||||
|
|
||||||
if operationType == OperationTypeEnum.IMAGE_GENERATE:
|
if operationType == OperationTypeEnum.IMAGE_GENERATE:
|
||||||
maxPromptLength = 4000
|
imagePrompt = self._buildImagePrompt(section, generationHint, language)
|
||||||
if len(generationPrompt) > maxPromptLength:
|
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
|
||||||
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
|
|
||||||
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
|
|
||||||
|
|
||||||
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
|
|
||||||
self.services.utils.writeDebugFile(
|
|
||||||
generationPrompt,
|
|
||||||
f"{chapterId}_section_{sectionId}_prompt"
|
|
||||||
)
|
|
||||||
|
|
||||||
request = AiCallRequest(
|
request = AiCallRequest(
|
||||||
prompt=generationPrompt,
|
prompt=imagePrompt,
|
||||||
contentParts=[],
|
contentParts=[],
|
||||||
options=AiCallOptions(
|
options=AiCallOptions(
|
||||||
operationType=operationType,
|
operationType=operationType,
|
||||||
|
|
@ -1110,8 +1090,6 @@ class StructureFiller:
|
||||||
checkWorkflowStopped(self.services)
|
checkWorkflowStopped(self.services)
|
||||||
aiResponse = await self.aiService.callAi(request)
|
aiResponse = await self.aiService.callAi(request)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
|
|
||||||
self.services.utils.writeDebugFile(
|
self.services.utils.writeDebugFile(
|
||||||
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
||||||
f"{chapterId}_section_{sectionId}_response"
|
f"{chapterId}_section_{sectionId}_response"
|
||||||
|
|
@ -1164,7 +1142,7 @@ class StructureFiller:
|
||||||
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
|
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
|
||||||
aiResponse = _AiResponseFallback(aiResponseJson)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
except Exception as parseError:
|
except Exception as parseError:
|
||||||
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
|
logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
|
||||||
aiResponse = _AiResponseFallback(aiResponseJson)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
|
|
@ -1341,19 +1319,10 @@ class StructureFiller:
|
||||||
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
|
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
|
||||||
|
|
||||||
if operationType == OperationTypeEnum.IMAGE_GENERATE:
|
if operationType == OperationTypeEnum.IMAGE_GENERATE:
|
||||||
maxPromptLength = 4000
|
imagePrompt = self._buildImagePrompt(section, generationHint, language)
|
||||||
if len(generationPrompt) > maxPromptLength:
|
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
|
||||||
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
|
|
||||||
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
|
|
||||||
|
|
||||||
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
|
|
||||||
self.services.utils.writeDebugFile(
|
|
||||||
generationPrompt,
|
|
||||||
f"{chapterId}_section_{sectionId}_prompt"
|
|
||||||
)
|
|
||||||
|
|
||||||
request = AiCallRequest(
|
request = AiCallRequest(
|
||||||
prompt=generationPrompt,
|
prompt=imagePrompt,
|
||||||
contentParts=[],
|
contentParts=[],
|
||||||
options=AiCallOptions(
|
options=AiCallOptions(
|
||||||
operationType=operationType,
|
operationType=operationType,
|
||||||
|
|
@ -1363,8 +1332,6 @@ class StructureFiller:
|
||||||
)
|
)
|
||||||
aiResponse = await self.aiService.callAi(request)
|
aiResponse = await self.aiService.callAi(request)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
|
|
||||||
self.services.utils.writeDebugFile(
|
self.services.utils.writeDebugFile(
|
||||||
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
||||||
f"{chapterId}_section_{sectionId}_response"
|
f"{chapterId}_section_{sectionId}_response"
|
||||||
|
|
@ -1418,7 +1385,7 @@ class StructureFiller:
|
||||||
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
|
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
|
||||||
aiResponse = _AiResponseFallback(aiResponseJson)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
except Exception as parseError:
|
except Exception as parseError:
|
||||||
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
|
logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
|
||||||
aiResponse = _AiResponseFallback(aiResponseJson)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
|
|
@ -2127,6 +2094,14 @@ Return only valid JSON. Do not include any explanatory text outside the JSON.
|
||||||
"""
|
"""
|
||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
|
def _buildImagePrompt(self, section: Dict[str, Any], generationHint: str, language: str = "de") -> str:
|
||||||
|
"""Build a concise image-generation prompt from generationHint only.
|
||||||
|
Image models need short, visual descriptions - not the full document
|
||||||
|
context or user prompt that can be hundreds of KB."""
|
||||||
|
sectionTitle = section.get("title", "")
|
||||||
|
description = generationHint or sectionTitle or "Generate an image"
|
||||||
|
return f"{description}\nLanguage for any text in the image: {language.upper()}"
|
||||||
|
|
||||||
def _getContentStructureExample(self, contentType: str) -> str:
|
def _getContentStructureExample(self, contentType: str) -> str:
|
||||||
"""Get the JSON structure example for a specific content type."""
|
"""Get the JSON structure example for a specific content type."""
|
||||||
structures = {
|
structures = {
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,7 @@ class ChatService:
|
||||||
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
||||||
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
|
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
|
||||||
self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandate_id)
|
self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandate_id)
|
||||||
self.interfaceDbComponent = getComponentInterface(
|
self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandate_id, featureInstanceId=context.feature_instance_id)
|
||||||
context.user,
|
|
||||||
mandateId=context.mandate_id,
|
|
||||||
featureInstanceId=context.feature_instance_id,
|
|
||||||
)
|
|
||||||
self.interfaceDbChat = getChatInterface(
|
self.interfaceDbChat = getChatInterface(
|
||||||
context.user,
|
context.user,
|
||||||
mandateId=context.mandate_id,
|
mandateId=context.mandate_id,
|
||||||
|
|
|
||||||
|
|
@ -122,26 +122,37 @@ class BaseRenderer(ABC):
|
||||||
"title": {
|
"title": {
|
||||||
"font_size": h1["sizePt"], "color": h1["color"],
|
"font_size": h1["sizePt"], "color": h1["color"],
|
||||||
"bold": h1.get("weight") == "bold", "align": "left",
|
"bold": h1.get("weight") == "bold", "align": "left",
|
||||||
|
"space_before": 0,
|
||||||
|
"space_after": h1.get("spaceAfterPt", 8),
|
||||||
},
|
},
|
||||||
"heading1": {
|
"heading1": {
|
||||||
"font_size": h1["sizePt"], "color": h1["color"],
|
"font_size": h1["sizePt"], "color": h1["color"],
|
||||||
"bold": h1.get("weight") == "bold", "align": "left",
|
"bold": h1.get("weight") == "bold", "align": "left",
|
||||||
|
"space_before": h1.get("spaceBeforePt", 24),
|
||||||
|
"space_after": h1.get("spaceAfterPt", 8),
|
||||||
},
|
},
|
||||||
"heading2": {
|
"heading2": {
|
||||||
"font_size": h2["sizePt"], "color": h2["color"],
|
"font_size": h2["sizePt"], "color": h2["color"],
|
||||||
"bold": h2.get("weight") == "bold", "align": "left",
|
"bold": h2.get("weight") == "bold", "align": "left",
|
||||||
|
"space_before": h2.get("spaceBeforePt", 20),
|
||||||
|
"space_after": h2.get("spaceAfterPt", 6),
|
||||||
},
|
},
|
||||||
"heading3": {
|
"heading3": {
|
||||||
"font_size": h3["sizePt"], "color": h3["color"],
|
"font_size": h3["sizePt"], "color": h3["color"],
|
||||||
"bold": h3.get("weight") == "bold", "align": "left",
|
"bold": h3.get("weight") == "bold", "align": "left",
|
||||||
|
"space_before": h3.get("spaceBeforePt", 16),
|
||||||
|
"space_after": h3.get("spaceAfterPt", 4),
|
||||||
},
|
},
|
||||||
"heading4": {
|
"heading4": {
|
||||||
"font_size": h4["sizePt"], "color": h4["color"],
|
"font_size": h4["sizePt"], "color": h4["color"],
|
||||||
"bold": h4.get("weight") == "bold", "align": "left",
|
"bold": h4.get("weight") == "bold", "align": "left",
|
||||||
|
"space_before": h4.get("spaceBeforePt", 12),
|
||||||
|
"space_after": h4.get("spaceAfterPt", 3),
|
||||||
},
|
},
|
||||||
"paragraph": {
|
"paragraph": {
|
||||||
"font_size": para["sizePt"], "color": para["color"],
|
"font_size": para["sizePt"], "color": para["color"],
|
||||||
"bold": False, "align": "left",
|
"bold": False, "align": "left",
|
||||||
|
"line_height": para.get("lineSpacing", 1.15),
|
||||||
},
|
},
|
||||||
"table_header": {
|
"table_header": {
|
||||||
"background": tbl["headerBg"], "text_color": tbl["headerFg"],
|
"background": tbl["headerBg"], "text_color": tbl["headerFg"],
|
||||||
|
|
@ -157,6 +168,7 @@ class BaseRenderer(ABC):
|
||||||
"bullet_list": {
|
"bullet_list": {
|
||||||
"font_size": lst["sizePt"], "color": para["color"],
|
"font_size": lst["sizePt"], "color": para["color"],
|
||||||
"indent": lst["indentPt"],
|
"indent": lst["indentPt"],
|
||||||
|
"bullet_char": lst.get("bulletChar", "\u2022"),
|
||||||
},
|
},
|
||||||
"code_block": {
|
"code_block": {
|
||||||
"font": style["fonts"]["monospace"],
|
"font": style["fonts"]["monospace"],
|
||||||
|
|
|
||||||
|
|
@ -851,25 +851,35 @@ class RendererPdf(BaseRenderer):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _renderJsonBulletList(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]:
|
def _renderJsonBulletList(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]:
|
||||||
"""Render a JSON bullet list to PDF elements using AI-generated styles."""
|
"""Render a JSON bullet list to PDF elements."""
|
||||||
try:
|
try:
|
||||||
content = list_data.get("content", {})
|
content = list_data.get("content", {})
|
||||||
if not isinstance(content, dict):
|
if not isinstance(content, dict):
|
||||||
return []
|
return []
|
||||||
items = content.get("items", [])
|
items = content.get("items", [])
|
||||||
bulletStyleDef = styles.get("bullet_list", {})
|
bulletStyleDef = styles.get("bullet_list", {})
|
||||||
normalStyle = self._createNormalStyle(styles)
|
indent = bulletStyleDef.get("indent", 18)
|
||||||
|
bulletStyle = ParagraphStyle(
|
||||||
|
"BulletItem",
|
||||||
|
fontSize=bulletStyleDef.get("font_size", 11),
|
||||||
|
textColor=self._hexToColor(bulletStyleDef.get("color", "#333333")),
|
||||||
|
leftIndent=indent,
|
||||||
|
firstLineIndent=-indent,
|
||||||
|
spaceAfter=2,
|
||||||
|
leading=bulletStyleDef.get("font_size", 11) * 1.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
bulletChar = bulletStyleDef.get("bullet_char", "\u2022")
|
||||||
elements = []
|
elements = []
|
||||||
for item in items:
|
for item in items:
|
||||||
runs = self._inlineRunsForListItem(item)
|
runs = self._inlineRunsForListItem(item)
|
||||||
if isinstance(item, list):
|
if isinstance(item, list):
|
||||||
xml = self._renderInlineRunsToPdfXml(runs)
|
xml = self._renderInlineRunsToPdfXml(runs)
|
||||||
elements.append(Paragraph(f"\u2022 {_wrapEmojiSpansInXml(xml)}", normalStyle))
|
elements.append(Paragraph(f"{bulletChar} {_wrapEmojiSpansInXml(xml)}", bulletStyle))
|
||||||
elif isinstance(item, str):
|
elif isinstance(item, str):
|
||||||
elements.append(Paragraph(f"\u2022 {self._markdownInlineToReportlabXml(item)}", normalStyle))
|
elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item)}", bulletStyle))
|
||||||
elif isinstance(item, dict) and "text" in item:
|
elif isinstance(item, dict) and "text" in item:
|
||||||
elements.append(Paragraph(f"\u2022 {self._markdownInlineToReportlabXml(item['text'])}", normalStyle))
|
elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item['text'])}", bulletStyle))
|
||||||
|
|
||||||
if elements:
|
if elements:
|
||||||
elements.append(Spacer(1, bulletStyleDef.get("space_after", 3)))
|
elements.append(Spacer(1, bulletStyleDef.get("space_after", 3)))
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ DEFAULT_STYLE: Dict[str, Any] = {
|
||||||
"background": "#FFFFFF",
|
"background": "#FFFFFF",
|
||||||
},
|
},
|
||||||
"headings": {
|
"headings": {
|
||||||
"h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 12, "spaceAfterPt": 6},
|
"h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 24, "spaceAfterPt": 8},
|
||||||
"h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 10, "spaceAfterPt": 4},
|
"h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6},
|
||||||
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 8, "spaceAfterPt": 3},
|
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4},
|
||||||
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 6, "spaceAfterPt": 2},
|
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3},
|
||||||
},
|
},
|
||||||
"paragraph": {"sizePt": 11, "lineSpacing": 1.15, "color": "#333333"},
|
"paragraph": {"sizePt": 11, "lineSpacing": 1.15, "color": "#333333"},
|
||||||
"table": {
|
"table": {
|
||||||
|
|
|
||||||
|
|
@ -410,16 +410,35 @@ class ActionNodeExecutor:
|
||||||
resolvedParams.pop("subject", None)
|
resolvedParams.pop("subject", None)
|
||||||
resolvedParams.pop("body", None)
|
resolvedParams.pop("body", None)
|
||||||
|
|
||||||
# 8. Execute action
|
# 8. Create progress parent so nested actions have a hierarchy
|
||||||
|
import time as _time
|
||||||
|
nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(_time.time())}"
|
||||||
|
chatService = getattr(self.services, "chat", None)
|
||||||
|
if chatService:
|
||||||
|
try:
|
||||||
|
chatService.progressLogStart(nodeOperationId, methodName.capitalize(), actionName, f"Node {nodeId}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
resolvedParams["parentOperationId"] = nodeOperationId
|
||||||
|
|
||||||
|
# 9. Execute action
|
||||||
logger.info("ActionNodeExecutor node %s calling %s.%s with %d params", nodeId, methodName, actionName, len(resolvedParams))
|
logger.info("ActionNodeExecutor node %s calling %s.%s with %d params", nodeId, methodName, actionName, len(resolvedParams))
|
||||||
|
actionSuccess = False
|
||||||
try:
|
try:
|
||||||
executor = ActionExecutor(self.services)
|
executor = ActionExecutor(self.services)
|
||||||
result = await executor.executeAction(methodName, actionName, resolvedParams)
|
result = await executor.executeAction(methodName, actionName, resolvedParams)
|
||||||
|
actionSuccess = True
|
||||||
except (_SubscriptionInactiveException, _BillingContextError):
|
except (_SubscriptionInactiveException, _BillingContextError):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e)
|
logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e)
|
||||||
return _normalizeError(e, outputSchema)
|
return _normalizeError(e, outputSchema)
|
||||||
|
finally:
|
||||||
|
if chatService:
|
||||||
|
try:
|
||||||
|
chatService.progressLogFinish(nodeOperationId, actionSuccess)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 9. Persist generated documents as files and build JSON-safe output
|
# 9. Persist generated documents as files and build JSON-safe output
|
||||||
docsList = []
|
docsList = []
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,11 @@ def _resolve_file_refs_to_content_parts(services, fileIdRefs) -> List[ContentPar
|
||||||
fileId = ref.documentId
|
fileId = ref.documentId
|
||||||
fileMeta = mgmt.getFile(fileId)
|
fileMeta = mgmt.getFile(fileId)
|
||||||
if not fileMeta:
|
if not fileMeta:
|
||||||
logger.warning(f"_resolve_file_refs_to_content_parts: file {fileId} not found")
|
logger.warning("_resolve_file_refs_to_content_parts: file %s not found "
|
||||||
|
"(lookup scope: mandate=%s, featureInstanceId=%s, userId=%s)",
|
||||||
|
fileId, getattr(mgmt, "mandateId", "?"),
|
||||||
|
getattr(mgmt, "featureInstanceId", "?"),
|
||||||
|
getattr(mgmt, "userId", "?"))
|
||||||
continue
|
continue
|
||||||
fileData = mgmt.getFileData(fileId)
|
fileData = mgmt.getFileData(fileId)
|
||||||
if not fileData:
|
if not fileData:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
logger.exception("redmine.listTickets failed")
|
||||||
return ActionResult.isFailure(error=f"List tickets failed: {exc}")
|
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
|
limit = 100
|
||||||
try:
|
try:
|
||||||
limit = max(1, min(500, int(parameters.get("limit") or 100)))
|
limit = max(1, min(500, int(parameters.get("limit") or 100)))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
limit = 100
|
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={
|
return ActionResult.isSuccess(data={
|
||||||
"count": len(truncated),
|
"count": len(page),
|
||||||
"totalMatched": len(tickets),
|
"totalMatched": len(tickets),
|
||||||
"truncated": len(tickets) > limit,
|
"offset": offset,
|
||||||
"tickets": [ticketToDict(t) for t in truncated],
|
"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.createTicket import createTicketAction
|
||||||
from .actions.getStats import getStatsAction
|
from .actions.getStats import getStatsAction
|
||||||
|
from .actions.listRelations import listRelationsAction
|
||||||
from .actions.listTickets import listTicketsAction
|
from .actions.listTickets import listTicketsAction
|
||||||
from .actions.readTicket import readTicket
|
from .actions.readTicket import readTicket
|
||||||
from .actions.runSync import runSyncAction
|
from .actions.runSync import runSyncAction
|
||||||
|
|
@ -90,9 +91,42 @@ class MethodRedmine(MethodBase):
|
||||||
name="limit", type="int", frontendType=FrontendType.TEXT,
|
name="limit", type="int", frontendType=FrontendType.TEXT,
|
||||||
required=False, description="Max tickets in the result (1-500, default 100).",
|
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__),
|
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(
|
"createTicket": WorkflowActionDefinition(
|
||||||
actionId="redmine.createTicket",
|
actionId="redmine.createTicket",
|
||||||
description="Create a new Redmine ticket. Requires subject and trackerId.",
|
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.
|
# rather than through the action dict also work.
|
||||||
self.readTicket = readTicket.__get__(self, self.__class__)
|
self.readTicket = readTicket.__get__(self, self.__class__)
|
||||||
self.listTickets = listTicketsAction.__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.createTicket = createTicketAction.__get__(self, self.__class__)
|
||||||
self.updateTicket = updateTicketAction.__get__(self, self.__class__)
|
self.updateTicket = updateTicketAction.__get__(self, self.__class__)
|
||||||
self.getStats = getStatsAction.__get__(self, self.__class__)
|
self.getStats = getStatsAction.__get__(self, self.__class__)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue