commit
42e79a724a
49 changed files with 2055 additions and 1037 deletions
|
|
@ -103,6 +103,7 @@ class CoachingSession(BaseModel):
|
||||||
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")
|
||||||
status: CoachingSessionStatus = Field(default=CoachingSessionStatus.ACTIVE)
|
status: CoachingSessionStatus = Field(default=CoachingSessionStatus.ACTIVE)
|
||||||
|
personaId: Optional[str] = Field(default=None, description="FK to CoachingPersona (Iteration 2)")
|
||||||
summary: Optional[str] = Field(default=None, description="AI-generated session summary")
|
summary: Optional[str] = Field(default=None, description="AI-generated session summary")
|
||||||
coachNotes: Optional[str] = Field(default=None, description="JSON: AI internal notes for continuity")
|
coachNotes: Optional[str] = Field(default=None, description="JSON: AI internal notes for continuity")
|
||||||
compressedHistorySummary: Optional[str] = Field(default=None, description="AI summary of older messages for long sessions")
|
compressedHistorySummary: Optional[str] = Field(default=None, description="AI summary of older messages for long sessions")
|
||||||
|
|
@ -183,6 +184,62 @@ class CoachingUserProfile(BaseModel):
|
||||||
updatedAt: Optional[str] = Field(default=None)
|
updatedAt: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Iteration 2: Personas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class CoachingPersona(BaseModel):
|
||||||
|
"""A roleplay persona for coaching sessions."""
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
userId: str = Field(description="Owner user ID ('system' for builtins)")
|
||||||
|
mandateId: Optional[str] = Field(default=None)
|
||||||
|
instanceId: Optional[str] = Field(default=None)
|
||||||
|
key: str = Field(description="Unique key, e.g. 'critical_cfo_f'")
|
||||||
|
label: str = Field(description="Display label, e.g. 'Kritische CFO'")
|
||||||
|
description: str = Field(description="Detailed role description for the AI")
|
||||||
|
systemPromptOverride: Optional[str] = Field(default=None, description="Full system prompt override for this persona")
|
||||||
|
gender: Optional[str] = Field(default=None, description="m or f")
|
||||||
|
category: str = Field(default="builtin", description="'builtin' or 'custom'")
|
||||||
|
isActive: bool = Field(default=True)
|
||||||
|
createdAt: Optional[str] = Field(default=None)
|
||||||
|
updatedAt: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Iteration 2: Documents
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class CoachingDocument(BaseModel):
|
||||||
|
"""A document attached to a coaching context."""
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
contextId: str = Field(description="FK to CoachingContext")
|
||||||
|
userId: str = Field(description="Owner user ID")
|
||||||
|
mandateId: str = Field(description="Mandate ID")
|
||||||
|
instanceId: Optional[str] = Field(default=None)
|
||||||
|
fileName: str = Field(description="Original file name")
|
||||||
|
mimeType: str = Field(default="application/octet-stream")
|
||||||
|
fileSize: int = Field(default=0)
|
||||||
|
extractedText: Optional[str] = Field(default=None, description="Text content extracted from file")
|
||||||
|
summary: Optional[str] = Field(default=None, description="AI-generated summary")
|
||||||
|
fileRef: Optional[str] = Field(default=None, description="Reference to file in storage")
|
||||||
|
createdAt: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Iteration 2: Badges / Gamification
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class CoachingBadge(BaseModel):
|
||||||
|
"""An achievement badge awarded to a user."""
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
userId: str = Field(description="Owner user ID")
|
||||||
|
mandateId: str = Field(description="Mandate ID")
|
||||||
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
|
badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'")
|
||||||
|
awardedAt: Optional[str] = Field(default=None)
|
||||||
|
createdAt: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# API Request/Response Models
|
# API Request/Response Models
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -232,6 +289,25 @@ class UpdateProfileRequest(BaseModel):
|
||||||
emailSummaryEnabled: Optional[bool] = None
|
emailSummaryEnabled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StartSessionRequest(BaseModel):
|
||||||
|
personaId: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePersonaRequest(BaseModel):
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
gender: Optional[str] = None
|
||||||
|
systemPromptOverride: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePersonaRequest(BaseModel):
|
||||||
|
label: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
gender: Optional[str] = None
|
||||||
|
systemPromptOverride: Optional[str] = None
|
||||||
|
isActive: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class DashboardData(BaseModel):
|
class DashboardData(BaseModel):
|
||||||
"""Aggregated dashboard data for the user."""
|
"""Aggregated dashboard data for the user."""
|
||||||
totalContexts: int = 0
|
totalContexts: int = 0
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Interface to CommCoach database.
|
||||||
Uses the PostgreSQL connector for data access with strict user ownership.
|
Uses the PostgreSQL connector for data access with strict user ownership.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
|
@ -237,6 +238,98 @@ class CommcoachObjects:
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Personas
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getPersonas(self, userId: str, instanceId: str) -> List[Dict[str, Any]]:
|
||||||
|
from .datamodelCommcoach import CoachingPersona
|
||||||
|
builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"})
|
||||||
|
custom = self.db.getRecordset(CoachingPersona, recordFilter={"userId": userId, "instanceId": instanceId})
|
||||||
|
all = builtins + custom
|
||||||
|
return [p for p in all if p.get("isActive", True)]
|
||||||
|
|
||||||
|
def getPersona(self, personaId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
from .datamodelCommcoach import CoachingPersona
|
||||||
|
records = self.db.getRecordset(CoachingPersona, recordFilter={"id": personaId})
|
||||||
|
return records[0] if records else None
|
||||||
|
|
||||||
|
def createPersona(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
from .datamodelCommcoach import CoachingPersona
|
||||||
|
data["createdAt"] = getIsoTimestamp()
|
||||||
|
data["updatedAt"] = getIsoTimestamp()
|
||||||
|
return self.db.recordCreate(CoachingPersona, data)
|
||||||
|
|
||||||
|
def updatePersona(self, personaId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
from .datamodelCommcoach import CoachingPersona
|
||||||
|
updates["updatedAt"] = getIsoTimestamp()
|
||||||
|
return self.db.recordModify(CoachingPersona, personaId, updates)
|
||||||
|
|
||||||
|
def deletePersona(self, personaId: str) -> bool:
|
||||||
|
from .datamodelCommcoach import CoachingPersona
|
||||||
|
return self.db.recordDelete(CoachingPersona, personaId)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Documents
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getDocuments(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
|
||||||
|
from .datamodelCommcoach import CoachingDocument
|
||||||
|
records = self.db.getRecordset(CoachingDocument, recordFilter={"contextId": contextId, "userId": userId})
|
||||||
|
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
|
||||||
|
return records
|
||||||
|
|
||||||
|
def getDocument(self, documentId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
from .datamodelCommcoach import CoachingDocument
|
||||||
|
records = self.db.getRecordset(CoachingDocument, recordFilter={"id": documentId})
|
||||||
|
return records[0] if records else None
|
||||||
|
|
||||||
|
def createDocument(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
from .datamodelCommcoach import CoachingDocument
|
||||||
|
data["createdAt"] = getIsoTimestamp()
|
||||||
|
return self.db.recordCreate(CoachingDocument, data)
|
||||||
|
|
||||||
|
def deleteDocument(self, documentId: str) -> bool:
|
||||||
|
from .datamodelCommcoach import CoachingDocument
|
||||||
|
return self.db.recordDelete(CoachingDocument, documentId)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Badges
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getBadges(self, userId: str, instanceId: str) -> List[Dict[str, Any]]:
|
||||||
|
from .datamodelCommcoach import CoachingBadge
|
||||||
|
records = self.db.getRecordset(CoachingBadge, recordFilter={"userId": userId, "instanceId": instanceId})
|
||||||
|
records.sort(key=lambda r: r.get("awardedAt") or "", reverse=True)
|
||||||
|
return records
|
||||||
|
|
||||||
|
def hasBadge(self, userId: str, instanceId: str, badgeKey: str) -> bool:
|
||||||
|
from .datamodelCommcoach import CoachingBadge
|
||||||
|
records = self.db.getRecordset(CoachingBadge, recordFilter={"userId": userId, "instanceId": instanceId, "badgeKey": badgeKey})
|
||||||
|
return len(records) > 0
|
||||||
|
|
||||||
|
def awardBadge(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
from .datamodelCommcoach import CoachingBadge
|
||||||
|
data["awardedAt"] = getIsoTimestamp()
|
||||||
|
data["createdAt"] = getIsoTimestamp()
|
||||||
|
return self.db.recordCreate(CoachingBadge, data)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Score History
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getScoreHistory(self, contextId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
scores = self.getScores(contextId, userId)
|
||||||
|
history: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for s in scores:
|
||||||
|
dim = s.get("dimension", "unknown")
|
||||||
|
if dim not in history:
|
||||||
|
history[dim] = []
|
||||||
|
history[dim].append({"score": s.get("score"), "trend": s.get("trend"), "evidence": s.get("evidence"), "createdAt": s.get("createdAt"), "sessionId": s.get("sessionId")})
|
||||||
|
for dim in history:
|
||||||
|
history[dim].sort(key=lambda x: x.get("createdAt") or "")
|
||||||
|
return history
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# User Profile
|
# User Profile
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -292,14 +385,23 @@ class CommcoachObjects:
|
||||||
|
|
||||||
contextSummaries = []
|
contextSummaries = []
|
||||||
for ctx in activeContexts:
|
for ctx in activeContexts:
|
||||||
|
goalProgress = _calcGoalProgress(ctx.get("goals"))
|
||||||
contextSummaries.append({
|
contextSummaries.append({
|
||||||
"id": ctx.get("id"),
|
"id": ctx.get("id"),
|
||||||
"title": ctx.get("title"),
|
"title": ctx.get("title"),
|
||||||
"category": ctx.get("category"),
|
"category": ctx.get("category"),
|
||||||
"sessionCount": ctx.get("sessionCount", 0),
|
"sessionCount": ctx.get("sessionCount", 0),
|
||||||
"lastSessionAt": ctx.get("lastSessionAt"),
|
"lastSessionAt": ctx.get("lastSessionAt"),
|
||||||
|
"goalProgress": goalProgress,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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),
|
"totalContexts": len(contexts),
|
||||||
"activeContexts": len(activeContexts),
|
"activeContexts": len(activeContexts),
|
||||||
|
|
@ -312,4 +414,31 @@ class CommcoachObjects:
|
||||||
"openTasks": self.getOpenTaskCount(userId, instanceId),
|
"openTasks": self.getOpenTaskCount(userId, instanceId),
|
||||||
"completedTasks": self.getCompletedTaskCount(userId, instanceId),
|
"completedTasks": self.getCompletedTaskCount(userId, instanceId),
|
||||||
"contexts": contextSummaries,
|
"contexts": contextSummaries,
|
||||||
|
"goalProgress": overallGoalProgress,
|
||||||
|
"badges": self.getBadges(userId, instanceId),
|
||||||
|
"level": _calcLevel(profile.get("totalSessions", 0) if profile else 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _calcLevel(totalSessions: int) -> Dict[str, Any]:
|
||||||
|
levels = [(50, 5, "Meister"), (25, 4, "Experte"), (10, 3, "Fortgeschritten"), (3, 2, "Engagiert")]
|
||||||
|
for threshold, number, label in levels:
|
||||||
|
if totalSessions >= threshold:
|
||||||
|
return {"number": number, "label": label, "totalSessions": totalSessions}
|
||||||
|
return {"number": 1, "label": "Einsteiger", "totalSessions": totalSessions}
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,9 @@ UI_OBJECTS = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.commcoach.coaching",
|
"objectKey": "ui.feature.commcoach.coaching",
|
||||||
"label": {"en": "Coaching", "de": "Coaching", "fr": "Coaching"},
|
"label": {"en": "Coaching & Dossier", "de": "Coaching & Dossier", "fr": "Coaching & Dossier"},
|
||||||
"meta": {"area": "coaching"}
|
"meta": {"area": "coaching"}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"objectKey": "ui.feature.commcoach.dossier",
|
|
||||||
"label": {"en": "Dossier", "de": "Dossier", "fr": "Dossier"},
|
|
||||||
"meta": {"area": "dossier"}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.commcoach.settings",
|
"objectKey": "ui.feature.commcoach.settings",
|
||||||
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Parametres"},
|
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Parametres"},
|
||||||
|
|
@ -68,6 +63,21 @@ DATA_OBJECTS = [
|
||||||
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"},
|
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"},
|
||||||
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "preferredLanguage"]}
|
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "preferredLanguage"]}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "data.feature.commcoach.CoachingPersona",
|
||||||
|
"label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"},
|
||||||
|
"meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "data.feature.commcoach.CoachingDocument",
|
||||||
|
"label": {"en": "Coaching Document", "de": "Coaching-Dokument", "fr": "Document coaching"},
|
||||||
|
"meta": {"table": "CoachingDocument", "fields": ["id", "contextId", "fileName"]}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "data.feature.commcoach.CoachingBadge",
|
||||||
|
"label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"},
|
||||||
|
"meta": {"table": "CoachingBadge", "fields": ["id", "badgeKey", "awardedAt"]}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.*",
|
"objectKey": "data.feature.commcoach.*",
|
||||||
"label": {"en": "All CommCoach Data", "de": "Alle CommCoach-Daten", "fr": "Toutes les donnees CommCoach"},
|
"label": {"en": "All CommCoach Data", "de": "Alle CommCoach-Daten", "fr": "Toutes les donnees CommCoach"},
|
||||||
|
|
@ -184,6 +194,8 @@ def registerFeature(catalogService) -> bool:
|
||||||
)
|
)
|
||||||
|
|
||||||
_syncTemplateRolesToDb()
|
_syncTemplateRolesToDb()
|
||||||
|
_seedBuiltinPersonas()
|
||||||
|
_registerScheduler()
|
||||||
|
|
||||||
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")
|
||||||
return True
|
return True
|
||||||
|
|
@ -193,6 +205,29 @@ def registerFeature(catalogService) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _seedBuiltinPersonas():
|
||||||
|
"""Seed builtin roleplay personas into the database."""
|
||||||
|
try:
|
||||||
|
from .serviceCommcoachPersonas import seedBuiltinPersonas
|
||||||
|
from .interfaceFeatureCommcoach import CommcoachInterface
|
||||||
|
from modules.interfaces.interfaceDbManagement import getInterface as getDbInterface
|
||||||
|
db = getDbInterface()
|
||||||
|
interface = CommcoachInterface(db)
|
||||||
|
seedBuiltinPersonas(interface)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"CommCoach persona seeding failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _registerScheduler():
|
||||||
|
"""Register CommCoach scheduled jobs (daily reminders)."""
|
||||||
|
try:
|
||||||
|
from modules.shared.eventManagement import eventManager
|
||||||
|
from .serviceCommcoachScheduler import registerScheduledJobs
|
||||||
|
registerScheduledJobs(eventManager)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"CommCoach scheduler registration failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
|
||||||
def _syncTemplateRolesToDb() -> int:
|
def _syncTemplateRolesToDb() -> int:
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@ import logging
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse, Response
|
||||||
|
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
from modules.shared.timeUtils import getIsoTimestamp
|
from modules.shared.timeUtils import getIsoTimestamp
|
||||||
|
|
@ -23,14 +24,33 @@ from .datamodelCommcoach import (
|
||||||
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
|
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
|
||||||
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
|
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
|
||||||
CoachingTask, CoachingTaskStatus,
|
CoachingTask, CoachingTaskStatus,
|
||||||
|
CoachingPersona, CoachingDocument, CoachingBadge,
|
||||||
CreateContextRequest, UpdateContextRequest,
|
CreateContextRequest, UpdateContextRequest,
|
||||||
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
|
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
|
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest,
|
||||||
)
|
)
|
||||||
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
|
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(context: RequestContext, action: str, resourceType: str = None, resourceId: str = None, details: str = ""):
|
||||||
|
"""Log an audit event for CommCoach. Non-blocking, best-effort."""
|
||||||
|
try:
|
||||||
|
from modules.shared.auditLogger import audit_logger
|
||||||
|
audit_logger.logEvent(
|
||||||
|
userId=str(context.user.id),
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
category="commcoach",
|
||||||
|
action=action,
|
||||||
|
resourceType=resourceType,
|
||||||
|
resourceId=resourceId,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/commcoach",
|
prefix="/api/commcoach",
|
||||||
tags=["CommCoach"],
|
tags=["CommCoach"],
|
||||||
|
|
@ -116,6 +136,7 @@ async def createContext(
|
||||||
|
|
||||||
created = interface.createContext(contextData)
|
created = interface.createContext(contextData)
|
||||||
logger.info(f"CommCoach context created: {created.get('id')} for user {userId}")
|
logger.info(f"CommCoach context created: {created.get('id')} for user {userId}")
|
||||||
|
_audit(context, "commcoach.context.created", "CoachingContext", created.get("id"), f"Title: {body.title}")
|
||||||
return {"context": created}
|
return {"context": created}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -208,6 +229,7 @@ async def archiveContext(
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(ctx, context)
|
||||||
|
|
||||||
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value})
|
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value})
|
||||||
|
_audit(context, "commcoach.context.archived", "CoachingContext", contextId)
|
||||||
return {"context": updated}
|
return {"context": updated}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -262,6 +284,7 @@ async def startSession(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
contextId: str,
|
||||||
|
personaId: Optional[str] = None,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""Start a new coaching session or resume active one. Returns SSE stream with sessionState, messages, and complete."""
|
"""Start a new coaching session or resume active one. Returns SSE stream with sessionState, messages, and complete."""
|
||||||
|
|
@ -339,6 +362,7 @@ async def startSession(
|
||||||
userId=userId,
|
userId=userId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
instanceId=instanceId,
|
instanceId=instanceId,
|
||||||
|
personaId=personaId,
|
||||||
).model_dump()
|
).model_dump()
|
||||||
created = interface.createSession(sessionData)
|
created = interface.createSession(sessionData)
|
||||||
sessionId = created.get("id")
|
sessionId = created.get("id")
|
||||||
|
|
@ -369,6 +393,7 @@ async def startSession(
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}")
|
logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}")
|
||||||
|
_audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Context: {contextId}")
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_newSessionEventGenerator(),
|
_newSessionEventGenerator(),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
|
|
@ -419,6 +444,7 @@ async def completeSession(
|
||||||
|
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
result = await service.completeSession(sessionId, interface)
|
result = await service.completeSession(sessionId, interface)
|
||||||
|
_audit(context, "commcoach.session.completed", "CoachingSession", sessionId)
|
||||||
return {"session": result}
|
return {"session": result}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -866,3 +892,349 @@ async def testVoice(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Voice test failed: {e}")
|
logger.error(f"Voice test failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"TTS test failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"TTS test failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Export Endpoints (Iteration 2)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/contexts/{contextId}/export")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def exportDossier(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
contextId: str,
|
||||||
|
format: str = "md",
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Export a dossier as Markdown or PDF."""
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
userId = str(context.user.id)
|
||||||
|
|
||||||
|
ctx = interface.getContext(contextId)
|
||||||
|
if not ctx:
|
||||||
|
raise HTTPException(status_code=404, detail="Context not found")
|
||||||
|
_validateOwnership(ctx, context)
|
||||||
|
|
||||||
|
tasks = interface.getTasks(contextId, userId)
|
||||||
|
scores = interface.getScores(contextId, userId)
|
||||||
|
sessions = interface.getSessions(contextId, userId)
|
||||||
|
|
||||||
|
from .serviceCommcoachExport import buildDossierMarkdown, renderDossierPdf
|
||||||
|
_audit(context, "commcoach.export.requested", "CoachingContext", contextId, f"format={format}")
|
||||||
|
|
||||||
|
if format == "pdf":
|
||||||
|
pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores)
|
||||||
|
if pdfBytes:
|
||||||
|
return Response(content=pdfBytes, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'})
|
||||||
|
format = "md"
|
||||||
|
|
||||||
|
md = buildDossierMarkdown(ctx, sessions, tasks, scores)
|
||||||
|
return Response(content=md, media_type="text/markdown",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.md"'})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/sessions/{sessionId}/export")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def exportSession(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
sessionId: str,
|
||||||
|
format: str = "md",
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Export a session as Markdown or PDF."""
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
|
session = interface.getSession(sessionId)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
_validateOwnership(session, context)
|
||||||
|
|
||||||
|
contextId = session.get("contextId")
|
||||||
|
userId = str(context.user.id)
|
||||||
|
messages = interface.getMessages(sessionId)
|
||||||
|
tasks = interface.getTasks(contextId, userId) if contextId else []
|
||||||
|
scores = interface.getScores(contextId, userId) if contextId else []
|
||||||
|
|
||||||
|
from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf
|
||||||
|
_audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}")
|
||||||
|
|
||||||
|
if format == "pdf":
|
||||||
|
pdfBytes = await renderSessionPdf(session, messages, tasks, scores)
|
||||||
|
if pdfBytes:
|
||||||
|
return Response(content=pdfBytes, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.pdf"'})
|
||||||
|
format = "md"
|
||||||
|
|
||||||
|
md = buildSessionMarkdown(session, messages, tasks, scores)
|
||||||
|
return Response(content=md, media_type="text/markdown",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.md"'})
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Persona Endpoints (Iteration 2)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/personas")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def listPersonas(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
userId = str(context.user.id)
|
||||||
|
personas = interface.getPersonas(userId, instanceId)
|
||||||
|
return {"personas": personas}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instanceId}/personas")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def createPersona(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
body: CreatePersonaRequest,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
userId = str(context.user.id)
|
||||||
|
|
||||||
|
data = CoachingPersona(
|
||||||
|
userId=userId,
|
||||||
|
mandateId=mandateId,
|
||||||
|
instanceId=instanceId,
|
||||||
|
key=f"custom_{str(uuid.uuid4())[:8]}",
|
||||||
|
label=body.label,
|
||||||
|
description=body.description,
|
||||||
|
gender=body.gender,
|
||||||
|
systemPromptOverride=body.systemPromptOverride,
|
||||||
|
category="custom",
|
||||||
|
).model_dump()
|
||||||
|
created = interface.createPersona(data)
|
||||||
|
return {"persona": created}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{instanceId}/personas/{personaId}")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def updatePersonaRoute(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
personaId: str,
|
||||||
|
body: UpdatePersonaRequest,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
|
persona = interface.getPersona(personaId)
|
||||||
|
if not persona:
|
||||||
|
raise HTTPException(status_code=404, detail="Persona not found")
|
||||||
|
if persona.get("category") == "builtin":
|
||||||
|
raise HTTPException(status_code=403, detail="Builtin personas cannot be edited")
|
||||||
|
_validateOwnership(persona, context)
|
||||||
|
|
||||||
|
updates = body.model_dump(exclude_none=True)
|
||||||
|
updated = interface.updatePersona(personaId, updates)
|
||||||
|
return {"persona": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{instanceId}/personas/{personaId}")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def deletePersonaRoute(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
personaId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
|
persona = interface.getPersona(personaId)
|
||||||
|
if not persona:
|
||||||
|
raise HTTPException(status_code=404, detail="Persona not found")
|
||||||
|
if persona.get("category") == "builtin":
|
||||||
|
raise HTTPException(status_code=403, detail="Builtin personas cannot be deleted")
|
||||||
|
_validateOwnership(persona, context)
|
||||||
|
|
||||||
|
interface.deletePersona(personaId)
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Document Endpoints (Iteration 2)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/contexts/{contextId}/documents")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def listDocuments(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
contextId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
userId = str(context.user.id)
|
||||||
|
docs = interface.getDocuments(contextId, userId)
|
||||||
|
return {"documents": docs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instanceId}/contexts/{contextId}/documents")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def uploadDocument(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
contextId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Upload a document and bind it to a context. Stores file in Management DB."""
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
userId = str(context.user.id)
|
||||||
|
|
||||||
|
ctx = interface.getContext(contextId)
|
||||||
|
if not ctx:
|
||||||
|
raise HTTPException(status_code=404, detail="Context not found")
|
||||||
|
_validateOwnership(ctx, context)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
file = form.get("file")
|
||||||
|
if not file or not hasattr(file, "read"):
|
||||||
|
raise HTTPException(status_code=400, detail="No file uploaded")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
fileName = getattr(file, "filename", "document")
|
||||||
|
mimeType = getattr(file, "content_type", "application/octet-stream")
|
||||||
|
fileSize = len(content)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="Leere Datei hochgeladen")
|
||||||
|
|
||||||
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
|
mgmtInterface = interfaceDbManagement.getInterface(currentUser=context.user)
|
||||||
|
fileItem, _dupType = mgmtInterface.saveUploadedFile(content, fileName)
|
||||||
|
fileRef = fileItem.id
|
||||||
|
|
||||||
|
extractedText = _extractText(content, mimeType, fileName)
|
||||||
|
summary = None
|
||||||
|
if extractedText and len(extractedText.strip()) > 50:
|
||||||
|
try:
|
||||||
|
from .serviceCommcoach import CommcoachService
|
||||||
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
|
aiResp = await service._callAi(
|
||||||
|
"Du fasst Dokumente in 2-3 Saetzen zusammen.",
|
||||||
|
f"Fasse folgendes Dokument zusammen:\n\n{extractedText[:3000]}"
|
||||||
|
)
|
||||||
|
if aiResp and aiResp.errorCount == 0 and aiResp.content:
|
||||||
|
summary = aiResp.content.strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Document summary failed: {e}")
|
||||||
|
|
||||||
|
docData = CoachingDocument(
|
||||||
|
contextId=contextId,
|
||||||
|
userId=userId,
|
||||||
|
mandateId=mandateId,
|
||||||
|
instanceId=instanceId,
|
||||||
|
fileName=fileName,
|
||||||
|
mimeType=mimeType,
|
||||||
|
fileSize=fileSize,
|
||||||
|
extractedText=extractedText[:10000] if extractedText else None,
|
||||||
|
summary=summary,
|
||||||
|
fileRef=fileRef,
|
||||||
|
).model_dump()
|
||||||
|
created = interface.createDocument(docData)
|
||||||
|
return {"document": created}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{instanceId}/documents/{documentId}")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def deleteDocumentRoute(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
documentId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
|
doc = interface.getDocument(documentId)
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
_validateOwnership(doc, context)
|
||||||
|
|
||||||
|
fileRef = doc.get("fileRef")
|
||||||
|
if fileRef:
|
||||||
|
try:
|
||||||
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
|
mgmtInterface = interfaceDbManagement.getInterface(
|
||||||
|
currentUser=context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
|
)
|
||||||
|
mgmtInterface.deleteFile(fileRef)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete file {fileRef}: {e}")
|
||||||
|
|
||||||
|
interface.deleteDocument(documentId)
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _extractText(content: bytes, mimeType: str, fileName: str) -> Optional[str]:
|
||||||
|
"""Extract text from uploaded file content."""
|
||||||
|
try:
|
||||||
|
if mimeType == "text/plain" or fileName.endswith(".txt"):
|
||||||
|
return content.decode("utf-8", errors="replace")
|
||||||
|
if mimeType == "text/markdown" or fileName.endswith(".md"):
|
||||||
|
return content.decode("utf-8", errors="replace")
|
||||||
|
if "pdf" in mimeType or fileName.endswith(".pdf"):
|
||||||
|
try:
|
||||||
|
import io
|
||||||
|
from PyPDF2 import PdfReader
|
||||||
|
reader = PdfReader(io.BytesIO(content))
|
||||||
|
text = ""
|
||||||
|
for page in reader.pages:
|
||||||
|
text += page.extract_text() or ""
|
||||||
|
return text
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("PyPDF2 not installed, cannot extract PDF text")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Text extraction failed for {fileName}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Badge + Score History Endpoints (Iteration 2)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/badges")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def listBadges(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
userId = str(context.user.id)
|
||||||
|
badges = interface.getBadges(userId, instanceId)
|
||||||
|
return {"badges": badges}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/contexts/{contextId}/scores/history")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getScoreHistory(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
contextId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
interface = _getInterface(context, instanceId)
|
||||||
|
userId = str(context.user.id)
|
||||||
|
history = interface.getScoreHistory(contextId, userId)
|
||||||
|
return {"history": history}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,147 @@ def cleanupSessionEvents(sessionId: str):
|
||||||
_sessionEvents.pop(sessionId, None)
|
_sessionEvents.pop(sessionId, None)
|
||||||
|
|
||||||
|
|
||||||
|
CHUNK_WORD_SIZE = 4
|
||||||
|
CHUNK_DELAY_SECONDS = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _parseAiJsonResponse(rawText: str) -> Dict[str, Any]:
|
||||||
|
"""Parse the structured JSON response from AI. Strips optional markdown code fences."""
|
||||||
|
text = rawText.strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
lines = text.split("\n")
|
||||||
|
lines = lines[1:]
|
||||||
|
if lines and lines[-1].strip() == "```":
|
||||||
|
lines = lines[:-1]
|
||||||
|
text = "\n".join(lines)
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"AI JSON parse failed, using raw text: {text[:200]}")
|
||||||
|
return {"text": rawText.strip(), "speech": "", "documents": []}
|
||||||
|
|
||||||
|
|
||||||
|
async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mandateId: str,
|
||||||
|
instanceId: str, interface):
|
||||||
|
"""Generate TTS audio from speech text and emit as SSE event."""
|
||||||
|
if not speechText:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
|
import base64
|
||||||
|
voiceInterface = getVoiceInterface(currentUser, mandateId)
|
||||||
|
profile = interface.getProfile(str(currentUser.id), instanceId)
|
||||||
|
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
|
||||||
|
voiceName = profile.get("preferredVoice") if profile else None
|
||||||
|
ttsResult = await voiceInterface.textToSpeech(
|
||||||
|
text=_stripMarkdownForTts(speechText),
|
||||||
|
languageCode=language,
|
||||||
|
voiceName=voiceName,
|
||||||
|
)
|
||||||
|
if ttsResult and isinstance(ttsResult, dict):
|
||||||
|
audioBytes = ttsResult.get("audioContent")
|
||||||
|
if audioBytes:
|
||||||
|
audioB64 = base64.b64encode(
|
||||||
|
audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode()
|
||||||
|
).decode()
|
||||||
|
await emitSessionEvent(sessionId, "ttsAudio", {"audio": audioB64, "format": "mp3"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"TTS failed for session {sessionId}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _saveGeneratedDocument(doc: Dict[str, Any], contextId: str, userId: str,
|
||||||
|
mandateId: str, instanceId: str, interface, sessionId: str,
|
||||||
|
user=None):
|
||||||
|
"""Save a document generated by AI. Stores file in Management DB."""
|
||||||
|
from .datamodelCommcoach import CoachingDocument
|
||||||
|
try:
|
||||||
|
title = doc.get("title", "Dokument")
|
||||||
|
content = doc.get("content", "")
|
||||||
|
contentBytes = content.encode("utf-8")
|
||||||
|
fileName = f"{title}.md"
|
||||||
|
|
||||||
|
fileRef = None
|
||||||
|
try:
|
||||||
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
|
mgmtInterface = interfaceDbManagement.getInterface(
|
||||||
|
currentUser=user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
|
)
|
||||||
|
fileItem = mgmtInterface.createFile(name=fileName, mimeType="text/markdown", content=contentBytes)
|
||||||
|
mgmtInterface.createFileData(fileItem.id, contentBytes)
|
||||||
|
fileRef = fileItem.id
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to store generated document in file DB: {e}")
|
||||||
|
|
||||||
|
docData = CoachingDocument(
|
||||||
|
contextId=contextId,
|
||||||
|
userId=userId,
|
||||||
|
mandateId=mandateId,
|
||||||
|
instanceId=instanceId,
|
||||||
|
fileName=fileName,
|
||||||
|
mimeType="text/markdown",
|
||||||
|
fileSize=len(contentBytes),
|
||||||
|
extractedText=content,
|
||||||
|
summary=title,
|
||||||
|
fileRef=fileRef,
|
||||||
|
).model_dump()
|
||||||
|
created = interface.createDocument(docData)
|
||||||
|
await emitSessionEvent(sessionId, "documentCreated", created)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to save generated document: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _emitChunkedResponse(sessionId: str, createdMsg: Dict[str, Any], fullText: str):
|
||||||
|
"""Emit response as messageChunk events for progressive display, then the full message."""
|
||||||
|
msgId = createdMsg.get("id")
|
||||||
|
words = fullText.split()
|
||||||
|
emitted = ""
|
||||||
|
for i in range(0, len(words), CHUNK_WORD_SIZE):
|
||||||
|
chunk = " ".join(words[i:i + CHUNK_WORD_SIZE])
|
||||||
|
emitted = (emitted + " " + chunk).strip() if emitted else chunk
|
||||||
|
await emitSessionEvent(sessionId, "messageChunk", {
|
||||||
|
"id": msgId,
|
||||||
|
"role": "assistant",
|
||||||
|
"chunk": chunk,
|
||||||
|
"accumulated": emitted,
|
||||||
|
})
|
||||||
|
await asyncio.sleep(CHUNK_DELAY_SECONDS)
|
||||||
|
await emitSessionEvent(sessionId, "message", {
|
||||||
|
"id": msgId,
|
||||||
|
"role": "assistant",
|
||||||
|
"content": fullText,
|
||||||
|
"createdAt": createdMsg.get("createdAt"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolvePersona(session: Optional[Dict[str, Any]], interface) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Resolve persona data from session's personaId."""
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
personaId = session.get("personaId")
|
||||||
|
if not personaId:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return interface.getPersona(personaId)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _getDocumentSummaries(contextId: str, userId: str, interface) -> Optional[List[str]]:
|
||||||
|
"""Get document summaries for context to include in the AI prompt."""
|
||||||
|
try:
|
||||||
|
docs = interface.getDocuments(contextId, userId)
|
||||||
|
summaries = []
|
||||||
|
for doc in docs[:5]:
|
||||||
|
summary = doc.get("summary")
|
||||||
|
if summary:
|
||||||
|
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {summary}")
|
||||||
|
elif doc.get("extractedText"):
|
||||||
|
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {doc['extractedText'][:200]}...")
|
||||||
|
return summaries if summaries else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class CommcoachService:
|
class CommcoachService:
|
||||||
"""Coaching orchestrator: processes messages, calls AI, extracts tasks and scores."""
|
"""Coaching orchestrator: processes messages, calls AI, extracts tasks and scores."""
|
||||||
|
|
||||||
|
|
@ -143,7 +284,7 @@ class CommcoachService:
|
||||||
try:
|
try:
|
||||||
summaryPrompt = aiPrompts.buildEarlierConversationSummaryPrompt(toSummarize)
|
summaryPrompt = aiPrompts.buildEarlierConversationSummaryPrompt(toSummarize)
|
||||||
summaryResponse = await self._callAi(
|
summaryResponse = await self._callAi(
|
||||||
"Du fasst Coaching-Gespraeche praezise zusammen.", summaryPrompt
|
"Du fasst Coaching-Gespräche präzise zusammen.", summaryPrompt
|
||||||
)
|
)
|
||||||
if summaryResponse and summaryResponse.errorCount == 0 and summaryResponse.content:
|
if summaryResponse and summaryResponse.errorCount == 0 and summaryResponse.content:
|
||||||
earlierSummary = summaryResponse.content.strip()
|
earlierSummary = summaryResponse.content.strip()
|
||||||
|
|
@ -163,6 +304,9 @@ class CommcoachService:
|
||||||
contextId, sessionId, userContent, context, interface
|
contextId, sessionId, userContent, context, interface
|
||||||
)
|
)
|
||||||
|
|
||||||
|
persona = _resolvePersona(session, interface)
|
||||||
|
documentSummaries = _getDocumentSummaries(contextId, self.userId, interface)
|
||||||
|
|
||||||
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
||||||
context,
|
context,
|
||||||
previousMessages,
|
previousMessages,
|
||||||
|
|
@ -172,10 +316,12 @@ class CommcoachService:
|
||||||
rollingOverview=retrievalResult.get("rollingOverview"),
|
rollingOverview=retrievalResult.get("rollingOverview"),
|
||||||
retrievedSession=retrievalResult.get("retrievedSession"),
|
retrievedSession=retrievalResult.get("retrievedSession"),
|
||||||
retrievedByTopic=retrievalResult.get("retrievedByTopic"),
|
retrievedByTopic=retrievalResult.get("retrievedByTopic"),
|
||||||
|
persona=persona,
|
||||||
|
documentSummaries=documentSummaries,
|
||||||
)
|
)
|
||||||
|
|
||||||
if retrievalResult.get("intent") == RetrievalIntent.SUMMARIZE_ALL:
|
if retrievalResult.get("intent") == RetrievalIntent.SUMMARIZE_ALL:
|
||||||
systemPrompt += "\n\nWICHTIG: Der Benutzer moechte eine Gesamtzusammenfassung. Erstelle eine umfassende Zusammenfassung aller genannten Sessions und der aktuellen Session."
|
systemPrompt += "\n\nWICHTIG: Der Benutzer möchte eine Gesamtzusammenfassung. Erstelle eine umfassende Zusammenfassung aller genannten Sessions und der aktuellen Session."
|
||||||
|
|
||||||
# Call AI
|
# Call AI
|
||||||
await emitSessionEvent(sessionId, "status", {"label": "Coach denkt nach..."})
|
await emitSessionEvent(sessionId, "status", {"label": "Coach denkt nach..."})
|
||||||
|
|
@ -187,52 +333,38 @@ class CommcoachService:
|
||||||
await emitSessionEvent(sessionId, "error", {"message": f"AI error: {str(e)}"})
|
await emitSessionEvent(sessionId, "error", {"message": f"AI error: {str(e)}"})
|
||||||
return createdUserMsg
|
return createdUserMsg
|
||||||
|
|
||||||
responseText = aiResponse.content.strip() if aiResponse and aiResponse.errorCount == 0 else "Entschuldigung, ich konnte gerade nicht antworten. Bitte versuche es erneut."
|
responseRaw = aiResponse.content.strip() if aiResponse and aiResponse.errorCount == 0 else ""
|
||||||
|
|
||||||
|
if not responseRaw:
|
||||||
|
parsed = {"text": "Entschuldigung, ich konnte gerade nicht antworten. Bitte versuche es erneut.", "speech": "", "documents": []}
|
||||||
|
else:
|
||||||
|
parsed = _parseAiJsonResponse(responseRaw)
|
||||||
|
|
||||||
|
textContent = parsed.get("text", "")
|
||||||
|
speechContent = parsed.get("speech", "")
|
||||||
|
documents = parsed.get("documents", [])
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
await _saveGeneratedDocument(doc, contextId, self.userId, self.mandateId, self.instanceId, interface, sessionId, user=self.currentUser)
|
||||||
|
|
||||||
# Store assistant message
|
|
||||||
assistantMsg = CoachingMessage(
|
assistantMsg = CoachingMessage(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
contextId=contextId,
|
contextId=contextId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
role=CoachingMessageRole.ASSISTANT,
|
role=CoachingMessageRole.ASSISTANT,
|
||||||
content=responseText,
|
content=textContent,
|
||||||
contentType=CoachingMessageContentType.TEXT,
|
contentType=CoachingMessageContentType.TEXT,
|
||||||
).model_dump()
|
).model_dump()
|
||||||
createdAssistantMsg = interface.createMessage(assistantMsg)
|
createdAssistantMsg = interface.createMessage(assistantMsg)
|
||||||
|
|
||||||
# Update session message count
|
|
||||||
messages = interface.getMessages(sessionId)
|
messages = interface.getMessages(sessionId)
|
||||||
interface.updateSession(sessionId, {"messageCount": len(messages)})
|
interface.updateSession(sessionId, {"messageCount": len(messages)})
|
||||||
|
|
||||||
await emitSessionEvent(sessionId, "message", {
|
ttsTask = asyncio.create_task(
|
||||||
"id": createdAssistantMsg.get("id"),
|
_generateAndEmitTts(sessionId, speechContent, self.currentUser, self.mandateId, self.instanceId, interface)
|
||||||
"role": "assistant",
|
)
|
||||||
"content": responseText,
|
await _emitChunkedResponse(sessionId, createdAssistantMsg, textContent)
|
||||||
"createdAt": createdAssistantMsg.get("createdAt"),
|
await ttsTask
|
||||||
})
|
|
||||||
|
|
||||||
if responseText:
|
|
||||||
try:
|
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
|
||||||
import base64
|
|
||||||
voiceInterface = getVoiceInterface(self.currentUser, self.mandateId)
|
|
||||||
profile = interface.getProfile(self.userId, self.instanceId)
|
|
||||||
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
|
|
||||||
voiceName = profile.get("preferredVoice") if profile else None
|
|
||||||
ttsResult = await voiceInterface.textToSpeech(
|
|
||||||
text=_stripMarkdownForTts(responseText),
|
|
||||||
languageCode=language,
|
|
||||||
voiceName=voiceName,
|
|
||||||
)
|
|
||||||
if ttsResult and isinstance(ttsResult, dict):
|
|
||||||
audioBytes = ttsResult.get("audioContent")
|
|
||||||
if audioBytes:
|
|
||||||
audioB64 = base64.b64encode(
|
|
||||||
audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode()
|
|
||||||
).decode()
|
|
||||||
await emitSessionEvent(sessionId, "ttsAudio", {"audio": audioB64, "format": "mp3"})
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"TTS failed for text message session {sessionId}: {e}")
|
|
||||||
|
|
||||||
await emitSessionEvent(sessionId, "complete", {})
|
await emitSessionEvent(sessionId, "complete", {})
|
||||||
return createdAssistantMsg
|
return createdAssistantMsg
|
||||||
|
|
@ -259,10 +391,26 @@ class CommcoachService:
|
||||||
allSessions, excludeSessionId=sessionId, limit=PREVIOUS_SESSION_SUMMARIES_COUNT
|
allSessions, excludeSessionId=sessionId, limit=PREVIOUS_SESSION_SUMMARIES_COUNT
|
||||||
)
|
)
|
||||||
|
|
||||||
|
session = interface.getSession(sessionId)
|
||||||
|
persona = _resolvePersona(session, interface)
|
||||||
|
documentSummaries = _getDocumentSummaries(contextId, self.userId, interface)
|
||||||
|
|
||||||
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
||||||
context, previousMessages, tasks, previousSessionSummaries=previousSessionSummaries
|
context, previousMessages, tasks,
|
||||||
|
previousSessionSummaries=previousSessionSummaries,
|
||||||
|
persona=persona,
|
||||||
|
documentSummaries=documentSummaries,
|
||||||
)
|
)
|
||||||
openingUserPrompt = "Beginne die Coaching-Session mit einer kurzen Begruesssung, fasse in einem Satz zusammen wo wir stehen (falls vorherige Sessions), und stelle eine gezielte Einstiegsfrage zum Thema."
|
|
||||||
|
isFirstSession = not previousSessionSummaries or len(previousSessionSummaries) == 0
|
||||||
|
|
||||||
|
if persona and persona.get("key") != "coach":
|
||||||
|
personaLabel = persona.get("label", "Gesprächspartner")
|
||||||
|
openingUserPrompt = f"Beginne das Gespräch in deiner Rolle als {personaLabel}. Stelle dich kurz vor und eröffne die Situation gemäss deiner Rollenbeschreibung."
|
||||||
|
elif isFirstSession:
|
||||||
|
openingUserPrompt = "Dies ist die ERSTE Session zu diesem Thema. Begrüsse den Benutzer, stelle das Thema kurz vor und stelle eine offene Einstiegsfrage. Erfinde KEINE vorherigen Gespräche oder Zusammenfassungen."
|
||||||
|
else:
|
||||||
|
openingUserPrompt = "Begrüsse den Benutzer zurück, fasse in einem Satz zusammen wo wir stehen, und stelle eine gezielte Einstiegsfrage."
|
||||||
|
|
||||||
try:
|
try:
|
||||||
aiResponse = await self._callAi(systemPrompt, openingUserPrompt)
|
aiResponse = await self._callAi(systemPrompt, openingUserPrompt)
|
||||||
|
|
@ -272,54 +420,41 @@ class CommcoachService:
|
||||||
await emitSessionEvent(sessionId, "complete", {})
|
await emitSessionEvent(sessionId, "complete", {})
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
openingContent = (
|
responseRaw = (
|
||||||
aiResponse.content.strip()
|
aiResponse.content.strip()
|
||||||
if aiResponse and aiResponse.errorCount == 0
|
if aiResponse and aiResponse.errorCount == 0
|
||||||
else f"Willkommen zur Coaching-Session zum Thema \"{context.get('title')}\". Was moechtest du heute besprechen?"
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not responseRaw:
|
||||||
|
parsed = {"text": f"Willkommen zur Coaching-Session zum Thema \"{context.get('title')}\". Was möchtest du heute besprechen?", "speech": "", "documents": []}
|
||||||
|
else:
|
||||||
|
parsed = _parseAiJsonResponse(responseRaw)
|
||||||
|
|
||||||
|
textContent = parsed.get("text", "")
|
||||||
|
speechContent = parsed.get("speech", "")
|
||||||
|
documents = parsed.get("documents", [])
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
await _saveGeneratedDocument(doc, contextId, self.userId, self.mandateId, self.instanceId, interface, sessionId, user=self.currentUser)
|
||||||
|
|
||||||
assistantMsg = CoachingMessage(
|
assistantMsg = CoachingMessage(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
contextId=contextId,
|
contextId=contextId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
role=CoachingMessageRole.ASSISTANT,
|
role=CoachingMessageRole.ASSISTANT,
|
||||||
content=openingContent,
|
content=textContent,
|
||||||
contentType=CoachingMessageContentType.TEXT,
|
contentType=CoachingMessageContentType.TEXT,
|
||||||
).model_dump()
|
).model_dump()
|
||||||
createdMsg = interface.createMessage(assistantMsg)
|
createdMsg = interface.createMessage(assistantMsg)
|
||||||
interface.updateSession(sessionId, {"messageCount": 1})
|
interface.updateSession(sessionId, {"messageCount": 1})
|
||||||
|
|
||||||
await emitSessionEvent(sessionId, "message", {
|
ttsTask = asyncio.create_task(
|
||||||
"id": createdMsg.get("id"),
|
_generateAndEmitTts(sessionId, speechContent, self.currentUser, self.mandateId, self.instanceId, interface)
|
||||||
"sessionId": sessionId,
|
)
|
||||||
"contextId": contextId,
|
await _emitChunkedResponse(sessionId, createdMsg, textContent)
|
||||||
"role": "assistant",
|
await ttsTask
|
||||||
"content": openingContent,
|
|
||||||
"contentType": "text",
|
|
||||||
"createdAt": createdMsg.get("createdAt"),
|
|
||||||
})
|
|
||||||
if openingContent:
|
|
||||||
try:
|
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
|
||||||
import base64
|
|
||||||
voiceInterface = getVoiceInterface(self.currentUser, self.mandateId)
|
|
||||||
profile = interface.getProfile(self.userId, self.instanceId)
|
|
||||||
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
|
|
||||||
voiceName = profile.get("preferredVoice") if profile else None
|
|
||||||
ttsResult = await voiceInterface.textToSpeech(
|
|
||||||
text=_stripMarkdownForTts(openingContent),
|
|
||||||
languageCode=language,
|
|
||||||
voiceName=voiceName,
|
|
||||||
)
|
|
||||||
if ttsResult and isinstance(ttsResult, dict):
|
|
||||||
audioBytes = ttsResult.get("audioContent")
|
|
||||||
if audioBytes:
|
|
||||||
audioB64 = base64.b64encode(
|
|
||||||
audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode()
|
|
||||||
).decode()
|
|
||||||
await emitSessionEvent(sessionId, "ttsAudio", {"audio": audioB64, "format": "mp3"})
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"TTS failed for opening: {e}")
|
|
||||||
await emitSessionEvent(sessionId, "complete", {})
|
await emitSessionEvent(sessionId, "complete", {})
|
||||||
|
|
||||||
logger.info(f"CommCoach session opening completed: {sessionId}")
|
logger.info(f"CommCoach session opening completed: {sessionId}")
|
||||||
|
|
@ -365,36 +500,7 @@ class CommcoachService:
|
||||||
await emitSessionEvent(sessionId, "error", {"message": msg, "detail": sttError})
|
await emitSessionEvent(sessionId, "error", {"message": msg, "detail": sttError})
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Process through normal pipeline
|
|
||||||
result = await self.processMessage(sessionId, contextId, transcribedText, interface)
|
result = await self.processMessage(sessionId, contextId, transcribedText, interface)
|
||||||
|
|
||||||
# Generate TTS for the response
|
|
||||||
assistantContent = result.get("content", "")
|
|
||||||
if assistantContent:
|
|
||||||
await emitSessionEvent(sessionId, "status", {"label": "Antwort wird gesprochen..."})
|
|
||||||
try:
|
|
||||||
profile = interface.getProfile(self.userId, self.instanceId)
|
|
||||||
voiceName = profile.get("preferredVoice") if profile else None
|
|
||||||
|
|
||||||
ttsResult = await voiceInterface.textToSpeech(
|
|
||||||
text=_stripMarkdownForTts(assistantContent),
|
|
||||||
languageCode=language,
|
|
||||||
voiceName=voiceName,
|
|
||||||
)
|
|
||||||
if ttsResult and isinstance(ttsResult, dict):
|
|
||||||
import base64
|
|
||||||
audioBytes = ttsResult.get("audioContent")
|
|
||||||
if audioBytes:
|
|
||||||
audioB64 = base64.b64encode(
|
|
||||||
audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode()
|
|
||||||
).decode()
|
|
||||||
await emitSessionEvent(sessionId, "ttsAudio", {
|
|
||||||
"audio": audioB64,
|
|
||||||
"format": "mp3",
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"TTS failed for session {sessionId}: {e}")
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def completeSession(self, sessionId: str, interface) -> Dict[str, Any]:
|
async def completeSession(self, sessionId: str, interface) -> Dict[str, Any]:
|
||||||
|
|
@ -424,7 +530,7 @@ class CommcoachService:
|
||||||
# Generate summary
|
# Generate summary
|
||||||
try:
|
try:
|
||||||
summaryPrompt = aiPrompts.buildSummaryPrompt(messages, context.get("title", "Coaching"))
|
summaryPrompt = aiPrompts.buildSummaryPrompt(messages, context.get("title", "Coaching"))
|
||||||
summaryResponse = await self._callAi("Du bist ein praeziser Zusammenfasser.", summaryPrompt)
|
summaryResponse = await self._callAi("Du bist ein präziser Zusammenfasser.", summaryPrompt)
|
||||||
summary = summaryResponse.content.strip() if summaryResponse and summaryResponse.errorCount == 0 else None
|
summary = summaryResponse.content.strip() if summaryResponse and summaryResponse.errorCount == 0 else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Summary generation failed: {e}")
|
logger.warning(f"Summary generation failed: {e}")
|
||||||
|
|
@ -447,7 +553,7 @@ class CommcoachService:
|
||||||
# Extract tasks
|
# Extract tasks
|
||||||
try:
|
try:
|
||||||
taskPrompt = aiPrompts.buildTaskExtractionPrompt(messages)
|
taskPrompt = aiPrompts.buildTaskExtractionPrompt(messages)
|
||||||
taskResponse = await self._callAi("Du extrahierst Aufgaben aus Gespraechen.", taskPrompt)
|
taskResponse = await self._callAi("Du extrahierst Aufgaben aus Gesprächen.", taskPrompt)
|
||||||
if taskResponse and taskResponse.errorCount == 0:
|
if taskResponse and taskResponse.errorCount == 0:
|
||||||
extractedTasks = aiPrompts.parseJsonResponse(taskResponse.content, [])
|
extractedTasks = aiPrompts.parseJsonResponse(taskResponse.content, [])
|
||||||
if isinstance(extractedTasks, list):
|
if isinstance(extractedTasks, list):
|
||||||
|
|
@ -497,6 +603,24 @@ class CommcoachService:
|
||||||
logger.warning(f"Scoring failed: {e}")
|
logger.warning(f"Scoring failed: {e}")
|
||||||
competenceScore = None
|
competenceScore = None
|
||||||
|
|
||||||
|
# Generate insights
|
||||||
|
try:
|
||||||
|
insightPrompt = aiPrompts.buildInsightPrompt(messages, summary)
|
||||||
|
insightResponse = await self._callAi("Du generierst kurze Coaching-Insights.", insightPrompt)
|
||||||
|
if insightResponse and insightResponse.errorCount == 0 and insightResponse.content:
|
||||||
|
insights = aiPrompts.parseJsonResponse(insightResponse.content, [])
|
||||||
|
if isinstance(insights, list):
|
||||||
|
existingInsights = aiPrompts._parseJsonField(context.get("insights") if context else None, [])
|
||||||
|
for ins in insights[:3]:
|
||||||
|
insightText = ins.get("text", ins) if isinstance(ins, dict) else str(ins)
|
||||||
|
if insightText:
|
||||||
|
existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()})
|
||||||
|
await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId})
|
||||||
|
if contextId and existingInsights:
|
||||||
|
interface.updateContext(contextId, {"insights": json.dumps(existingInsights[-10:])})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Insight generation failed: {e}")
|
||||||
|
|
||||||
# Calculate duration
|
# Calculate duration
|
||||||
startedAt = session.get("startedAt", "")
|
startedAt = session.get("startedAt", "")
|
||||||
durationSeconds = 0
|
durationSeconds = 0
|
||||||
|
|
@ -535,6 +659,18 @@ class CommcoachService:
|
||||||
# Update user profile streak
|
# Update user profile streak
|
||||||
self._updateStreak(interface)
|
self._updateStreak(interface)
|
||||||
|
|
||||||
|
# Check and award badges
|
||||||
|
try:
|
||||||
|
from .serviceCommcoachGamification import checkAndAwardBadges
|
||||||
|
updatedSession = interface.getSession(sessionId)
|
||||||
|
newBadges = await checkAndAwardBadges(
|
||||||
|
interface, self.userId, self.mandateId, self.instanceId, session=updatedSession
|
||||||
|
)
|
||||||
|
for badge in newBadges:
|
||||||
|
await emitSessionEvent(sessionId, "badgeAwarded", badge)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Badge check failed: {e}")
|
||||||
|
|
||||||
# Send email summary
|
# Send email summary
|
||||||
if summary:
|
if summary:
|
||||||
await self._sendSessionEmail(session, summary, interface)
|
await self._sendSessionEmail(session, summary, interface)
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,16 @@ def buildResumeGreetingPrompt(messages: List[Dict[str, Any]], contextTitle: str)
|
||||||
for msg in recent:
|
for msg in recent:
|
||||||
role = "Benutzer" if msg.get("role") == "user" else "Coach"
|
role = "Benutzer" if msg.get("role") == "user" else "Coach"
|
||||||
conversation += f"\n{role}: {msg.get('content', '')[:200]}"
|
conversation += f"\n{role}: {msg.get('content', '')[:200]}"
|
||||||
return f"""Der User kehrt zur laufenden Coaching-Session zum Thema "{contextTitle}" zurueck.
|
return f"""Der User kehrt zur laufenden Coaching-Session zum Thema "{contextTitle}" zurück.
|
||||||
Bisheriger Verlauf:
|
Bisheriger Verlauf:
|
||||||
{conversation}
|
{conversation}
|
||||||
|
|
||||||
Erstelle eine kurze, freundliche Begruesssung fuer den Wiedereinstieg (2-3 Saetze):
|
Erstelle eine kurze, freundliche Begrüssung für den Wiedereinstieg (2-3 Sätze):
|
||||||
- Begruesse den User zurueck
|
- Begrüsse den User zurück
|
||||||
- Fasse in einem Satz zusammen, worum es zuletzt ging
|
- Fasse in einem Satz zusammen, worum es zuletzt ging
|
||||||
- Lade ein, dort weiterzumachen oder eine neue Frage zu stellen
|
- Lade ein, dort weiterzumachen oder eine neue Frage zu stellen
|
||||||
|
|
||||||
Antworte NUR mit der Begruesssung, keine Erklaerungen."""
|
Antworte NUR mit der Begrüssung, keine Erklärungen."""
|
||||||
|
|
||||||
|
|
||||||
def buildEarlierConversationSummaryPrompt(messages: List[Dict[str, Any]]) -> str:
|
def buildEarlierConversationSummaryPrompt(messages: List[Dict[str, Any]]) -> str:
|
||||||
|
|
@ -43,12 +43,12 @@ def buildEarlierConversationSummaryPrompt(messages: List[Dict[str, Any]]) -> str
|
||||||
role = "Benutzer" if msg.get("role") == "user" else "Coach"
|
role = "Benutzer" if msg.get("role") == "user" else "Coach"
|
||||||
conversation += f"\n{role}: {msg.get('content', '')}"
|
conversation += f"\n{role}: {msg.get('content', '')}"
|
||||||
|
|
||||||
return f"""Fasse das folgende Coaching-Gespraech in 4-6 Saetzen zusammen.
|
return f"""Fasse das folgende Coaching-Gespräch in 4-6 Sätzen zusammen.
|
||||||
Behalte: Kernthemen, wichtige Erkenntnisse, erwaehnte Aufgaben, emotionale Wendepunkte, Fortschritte.
|
Behalte: Kernthemen, wichtige Erkenntnisse, erwähnte Aufgaben, emotionale Wendepunkte, Fortschritte.
|
||||||
Entferne Wiederholungen und Fuelltext.
|
Entferne Wiederholungen und Fülltext.
|
||||||
Antworte NUR mit der Zusammenfassung, keine Erklaerungen.
|
Antworte NUR mit der Zusammenfassung, keine Erklärungen.
|
||||||
|
|
||||||
Gespraech:
|
Gespräch:
|
||||||
{conversation}"""
|
{conversation}"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -93,6 +93,8 @@ def buildCoachingSystemPrompt(
|
||||||
rollingOverview: Optional[str] = None,
|
rollingOverview: Optional[str] = None,
|
||||||
retrievedSession: Optional[Dict[str, Any]] = None,
|
retrievedSession: Optional[Dict[str, Any]] = None,
|
||||||
retrievedByTopic: Optional[List[Dict[str, Any]]] = None,
|
retrievedByTopic: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
persona: Optional[Dict[str, Any]] = None,
|
||||||
|
documentSummaries: Optional[List[str]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build the system prompt for a coaching session, including context history, tasks, and session continuity."""
|
"""Build the system prompt for a coaching session, including context history, tasks, and session continuity."""
|
||||||
contextTitle = context.get("title", "General Coaching")
|
contextTitle = context.get("title", "General Coaching")
|
||||||
|
|
@ -109,23 +111,72 @@ def buildCoachingSystemPrompt(
|
||||||
|
|
||||||
summaries = previousSessionSummaries or []
|
summaries = previousSessionSummaries or []
|
||||||
|
|
||||||
prompt = f"""Du bist ein erfahrener Kommunikations-Coach fuer Fuehrungskraefte. Du arbeitest mit dem Benutzer am Thema: "{contextTitle}" (Kategorie: {contextCategory}).
|
if persona and persona.get("key") != "coach":
|
||||||
|
if persona.get("systemPromptOverride"):
|
||||||
|
prompt = persona["systemPromptOverride"]
|
||||||
|
else:
|
||||||
|
personaLabel = persona.get("label", "Gesprächspartner")
|
||||||
|
personaDescription = persona.get("description", "")
|
||||||
|
personaGender = persona.get("gender", "")
|
||||||
|
genderHint = " (weiblich)" if personaGender == "f" else " (männlich)" if personaGender == "m" else ""
|
||||||
|
prompt = f"""Du spielst die Rolle von "{personaLabel}"{genderHint} in einem Roleplay-Szenario zum Thema: "{contextTitle}" (Kategorie: {contextCategory}).
|
||||||
|
|
||||||
|
Rollenbeschreibung: {personaDescription}
|
||||||
|
|
||||||
|
WICHTIG für dein Verhalten:
|
||||||
|
- Du BIST {personaLabel}. Du bist NICHT der Coach. Sprich IMMER direkt als diese Person.
|
||||||
|
- Beschreibe KEINE Szenarien. Beginne SOFORT mit dem Dialog in deiner Rolle.
|
||||||
|
- Reagiere authentisch und emotional gemäss deiner Rollenbeschreibung.
|
||||||
|
- Verwende eine Sprache und Tonalität, die zu deiner Rolle passt.
|
||||||
|
- Der Benutzer übt ein Gespräch mit dir. Gib ihm realistische Reaktionen.
|
||||||
|
- Wenn der Benutzer gut kommuniziert, zeige das durch angemessene positive Reaktionen.
|
||||||
|
- Wenn der Benutzer schlecht kommuniziert, eskaliere entsprechend deiner Rolle.
|
||||||
|
|
||||||
|
Kommunikationsstil:
|
||||||
|
- Sprich natürlich, wie die beschriebene Person sprechen würde.
|
||||||
|
- Verwende keine Emojis.
|
||||||
|
- Antworte in der Sprache des Benutzers.
|
||||||
|
- Halte Antworten realistisch kurz (wie in einem echten Gespräch)."""
|
||||||
|
else:
|
||||||
|
prompt = f"""Du bist ein erfahrener Kommunikations-Coach für Führungskräfte. Du arbeitest mit dem Benutzer am Thema: "{contextTitle}" (Kategorie: {contextCategory}).
|
||||||
|
|
||||||
Deine Rolle:
|
Deine Rolle:
|
||||||
- Stelle gezielte diagnostische Rueckfragen, um das Problem/Thema besser zu verstehen
|
- Stelle gezielte diagnostische Rückfragen, um das Problem/Thema besser zu verstehen
|
||||||
- Gib konkrete, praxisnahe Tipps und Uebungen
|
- Gib konkrete, praxisnahe Tipps und Übungen
|
||||||
- Baue auf fruehere Sessions auf (Kontext-Kontinuitaet)
|
- Baue auf frühere Sessions auf (Kontext-Kontinuität)
|
||||||
- Erkenne Fortschritte und benenne sie
|
- Erkenne Fortschritte und benenne sie
|
||||||
- Schlage am Ende der Session konkrete naechste Schritte vor (als Tasks)
|
- Schlage am Ende der Session konkrete nächste Schritte vor (als Tasks)
|
||||||
- Kommuniziere empathisch, klar und auf Augenhoehe
|
- Kommuniziere empathisch, klar und auf Augenhöhe
|
||||||
|
|
||||||
|
Roleplay:
|
||||||
|
- Wenn der Benutzer dich bittet, eine bestimmte Person zu spielen (z.B. einen kritischen Kunden, einen Vorgesetzten, einen Mitarbeiter), dann wechsle SOFORT in diese Rolle.
|
||||||
|
- Beschreibe KEIN Szenario. Sprich direkt ALS diese Person. Beginne sofort mit dem Dialog in der Rolle.
|
||||||
|
- Bleibe in der Rolle, bis der Benutzer explizit sagt, dass das Roleplay beendet ist oder Feedback möchte.
|
||||||
|
- Reagiere authentisch, emotional und realistisch wie die beschriebene Person.
|
||||||
|
|
||||||
Kommunikationsstil:
|
Kommunikationsstil:
|
||||||
- Duze den Benutzer
|
- Duze den Benutzer
|
||||||
- Sei direkt aber wertschaetzend
|
- Sei direkt aber wertschätzend
|
||||||
- Verwende keine Emojis
|
- Verwende keine Emojis
|
||||||
- Antworte in der Sprache des Benutzers
|
- Antworte in der Sprache des Benutzers
|
||||||
- Halte Antworten fokussiert (max 3-4 Absaetze)
|
- Halte Antworten fokussiert (max 3-4 Absätze)"""
|
||||||
- WICHTIG: Schreibe reinen Redetext ohne jegliche Formatierung. Kein Markdown, keine Sternchen, keine Hashes, keine Aufzaehlungszeichen, keine Backticks. Deine Antworten werden direkt vorgelesen."""
|
|
||||||
|
prompt += """
|
||||||
|
|
||||||
|
Antwortformat:
|
||||||
|
Du antwortest IMMER als reines JSON-Objekt mit exakt diesen Feldern:
|
||||||
|
{"text": "...", "speech": "...", "documents": []}
|
||||||
|
|
||||||
|
"text": Dein schriftlicher Chat-Text. Details, Struktur, Übungen, Beispiele. Markdown-Formatierung erlaubt.
|
||||||
|
"speech": Dein gesprochener Kommentar. Natürlich, wie ein Gespräch. Fasse zusammen, kommentiere, motiviere, stelle Fragen. Lies NICHT den Text vor, ergänze ihn mündlich. 2-4 Sätze, reiner Redetext ohne Formatierung.
|
||||||
|
"documents": Dokumente (Zusammenfassungen, Checklisten, Übungen, Protokolle). Erstelle ein Dokument wenn: der Benutzer explizit darum bittet, du strukturierte Inhalte (Listen, Pläne, Checklisten) lieferst, oder Material zum Aufbewahren sinnvoll ist. Jedes Dokument: {"title": "...", "content": "Markdown-Inhalt"}. Wenn keine: leeres Array [].
|
||||||
|
|
||||||
|
Kanalverteilung:
|
||||||
|
- Fakten, Listen, Übungen -> text
|
||||||
|
- Empathie, Einordnung, Nachfragen -> speech
|
||||||
|
- Materialien zum Aufbewahren -> documents
|
||||||
|
|
||||||
|
WICHTIG: Antworte NUR mit dem JSON-Objekt. Kein Text vor oder nach dem JSON."""
|
||||||
|
|
||||||
if contextDescription:
|
if contextDescription:
|
||||||
prompt += f"\n\nKontext-Beschreibung: {contextDescription}"
|
prompt += f"\n\nKontext-Beschreibung: {contextDescription}"
|
||||||
|
|
@ -139,7 +190,7 @@ Kommunikationsstil:
|
||||||
prompt += f"\n\nBisherige Erkenntnisse:\n" + "\n".join(f"- {i}" for i in insightTexts)
|
prompt += f"\n\nBisherige Erkenntnisse:\n" + "\n".join(f"- {i}" for i in insightTexts)
|
||||||
|
|
||||||
if rollingOverview:
|
if rollingOverview:
|
||||||
prompt += f"\n\nGesamtueberblick bisheriger Sessions:\n{rollingOverview[:600]}"
|
prompt += f"\n\nGesamtüberblick bisheriger Sessions:\n{rollingOverview[:600]}"
|
||||||
|
|
||||||
if summaries:
|
if summaries:
|
||||||
prompt += "\n\nBisherige Sessions (Zusammenfassungen):"
|
prompt += "\n\nBisherige Sessions (Zusammenfassungen):"
|
||||||
|
|
@ -180,7 +231,12 @@ Kommunikationsstil:
|
||||||
prompt += f"\n\nAbgeschlossene Aufgaben: {len(doneTasks)}"
|
prompt += f"\n\nAbgeschlossene Aufgaben: {len(doneTasks)}"
|
||||||
|
|
||||||
if earlierSummary:
|
if earlierSummary:
|
||||||
prompt += f"\n\nAelterer Gespraechsverlauf (zusammengefasst):\n{earlierSummary[:800]}"
|
prompt += f"\n\nÄlterer Gesprächsverlauf (zusammengefasst):\n{earlierSummary[:800]}"
|
||||||
|
|
||||||
|
if documentSummaries:
|
||||||
|
prompt += "\n\nRelevante Dokumente zum Kontext:"
|
||||||
|
for docSummary in documentSummaries[:5]:
|
||||||
|
prompt += f"\n- {docSummary[:300]}"
|
||||||
|
|
||||||
if previousMessages:
|
if previousMessages:
|
||||||
prompt += "\n\nVorige Nachrichten dieser Session (Kontext):"
|
prompt += "\n\nVorige Nachrichten dieser Session (Kontext):"
|
||||||
|
|
@ -202,12 +258,12 @@ def buildSummaryPrompt(messages: List[Dict[str, Any]], contextTitle: str) -> str
|
||||||
return f"""Erstelle eine kompakte Zusammenfassung dieser Coaching-Session zum Thema "{contextTitle}".
|
return f"""Erstelle eine kompakte Zusammenfassung dieser Coaching-Session zum Thema "{contextTitle}".
|
||||||
|
|
||||||
Struktur:
|
Struktur:
|
||||||
1. **Kernthema**: Was wurde besprochen (1-2 Saetze)
|
1. **Kernthema**: Was wurde besprochen (1-2 Sätze)
|
||||||
2. **Erkenntnisse**: Was wurde erkannt/gelernt (Stichpunkte)
|
2. **Erkenntnisse**: Was wurde erkannt/gelernt (Stichpunkte)
|
||||||
3. **Naechste Schritte**: Konkrete Aufgaben fuer den Benutzer (Stichpunkte)
|
3. **Nächste Schritte**: Konkrete Aufgaben für den Benutzer (Stichpunkte)
|
||||||
4. **Fortschritt**: Einschaetzung des Fortschritts
|
4. **Fortschritt**: Einschätzung des Fortschritts
|
||||||
|
|
||||||
Gespraech:
|
Gespräch:
|
||||||
{conversation}
|
{conversation}
|
||||||
|
|
||||||
Antworte auf Deutsch, sachlich und kompakt."""
|
Antworte auf Deutsch, sachlich und kompakt."""
|
||||||
|
|
@ -224,21 +280,21 @@ def buildScoringPrompt(messages: List[Dict[str, Any]], contextCategory: str) ->
|
||||||
Kategorie: {contextCategory}
|
Kategorie: {contextCategory}
|
||||||
|
|
||||||
Bewerte folgende Dimensionen auf einer Skala von 0-100:
|
Bewerte folgende Dimensionen auf einer Skala von 0-100:
|
||||||
- empathy: Einfuehlungsvermoegen
|
- empathy: Einfühlungsvermögen
|
||||||
- clarity: Klarheit der Kommunikation
|
- clarity: Klarheit der Kommunikation
|
||||||
- assertiveness: Durchsetzungsfaehigkeit
|
- assertiveness: Durchsetzungsfähigkeit
|
||||||
- listening: Zuhoerfaehigkeit
|
- listening: Zuhörfähigkeit
|
||||||
- selfReflection: Selbstreflexion
|
- selfReflection: Selbstreflexion
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH als JSON-Array:
|
Antworte AUSSCHLIESSLICH als JSON-Array:
|
||||||
[
|
[
|
||||||
{{"dimension": "empathy", "score": 65, "trend": "improving", "evidence": "Zeigt zunehmendes Verstaendnis..."}},
|
{{"dimension": "empathy", "score": 65, "trend": "improving", "evidence": "Zeigt zunehmendes Verständnis..."}},
|
||||||
{{"dimension": "clarity", "score": 70, "trend": "stable", "evidence": "..."}}
|
{{"dimension": "clarity", "score": 70, "trend": "stable", "evidence": "..."}}
|
||||||
]
|
]
|
||||||
|
|
||||||
Trend: "improving", "stable", oder "declining" basierend auf dem Gespraechsverlauf.
|
Trend: "improving", "stable", oder "declining" basierend auf dem Gesprächsverlauf.
|
||||||
|
|
||||||
Gespraech:
|
Gespräch:
|
||||||
{conversation}"""
|
{conversation}"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -250,7 +306,7 @@ Antworte AUSSCHLIESSLICH als JSON-Array von Strings:
|
||||||
|
|
||||||
Zusammenfassung: {summary[:500]}
|
Zusammenfassung: {summary[:500]}
|
||||||
|
|
||||||
Nur konkrete Themen (z.B. Delegation, Feedback-Gespraech, Konflikt mit Vorgesetztem)."""
|
Nur konkrete Themen (z.B. Delegation, Feedback-Gespräch, Konflikt mit Vorgesetztem)."""
|
||||||
|
|
||||||
|
|
||||||
def buildFullContextSummaryPrompt(
|
def buildFullContextSummaryPrompt(
|
||||||
|
|
@ -281,15 +337,15 @@ def buildFullContextSummaryPrompt(
|
||||||
return f"""Erstelle eine kompakte Gesamtzusammenfassung aller Coaching-Sessions zum Thema "{contextTitle}".
|
return f"""Erstelle eine kompakte Gesamtzusammenfassung aller Coaching-Sessions zum Thema "{contextTitle}".
|
||||||
|
|
||||||
Struktur:
|
Struktur:
|
||||||
1. **Gesamtueberblick**: Was wurde ueber alle Sessions hinweg besprochen
|
1. **Gesamtüberblick**: Was wurde über alle Sessions hinweg besprochen
|
||||||
2. **Entwicklung**: Wie hat sich das Thema/thematische Schwerpunkte entwickelt
|
2. **Entwicklung**: Wie hat sich das Thema/thematische Schwerpunkte entwickelt
|
||||||
3. **Offene Punkte**: Was steht noch aus
|
3. **Offene Punkte**: Was steht noch aus
|
||||||
4. **Empfehlung**: Kurzer naechster Fokus
|
4. **Empfehlung**: Kurzer nächster Fokus
|
||||||
|
|
||||||
Inhalt:
|
Inhalt:
|
||||||
{combined[:6000]}
|
{combined[:6000]}
|
||||||
|
|
||||||
Antworte auf Deutsch, sachlich, 4-6 Absaetze."""
|
Antworte auf Deutsch, sachlich, 4-6 Absätze."""
|
||||||
|
|
||||||
|
|
||||||
def buildRollingOverviewPrompt(sessionSummaries: List[Dict[str, Any]], contextTitle: str) -> str:
|
def buildRollingOverviewPrompt(sessionSummaries: List[Dict[str, Any]], contextTitle: str) -> str:
|
||||||
|
|
@ -302,7 +358,7 @@ def buildRollingOverviewPrompt(sessionSummaries: List[Dict[str, Any]], contextTi
|
||||||
parts.append(f"- {dateStr}: {summary[:300]}")
|
parts.append(f"- {dateStr}: {summary[:300]}")
|
||||||
|
|
||||||
combined = "\n".join(parts)
|
combined = "\n".join(parts)
|
||||||
return f"""Fasse die folgenden Coaching-Sessions zum Thema "{contextTitle}" in 4-6 Saetzen zusammen.
|
return f"""Fasse die folgenden Coaching-Sessions zum Thema "{contextTitle}" in 4-6 Sätzen zusammen.
|
||||||
Behalte: Kernthemen, Fortschritte, wichtige Erkenntnisse, offene Punkte.
|
Behalte: Kernthemen, Fortschritte, wichtige Erkenntnisse, offene Punkte.
|
||||||
Entferne Wiederholungen.
|
Entferne Wiederholungen.
|
||||||
|
|
||||||
|
|
@ -312,6 +368,28 @@ Sessions:
|
||||||
Antworte NUR mit der Zusammenfassung."""
|
Antworte NUR mit der Zusammenfassung."""
|
||||||
|
|
||||||
|
|
||||||
|
def buildInsightPrompt(messages: List[Dict[str, Any]], summary: Optional[str] = None) -> str:
|
||||||
|
"""Build a prompt to generate coaching insights from a completed session."""
|
||||||
|
conversation = ""
|
||||||
|
for msg in messages[-15:]:
|
||||||
|
role = "Benutzer" if msg.get("role") == "user" else "Coach"
|
||||||
|
conversation += f"\n{role}: {msg.get('content', '')[:300]}"
|
||||||
|
|
||||||
|
summarySection = f"\nZusammenfassung: {summary[:500]}" if summary else ""
|
||||||
|
|
||||||
|
return f"""Generiere 1-3 kurze Coaching-Insights aus dieser Session.
|
||||||
|
Ein Insight ist eine prägende Erkenntnis oder ein Aha-Moment des Benutzers.
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH als JSON-Array:
|
||||||
|
[{{"text": "Erkenntnis in einem Satz"}}]
|
||||||
|
|
||||||
|
Nur echte Erkenntnisse, keine Banalitäten. Wenn keine klaren Insights: leeres Array [].
|
||||||
|
{summarySection}
|
||||||
|
|
||||||
|
Gespräch:
|
||||||
|
{conversation}"""
|
||||||
|
|
||||||
|
|
||||||
def buildTaskExtractionPrompt(messages: List[Dict[str, Any]]) -> str:
|
def buildTaskExtractionPrompt(messages: List[Dict[str, Any]]) -> str:
|
||||||
"""Build a prompt to extract actionable tasks from a session."""
|
"""Build a prompt to extract actionable tasks from a session."""
|
||||||
recentForTasks = messages[-25:] if len(messages) > 25 else messages
|
recentForTasks = messages[-25:] if len(messages) > 25 else messages
|
||||||
|
|
@ -320,7 +398,7 @@ def buildTaskExtractionPrompt(messages: List[Dict[str, Any]]) -> str:
|
||||||
role = "Benutzer" if msg.get("role") == "user" else "Coach"
|
role = "Benutzer" if msg.get("role") == "user" else "Coach"
|
||||||
conversation += f"\n{role}: {msg.get('content', '')}"
|
conversation += f"\n{role}: {msg.get('content', '')}"
|
||||||
|
|
||||||
return f"""Extrahiere konkrete Aufgaben/naechste Schritte aus diesem Coaching-Gespraech.
|
return f"""Extrahiere konkrete Aufgaben/nächste Schritte aus diesem Coaching-Gespräch.
|
||||||
Nur Aufgaben, die der Benutzer selbst umsetzen soll.
|
Nur Aufgaben, die der Benutzer selbst umsetzen soll.
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH als JSON-Array:
|
Antworte AUSSCHLIESSLICH als JSON-Array:
|
||||||
|
|
@ -331,7 +409,7 @@ Antworte AUSSCHLIESSLICH als JSON-Array:
|
||||||
priority: "low", "medium", oder "high"
|
priority: "low", "medium", oder "high"
|
||||||
Maximal 3 Aufgaben. Wenn keine klar erkennbar: leeres Array [].
|
Maximal 3 Aufgaben. Wenn keine klar erkennbar: leeres Array [].
|
||||||
|
|
||||||
Gespraech:
|
Gespräch:
|
||||||
{conversation}"""
|
{conversation}"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
288
modules/features/commcoach/serviceCommcoachExport.py
Normal file
288
modules/features/commcoach/serviceCommcoachExport.py
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
CommCoach Export Service.
|
||||||
|
Generates Markdown and PDF exports for dossiers and sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def buildDossierMarkdown(context: Dict[str, Any], sessions: List[Dict[str, Any]],
|
||||||
|
tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]]) -> str:
|
||||||
|
"""Build a Markdown export of a full coaching dossier (context)."""
|
||||||
|
title = context.get("title", "Coaching Dossier")
|
||||||
|
description = context.get("description", "")
|
||||||
|
category = context.get("category", "custom")
|
||||||
|
createdAt = _formatDate(context.get("createdAt"))
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# {title}",
|
||||||
|
"",
|
||||||
|
f"**Kategorie:** {category} ",
|
||||||
|
f"**Erstellt:** {createdAt} ",
|
||||||
|
]
|
||||||
|
if description:
|
||||||
|
lines.append(f"**Beschreibung:** {description} ")
|
||||||
|
|
||||||
|
goalsRaw = context.get("goals")
|
||||||
|
goals = _parseJson(goalsRaw, [])
|
||||||
|
if goals:
|
||||||
|
lines += ["", "## Ziele", ""]
|
||||||
|
for g in goals:
|
||||||
|
text = g.get("text", g) if isinstance(g, dict) else str(g)
|
||||||
|
status = g.get("status", "open") if isinstance(g, dict) else "open"
|
||||||
|
marker = "[x]" if status in ("done", "completed") else "[ ]"
|
||||||
|
lines.append(f"- {marker} {text}")
|
||||||
|
|
||||||
|
insightsRaw = context.get("insights")
|
||||||
|
insights = _parseJson(insightsRaw, [])
|
||||||
|
if insights:
|
||||||
|
lines += ["", "## Erkenntnisse", ""]
|
||||||
|
for ins in insights:
|
||||||
|
text = ins.get("text", ins) if isinstance(ins, dict) else str(ins)
|
||||||
|
lines.append(f"- {text}")
|
||||||
|
|
||||||
|
completedSessions = [s for s in sessions if s.get("status") == "completed"]
|
||||||
|
completedSessions.sort(key=lambda s: s.get("startedAt") or s.get("createdAt") or "")
|
||||||
|
if completedSessions:
|
||||||
|
lines += ["", "## Sessions", ""]
|
||||||
|
for i, s in enumerate(completedSessions, 1):
|
||||||
|
dateStr = _formatDate(s.get("startedAt") or s.get("createdAt"))
|
||||||
|
duration = s.get("durationSeconds", 0)
|
||||||
|
durationMin = duration // 60 if duration else 0
|
||||||
|
score = s.get("competenceScore")
|
||||||
|
persona = s.get("personaId") or "Coach"
|
||||||
|
lines.append(f"### Session {i} -- {dateStr}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"**Dauer:** {durationMin} Min. | **Score:** {score or '--'} | **Persona:** {persona} ")
|
||||||
|
summary = s.get("summary")
|
||||||
|
if summary:
|
||||||
|
lines.append(f"\n{summary}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
openTasks = [t for t in tasks if t.get("status") in ("open", "inProgress")]
|
||||||
|
doneTasks = [t for t in tasks if t.get("status") == "done"]
|
||||||
|
lines += ["", "## Aufgaben", ""]
|
||||||
|
if openTasks:
|
||||||
|
lines.append("**Offen:**")
|
||||||
|
for t in openTasks:
|
||||||
|
lines.append(f"- [ ] {t.get('title')} ({t.get('priority', 'medium')})")
|
||||||
|
lines.append("")
|
||||||
|
if doneTasks:
|
||||||
|
lines.append("**Erledigt:**")
|
||||||
|
for t in doneTasks:
|
||||||
|
lines.append(f"- [x] {t.get('title')}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if scores:
|
||||||
|
lines += ["", "## Kompetenz-Scores", ""]
|
||||||
|
dimScores = _groupScoresByDimension(scores)
|
||||||
|
for dim, entries in dimScores.items():
|
||||||
|
latest = entries[-1]
|
||||||
|
lines.append(f"- **{dim}**: {latest.get('score', '--')} ({latest.get('trend', 'stable')})")
|
||||||
|
|
||||||
|
lines += ["", "---", f"*Exportiert am {_formatDate(None)}*", ""]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def buildSessionMarkdown(session: Dict[str, Any], messages: List[Dict[str, Any]],
|
||||||
|
tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]]) -> str:
|
||||||
|
"""Build a Markdown export of a single session."""
|
||||||
|
dateStr = _formatDate(session.get("startedAt") or session.get("createdAt"))
|
||||||
|
duration = session.get("durationSeconds", 0)
|
||||||
|
durationMin = duration // 60 if duration else 0
|
||||||
|
score = session.get("competenceScore")
|
||||||
|
persona = session.get("personaId") or "Coach"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# Coaching Session -- {dateStr}",
|
||||||
|
"",
|
||||||
|
f"**Dauer:** {durationMin} Min. | **Score:** {score or '--'} | **Persona:** {persona} ",
|
||||||
|
]
|
||||||
|
|
||||||
|
summary = session.get("summary")
|
||||||
|
if summary:
|
||||||
|
lines += ["", "## Zusammenfassung", "", summary]
|
||||||
|
|
||||||
|
if messages:
|
||||||
|
lines += ["", "## Gesprächsverlauf", ""]
|
||||||
|
for msg in messages:
|
||||||
|
role = "Du" if msg.get("role") == "user" else "Coach"
|
||||||
|
content = msg.get("content", "")
|
||||||
|
lines.append(f"**{role}:** {content}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
sessionTasks = [t for t in tasks if t.get("sessionId") == session.get("id")]
|
||||||
|
if sessionTasks:
|
||||||
|
lines += ["## Aufgaben", ""]
|
||||||
|
for t in sessionTasks:
|
||||||
|
marker = "[x]" if t.get("status") == "done" else "[ ]"
|
||||||
|
lines.append(f"- {marker} {t.get('title')}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
sessionScores = [s for s in scores if s.get("sessionId") == session.get("id")]
|
||||||
|
if sessionScores:
|
||||||
|
lines += ["## Scores", ""]
|
||||||
|
for s in sessionScores:
|
||||||
|
lines.append(f"- **{s.get('dimension')}**: {s.get('score')} ({s.get('trend', 'stable')})")
|
||||||
|
if s.get("evidence"):
|
||||||
|
lines.append(f" _{s.get('evidence')}_")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines += ["---", f"*Exportiert am {_formatDate(None)}*", ""]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def renderDossierPdf(context: Dict[str, Any], sessions: List[Dict[str, Any]],
|
||||||
|
tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]],
|
||||||
|
aiService=None) -> Optional[bytes]:
|
||||||
|
"""Render a dossier as PDF using the existing RendererPdf."""
|
||||||
|
try:
|
||||||
|
from modules.services.serviceGeneration.renderers.rendererPdf import RendererPdf
|
||||||
|
extractedContent = _buildPdfContent(context, sessions, tasks, scores, isDossier=True)
|
||||||
|
renderer = RendererPdf()
|
||||||
|
docs = await renderer.render(extractedContent=extractedContent, title=context.get("title", "Dossier"), aiService=aiService)
|
||||||
|
if docs and len(docs) > 0:
|
||||||
|
return docs[0].documentData
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("RendererPdf not available, falling back to markdown-based PDF")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"PDF rendering failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def renderSessionPdf(session: Dict[str, Any], messages: List[Dict[str, Any]],
|
||||||
|
tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]],
|
||||||
|
aiService=None) -> Optional[bytes]:
|
||||||
|
"""Render a session as PDF."""
|
||||||
|
try:
|
||||||
|
from modules.services.serviceGeneration.renderers.rendererPdf import RendererPdf
|
||||||
|
title = f"Session {_formatDate(session.get('startedAt'))}"
|
||||||
|
extractedContent = _buildPdfContent({"title": title}, [session], tasks, scores, isDossier=False, messages=messages)
|
||||||
|
renderer = RendererPdf()
|
||||||
|
docs = await renderer.render(extractedContent=extractedContent, title=title, aiService=aiService)
|
||||||
|
if docs and len(docs) > 0:
|
||||||
|
return docs[0].documentData
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("RendererPdf not available")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Session PDF rendering failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _buildPdfContent(context, sessions, tasks, scores, isDossier=True, messages=None) -> Dict[str, Any]:
|
||||||
|
"""Convert dossier/session data into the extractedContent format expected by RendererPdf."""
|
||||||
|
title = context.get("title", "Export")
|
||||||
|
sections = []
|
||||||
|
|
||||||
|
sections.append({
|
||||||
|
"id": "header",
|
||||||
|
"content_type": "heading",
|
||||||
|
"elements": [{"text": title, "level": 1}],
|
||||||
|
})
|
||||||
|
|
||||||
|
if isDossier and context.get("description"):
|
||||||
|
sections.append({
|
||||||
|
"id": "desc",
|
||||||
|
"content_type": "paragraph",
|
||||||
|
"elements": [{"text": context.get("description")}],
|
||||||
|
})
|
||||||
|
|
||||||
|
completedSessions = [s for s in sessions if s.get("status") == "completed"] if isDossier else sessions
|
||||||
|
if completedSessions:
|
||||||
|
sessionRows = []
|
||||||
|
for s in completedSessions:
|
||||||
|
sessionRows.append({
|
||||||
|
"cells": [
|
||||||
|
_formatDate(s.get("startedAt") or s.get("createdAt")),
|
||||||
|
str(s.get("competenceScore") or "--"),
|
||||||
|
s.get("summary", "")[:200] if s.get("summary") else "",
|
||||||
|
]
|
||||||
|
})
|
||||||
|
sections.append({
|
||||||
|
"id": "sessions",
|
||||||
|
"content_type": "heading",
|
||||||
|
"elements": [{"text": "Sessions", "level": 2}],
|
||||||
|
})
|
||||||
|
sections.append({
|
||||||
|
"id": "sessions_table",
|
||||||
|
"content_type": "table",
|
||||||
|
"elements": [{
|
||||||
|
"headers": ["Datum", "Score", "Zusammenfassung"],
|
||||||
|
"rows": sessionRows,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
if messages:
|
||||||
|
chatElements = []
|
||||||
|
for msg in messages:
|
||||||
|
role = "Du" if msg.get("role") == "user" else "Coach"
|
||||||
|
chatElements.append({"text": f"{role}: {msg.get('content', '')}"})
|
||||||
|
sections.append({
|
||||||
|
"id": "chat",
|
||||||
|
"content_type": "heading",
|
||||||
|
"elements": [{"text": "Gesprächsverlauf", "level": 2}],
|
||||||
|
})
|
||||||
|
sections.append({
|
||||||
|
"id": "chat_content",
|
||||||
|
"content_type": "paragraph",
|
||||||
|
"elements": chatElements,
|
||||||
|
})
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
taskItems = [{"text": f"{'[x]' if t.get('status') == 'done' else '[ ]'} {t.get('title')}"} for t in tasks]
|
||||||
|
sections.append({
|
||||||
|
"id": "tasks",
|
||||||
|
"content_type": "heading",
|
||||||
|
"elements": [{"text": "Aufgaben", "level": 2}],
|
||||||
|
})
|
||||||
|
sections.append({
|
||||||
|
"id": "task_list",
|
||||||
|
"content_type": "bullet_list",
|
||||||
|
"elements": taskItems,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"metadata": {"title": title},
|
||||||
|
"documents": [{"id": "main", "title": title, "sections": sections}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _formatDate(isoStr: Optional[str]) -> str:
|
||||||
|
if not isoStr:
|
||||||
|
return datetime.now().strftime("%d.%m.%Y")
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(str(isoStr).replace("Z", "+00:00"))
|
||||||
|
return dt.strftime("%d.%m.%Y")
|
||||||
|
except Exception:
|
||||||
|
return isoStr
|
||||||
|
|
||||||
|
|
||||||
|
def _parseJson(value, fallback):
|
||||||
|
if not value:
|
||||||
|
return fallback
|
||||||
|
if isinstance(value, (list, dict)):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _groupScoresByDimension(scores: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
groups: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for s in scores:
|
||||||
|
dim = s.get("dimension", "unknown")
|
||||||
|
if dim not in groups:
|
||||||
|
groups[dim] = []
|
||||||
|
groups[dim].append(s)
|
||||||
|
for dim in groups:
|
||||||
|
groups[dim].sort(key=lambda x: x.get("createdAt") or "")
|
||||||
|
return groups
|
||||||
149
modules/features/commcoach/serviceCommcoachGamification.py
Normal file
149
modules/features/commcoach/serviceCommcoachGamification.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
CommCoach Gamification - Badge definitions and award logic.
|
||||||
|
Checks and awards badges after each session completion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BADGE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"first_session": {
|
||||||
|
"label": "Erste Session",
|
||||||
|
"description": "Deine erste Coaching-Session abgeschlossen",
|
||||||
|
"icon": "star",
|
||||||
|
},
|
||||||
|
"streak_3": {
|
||||||
|
"label": "3-Tage-Serie",
|
||||||
|
"description": "3 Tage in Folge eine Session absolviert",
|
||||||
|
"icon": "fire",
|
||||||
|
},
|
||||||
|
"streak_7": {
|
||||||
|
"label": "Wochenserie",
|
||||||
|
"description": "7 Tage in Folge eine Session absolviert",
|
||||||
|
"icon": "fire",
|
||||||
|
},
|
||||||
|
"streak_30": {
|
||||||
|
"label": "Monatsserie",
|
||||||
|
"description": "30 Tage in Folge eine Session absolviert",
|
||||||
|
"icon": "fire",
|
||||||
|
},
|
||||||
|
"sessions_5": {
|
||||||
|
"label": "Engagiert",
|
||||||
|
"description": "5 Sessions abgeschlossen",
|
||||||
|
"icon": "trophy",
|
||||||
|
},
|
||||||
|
"sessions_10": {
|
||||||
|
"label": "Fortgeschritten",
|
||||||
|
"description": "10 Sessions abgeschlossen",
|
||||||
|
"icon": "trophy",
|
||||||
|
},
|
||||||
|
"sessions_25": {
|
||||||
|
"label": "Experte",
|
||||||
|
"description": "25 Sessions abgeschlossen",
|
||||||
|
"icon": "trophy",
|
||||||
|
},
|
||||||
|
"sessions_50": {
|
||||||
|
"label": "Meister",
|
||||||
|
"description": "50 Sessions abgeschlossen",
|
||||||
|
"icon": "trophy",
|
||||||
|
},
|
||||||
|
"high_score": {
|
||||||
|
"label": "Bestleistung",
|
||||||
|
"description": "Durchschnittsscore über 80 in einer Session",
|
||||||
|
"icon": "medal",
|
||||||
|
},
|
||||||
|
"multi_context": {
|
||||||
|
"label": "Vielseitig",
|
||||||
|
"description": "3 verschiedene Coaching-Themen aktiv",
|
||||||
|
"icon": "layers",
|
||||||
|
},
|
||||||
|
"roleplay_first": {
|
||||||
|
"label": "Rollenspieler",
|
||||||
|
"description": "Erste Roleplay-Session mit einer Persona abgeschlossen",
|
||||||
|
"icon": "theater",
|
||||||
|
},
|
||||||
|
"all_dimensions": {
|
||||||
|
"label": "Ganzheitlich",
|
||||||
|
"description": "In allen 5 Kompetenz-Dimensionen bewertet",
|
||||||
|
"icon": "compass",
|
||||||
|
},
|
||||||
|
"task_completer": {
|
||||||
|
"label": "Umsetzer",
|
||||||
|
"description": "10 Coaching-Aufgaben erledigt",
|
||||||
|
"icon": "check-circle",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId: str,
|
||||||
|
session: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Check badge conditions and award any newly earned badges. Returns list of newly awarded badges."""
|
||||||
|
awarded: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
profile = interface.getProfile(userId, instanceId)
|
||||||
|
if not profile:
|
||||||
|
return awarded
|
||||||
|
|
||||||
|
totalSessions = profile.get("totalSessions", 0)
|
||||||
|
streakDays = profile.get("streakDays", 0)
|
||||||
|
|
||||||
|
badgesToCheck = [
|
||||||
|
("first_session", totalSessions >= 1),
|
||||||
|
("sessions_5", totalSessions >= 5),
|
||||||
|
("sessions_10", totalSessions >= 10),
|
||||||
|
("sessions_25", totalSessions >= 25),
|
||||||
|
("sessions_50", totalSessions >= 50),
|
||||||
|
("streak_3", streakDays >= 3),
|
||||||
|
("streak_7", streakDays >= 7),
|
||||||
|
("streak_30", streakDays >= 30),
|
||||||
|
]
|
||||||
|
|
||||||
|
if session and session.get("competenceScore"):
|
||||||
|
try:
|
||||||
|
score = float(session["competenceScore"])
|
||||||
|
if score >= 80:
|
||||||
|
badgesToCheck.append(("high_score", True))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if session and session.get("personaId") and session["personaId"] != "coach":
|
||||||
|
badgesToCheck.append(("roleplay_first", True))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .datamodelCommcoach import CoachingContextStatus
|
||||||
|
allContexts = interface.db.getRecordset(
|
||||||
|
interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues
|
||||||
|
) if False else []
|
||||||
|
except Exception:
|
||||||
|
allContexts = []
|
||||||
|
|
||||||
|
completedTasks = interface.getCompletedTaskCount(userId) if hasattr(interface, 'getCompletedTaskCount') else 0
|
||||||
|
if completedTasks >= 10:
|
||||||
|
badgesToCheck.append(("task_completer", True))
|
||||||
|
|
||||||
|
for badgeKey, condition in badgesToCheck:
|
||||||
|
if condition and not interface.hasBadge(userId, instanceId, badgeKey):
|
||||||
|
badgeData = {
|
||||||
|
"userId": userId,
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"instanceId": instanceId,
|
||||||
|
"badgeKey": badgeKey,
|
||||||
|
}
|
||||||
|
newBadge = interface.awardBadge(badgeData)
|
||||||
|
definition = BADGE_DEFINITIONS.get(badgeKey, {})
|
||||||
|
newBadge["label"] = definition.get("label", badgeKey)
|
||||||
|
newBadge["description"] = definition.get("description", "")
|
||||||
|
newBadge["icon"] = definition.get("icon", "star")
|
||||||
|
awarded.append(newBadge)
|
||||||
|
logger.info(f"Badge '{badgeKey}' awarded to user {userId}")
|
||||||
|
|
||||||
|
return awarded
|
||||||
|
|
||||||
|
|
||||||
|
def getBadgeDefinitions() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Return all badge definitions for the frontend."""
|
||||||
|
return BADGE_DEFINITIONS
|
||||||
139
modules/features/commcoach/serviceCommcoachPersonas.py
Normal file
139
modules/features/commcoach/serviceCommcoachPersonas.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
CommCoach Personas - Built-in roleplay persona definitions.
|
||||||
|
Gender-balanced set of professional and personal interaction partners.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BUILTIN_PERSONAS: List[Dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"key": "coach",
|
||||||
|
"label": "Coach (Standard)",
|
||||||
|
"description": "Normaler Coaching-Modus ohne Roleplay. Der Coach stellt Fragen, gibt Tipps und begleitet dich.",
|
||||||
|
"gender": None,
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "critical_cfo_f",
|
||||||
|
"label": "Kritische CFO",
|
||||||
|
"description": "Sandra Meier, CFO eines mittelständischen Unternehmens. Analytisch, zahlengetrieben, ungeduldig bei vagen Aussagen. "
|
||||||
|
"Hinterfragt jeden Vorschlag nach ROI und Wirtschaftlichkeit. Spricht schnell und direkt. "
|
||||||
|
"Erwartet präzise Antworten und belastbare Daten. Wird irritiert bei Ausweichen oder Unsicherheit.",
|
||||||
|
"gender": "f",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "difficult_employee_m",
|
||||||
|
"label": "Schwieriger Mitarbeiter",
|
||||||
|
"description": "Thomas Huber, langjähriger Mitarbeiter der sich übergangen fühlt. Defensiv, emotional, nimmt Kritik persönlich. "
|
||||||
|
"Verweist ständig auf seine Erfahrung und frühere Verdienste. Reagiert mit Widerstand auf Veränderungen. "
|
||||||
|
"Braucht das Gefühl, gehört und wertgeschätzt zu werden, bevor er sich öffnet.",
|
||||||
|
"gender": "m",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "new_team_member_f",
|
||||||
|
"label": "Unsichere neue Mitarbeiterin",
|
||||||
|
"description": "Lisa Brunner, seit drei Wochen im Team. Fachlich kompetent aber unsicher in der neuen Umgebung. "
|
||||||
|
"Stellt viele Fragen, traut sich aber nicht, eigene Ideen einzubringen. Braucht klare Orientierung "
|
||||||
|
"und ermutigende Führung. Reagiert positiv auf Lob und konkrete Anleitungen.",
|
||||||
|
"gender": "f",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "board_member_m",
|
||||||
|
"label": "Verwaltungsrat",
|
||||||
|
"description": "Dr. Peter Keller, erfahrener Verwaltungsrat. Formell, strategisch denkend, zeitlich unter Druck. "
|
||||||
|
"Erwartet prägnante Präsentationen auf den Punkt. Unterbricht bei zu vielen Details. "
|
||||||
|
"Interessiert sich für das grosse Bild, Risiken und strategische Implikationen. Ungeduldig bei Smalltalk.",
|
||||||
|
"gender": "m",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "angry_customer_f",
|
||||||
|
"label": "Aufgebrachte Kundin",
|
||||||
|
"description": "Maria Rossi, Geschäftskunde die wütend ist wegen einer fehlerhaften Lieferung. Emotional, laut, "
|
||||||
|
"droht mit Vertragsauflösung. Will sofortige Lösungen, keine Erklärungen oder Entschuldigungen. "
|
||||||
|
"Kann beruhigt werden durch empathisches Zuhören und konkrete Sofortmassnahmen.",
|
||||||
|
"gender": "f",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "resistant_manager_m",
|
||||||
|
"label": "Widerständiger Abteilungsleiter",
|
||||||
|
"description": "Martin Weber, Abteilungsleiter seit 15 Jahren. Blockiert systematisch Veränderungsprojekte mit "
|
||||||
|
"Argumenten wie 'Das haben wir immer so gemacht' und 'Das funktioniert in der Praxis nicht'. "
|
||||||
|
"Schützt sein Team vor zusätzlicher Belastung. Respektiert nur Argumente mit konkretem Nutzen für seine Abteilung.",
|
||||||
|
"gender": "m",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ambitious_colleague_f",
|
||||||
|
"label": "Ehrgeizige Kollegin",
|
||||||
|
"description": "Anna Fischer, gleichrangige Kollegin die um dieselbe Beförderung konkurriert. Charmant aber strategisch. "
|
||||||
|
"Versucht subtil, die Ideen anderer als ihre eigenen darzustellen. Konkurriert um Ressourcen und "
|
||||||
|
"Sichtbarkeit beim Management. Kann kooperativ werden, wenn man ihr Win-Win-Szenarien aufzeigt.",
|
||||||
|
"gender": "f",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "partner_supportive_f",
|
||||||
|
"label": "Verständnisvolle Lebenspartnerin",
|
||||||
|
"description": "Claudia, deine Lebenspartnerin. Grundsätzlich unterstützend, aber zunehmend besorgt über deine "
|
||||||
|
"Work-Life-Balance. Möchte über Arbeitsbelastung sprechen und gemeinsame Zeit einfordern. "
|
||||||
|
"Reagiert emotional auf Abweisung, ist aber offen für kompromissorientierte Gespräche. "
|
||||||
|
"Wünscht sich, dass du mehr von deinen Gefühlen teilst.",
|
||||||
|
"gender": "f",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "partner_critical_m",
|
||||||
|
"label": "Kritischer Lebenspartner",
|
||||||
|
"description": "Michael, dein Lebenspartner. Frustriert über deine häufige Abwesenheit und ständiges Arbeiten. "
|
||||||
|
"Drückt Enttäuschung offen aus, manchmal mit Sarkasmus. Fühlt sich vernachlässigt und "
|
||||||
|
"hinterfragt deine Prioritäten. Braucht das Gefühl, dass die Beziehung dir genauso wichtig ist "
|
||||||
|
"wie die Karriere. Reagiert positiv auf ehrliche Selbstreflexion.",
|
||||||
|
"gender": "m",
|
||||||
|
"category": "builtin",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seedBuiltinPersonas(interface) -> int:
|
||||||
|
"""Create or update builtin personas in the database. Returns count of created personas."""
|
||||||
|
from .datamodelCommcoach import CoachingPersona
|
||||||
|
from modules.shared.timeUtils import getIsoTimestamp
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
for personaDef in BUILTIN_PERSONAS:
|
||||||
|
existing = interface.db.getRecordset(CoachingPersona, recordFilter={"key": personaDef["key"], "userId": "system"})
|
||||||
|
if existing:
|
||||||
|
interface.db.recordModify(CoachingPersona, existing[0]["id"], {
|
||||||
|
"label": personaDef["label"],
|
||||||
|
"description": personaDef["description"],
|
||||||
|
"gender": personaDef.get("gender"),
|
||||||
|
"updatedAt": getIsoTimestamp(),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
data = CoachingPersona(
|
||||||
|
userId="system",
|
||||||
|
key=personaDef["key"],
|
||||||
|
label=personaDef["label"],
|
||||||
|
description=personaDef["description"],
|
||||||
|
gender=personaDef.get("gender"),
|
||||||
|
category="builtin",
|
||||||
|
isActive=True,
|
||||||
|
).model_dump()
|
||||||
|
data["createdAt"] = getIsoTimestamp()
|
||||||
|
data["updatedAt"] = getIsoTimestamp()
|
||||||
|
interface.db.recordCreate(CoachingPersona, data)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
if created:
|
||||||
|
logger.info(f"Seeded {created} builtin CommCoach personas")
|
||||||
|
return created
|
||||||
|
|
@ -65,14 +65,14 @@ class TestBuildCoachingSystemPrompt:
|
||||||
def test_promptLanguageIsGerman(self):
|
def test_promptLanguageIsGerman(self):
|
||||||
context = {"title": "Test", "category": "custom"}
|
context = {"title": "Test", "category": "custom"}
|
||||||
prompt = buildCoachingSystemPrompt(context, [], [])
|
prompt = buildCoachingSystemPrompt(context, [], [])
|
||||||
assert "Fuehrungskraefte" in prompt or "Coach" in prompt
|
assert "Führungskräfte" in prompt or "Coach" in prompt
|
||||||
|
|
||||||
def test_withEarlierSummary(self):
|
def test_withEarlierSummary(self):
|
||||||
context = {"title": "Test", "category": "custom"}
|
context = {"title": "Test", "category": "custom"}
|
||||||
messages = [{"role": "user", "content": "Recent question"}]
|
messages = [{"role": "user", "content": "Recent question"}]
|
||||||
earlierSummary = "User discussed delegation. Coach suggested practice."
|
earlierSummary = "User discussed delegation. Coach suggested practice."
|
||||||
prompt = buildCoachingSystemPrompt(context, messages, [], earlierSummary=earlierSummary)
|
prompt = buildCoachingSystemPrompt(context, messages, [], earlierSummary=earlierSummary)
|
||||||
assert "Aelterer Gespraechsverlauf" in prompt
|
assert "Älterer Gesprächsverlauf" in prompt
|
||||||
assert "delegation" in prompt.lower()
|
assert "delegation" in prompt.lower()
|
||||||
assert "Recent question" in prompt
|
assert "Recent question" in prompt
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@ class TestBuildCoachingSystemPrompt:
|
||||||
prompt = buildCoachingSystemPrompt(
|
prompt = buildCoachingSystemPrompt(
|
||||||
context, [], [], rollingOverview="User arbeitet an Delegation. Fortschritt sichtbar."
|
context, [], [], rollingOverview="User arbeitet an Delegation. Fortschritt sichtbar."
|
||||||
)
|
)
|
||||||
assert "Gesamtueberblick" in prompt
|
assert "Gesamtüberblick" in prompt
|
||||||
assert "Delegation" in prompt
|
assert "Delegation" in prompt
|
||||||
|
|
||||||
def test_withRetrievedSession(self):
|
def test_withRetrievedSession(self):
|
||||||
|
|
|
||||||
|
|
@ -684,11 +684,11 @@ class TeamsbotService:
|
||||||
logger.debug(f"Session {sessionId}: Chat history stored (no AI trigger): [{speaker}] {text[:60]}")
|
logger.debug(f"Session {sessionId}: Chat history stored (no AI trigger): [{speaker}] {text[:60]}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Filter out the bot's own speech entirely — captions of the bot's
|
# Filter out the bot's own speech (caption/audioCapture) — garbled text
|
||||||
# own voice come back as garbled text (e.g. German TTS → English caption)
|
# pollutes context. Chat from the bot is clean text and must appear in
|
||||||
# which pollutes the context buffer and confuses AI analysis.
|
# the transcript for all participants.
|
||||||
isBotSpeaker = self._isBotSpeaker(speaker)
|
isBotSpeaker = self._isBotSpeaker(speaker)
|
||||||
if isBotSpeaker:
|
if isBotSpeaker and source != "chat":
|
||||||
logger.debug(f"Session {sessionId}: Ignoring own bot caption from: [{speaker}] {text[:80]}...")
|
logger.debug(f"Session {sessionId}: Ignoring own bot caption from: [{speaker}] {text[:80]}...")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -778,6 +778,10 @@ class TeamsbotService:
|
||||||
if self.config.responseMode == TeamsbotResponseMode.TRANSCRIBE_ONLY:
|
if self.config.responseMode == TeamsbotResponseMode.TRANSCRIBE_ONLY:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Bot's own chat: stored for display only, never trigger AI
|
||||||
|
if source == "chat" and isBotSpeaker:
|
||||||
|
return
|
||||||
|
|
||||||
# Stop phrases: trigger immediately without debounce (root cause: 3s debounce delayed stop)
|
# Stop phrases: trigger immediately without debounce (root cause: 3s debounce delayed stop)
|
||||||
if self._isStopPhrase(text):
|
if self._isStopPhrase(text):
|
||||||
logger.info(f"Session {sessionId}: Stop phrase detected, triggering analysis immediately")
|
logger.info(f"Session {sessionId}: Stop phrase detected, triggering analysis immediately")
|
||||||
|
|
|
||||||
|
|
@ -662,10 +662,6 @@ class AppObjects:
|
||||||
if authAuthority != AuthAuthority.LOCAL and authAuthority != AuthAuthority.LOCAL.value:
|
if authAuthority != AuthAuthority.LOCAL and authAuthority != AuthAuthority.LOCAL.value:
|
||||||
raise ValueError("User does not have local authentication enabled")
|
raise ValueError("User does not have local authentication enabled")
|
||||||
|
|
||||||
# Check if user has a reset token set (password reset required)
|
|
||||||
if userRecord.get("resetToken"):
|
|
||||||
raise ValueError("Passwort-Zurücksetzung erforderlich. Bitte prüfen Sie Ihre E-Mail.")
|
|
||||||
|
|
||||||
if not userRecord.get("hashedPassword"):
|
if not userRecord.get("hashedPassword"):
|
||||||
raise ValueError("User has no password set")
|
raise ValueError("User has no password set")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -602,8 +602,8 @@ def password_reset_request(
|
||||||
# Generate reset token
|
# Generate reset token
|
||||||
token, expires = rootInterface.generateResetTokenAndExpiry()
|
token, expires = rootInterface.generateResetTokenAndExpiry()
|
||||||
|
|
||||||
# Set reset token (clears password)
|
# Set reset token but keep existing password valid until new one is set
|
||||||
rootInterface.setResetToken(user.id, token, expires)
|
rootInterface.setResetToken(user.id, token, expires, clearPassword=False)
|
||||||
|
|
||||||
# Generate magic link using provided frontend URL
|
# Generate magic link using provided frontend URL
|
||||||
magicLink = f"{baseUrl}/reset?token={token}"
|
magicLink = f"{baseUrl}/reset?token={token}"
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,12 @@ async def chatStart(currentUser: User, userInput: UserInputRequest, workflowMode
|
||||||
logger.error(f"Error starting chat: {str(e)}")
|
logger.error(f"Error starting chat: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def chatStop(currentUser: User, workflowId: str, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ChatWorkflow:
|
async def chatStop(currentUser: User, workflowId: str, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None, featureCode: Optional[str] = None) -> ChatWorkflow:
|
||||||
"""Stops a running chat."""
|
"""Stops a running chat."""
|
||||||
try:
|
try:
|
||||||
services = getServices(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
services = getServices(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||||
if featureInstanceId:
|
if featureCode:
|
||||||
services.featureCode = 'chatplayground'
|
services.featureCode = featureCode
|
||||||
workflowManager = WorkflowManager(services)
|
workflowManager = WorkflowManager(services)
|
||||||
return await workflowManager.workflowStop(workflowId)
|
return await workflowManager.workflowStop(workflowId)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -101,8 +101,11 @@ async def executeAutomation(automationId: str, automation, creatorUser: User, se
|
||||||
logger.debug(f"Automation {automationId} restricted to providers: {automation.allowedProviders}")
|
logger.debug(f"Automation {automationId} restricted to providers: {automation.allowedProviders}")
|
||||||
|
|
||||||
# Context comes EXCLUSIVELY from the automation definition
|
# Context comes EXCLUSIVELY from the automation definition
|
||||||
automationMandateId = str(automation.mandateId)
|
automationMandateId = str(automation.mandateId) if automation.mandateId is not None else None
|
||||||
automationFeatureInstanceId = str(automation.featureInstanceId)
|
automationFeatureInstanceId = str(automation.featureInstanceId) if automation.featureInstanceId is not None else None
|
||||||
|
|
||||||
|
if not automationMandateId or not automationFeatureInstanceId:
|
||||||
|
raise ValueError(f"Automation {automationId} missing mandateId or featureInstanceId")
|
||||||
|
|
||||||
logger.info(f"Executing automation {automationId} as user {creatorUser.id} with mandateId={automationMandateId}, featureInstanceId={automationFeatureInstanceId}")
|
logger.info(f"Executing automation {automationId} as user {creatorUser.id} with mandateId={automationMandateId}, featureInstanceId={automationFeatureInstanceId}")
|
||||||
|
|
||||||
|
|
@ -118,7 +121,7 @@ async def executeAutomation(automationId: str, automation, creatorUser: User, se
|
||||||
logger.error(f"Placeholders: {placeholders}")
|
logger.error(f"Placeholders: {placeholders}")
|
||||||
logger.error(f"Generated planJson (first 1000 chars): {planJson[:1000]}")
|
logger.error(f"Generated planJson (first 1000 chars): {planJson[:1000]}")
|
||||||
logger.error(f"Error position: line {e.lineno}, column {e.colno}, char {e.pos}")
|
logger.error(f"Error position: line {e.lineno}, column {e.colno}, char {e.pos}")
|
||||||
if e.pos:
|
if e.pos is not None:
|
||||||
start = max(0, e.pos - 100)
|
start = max(0, e.pos - 100)
|
||||||
end = min(len(planJson), e.pos + 100)
|
end = min(len(planJson), e.pos + 100)
|
||||||
logger.error(f"Context around error: ...{planJson[start:end]}...")
|
logger.error(f"Context around error: ...{planJson[start:end]}...")
|
||||||
|
|
@ -233,20 +236,10 @@ def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
|
||||||
cronKwargs = parseScheduleToCron(schedule)
|
cronKwargs = parseScheduleToCron(schedule)
|
||||||
|
|
||||||
if isActive:
|
if isActive:
|
||||||
# Remove existing event if present (handles schedule changes)
|
|
||||||
if currentEventId:
|
|
||||||
try:
|
|
||||||
eventManager.remove(currentEventId)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error removing old event {currentEventId}: {str(e)}")
|
|
||||||
|
|
||||||
# Register new event
|
|
||||||
newEventId = f"automation.{automationId}"
|
newEventId = f"automation.{automationId}"
|
||||||
|
|
||||||
# Create event handler function
|
|
||||||
handler = createAutomationEventHandler(automationId, eventUser)
|
handler = createAutomationEventHandler(automationId, eventUser)
|
||||||
|
|
||||||
# Register cron job
|
# Register with replaceExisting=True (atomically replaces old event)
|
||||||
eventManager.registerCron(
|
eventManager.registerCron(
|
||||||
jobId=newEventId,
|
jobId=newEventId,
|
||||||
func=handler,
|
func=handler,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ def start(eventUser) -> bool:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Automation: Error setting up events on startup: {str(e)}")
|
logger.error(f"Automation: Error setting up events on startup: {str(e)}")
|
||||||
# Don't fail startup if automation sync fails
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Automation templates for workflow definitions.
|
||||||
Contains predefined workflow templates that can be used to create automation definitions.
|
Contains predefined workflow templates that can be used to create automation definitions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any
|
||||||
|
|
||||||
# Automation templates structure
|
# Automation templates structure
|
||||||
AUTOMATION_TEMPLATES: Dict[str, Any] = {
|
AUTOMATION_TEMPLATES: Dict[str, Any] = {
|
||||||
|
|
|
||||||
|
|
@ -69,50 +69,42 @@ def replacePlaceholders(template: str, placeholders: Dict[str, str]) -> str:
|
||||||
result = result.replace(arrayPattern, arrayValue)
|
result = result.replace(arrayPattern, arrayValue)
|
||||||
continue # Skip the regular replacement below
|
continue # Skip the regular replacement below
|
||||||
|
|
||||||
# Regular replacement - check if in quoted context
|
# Replace occurrences one-by-one to handle mixed contexts
|
||||||
patternStart = result.find(pattern)
|
while pattern in result:
|
||||||
isQuoted = False
|
patternStart = result.find(pattern)
|
||||||
if patternStart > 0:
|
isQuoted = False
|
||||||
charBefore = result[patternStart - 1] if patternStart > 0 else None
|
if patternStart > 0:
|
||||||
patternEnd = patternStart + len(pattern)
|
charBefore = result[patternStart - 1]
|
||||||
charAfter = result[patternEnd] if patternEnd < len(result) else None
|
patternEnd = patternStart + len(pattern)
|
||||||
if charBefore == '"' and charAfter == '"':
|
charAfter = result[patternEnd] if patternEnd < len(result) else None
|
||||||
isQuoted = True
|
if charBefore == '"' and charAfter == '"':
|
||||||
|
isQuoted = True
|
||||||
# Handle different value types
|
|
||||||
if isinstance(value, (list, dict)):
|
if isinstance(value, (list, dict)):
|
||||||
# Python list/dict - convert to JSON
|
replacement = json.dumps(value)
|
||||||
replacement = json.dumps(value)
|
elif isinstance(value, str):
|
||||||
elif isinstance(value, str):
|
try:
|
||||||
# String value - check if it's a JSON string representing list/dict
|
parsed = json.loads(value)
|
||||||
try:
|
if isinstance(parsed, (list, dict)):
|
||||||
parsed = json.loads(value)
|
if isQuoted:
|
||||||
if isinstance(parsed, (list, dict)):
|
escaped = json.dumps(value)
|
||||||
# It's a JSON string of a list/dict
|
replacement = escaped[1:-1]
|
||||||
if isQuoted:
|
else:
|
||||||
# In quoted context, escape the JSON string
|
replacement = value
|
||||||
escaped = json.dumps(value)
|
|
||||||
replacement = escaped[1:-1] # Remove outer quotes
|
|
||||||
else:
|
else:
|
||||||
# In unquoted context, use JSON directly
|
if isQuoted:
|
||||||
replacement = value
|
escaped = json.dumps(value)
|
||||||
else:
|
replacement = escaped[1:-1]
|
||||||
# It's a JSON string of a primitive
|
else:
|
||||||
|
replacement = value
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
if isQuoted:
|
if isQuoted:
|
||||||
escaped = json.dumps(value)
|
escaped = json.dumps(value)
|
||||||
replacement = escaped[1:-1]
|
replacement = escaped[1:-1]
|
||||||
else:
|
else:
|
||||||
replacement = value
|
replacement = value
|
||||||
except (json.JSONDecodeError, ValueError):
|
else:
|
||||||
# Not valid JSON - treat as plain string
|
replacement = str(value)
|
||||||
if isQuoted:
|
result = result[:patternStart] + replacement + result[patternStart + len(pattern):]
|
||||||
escaped = json.dumps(value)
|
|
||||||
replacement = escaped[1:-1]
|
|
||||||
else:
|
|
||||||
replacement = value
|
|
||||||
else:
|
|
||||||
# Numbers, booleans, None - convert to string
|
|
||||||
replacement = str(value)
|
|
||||||
result = result.replace(pattern, replacement)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,11 @@ async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
documentName=docData.documentName,
|
documentName=docData.documentName,
|
||||||
documentData=docData.documentData,
|
documentData=docData.documentData,
|
||||||
mimeType=docData.mimeType,
|
mimeType=docData.mimeType,
|
||||||
sourceJson=docData.sourceJson if hasattr(docData, 'sourceJson') else None
|
sourceJson=docData.sourceJson if hasattr(docData, 'sourceJson') else None,
|
||||||
|
validationMetadata={
|
||||||
|
"actionType": "ai.generateCode",
|
||||||
|
"resultType": resultType,
|
||||||
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
# If no documents but content exists, create a document from content
|
# If no documents but content exists, create a document from content
|
||||||
|
|
@ -112,7 +116,11 @@ async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
documents.append(ActionDocument(
|
documents.append(ActionDocument(
|
||||||
documentName=docName,
|
documentName=docName,
|
||||||
documentData=aiResponse.content.encode('utf-8') if isinstance(aiResponse.content, str) else aiResponse.content,
|
documentData=aiResponse.content.encode('utf-8') if isinstance(aiResponse.content, str) else aiResponse.content,
|
||||||
mimeType=mimeType
|
mimeType=mimeType,
|
||||||
|
validationMetadata={
|
||||||
|
"actionType": "ai.generateCode",
|
||||||
|
"resultType": resultType,
|
||||||
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
return ActionResult.isSuccess(documents=documents)
|
return ActionResult.isSuccess(documents=documents)
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,12 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
documentName=docData.documentName,
|
documentName=docData.documentName,
|
||||||
documentData=docData.documentData,
|
documentData=docData.documentData,
|
||||||
mimeType=docData.mimeType,
|
mimeType=docData.mimeType,
|
||||||
sourceJson=docData.sourceJson if hasattr(docData, 'sourceJson') else None
|
sourceJson=docData.sourceJson if hasattr(docData, 'sourceJson') else None,
|
||||||
|
validationMetadata={
|
||||||
|
"actionType": "ai.generateDocument",
|
||||||
|
"documentType": documentType,
|
||||||
|
"resultType": resultType,
|
||||||
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
# If no documents but content exists, create a document from content
|
# If no documents but content exists, create a document from content
|
||||||
|
|
@ -112,7 +117,12 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
documents.append(ActionDocument(
|
documents.append(ActionDocument(
|
||||||
documentName=docName,
|
documentName=docName,
|
||||||
documentData=aiResponse.content.encode('utf-8') if isinstance(aiResponse.content, str) else aiResponse.content,
|
documentData=aiResponse.content.encode('utf-8') if isinstance(aiResponse.content, str) else aiResponse.content,
|
||||||
mimeType=mimeType
|
mimeType=mimeType,
|
||||||
|
validationMetadata={
|
||||||
|
"actionType": "ai.generateDocument",
|
||||||
|
"documentType": documentType,
|
||||||
|
"resultType": resultType,
|
||||||
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
return ActionResult.isSuccess(documents=documents)
|
return ActionResult.isSuccess(documents=documents)
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ from modules.datamodels.datamodelExtraction import ContentPart
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def process(self, parameters: Dict[str, Any]) -> ActionResult:
|
async def process(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
operationId = None
|
||||||
try:
|
try:
|
||||||
# Init progress logger
|
|
||||||
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
|
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
|
||||||
operationId = f"ai_process_{workflowId}_{int(time.time())}"
|
operationId = f"ai_process_{workflowId}_{int(time.time())}"
|
||||||
|
|
||||||
|
|
@ -83,7 +83,8 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
output_format = None
|
output_format = None
|
||||||
logger.debug("resultType not provided - formats will be determined from prompt by AI")
|
logger.debug("resultType not provided - formats will be determined from prompt by AI")
|
||||||
|
|
||||||
output_mime_type = "application/octet-stream" # Prefer service-provided mimeType when available
|
mimeMap = {"txt": "text/plain", "json": "application/json", "html": "text/html", "md": "text/markdown", "csv": "text/csv", "xml": "application/xml"}
|
||||||
|
output_mime_type = mimeMap.get(normalized_result_type, "text/plain") if normalized_result_type else "text/plain"
|
||||||
|
|
||||||
# Phase 7.3: Pass both documentList and contentParts to AI service
|
# Phase 7.3: Pass both documentList and contentParts to AI service
|
||||||
# (Extraction logic removed - handled by AI service)
|
# (Extraction logic removed - handled by AI service)
|
||||||
|
|
@ -264,11 +265,11 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in AI processing: {str(e)}")
|
logger.error(f"Error in AI processing: {str(e)}")
|
||||||
|
|
||||||
# Complete progress tracking with failure
|
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogFinish(operationId, False)
|
if operationId:
|
||||||
except:
|
self.services.chat.progressLogFinish(operationId, False)
|
||||||
pass # Don't fail on progress logging errors
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return ActionResult.isFailure(
|
return ActionResult.isFailure(
|
||||||
error=str(e)
|
error=str(e)
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,19 @@
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
|
async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
operationId = None
|
||||||
try:
|
try:
|
||||||
prompt = parameters.get("prompt")
|
prompt = parameters.get("prompt")
|
||||||
if not prompt:
|
if not prompt:
|
||||||
return ActionResult.isFailure(error="Research prompt is required")
|
return ActionResult.isFailure(error="Research prompt is required")
|
||||||
|
|
||||||
# Init progress logger
|
|
||||||
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
|
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
|
||||||
operationId = f"web_research_{workflowId}_{int(time.time())}"
|
operationId = f"web_research_{workflowId}_{int(time.time())}"
|
||||||
|
|
||||||
|
|
@ -78,9 +79,10 @@ async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
"researchDepth": parameters.get("researchDepth", "general"),
|
"researchDepth": parameters.get("researchDepth", "general"),
|
||||||
"resultFormat": "json"
|
"resultFormat": "json"
|
||||||
}
|
}
|
||||||
|
documentData = json.dumps(result, ensure_ascii=False) if isinstance(result, dict) else result
|
||||||
actionDocument = ActionDocument(
|
actionDocument = ActionDocument(
|
||||||
documentName=meaningfulName,
|
documentName=meaningfulName,
|
||||||
documentData=result,
|
documentData=documentData,
|
||||||
mimeType="application/json",
|
mimeType="application/json",
|
||||||
validationMetadata=validationMetadata
|
validationMetadata=validationMetadata
|
||||||
)
|
)
|
||||||
|
|
@ -90,8 +92,9 @@ async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in web research: {str(e)}")
|
logger.error(f"Error in web research: {str(e)}")
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogFinish(operationId, False)
|
if operationId:
|
||||||
except:
|
self.services.chat.progressLogFinish(operationId, False)
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return ActionResult.isFailure(error=str(e))
|
return ActionResult.isFailure(error=str(e))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
from typing import Dict, List, Optional, Any, Literal
|
from typing import Dict, List, Optional, Any
|
||||||
from datetime import datetime, UTC
|
from datetime import datetime, UTC
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import inspect
|
|
||||||
|
|
||||||
from modules.datamodels.datamodelWorkflowActions import WorkflowActionDefinition, WorkflowActionParameter
|
from modules.datamodels.datamodelWorkflowActions import WorkflowActionDefinition, WorkflowActionParameter
|
||||||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||||
|
|
@ -258,9 +257,13 @@ class MethodBase:
|
||||||
raise ValueError(f"Expected dict for type '{expectedType}', got {type(value).__name__}")
|
raise ValueError(f"Expected dict for type '{expectedType}', got {type(value).__name__}")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
# Handle simple types
|
# Handle simple types (bool must be checked before int since bool is subclass of int)
|
||||||
if expectedType in typeMap:
|
if expectedType in typeMap:
|
||||||
expectedTypeClass = typeMap[expectedType]
|
expectedTypeClass = typeMap[expectedType]
|
||||||
|
if expectedType == 'int' and isinstance(value, bool):
|
||||||
|
raise ValueError(f"Expected int, got bool: {value}")
|
||||||
|
if expectedType == 'bool' and isinstance(value, int) and not isinstance(value, bool):
|
||||||
|
return bool(value)
|
||||||
if not isinstance(value, expectedTypeClass):
|
if not isinstance(value, expectedTypeClass):
|
||||||
try:
|
try:
|
||||||
return expectedTypeClass(value)
|
return expectedTypeClass(value)
|
||||||
|
|
@ -290,10 +293,11 @@ class MethodBase:
|
||||||
|
|
||||||
def getActionSignature(self, actionName: str) -> str:
|
def getActionSignature(self, actionName: str) -> str:
|
||||||
"""Get formatted action signature for AI prompt generation (detailed version)"""
|
"""Get formatted action signature for AI prompt generation (detailed version)"""
|
||||||
if actionName not in self.actions:
|
allActions = self.actions
|
||||||
|
if actionName not in allActions:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
action = self.actions[actionName]
|
action = allActions[actionName]
|
||||||
paramList = []
|
paramList = []
|
||||||
|
|
||||||
# Extract detailed parameter information from docstring
|
# Extract detailed parameter information from docstring
|
||||||
|
|
|
||||||
|
|
@ -89,14 +89,26 @@ async def queryDatabase(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
# Update progress
|
# Update progress
|
||||||
self.services.chat.progressLogUpdate(operationId, 0.3, "Validating query")
|
self.services.chat.progressLogUpdate(operationId, 0.3, "Validating query")
|
||||||
|
|
||||||
|
# Validate: only SELECT queries allowed
|
||||||
|
sqlNormalized = sqlQuery.strip().upper()
|
||||||
|
if not sqlNormalized.startswith("SELECT"):
|
||||||
|
return ActionResult.isFailure(error="Only SELECT queries are allowed")
|
||||||
|
forbiddenKeywords = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "EXEC", "EXECUTE"]
|
||||||
|
for kw in forbiddenKeywords:
|
||||||
|
if f" {kw} " in f" {sqlNormalized} " or sqlNormalized.startswith(f"{kw} "):
|
||||||
|
return ActionResult.isFailure(error=f"Forbidden SQL keyword detected: {kw}")
|
||||||
|
|
||||||
# Initialize connector
|
# Initialize connector
|
||||||
connector = PreprocessorConnector()
|
connector = PreprocessorConnector()
|
||||||
|
|
||||||
# Update progress
|
# Update progress
|
||||||
self.services.chat.progressLogUpdate(operationId, 0.5, "Executing query")
|
self.services.chat.progressLogUpdate(operationId, 0.5, "Executing query")
|
||||||
|
|
||||||
# Execute query
|
try:
|
||||||
result = await connector.executeQuery(sqlQuery)
|
result = await connector.executeQuery(sqlQuery)
|
||||||
|
except Exception:
|
||||||
|
await connector.close()
|
||||||
|
raise
|
||||||
|
|
||||||
# Update progress
|
# Update progress
|
||||||
self.services.chat.progressLogUpdate(operationId, 0.8, "Formatting results")
|
self.services.chat.progressLogUpdate(operationId, 0.8, "Formatting results")
|
||||||
|
|
@ -134,10 +146,9 @@ async def queryDatabase(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error executing database query: {str(e)}")
|
logger.error(f"Error executing database query: {str(e)}")
|
||||||
|
|
||||||
# Complete progress tracking with failure
|
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogFinish(operationId, False)
|
self.services.chat.progressLogFinish(operationId, False)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return ActionResult.isFailure(
|
return ActionResult.isFailure(
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrat
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
operationId = None
|
||||||
try:
|
try:
|
||||||
# Init progress logger
|
|
||||||
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
|
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
|
||||||
operationId = f"context_extract_{workflowId}_{int(time.time())}"
|
operationId = f"context_extract_{workflowId}_{int(time.time())}"
|
||||||
|
|
||||||
|
|
@ -208,11 +208,11 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in content extraction: {str(e)}")
|
logger.error(f"Error in content extraction: {str(e)}")
|
||||||
|
|
||||||
# Complete progress tracking with failure
|
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogFinish(operationId, False)
|
if operationId:
|
||||||
except:
|
self.services.chat.progressLogFinish(operationId, False)
|
||||||
pass # Don't fail on progress logging errors
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return ActionResult.isFailure(error=str(e))
|
return ActionResult.isFailure(error=str(e))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,13 @@ async def getDocumentIndex(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
documentsIndex = self.services.chat.getAvailableDocuments(workflow)
|
documentsIndex = self.services.chat.getAvailableDocuments(workflow)
|
||||||
|
|
||||||
if not documentsIndex or documentsIndex == "No documents available" or documentsIndex == "NO DOCUMENTS AVAILABLE - This workflow has no documents to process.":
|
if not documentsIndex or documentsIndex == "No documents available" or documentsIndex == "NO DOCUMENTS AVAILABLE - This workflow has no documents to process.":
|
||||||
# Return empty index structure
|
indexData = {
|
||||||
|
"workflowId": getattr(workflow, 'id', 'unknown'),
|
||||||
|
"totalDocuments": 0,
|
||||||
|
"rounds": [],
|
||||||
|
"documentReferences": []
|
||||||
|
}
|
||||||
if resultType == "json":
|
if resultType == "json":
|
||||||
indexData = {
|
|
||||||
"workflowId": getattr(workflow, 'id', 'unknown'),
|
|
||||||
"totalDocuments": 0,
|
|
||||||
"rounds": [],
|
|
||||||
"documentReferences": []
|
|
||||||
}
|
|
||||||
indexContent = json.dumps(indexData, indent=2, ensure_ascii=False)
|
indexContent = json.dumps(indexData, indent=2, ensure_ascii=False)
|
||||||
else:
|
else:
|
||||||
indexContent = "Document Index\n==============\n\nNo documents available in this workflow.\n"
|
indexContent = "Document Index\n==============\n\nNo documents available in this workflow.\n"
|
||||||
|
|
@ -64,7 +63,7 @@ async def getDocumentIndex(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
document = ActionDocument(
|
document = ActionDocument(
|
||||||
documentName=filename,
|
documentName=filename,
|
||||||
documentData=indexContent,
|
documentData=indexContent,
|
||||||
mimeType="application/json" if resultType == "json" else "text/plain",
|
mimeType="application/json" if resultType == "json" else ("text/markdown" if resultType == "md" else "text/plain"),
|
||||||
validationMetadata=validationMetadata
|
validationMetadata=validationMetadata
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
|
async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
operationId = None
|
||||||
try:
|
try:
|
||||||
# Init progress logger
|
|
||||||
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
|
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
|
||||||
operationId = f"context_neutralize_{workflowId}_{int(time.time())}"
|
operationId = f"context_neutralize_{workflowId}_{int(time.time())}"
|
||||||
|
|
||||||
|
|
@ -228,10 +228,10 @@ async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in data neutralization: {str(e)}")
|
logger.error(f"Error in data neutralization: {str(e)}")
|
||||||
|
|
||||||
# Complete progress tracking with failure
|
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogFinish(operationId, False)
|
if operationId:
|
||||||
except:
|
self.services.chat.progressLogFinish(operationId, False)
|
||||||
pass # Don't fail on progress logging errors
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return ActionResult.isFailure(error=str(e))
|
return ActionResult.isFailure(error=str(e))
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ async def readEmails(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
|
||||||
connectionReference = parameters.get("connectionReference")
|
connectionReference = parameters.get("connectionReference")
|
||||||
folder = parameters.get("folder", "Inbox")
|
folder = parameters.get("folder", "Inbox")
|
||||||
limit = parameters.get("limit", 10)
|
limit = parameters.get("limit", 1000)
|
||||||
filter = parameters.get("filter")
|
filter = parameters.get("filter")
|
||||||
outputMimeType = parameters.get("outputMimeType", "application/json")
|
outputMimeType = parameters.get("outputMimeType", "application/json")
|
||||||
|
|
||||||
|
|
@ -110,7 +110,6 @@ async def readEmails(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"Graph API error: {response.status_code} - {response.text}")
|
logger.error(f"Graph API error: {response.status_code} - {response.text}")
|
||||||
logger.error(f"Request URL: {response.url}")
|
logger.error(f"Request URL: {response.url}")
|
||||||
logger.error(f"Request headers: {headers}")
|
|
||||||
logger.error(f"Request params: {params}")
|
logger.error(f"Request params: {params}")
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -217,8 +216,8 @@ async def readEmails(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
if operationId:
|
if operationId:
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogFinish(operationId, False)
|
self.services.chat.progressLogFinish(operationId, False)
|
||||||
except:
|
except Exception:
|
||||||
pass # Don't fail on progress logging errors
|
pass
|
||||||
return ActionResult.isFailure(
|
return ActionResult.isFailure(
|
||||||
error=str(e)
|
error=str(e)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ async def searchEmails(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
try:
|
try:
|
||||||
error_data = response.json()
|
error_data = response.json()
|
||||||
logger.error(f"Microsoft Graph API error: {response.status_code} - {error_data}")
|
logger.error(f"Microsoft Graph API error: {response.status_code} - {error_data}")
|
||||||
except:
|
except Exception:
|
||||||
logger.error(f"Microsoft Graph API error: {response.status_code} - {response.text}")
|
logger.error(f"Microsoft Graph API error: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
# Check for specific error types and provide helpful messages
|
# Check for specific error types and provide helpful messages
|
||||||
|
|
@ -111,8 +111,6 @@ async def searchEmails(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
|
||||||
raise Exception(f"Microsoft Graph API returned {response.status_code}: {response.text}")
|
raise Exception(f"Microsoft Graph API returned {response.status_code}: {response.text}")
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
search_data = response.json()
|
search_data = response.json()
|
||||||
emails = search_data.get("value", [])
|
emails = search_data.get("value", [])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -293,8 +293,18 @@ async def sendDraftEmail(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.error("requests module not available")
|
logger.error("requests module not available")
|
||||||
|
if operationId:
|
||||||
|
try:
|
||||||
|
self.services.chat.progressLogFinish(operationId, False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return ActionResult.isFailure(error="requests module not available")
|
return ActionResult.isFailure(error="requests module not available")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in sendDraftEmail: {str(e)}")
|
logger.error(f"Error in sendDraftEmail: {str(e)}")
|
||||||
|
if operationId:
|
||||||
|
try:
|
||||||
|
self.services.chat.progressLogFinish(operationId, False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return ActionResult.isFailure(error=str(e))
|
return ActionResult.isFailure(error=str(e))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,25 +40,21 @@ class ConnectionHelper:
|
||||||
|
|
||||||
logger.debug(f"Found connection: {userConnection.id}, status: {userConnection.status.value}, authority: {userConnection.authority.value}")
|
logger.debug(f"Found connection: {userConnection.id}, status: {userConnection.status.value}, authority: {userConnection.authority.value}")
|
||||||
|
|
||||||
# Get a fresh token for this connection
|
# Check status BEFORE fetching token (avoids unnecessary network call)
|
||||||
token = self.services.chat.getFreshConnectionToken(userConnection.id)
|
|
||||||
if not token:
|
|
||||||
logger.error(f"Fresh token not found for connection: {userConnection.id}")
|
|
||||||
logger.debug(f"Connection details: {userConnection}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.debug(f"Fresh token retrieved for connection {userConnection.id}")
|
|
||||||
|
|
||||||
# Check if connection is active
|
|
||||||
if userConnection.status.value != "active":
|
if userConnection.status.value != "active":
|
||||||
logger.error(f"Connection is not active: {userConnection.id}, status: {userConnection.status.value}")
|
logger.error(f"Connection is not active: {userConnection.id}, status: {userConnection.status.value}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
token = self.services.chat.getFreshConnectionToken(userConnection.id)
|
||||||
|
if not token:
|
||||||
|
logger.error(f"Fresh token not found for connection: {userConnection.id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug(f"Fresh token retrieved for connection {userConnection.id}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": userConnection.id,
|
"id": userConnection.id,
|
||||||
"accessToken": token.tokenAccess,
|
"accessToken": token.tokenAccess,
|
||||||
"refreshToken": token.tokenRefresh,
|
|
||||||
"scopes": ["Mail.ReadWrite", "Mail.Send", "Mail.ReadWrite.Shared", "User.Read"] # Valid Microsoft Graph API scopes
|
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting Microsoft connection: {str(e)}")
|
logger.error(f"Error getting Microsoft connection: {str(e)}")
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,10 @@ class EmailProcessingHelper:
|
||||||
# This is an advanced search query, return as-is
|
# This is an advanced search query, return as-is
|
||||||
return clean_query
|
return clean_query
|
||||||
|
|
||||||
# For basic text search, ensure it's safe for contains() filter
|
# Escape single quotes for OData safety (double them)
|
||||||
# Remove any characters that might break the OData filter syntax
|
safe_query = clean_query.replace("'", "''")
|
||||||
# Remove or escape characters that could break OData filter syntax
|
# Remove backslashes and double quotes
|
||||||
safe_query = re.sub(r'[\\\'"]', '', clean_query)
|
safe_query = re.sub(r'[\\"]', '', safe_query)
|
||||||
|
|
||||||
return safe_query
|
return safe_query
|
||||||
|
|
||||||
|
|
@ -173,12 +173,14 @@ class EmailProcessingHelper:
|
||||||
|
|
||||||
# Handle email address filters (only if it's NOT a search query)
|
# Handle email address filters (only if it's NOT a search query)
|
||||||
if '@' in filter_text and '.' in filter_text and ' ' not in filter_text and not filter_text.startswith('from:'):
|
if '@' in filter_text and '.' in filter_text and ' ' not in filter_text and not filter_text.startswith('from:'):
|
||||||
return {"$filter": f"from/fromAddress/address eq '{filter_text}'"}
|
safeEmail = filter_text.replace("'", "''")
|
||||||
|
return {"$filter": f"from/fromAddress/address eq '{safeEmail}'"}
|
||||||
|
|
||||||
# Handle OData filter conditions (contains 'eq', 'ne', 'gt', 'lt', etc.)
|
# Handle OData filter conditions (contains 'eq', 'ne', 'gt', 'lt', etc.)
|
||||||
if any(op in filter_text.lower() for op in [' eq ', ' ne ', ' gt ', ' lt ', ' ge ', ' le ', ' and ', ' or ']):
|
if any(op in filter_text.lower() for op in [' eq ', ' ne ', ' gt ', ' lt ', ' ge ', ' le ', ' and ', ' or ']):
|
||||||
return {"$filter": filter_text}
|
return {"$filter": filter_text}
|
||||||
|
|
||||||
# Handle text content - search in subject
|
# Handle text content - search in subject (escape single quotes)
|
||||||
return {"$filter": f"contains(subject,'{filter_text}')"}
|
safeText = filter_text.replace("'", "''")
|
||||||
|
return {"$filter": f"contains(subject,'{safeText}')"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -240,11 +240,12 @@ async def uploadDocument(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
}
|
}
|
||||||
|
|
||||||
successfulUploads = len([r for r in uploadResults if r.get("uploadStatus") == "success"])
|
successfulUploads = len([r for r in uploadResults if r.get("uploadStatus") == "success"])
|
||||||
|
overallSuccess = successfulUploads > 0
|
||||||
self.services.chat.progressLogUpdate(operationId, 0.9, f"Uploaded {successfulUploads}/{len(uploadResults)} file(s)")
|
self.services.chat.progressLogUpdate(operationId, 0.9, f"Uploaded {successfulUploads}/{len(uploadResults)} file(s)")
|
||||||
self.services.chat.progressLogFinish(operationId, successfulUploads > 0)
|
self.services.chat.progressLogFinish(operationId, overallSuccess)
|
||||||
|
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
success=True,
|
success=overallSuccess,
|
||||||
documents=[
|
documents=[
|
||||||
ActionDocument(
|
ActionDocument(
|
||||||
documentName=self._generateMeaningfulFileName("sharepoint_upload", "json", None, "uploadDocument"),
|
documentName=self._generateMeaningfulFileName("sharepoint_upload", "json", None, "uploadDocument"),
|
||||||
|
|
@ -260,7 +261,7 @@ async def uploadDocument(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
if operationId:
|
if operationId:
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogFinish(operationId, False)
|
self.services.chat.progressLogFinish(operationId, False)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
success=False,
|
success=False,
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,20 @@ class ApiClientHelper:
|
||||||
"""Helper for Microsoft Graph API calls"""
|
"""Helper for Microsoft Graph API calls"""
|
||||||
|
|
||||||
def __init__(self, methodInstance):
|
def __init__(self, methodInstance):
|
||||||
"""
|
|
||||||
Initialize API client helper.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
methodInstance: Instance of MethodSharepoint (for access to services)
|
|
||||||
"""
|
|
||||||
self.method = methodInstance
|
self.method = methodInstance
|
||||||
self.services = methodInstance.services
|
self.services = methodInstance.services
|
||||||
|
self._session: aiohttp.ClientSession = None
|
||||||
|
|
||||||
|
async def _getSession(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=30)
|
||||||
|
self._session = aiohttp.ClientSession(timeout=timeout)
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
|
||||||
async def makeGraphApiCall(self, endpoint: str, method: str = "GET", data: bytes = None) -> Dict[str, Any]:
|
async def makeGraphApiCall(self, endpoint: str, method: str = "GET", data: bytes = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -50,60 +56,28 @@ class ApiClientHelper:
|
||||||
url = f"https://graph.microsoft.com/v1.0/{endpoint}"
|
url = f"https://graph.microsoft.com/v1.0/{endpoint}"
|
||||||
logger.info(f"Making Graph API call: {method} {url}")
|
logger.info(f"Making Graph API call: {method} {url}")
|
||||||
|
|
||||||
# Set timeout to 30 seconds
|
session = await self._getSession()
|
||||||
timeout = aiohttp.ClientTimeout(total=30)
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
successCodes = {"GET": [200], "PUT": [200, 201], "POST": [200, 201], "DELETE": [200, 204]}
|
||||||
if method == "GET":
|
httpMethod = getattr(session, method.lower(), None)
|
||||||
logger.debug(f"Starting GET request to {url}")
|
if not httpMethod:
|
||||||
async with session.get(url, headers=headers) as response:
|
return {"error": f"Unsupported HTTP method: {method}"}
|
||||||
logger.info(f"Graph API response: {response.status}")
|
|
||||||
if response.status == 200:
|
kwargs = {"headers": headers}
|
||||||
result = await response.json()
|
if data is not None:
|
||||||
logger.debug(f"Graph API success: {len(str(result))} characters response")
|
kwargs["data"] = data
|
||||||
return result
|
|
||||||
else:
|
async with httpMethod(url, **kwargs) as response:
|
||||||
errorText = await response.text()
|
logger.info(f"Graph API response: {response.status}")
|
||||||
logger.error(f"Graph API call failed: {response.status} - {errorText}")
|
if response.status in successCodes.get(method, [200]):
|
||||||
return {"error": f"API call failed: {response.status} - {errorText}"}
|
if method == "DELETE":
|
||||||
|
return {"success": True}
|
||||||
elif method == "PUT":
|
result = await response.json()
|
||||||
logger.debug(f"Starting PUT request to {url}")
|
return result
|
||||||
async with session.put(url, headers=headers, data=data) as response:
|
else:
|
||||||
logger.info(f"Graph API response: {response.status}")
|
errorText = await response.text()
|
||||||
if response.status in [200, 201]:
|
logger.error(f"Graph API call failed: {response.status} - {errorText}")
|
||||||
result = await response.json()
|
return {"error": f"API call failed: {response.status} - {errorText}"}
|
||||||
logger.debug(f"Graph API success: {len(str(result))} characters response")
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
errorText = await response.text()
|
|
||||||
logger.error(f"Graph API call failed: {response.status} - {errorText}")
|
|
||||||
return {"error": f"API call failed: {response.status} - {errorText}"}
|
|
||||||
|
|
||||||
elif method == "POST":
|
|
||||||
logger.debug(f"Starting POST request to {url}")
|
|
||||||
async with session.post(url, headers=headers, data=data) as response:
|
|
||||||
logger.info(f"Graph API response: {response.status}")
|
|
||||||
if response.status in [200, 201]:
|
|
||||||
result = await response.json()
|
|
||||||
logger.debug(f"Graph API success: {len(str(result))} characters response")
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
errorText = await response.text()
|
|
||||||
logger.error(f"Graph API call failed: {response.status} - {errorText}")
|
|
||||||
return {"error": f"API call failed: {response.status} - {errorText}"}
|
|
||||||
|
|
||||||
elif method == "DELETE":
|
|
||||||
logger.debug(f"Starting DELETE request to {url}")
|
|
||||||
async with session.delete(url, headers=headers) as response:
|
|
||||||
logger.info(f"Graph API response: {response.status}")
|
|
||||||
if response.status in [200, 204]:
|
|
||||||
logger.debug(f"Graph API DELETE success")
|
|
||||||
return {"success": True}
|
|
||||||
else:
|
|
||||||
errorText = await response.text()
|
|
||||||
logger.error(f"Graph API call failed: {response.status} - {errorText}")
|
|
||||||
return {"error": f"API call failed: {response.status} - {errorText}"}
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error(f"Graph API call timed out after 30 seconds: {endpoint}")
|
logger.error(f"Graph API call timed out after 30 seconds: {endpoint}")
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,19 @@ class AdaptiveLearningEngine:
|
||||||
"""Enhanced learning engine that tracks validation patterns and adapts prompts"""
|
"""Enhanced learning engine that tracks validation patterns and adapts prompts"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.validationHistory = [] # Store validation results with context
|
self.validationHistory = []
|
||||||
self.failurePatterns = defaultdict(list) # Track failure patterns by action type
|
self.failurePatterns = defaultdict(list)
|
||||||
self.successPatterns = defaultdict(list) # Track success patterns
|
self.successPatterns = defaultdict(list)
|
||||||
self.actionAttempts = defaultdict(int) # Track attempt counts per action
|
self.actionAttempts = defaultdict(int)
|
||||||
self.learningInsights = {} # Store learned insights per workflow
|
self.learningInsights = {}
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Reset all learned state for a new workflow session."""
|
||||||
|
self.validationHistory.clear()
|
||||||
|
self.failurePatterns.clear()
|
||||||
|
self.successPatterns.clear()
|
||||||
|
self.actionAttempts.clear()
|
||||||
|
self.learningInsights.clear()
|
||||||
|
|
||||||
def recordValidationResult(self, validationResult: Dict[str, Any], actionContext: Dict[str, Any],
|
def recordValidationResult(self, validationResult: Dict[str, Any], actionContext: Dict[str, Any],
|
||||||
workflowId: str, attemptNumber: int):
|
workflowId: str, attemptNumber: int):
|
||||||
|
|
@ -195,15 +203,6 @@ class AdaptiveLearningEngine:
|
||||||
for issue, count in list(commonIssues.items())[:3]: # Top 3 issues
|
for issue, count in list(commonIssues.items())[:3]: # Top 3 issues
|
||||||
guidance_parts.append(f"- {issue} (occurred {count} times)")
|
guidance_parts.append(f"- {issue} (occurred {count} times)")
|
||||||
|
|
||||||
# Add specific action guidance based on user prompt
|
|
||||||
if "email" in userPrompt.lower() and "outlook" in userPrompt.lower():
|
|
||||||
if any("account" in str(issue).lower() for issue in commonIssues.keys()):
|
|
||||||
guidance_parts.append("SPECIFIC GUIDANCE: Ensure email is sent from the correct account (valueon).")
|
|
||||||
if any("attachment" in str(issue).lower() for issue in commonIssues.keys()):
|
|
||||||
guidance_parts.append("SPECIFIC GUIDANCE: Verify PDF attachment is properly included.")
|
|
||||||
if any("summary" in str(issue).lower() for issue in commonIssues.keys()):
|
|
||||||
guidance_parts.append("SPECIFIC GUIDANCE: Include German summary in email body.")
|
|
||||||
|
|
||||||
return "\n".join(guidance_parts) if guidance_parts else "No specific guidance available."
|
return "\n".join(guidance_parts) if guidance_parts else "No specific guidance available."
|
||||||
|
|
||||||
def _generateParameterGuidance(self, actionName: str, parametersContext: str,
|
def _generateParameterGuidance(self, actionName: str, parametersContext: str,
|
||||||
|
|
@ -219,12 +218,11 @@ class AdaptiveLearningEngine:
|
||||||
if attemptNumber and attemptNumber >= 3:
|
if attemptNumber and attemptNumber >= 3:
|
||||||
guidanceParts.append(f"Attempt #{attemptNumber}: Adjust parameters based on validation feedback.")
|
guidanceParts.append(f"Attempt #{attemptNumber}: Adjust parameters based on validation feedback.")
|
||||||
|
|
||||||
# Generic issues summary
|
|
||||||
commonIssues = failureAnalysis.get('commonIssues', {}) or {}
|
commonIssues = failureAnalysis.get('commonIssues', {}) or {}
|
||||||
if commonIssues:
|
if commonIssues:
|
||||||
guidanceParts.append("Address the following parameter issues:")
|
guidanceParts.append("Address the following parameter issues:")
|
||||||
for issueKey, issueDesc in commonIssues.items():
|
for issueText, count in commonIssues.items():
|
||||||
guidanceParts.append(f"- {issueKey}: {issueDesc}")
|
guidanceParts.append(f"- {issueText} (occurred {count} time{'s' if count != 1 else ''})")
|
||||||
|
|
||||||
# Keep guidance format stable
|
# Keep guidance format stable
|
||||||
return "\n".join(guidanceParts) if guidanceParts else "Use standard parameter values."
|
return "\n".join(guidanceParts) if guidanceParts else "Use standard parameter values."
|
||||||
|
|
|
||||||
|
|
@ -273,16 +273,15 @@ class ContentValidator:
|
||||||
elif section.get("content_type") in ["paragraph", "heading"]:
|
elif section.get("content_type") in ["paragraph", "heading"]:
|
||||||
if elements and isinstance(elements, list) and len(elements) > 0:
|
if elements and isinstance(elements, list) and len(elements) > 0:
|
||||||
textElement = elements[0]
|
textElement = elements[0]
|
||||||
# Ensure textElement is a dictionary before accessing
|
|
||||||
if isinstance(textElement, dict):
|
if isinstance(textElement, dict):
|
||||||
content = textElement.get("content", {})
|
content = textElement.get("content", {})
|
||||||
if isinstance(content, dict):
|
if isinstance(content, dict):
|
||||||
text = content.get("text", "")
|
text = content.get("text", "")
|
||||||
else:
|
else:
|
||||||
text = textElement.get("text", "")
|
text = textElement.get("text", "")
|
||||||
if text:
|
if text:
|
||||||
sectionSummary["textLength"] = len(text)
|
sectionSummary["textLength"] = len(text)
|
||||||
sectionSummary["wordCount"] = len(text.split())
|
sectionSummary["wordCount"] = len(text.split())
|
||||||
if section.get("textLength"):
|
if section.get("textLength"):
|
||||||
sectionSummary["textLength"] = section.get("textLength")
|
sectionSummary["textLength"] = section.get("textLength")
|
||||||
|
|
||||||
|
|
@ -290,59 +289,47 @@ class ContentValidator:
|
||||||
elif section.get("content_type") == "code_block":
|
elif section.get("content_type") == "code_block":
|
||||||
if elements and isinstance(elements, list) and len(elements) > 0:
|
if elements and isinstance(elements, list) and len(elements) > 0:
|
||||||
codeElement = elements[0]
|
codeElement = elements[0]
|
||||||
content = codeElement.get("content", {})
|
if isinstance(codeElement, dict):
|
||||||
if isinstance(content, dict):
|
content = codeElement.get("content", {})
|
||||||
code = content.get("code", "")
|
if isinstance(content, dict):
|
||||||
language = content.get("language", "")
|
code = content.get("code", "")
|
||||||
if code:
|
language = content.get("language", "")
|
||||||
sectionSummary["codeLength"] = len(code)
|
if code:
|
||||||
sectionSummary["codeLineCount"] = code.count('\n') + 1
|
sectionSummary["codeLength"] = len(code)
|
||||||
if language:
|
sectionSummary["codeLineCount"] = code.count('\n') + 1
|
||||||
sectionSummary["language"] = language
|
if language:
|
||||||
|
sectionSummary["language"] = language
|
||||||
|
|
||||||
# Wenn contentPartIds vorhanden sind, aber keine elements: Füge ContentParts-Metadaten hinzu
|
|
||||||
contentPartIds = section.get("contentPartIds", [])
|
contentPartIds = section.get("contentPartIds", [])
|
||||||
if contentPartIds and not elements:
|
if contentPartIds and not elements:
|
||||||
# Prüfe ob contentPartsMetadata vorhanden ist
|
|
||||||
contentPartsMetadata = section.get("contentPartsMetadata", [])
|
contentPartsMetadata = section.get("contentPartsMetadata", [])
|
||||||
if contentPartsMetadata:
|
if contentPartsMetadata:
|
||||||
sectionSummary["contentPartsMetadata"] = contentPartsMetadata
|
sectionSummary["contentPartsMetadata"] = contentPartsMetadata
|
||||||
else:
|
else:
|
||||||
# Fallback: Zeige nur IDs wenn Metadaten nicht verfügbar
|
|
||||||
sectionSummary["contentPartIds"] = contentPartIds
|
sectionSummary["contentPartIds"] = contentPartIds
|
||||||
sectionSummary["note"] = "ContentParts referenced but metadata not available"
|
sectionSummary["note"] = "ContentParts referenced but metadata not available"
|
||||||
|
|
||||||
# Include any additional fields from section (generic approach)
|
|
||||||
# BUT exclude type-specific KPIs that don't belong to this content_type
|
|
||||||
# AND exclude internal planning fields that confuse validation
|
|
||||||
contentType = section.get("content_type", "")
|
contentType = section.get("content_type", "")
|
||||||
# Define KPIs that are ONLY valid for specific types
|
|
||||||
typeExclusiveKpis = {
|
typeExclusiveKpis = {
|
||||||
"table": ["columnCount", "rowCount", "headers"], # Only for tables
|
"table": ["columnCount", "rowCount", "headers"],
|
||||||
"bullet_list": ["itemCount"], # Only for bullet_list
|
"bullet_list": ["itemCount"],
|
||||||
"list": ["itemCount"] # Only for list
|
"list": ["itemCount"]
|
||||||
}
|
}
|
||||||
excludedKpis = []
|
excludedKpis = []
|
||||||
for kpiType, kpiFields in typeExclusiveKpis.items():
|
for kpiType, kpiFields in typeExclusiveKpis.items():
|
||||||
if kpiType != contentType:
|
if kpiType != contentType:
|
||||||
excludedKpis.extend(kpiFields)
|
excludedKpis.extend(kpiFields)
|
||||||
|
|
||||||
# Internal planning fields that should NOT be shown to validation AI
|
|
||||||
# These are implementation details, not content indicators
|
|
||||||
internalFields = ["generationHint", "useAiCall", "elements"]
|
internalFields = ["generationHint", "useAiCall", "elements"]
|
||||||
|
|
||||||
for key, value in section.items():
|
for key, value in section.items():
|
||||||
if key not in sectionSummary and key not in internalFields and key not in excludedKpis:
|
if key not in sectionSummary and key not in internalFields and key not in excludedKpis:
|
||||||
# Don't copy type-specific KPIs if they're 0/empty and we didn't extract them ourselves
|
|
||||||
# This prevents copying columnCount: 0, rowCount: 0, headers: [] from structure generation phase
|
|
||||||
if key in ["columnCount", "rowCount", "headers", "itemCount"]:
|
if key in ["columnCount", "rowCount", "headers", "itemCount"]:
|
||||||
# Skip if it's 0/empty - we'll only include KPIs we extracted from elements
|
|
||||||
if isinstance(value, int) and value == 0:
|
if isinstance(value, int) and value == 0:
|
||||||
continue
|
continue
|
||||||
if isinstance(value, list) and len(value) == 0:
|
if isinstance(value, list) and len(value) == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Include simple types (str, int, float, bool, list of primitives)
|
|
||||||
if isinstance(value, (str, int, float, bool)) or (isinstance(value, list) and len(value) <= 10):
|
if isinstance(value, (str, int, float, bool)) or (isinstance(value, list) and len(value) <= 10):
|
||||||
sectionSummary[key] = value
|
sectionSummary[key] = value
|
||||||
|
|
||||||
|
|
@ -486,7 +473,7 @@ class ContentValidator:
|
||||||
try:
|
try:
|
||||||
json_str = json.dumps(data)
|
json_str = json.dumps(data)
|
||||||
size_bytes = len(json_str.encode('utf-8'))
|
size_bytes = len(json_str.encode('utf-8'))
|
||||||
except:
|
except (TypeError, ValueError):
|
||||||
size_bytes = len(str(data).encode('utf-8'))
|
size_bytes = len(str(data).encode('utf-8'))
|
||||||
else:
|
else:
|
||||||
size_bytes = len(str(data).encode('utf-8'))
|
size_bytes = len(str(data).encode('utf-8'))
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ class LearningEngine:
|
||||||
self.strategies = {}
|
self.strategies = {}
|
||||||
self.feedbackHistory = []
|
self.feedbackHistory = []
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Reset all learned state for a new workflow session."""
|
||||||
|
self.strategies.clear()
|
||||||
|
self.feedbackHistory.clear()
|
||||||
|
|
||||||
def learnFromFeedback(self, feedback: Dict[str, Any], context: Any, taskIntent: Dict[str, Any]):
|
def learnFromFeedback(self, feedback: Dict[str, Any], context: Any, taskIntent: Dict[str, Any]):
|
||||||
"""Learns from feedback and updates strategies - works on TASK level, not workflow level"""
|
"""Learns from feedback and updates strategies - works on TASK level, not workflow level"""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ class ActionExecutor:
|
||||||
# Execute action and track success for progress log
|
# Execute action and track success for progress log
|
||||||
result = None
|
result = None
|
||||||
actionSuccess = False
|
actionSuccess = False
|
||||||
|
actionError = None
|
||||||
try:
|
try:
|
||||||
result = await self.executeAction(
|
result = await self.executeAction(
|
||||||
methodName=action.execMethod,
|
methodName=action.execMethod,
|
||||||
|
|
@ -144,23 +145,23 @@ class ActionExecutor:
|
||||||
)
|
)
|
||||||
actionSuccess = result.success if result else False
|
actionSuccess = result.success if result else False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error executing action: {str(e)}")
|
logger.error(f"Error executing action {action.execMethod}.{action.execAction}: {str(e)}")
|
||||||
actionSuccess = False
|
actionSuccess = False
|
||||||
|
actionError = str(e)
|
||||||
finally:
|
finally:
|
||||||
# Finish action progress tracking
|
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogFinish(actionOperationId, actionSuccess)
|
self.services.chat.progressLogFinish(actionOperationId, actionSuccess)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error finishing action progress log: {str(e)}")
|
logger.error(f"Error finishing action progress log: {str(e)}")
|
||||||
|
|
||||||
# If action execution failed, return error result
|
|
||||||
if result is None:
|
if result is None:
|
||||||
action.setError("Action execution failed")
|
errorMsg = actionError or "Action execution failed"
|
||||||
|
action.setError(errorMsg)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
success=False,
|
success=False,
|
||||||
documents=[],
|
documents=[],
|
||||||
resultLabel=action.execResultLabel,
|
resultLabel=action.execResultLabel,
|
||||||
error="Action execution failed"
|
error=errorMsg
|
||||||
)
|
)
|
||||||
|
|
||||||
resultLabel = action.execResultLabel
|
resultLabel = action.execResultLabel
|
||||||
|
|
|
||||||
|
|
@ -319,56 +319,27 @@ class MessageCreator:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating error message: {str(e)}")
|
logger.error(f"Error creating error message: {str(e)}")
|
||||||
|
|
||||||
def _extractRoundNumberFromLabel(self, label: str) -> int:
|
def _extractNumberFromLabelPart(self, label: str, prefix: str) -> int:
|
||||||
"""Extract round number from a document label like 'round1_task1_action1_diagram_analysis'"""
|
"""Extract number following a prefix in a label like 'round1_task1_action1_context'.
|
||||||
|
Works for prefix='round', 'task', 'action'. Returns 0 on failure.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if not label or not isinstance(label, str):
|
if not label or not isinstance(label, str):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Parse label format: round{round}_task{task}_action{action}_{context}
|
import re
|
||||||
if label.startswith('round'):
|
pattern = rf'{prefix}(\d+)'
|
||||||
roundPart = label.split('_')[0] # Get 'round1' part
|
match = re.search(pattern, label)
|
||||||
if roundPart.startswith('round'):
|
return int(match.group(1)) if match else 0
|
||||||
roundNumber = roundPart[5:] # Remove 'round' prefix
|
|
||||||
return int(roundNumber)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not extract round number from label '{label}': {str(e)}")
|
logger.warning(f"Could not extract {prefix} number from label '{label}': {str(e)}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def _extractRoundNumberFromLabel(self, label: str) -> int:
|
||||||
|
return self._extractNumberFromLabelPart(label, 'round')
|
||||||
|
|
||||||
def _extractTaskNumberFromLabel(self, label: str) -> int:
|
def _extractTaskNumberFromLabel(self, label: str) -> int:
|
||||||
"""Extract task number from a document label like 'round1_task1_action1_diagram_analysis'"""
|
return self._extractNumberFromLabelPart(label, 'task')
|
||||||
try:
|
|
||||||
if not label or not isinstance(label, str):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Parse label format: round{round}_task{task}_action{action}_{context}
|
|
||||||
if '_task' in label:
|
|
||||||
taskPart = label.split('_task')[1]
|
|
||||||
if taskPart and '_' in taskPart:
|
|
||||||
taskNumber = taskPart.split('_')[0]
|
|
||||||
return int(taskNumber)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not extract task number from label '{label}': {str(e)}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _extractActionNumberFromLabel(self, label: str) -> int:
|
def _extractActionNumberFromLabel(self, label: str) -> int:
|
||||||
"""Extract action number from a document label like 'round1_task1_action1_diagram_analysis'"""
|
return self._extractNumberFromLabelPart(label, 'action')
|
||||||
try:
|
|
||||||
if not label or not isinstance(label, str):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Parse label format: round{round}_task{task}_action{action}_{context}
|
|
||||||
if '_action' in label:
|
|
||||||
actionPart = label.split('_action')[1]
|
|
||||||
if actionPart and '_' in actionPart:
|
|
||||||
actionNumber = actionPart.split('_')[0]
|
|
||||||
return int(actionNumber)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not extract action number from label '{label}': {str(e)}")
|
|
||||||
return 0
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan, WorkflowModeEnum
|
from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan, WorkflowModeEnum
|
||||||
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, ProcessingModeEnum, PriorityEnum
|
|
||||||
from modules.workflows.processing.shared.promptGenerationTaskplan import (
|
from modules.workflows.processing.shared.promptGenerationTaskplan import (
|
||||||
generateTaskPlanningPrompt
|
generateTaskPlanningPrompt
|
||||||
)
|
)
|
||||||
|
|
@ -107,17 +106,6 @@ class TaskPlanner:
|
||||||
taskPlanningPromptTemplate = bundle.prompt
|
taskPlanningPromptTemplate = bundle.prompt
|
||||||
placeholders = bundle.placeholders
|
placeholders = bundle.placeholders
|
||||||
|
|
||||||
# Centralized AI call: Task planning (quality, detailed) with placeholders
|
|
||||||
options = AiCallOptions(
|
|
||||||
operationType=OperationTypeEnum.PLAN,
|
|
||||||
priority=PriorityEnum.QUALITY,
|
|
||||||
compressPrompt=False,
|
|
||||||
compressContext=False,
|
|
||||||
processingMode=ProcessingModeEnum.DETAILED,
|
|
||||||
maxCost=0.10,
|
|
||||||
maxProcessingTime=30
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = await self.services.ai.callAiPlanning(
|
prompt = await self.services.ai.callAiPlanning(
|
||||||
prompt=taskPlanningPromptTemplate,
|
prompt=taskPlanningPromptTemplate,
|
||||||
placeholders=placeholders,
|
placeholders=placeholders,
|
||||||
|
|
@ -141,9 +129,11 @@ class TaskPlanner:
|
||||||
raise ValueError("Task plan missing 'tasks' field")
|
raise ValueError("Task plan missing 'tasks' field")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing task plan response: {str(e)}")
|
logger.error(f"Error parsing task plan response: {str(e)}")
|
||||||
taskPlanDict = {'tasks': []}
|
raise ValueError(f"Failed to parse AI task plan response: {str(e)}") from e
|
||||||
|
|
||||||
if not self._validateTaskPlan(taskPlanDict):
|
from modules.workflows.processing.core.validator import WorkflowValidator
|
||||||
|
validator = WorkflowValidator(self.services)
|
||||||
|
if not validator.validateTask(taskPlanDict):
|
||||||
logger.error("Generated task plan failed validation")
|
logger.error("Generated task plan failed validation")
|
||||||
logger.error(f"AI Response: {prompt}")
|
logger.error(f"AI Response: {prompt}")
|
||||||
logger.error(f"Parsed Task Plan: {json.dumps(taskPlanDict, indent=2)}")
|
logger.error(f"Parsed Task Plan: {json.dumps(taskPlanDict, indent=2)}")
|
||||||
|
|
@ -207,61 +197,4 @@ class TaskPlanner:
|
||||||
logger.error(f"Error in generateTaskPlan: {str(e)}")
|
logger.error(f"Error in generateTaskPlan: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _validateTaskPlan(self, taskPlan: Dict[str, Any]) -> bool:
|
|
||||||
"""Validate task plan structure"""
|
|
||||||
try:
|
|
||||||
if not isinstance(taskPlan, dict):
|
|
||||||
logger.error("Task plan is not a dictionary")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if 'tasks' not in taskPlan or not isinstance(taskPlan['tasks'], list):
|
|
||||||
logger.error(f"Task plan missing 'tasks' field or not a list. Found: {type(taskPlan.get('tasks', 'MISSING'))}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# First pass: collect all task IDs to validate dependencies
|
|
||||||
taskIds = set()
|
|
||||||
for task in taskPlan['tasks']:
|
|
||||||
if not isinstance(task, dict):
|
|
||||||
logger.error(f"Task is not a dictionary: {type(task)}")
|
|
||||||
return False
|
|
||||||
if 'id' not in task:
|
|
||||||
logger.error(f"Task missing 'id' field: {task}")
|
|
||||||
return False
|
|
||||||
taskIds.add(task['id'])
|
|
||||||
|
|
||||||
# Second pass: validate each task
|
|
||||||
for i, task in enumerate(taskPlan['tasks']):
|
|
||||||
if not isinstance(task, dict):
|
|
||||||
logger.error(f"Task {i} is not a dictionary: {type(task)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
requiredFields = ['id', 'objective', 'successCriteria']
|
|
||||||
missingFields = [field for field in requiredFields if field not in task]
|
|
||||||
if missingFields:
|
|
||||||
logger.error(f"Task {i} missing required fields: {missingFields}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check for duplicate IDs (shouldn't happen after first pass, but safety check)
|
|
||||||
if task['id'] in taskIds and list(taskPlan['tasks']).count(task['id']) > 1:
|
|
||||||
logger.error(f"Task {i} has duplicate ID: {task['id']}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
dependencies = task.get('dependencies', [])
|
|
||||||
if not isinstance(dependencies, list):
|
|
||||||
logger.error(f"Task {i} dependencies is not a list: {type(dependencies)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
for dep in dependencies:
|
|
||||||
if dep not in taskIds and dep != 'task_0':
|
|
||||||
logger.error(f"Task {i} has invalid dependency: {dep} (available: {list(taskIds) + ['task_0']})")
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.info(f"Task plan validation successful with {len(taskIds)} tasks")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error validating task plan: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
@ -25,40 +25,35 @@ class WorkflowValidator:
|
||||||
logger.error(f"Task plan missing 'tasks' field or not a list. Found: {type(taskPlan.get('tasks', 'MISSING'))}")
|
logger.error(f"Task plan missing 'tasks' field or not a list. Found: {type(taskPlan.get('tasks', 'MISSING'))}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# First pass: collect all task IDs to validate dependencies
|
# Single pass: collect IDs (detect duplicates) and validate each task
|
||||||
taskIds = set()
|
taskIds = set()
|
||||||
for task in taskPlan['tasks']:
|
|
||||||
if not isinstance(task, dict):
|
|
||||||
logger.error(f"Task is not a dictionary: {type(task)}")
|
|
||||||
return False
|
|
||||||
if 'id' not in task:
|
|
||||||
logger.error(f"Task missing 'id' field: {task}")
|
|
||||||
return False
|
|
||||||
taskIds.add(task['id'])
|
|
||||||
|
|
||||||
# Second pass: validate each task
|
|
||||||
for i, task in enumerate(taskPlan['tasks']):
|
for i, task in enumerate(taskPlan['tasks']):
|
||||||
if not isinstance(task, dict):
|
if not isinstance(task, dict):
|
||||||
logger.error(f"Task {i} is not a dictionary: {type(task)}")
|
logger.error(f"Task {i} is not a dictionary: {type(task)}")
|
||||||
return False
|
return False
|
||||||
|
if 'id' not in task:
|
||||||
|
logger.error(f"Task {i} missing 'id' field: {task}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if task['id'] in taskIds:
|
||||||
|
logger.error(f"Task {i} has duplicate ID: {task['id']}")
|
||||||
|
return False
|
||||||
|
taskIds.add(task['id'])
|
||||||
|
|
||||||
requiredFields = ['id', 'objective', 'successCriteria']
|
requiredFields = ['id', 'objective', 'successCriteria']
|
||||||
missingFields = [field for field in requiredFields if field not in task]
|
missingFields = [field for field in requiredFields if field not in task]
|
||||||
if missingFields:
|
if missingFields:
|
||||||
logger.error(f"Task {i} missing required fields: {missingFields}")
|
logger.error(f"Task {i} missing required fields: {missingFields}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check for duplicate IDs (shouldn't happen after first pass, but safety check)
|
|
||||||
if task['id'] in taskIds and list(taskPlan['tasks']).count(task['id']) > 1:
|
|
||||||
logger.error(f"Task {i} has duplicate ID: {task['id']}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
dependencies = task.get('dependencies', [])
|
dependencies = task.get('dependencies', [])
|
||||||
if not isinstance(dependencies, list):
|
if not isinstance(dependencies, list):
|
||||||
logger.error(f"Task {i} dependencies is not a list: {type(dependencies)}")
|
logger.error(f"Task {i} dependencies is not a list: {type(dependencies)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for dep in dependencies:
|
# Second pass: validate dependencies (all IDs now known)
|
||||||
|
for i, task in enumerate(taskPlan['tasks']):
|
||||||
|
for dep in task.get('dependencies', []):
|
||||||
if dep not in taskIds and dep != 'task_0':
|
if dep not in taskIds and dep != 'task_0':
|
||||||
logger.error(f"Task {i} has invalid dependency: {dep} (available: {list(taskIds) + ['task_0']})")
|
logger.error(f"Task {i} has invalid dependency: {dep} (available: {list(taskIds) + ['task_0']})")
|
||||||
return False
|
return False
|
||||||
|
|
@ -93,7 +88,7 @@ class WorkflowValidator:
|
||||||
|
|
||||||
missingFields = []
|
missingFields = []
|
||||||
for field in requiredFields:
|
for field in requiredFields:
|
||||||
if field not in action or not action[field]:
|
if field not in action or action[field] is None:
|
||||||
missingFields.append(field)
|
missingFields.append(field)
|
||||||
if missingFields:
|
if missingFields:
|
||||||
logger.error(f"Action {i} missing required fields: {missingFields}")
|
logger.error(f"Action {i} missing required fields: {missingFields}")
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ class AutomationMode(BaseMode):
|
||||||
- Or as direct JSON in userInput
|
- Or as direct JSON in userInput
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Reset action map to prevent state leaks from previous runs
|
||||||
|
self.taskActionMap = {}
|
||||||
|
|
||||||
# AUTOMATION mode ALWAYS requires a JSON plan to be provided in userInput
|
# AUTOMATION mode ALWAYS requires a JSON plan to be provided in userInput
|
||||||
# Try to extract plan from userInput (embedded JSON or direct JSON)
|
# Try to extract plan from userInput (embedded JSON or direct JSON)
|
||||||
templatePlan = None
|
templatePlan = None
|
||||||
|
|
@ -340,78 +343,6 @@ class AutomationMode(BaseMode):
|
||||||
error=str(e)
|
error=str(e)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _createActionItem(self, actionData: Dict[str, Any]) -> Optional[ActionItem]:
|
|
||||||
"""Create ActionItem from action data"""
|
|
||||||
try:
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
# Ensure ID is present
|
|
||||||
if "id" not in actionData or not actionData["id"]:
|
|
||||||
actionData["id"] = f"action_{uuid.uuid4()}"
|
|
||||||
|
|
||||||
# Ensure required fields
|
|
||||||
if "status" not in actionData:
|
|
||||||
actionData["status"] = TaskStatus.PENDING
|
|
||||||
|
|
||||||
if "execMethod" not in actionData:
|
|
||||||
logger.error("execMethod is required for task action")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "execAction" not in actionData:
|
|
||||||
logger.error("execAction is required for task action")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "execParameters" not in actionData:
|
|
||||||
actionData["execParameters"] = {}
|
|
||||||
|
|
||||||
# Use generic field separation based on ActionItem model
|
|
||||||
simpleFields, objectFields = self.services.interfaceDbChat._separateObjectFields(ActionItem, actionData)
|
|
||||||
|
|
||||||
# Create action in database
|
|
||||||
createdAction = self.services.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
|
|
||||||
|
|
||||||
# Convert to ActionItem model
|
|
||||||
return ActionItem(
|
|
||||||
id=createdAction["id"],
|
|
||||||
execMethod=createdAction["execMethod"],
|
|
||||||
execAction=createdAction["execAction"],
|
|
||||||
execParameters=createdAction.get("execParameters", {}),
|
|
||||||
execResultLabel=createdAction.get("execResultLabel"),
|
|
||||||
expectedDocumentFormats=createdAction.get("expectedDocumentFormats"),
|
|
||||||
status=createdAction.get("status", TaskStatus.PENDING),
|
|
||||||
error=createdAction.get("error"),
|
|
||||||
retryCount=createdAction.get("retryCount", 0),
|
|
||||||
retryMax=createdAction.get("retryMax", 3),
|
|
||||||
processingTime=createdAction.get("processingTime"),
|
|
||||||
timestamp=parseTimestamp(createdAction.get("timestamp"), default=self.services.utils.timestampGetUtc()),
|
|
||||||
result=createdAction.get("result"),
|
|
||||||
userMessage=createdAction.get("userMessage")
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating task action: {str(e)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _updateWorkflowBeforeExecutingTask(self, taskNumber: int):
|
|
||||||
"""Update workflow object before executing a task"""
|
|
||||||
try:
|
|
||||||
workflow = self.services.workflow
|
|
||||||
updateData = {
|
|
||||||
"currentTask": taskNumber,
|
|
||||||
"currentAction": 0,
|
|
||||||
"totalActions": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
workflow.currentTask = taskNumber
|
|
||||||
workflow.currentAction = 0
|
|
||||||
workflow.totalActions = 0
|
|
||||||
|
|
||||||
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
|
|
||||||
logger.info(f"Updated workflow {workflow.id} before executing task {taskNumber}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating workflow before executing task: {str(e)}")
|
|
||||||
|
|
||||||
def _updateWorkflowAfterActionPlanning(self, totalActions: int):
|
def _updateWorkflowAfterActionPlanning(self, totalActions: int):
|
||||||
"""Update workflow object after action planning"""
|
"""Update workflow object after action planning"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -423,17 +354,6 @@ class AutomationMode(BaseMode):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating workflow after action planning: {str(e)}")
|
logger.error(f"Error updating workflow after action planning: {str(e)}")
|
||||||
|
|
||||||
def _updateWorkflowBeforeExecutingAction(self, actionNumber: int):
|
|
||||||
"""Update workflow object before executing an action"""
|
|
||||||
try:
|
|
||||||
workflow = self.services.workflow
|
|
||||||
updateData = {"currentAction": actionNumber}
|
|
||||||
workflow.currentAction = actionNumber
|
|
||||||
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
|
|
||||||
logger.info(f"Updated workflow {workflow.id} before executing action {actionNumber}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating workflow before executing action: {str(e)}")
|
|
||||||
|
|
||||||
def _setWorkflowTotals(self, totalTasks: int = None, totalActions: int = None):
|
def _setWorkflowTotals(self, totalTasks: int = None, totalActions: int = None):
|
||||||
"""Set total counts for workflow progress tracking"""
|
"""Set total counts for workflow progress tracking"""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,16 @@
|
||||||
# Abstract base class for workflow modes
|
# Abstract base class for workflow modes
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any, Optional
|
||||||
from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskResult, ActionItem
|
from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskResult, ActionItem, TaskStatus
|
||||||
from modules.datamodels.datamodelChat import ChatWorkflow
|
from modules.datamodels.datamodelChat import ChatWorkflow
|
||||||
from modules.workflows.processing.core.taskPlanner import TaskPlanner
|
from modules.workflows.processing.core.taskPlanner import TaskPlanner
|
||||||
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
||||||
from modules.workflows.processing.core.messageCreator import MessageCreator
|
from modules.workflows.processing.core.messageCreator import MessageCreator
|
||||||
from modules.workflows.processing.core.validator import WorkflowValidator
|
from modules.workflows.processing.core.validator import WorkflowValidator
|
||||||
|
from modules.shared.timeUtils import parseTimestamp
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -44,3 +46,75 @@ class BaseMode(ABC):
|
||||||
async def createTaskPlanMessage(self, taskPlan, workflow: ChatWorkflow):
|
async def createTaskPlanMessage(self, taskPlan, workflow: ChatWorkflow):
|
||||||
"""Create task plan message - common to all modes"""
|
"""Create task plan message - common to all modes"""
|
||||||
return await self.messageCreator.createTaskPlanMessage(taskPlan, workflow)
|
return await self.messageCreator.createTaskPlanMessage(taskPlan, workflow)
|
||||||
|
|
||||||
|
def _createActionItem(self, actionData: Dict[str, Any]) -> Optional[ActionItem]:
|
||||||
|
"""Create an ActionItem from action data, persist to DB, and return the model instance"""
|
||||||
|
try:
|
||||||
|
if "id" not in actionData or not actionData["id"]:
|
||||||
|
actionData["id"] = f"action_{uuid.uuid4()}"
|
||||||
|
|
||||||
|
if "status" not in actionData:
|
||||||
|
actionData["status"] = TaskStatus.PENDING
|
||||||
|
|
||||||
|
if "execMethod" not in actionData:
|
||||||
|
logger.error("execMethod is required for task action")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "execAction" not in actionData:
|
||||||
|
logger.error("execAction is required for task action")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "execParameters" not in actionData:
|
||||||
|
actionData["execParameters"] = {}
|
||||||
|
|
||||||
|
simpleFields, objectFields = self.services.interfaceDbChat._separateObjectFields(ActionItem, actionData)
|
||||||
|
createdAction = self.services.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
|
||||||
|
|
||||||
|
return ActionItem(
|
||||||
|
id=createdAction["id"],
|
||||||
|
execMethod=createdAction["execMethod"],
|
||||||
|
execAction=createdAction["execAction"],
|
||||||
|
execParameters=createdAction.get("execParameters", {}),
|
||||||
|
execResultLabel=createdAction.get("execResultLabel"),
|
||||||
|
expectedDocumentFormats=createdAction.get("expectedDocumentFormats"),
|
||||||
|
status=createdAction.get("status", TaskStatus.PENDING),
|
||||||
|
error=createdAction.get("error"),
|
||||||
|
retryCount=createdAction.get("retryCount", 0),
|
||||||
|
retryMax=createdAction.get("retryMax", 3),
|
||||||
|
processingTime=createdAction.get("processingTime"),
|
||||||
|
timestamp=parseTimestamp(createdAction.get("timestamp"), default=self.services.utils.timestampGetUtc()),
|
||||||
|
result=createdAction.get("result"),
|
||||||
|
userMessage=createdAction.get("userMessage")
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating task action: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _updateWorkflowBeforeExecutingTask(self, taskNumber: int):
|
||||||
|
"""Update workflow state before executing a task"""
|
||||||
|
try:
|
||||||
|
workflow = self.services.workflow
|
||||||
|
updateData = {
|
||||||
|
"currentTask": taskNumber,
|
||||||
|
"currentAction": 0,
|
||||||
|
"totalActions": 0
|
||||||
|
}
|
||||||
|
workflow.currentTask = taskNumber
|
||||||
|
workflow.currentAction = 0
|
||||||
|
workflow.totalActions = 0
|
||||||
|
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
|
||||||
|
logger.info(f"Updated workflow {workflow.id} before executing task {taskNumber}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating workflow before executing task: {str(e)}")
|
||||||
|
|
||||||
|
def _updateWorkflowBeforeExecutingAction(self, actionNumber: int):
|
||||||
|
"""Update workflow state before executing an action"""
|
||||||
|
try:
|
||||||
|
workflow = self.services.workflow
|
||||||
|
updateData = {"currentAction": actionNumber}
|
||||||
|
workflow.currentAction = actionNumber
|
||||||
|
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
|
||||||
|
logger.info(f"Updated workflow {workflow.id} before executing action {actionNumber}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating workflow before executing action: {str(e)}")
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ class DynamicMode(BaseMode):
|
||||||
|
|
||||||
step = 1
|
step = 1
|
||||||
decision = None
|
decision = None
|
||||||
|
lastStepFailed = False
|
||||||
|
|
||||||
while step <= state.max_steps:
|
while step <= state.max_steps:
|
||||||
checkWorkflowStopped(self.services)
|
checkWorkflowStopped(self.services)
|
||||||
|
|
@ -282,6 +283,7 @@ class DynamicMode(BaseMode):
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Dynamic step {step} error: {e}")
|
logger.error(f"Dynamic step {step} error: {e}")
|
||||||
|
lastStepFailed = True
|
||||||
break
|
break
|
||||||
|
|
||||||
# NEW: Use adaptive stopping logic
|
# NEW: Use adaptive stopping logic
|
||||||
|
|
@ -296,19 +298,24 @@ class DynamicMode(BaseMode):
|
||||||
step += 1
|
step += 1
|
||||||
|
|
||||||
# Summarize task result for dynamic mode
|
# Summarize task result for dynamic mode
|
||||||
status = TaskStatus.COMPLETED
|
|
||||||
success = True
|
|
||||||
# Get feedback from last decision if available
|
|
||||||
lastDecision = context.previousReviewResult[-1] if hasattr(context, 'previousReviewResult') and context.previousReviewResult else None
|
lastDecision = context.previousReviewResult[-1] if hasattr(context, 'previousReviewResult') and context.previousReviewResult else None
|
||||||
feedback = lastDecision.reason if lastDecision and isinstance(lastDecision, ReviewResult) else 'Completed'
|
feedback = lastDecision.reason if lastDecision and isinstance(lastDecision, ReviewResult) else 'Completed'
|
||||||
if lastDecision and isinstance(lastDecision, ReviewResult) and lastDecision.status == 'success':
|
|
||||||
|
if lastStepFailed:
|
||||||
|
status = TaskStatus.FAILED
|
||||||
|
success = False
|
||||||
|
elif lastDecision and isinstance(lastDecision, ReviewResult) and lastDecision.status in ('stop', 'failed'):
|
||||||
|
status = TaskStatus.FAILED
|
||||||
|
success = False
|
||||||
|
else:
|
||||||
|
status = TaskStatus.COMPLETED
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
# Create proper ReviewResult for completion message
|
# Create proper ReviewResult for completion message
|
||||||
completionReviewResult = ReviewResult(
|
completionReviewResult = ReviewResult(
|
||||||
status='success',
|
status='success' if success else 'failed',
|
||||||
reason=feedback,
|
reason=feedback,
|
||||||
qualityScore=lastDecision.qualityScore if lastDecision and isinstance(lastDecision, ReviewResult) else 8.0,
|
qualityScore=lastDecision.qualityScore if lastDecision and isinstance(lastDecision, ReviewResult) else (8.0 if success else 2.0),
|
||||||
metCriteria=[],
|
metCriteria=[],
|
||||||
improvements=[]
|
improvements=[]
|
||||||
)
|
)
|
||||||
|
|
@ -1003,12 +1010,15 @@ class DynamicMode(BaseMode):
|
||||||
# Detect repeated actions
|
# Detect repeated actions
|
||||||
actionCounts = {}
|
actionCounts = {}
|
||||||
for entry in actionHistory:
|
for entry in actionHistory:
|
||||||
# Extract action name (after first space, before next space or {)
|
# Format: "Step N: actionName ..." or "Refinement N: actionName ..."
|
||||||
parts = entry.split()
|
# Extract the action name after "prefix N:"
|
||||||
if len(parts) > 1:
|
colonIdx = entry.find(':')
|
||||||
# Skip "Step", "Refinement" prefixes and get the action name
|
if colonIdx >= 0:
|
||||||
actionName = parts[1] if parts[0] in ['Step', 'Refinement'] else parts[0]
|
afterColon = entry[colonIdx + 1:].strip().split()
|
||||||
actionCounts[actionName] = actionCounts.get(actionName, 0) + 1
|
actionName = afterColon[0] if afterColon else 'unknown'
|
||||||
|
else:
|
||||||
|
actionName = entry.split()[0] if entry.split() else 'unknown'
|
||||||
|
actionCounts[actionName] = actionCounts.get(actionName, 0) + 1
|
||||||
|
|
||||||
repeatedActions = [action for action, count in actionCounts.items() if count >= 2]
|
repeatedActions = [action for action, count in actionCounts.items() if count >= 2]
|
||||||
if repeatedActions:
|
if repeatedActions:
|
||||||
|
|
@ -1172,150 +1182,6 @@ Return only the user-friendly message, no technical details."""
|
||||||
logger.error(f"Error generating action result message: {str(e)}")
|
logger.error(f"Error generating action result message: {str(e)}")
|
||||||
return f"{method}.{actionName} action completed"
|
return f"{method}.{actionName} action completed"
|
||||||
|
|
||||||
def _createActionItem(self, actionData: Dict[str, Any]) -> ActionItem:
|
|
||||||
"""Creates a new task action for Dynamic mode"""
|
|
||||||
try:
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
# Ensure ID is present
|
|
||||||
if "id" not in actionData or not actionData["id"]:
|
|
||||||
actionData["id"] = f"action_{uuid.uuid4()}"
|
|
||||||
|
|
||||||
# Ensure required fields
|
|
||||||
if "status" not in actionData:
|
|
||||||
actionData["status"] = TaskStatus.PENDING
|
|
||||||
|
|
||||||
if "execMethod" not in actionData:
|
|
||||||
logger.error("execMethod is required for task action")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "execAction" not in actionData:
|
|
||||||
logger.error("execAction is required for task action")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "execParameters" not in actionData:
|
|
||||||
actionData["execParameters"] = {}
|
|
||||||
|
|
||||||
# Use generic field separation based on ActionItem model
|
|
||||||
simpleFields, objectFields = self.services.interfaceDbChat._separateObjectFields(ActionItem, actionData)
|
|
||||||
|
|
||||||
# Create action in database
|
|
||||||
createdAction = self.services.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
|
|
||||||
|
|
||||||
# Convert to ActionItem model
|
|
||||||
return ActionItem(
|
|
||||||
id=createdAction["id"],
|
|
||||||
execMethod=createdAction["execMethod"],
|
|
||||||
execAction=createdAction["execAction"],
|
|
||||||
execParameters=createdAction.get("execParameters", {}),
|
|
||||||
execResultLabel=createdAction.get("execResultLabel"),
|
|
||||||
expectedDocumentFormats=createdAction.get("expectedDocumentFormats"),
|
|
||||||
status=createdAction.get("status", TaskStatus.PENDING),
|
|
||||||
error=createdAction.get("error"),
|
|
||||||
retryCount=createdAction.get("retryCount", 0),
|
|
||||||
retryMax=createdAction.get("retryMax", 3),
|
|
||||||
processingTime=createdAction.get("processingTime"),
|
|
||||||
timestamp=parseTimestamp(createdAction.get("timestamp"), default=self.services.utils.timestampGetUtc()),
|
|
||||||
result=createdAction.get("result"),
|
|
||||||
resultDocuments=createdAction.get("resultDocuments", []),
|
|
||||||
userMessage=createdAction.get("userMessage")
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating task action: {str(e)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _updateWorkflowBeforeExecutingTask(self, taskNumber: int):
|
|
||||||
"""Update workflow object before executing a task"""
|
|
||||||
try:
|
|
||||||
workflow = self.services.workflow
|
|
||||||
updateData = {
|
|
||||||
"currentTask": taskNumber,
|
|
||||||
"currentAction": 0,
|
|
||||||
"totalActions": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update workflow object
|
|
||||||
workflow.currentTask = taskNumber
|
|
||||||
workflow.currentAction = 0
|
|
||||||
workflow.totalActions = 0
|
|
||||||
|
|
||||||
# Update in database
|
|
||||||
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
|
|
||||||
logger.info(f"Updated workflow {workflow.id} before executing task {taskNumber}: {updateData}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating workflow before executing task: {str(e)}")
|
|
||||||
|
|
||||||
def _updateWorkflowBeforeExecutingAction(self, actionNumber: int):
|
|
||||||
"""Update workflow object before executing an action"""
|
|
||||||
try:
|
|
||||||
workflow = self.services.workflow
|
|
||||||
updateData = {
|
|
||||||
"currentAction": actionNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update workflow object
|
|
||||||
workflow.currentAction = actionNumber
|
|
||||||
|
|
||||||
# Update in database
|
|
||||||
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
|
|
||||||
logger.info(f"Updated workflow {workflow.id} before executing action {actionNumber}: {updateData}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating workflow before executing action: {str(e)}")
|
|
||||||
|
|
||||||
def _createActionItem(self, actionData: Dict[str, Any]) -> ActionItem:
|
|
||||||
"""Creates a new task action for Dynamic mode"""
|
|
||||||
try:
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
# Ensure ID is present
|
|
||||||
if "id" not in actionData or not actionData["id"]:
|
|
||||||
actionData["id"] = f"action_{uuid.uuid4()}"
|
|
||||||
|
|
||||||
# Ensure required fields
|
|
||||||
if "status" not in actionData:
|
|
||||||
actionData["status"] = TaskStatus.PENDING
|
|
||||||
|
|
||||||
if "execMethod" not in actionData:
|
|
||||||
logger.error("execMethod is required for task action")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "execAction" not in actionData:
|
|
||||||
logger.error("execAction is required for task action")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "execParameters" not in actionData:
|
|
||||||
actionData["execParameters"] = {}
|
|
||||||
|
|
||||||
# Use generic field separation based on ActionItem model
|
|
||||||
simpleFields, objectFields = self.services.interfaceDbChat._separateObjectFields(ActionItem, actionData)
|
|
||||||
|
|
||||||
# Create action in database
|
|
||||||
createdAction = self.services.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
|
|
||||||
|
|
||||||
# Convert to ActionItem model
|
|
||||||
return ActionItem(
|
|
||||||
id=createdAction["id"],
|
|
||||||
execMethod=createdAction["execMethod"],
|
|
||||||
execAction=createdAction["execAction"],
|
|
||||||
execParameters=createdAction.get("execParameters", {}),
|
|
||||||
execResultLabel=createdAction.get("execResultLabel"),
|
|
||||||
expectedDocumentFormats=createdAction.get("expectedDocumentFormats"),
|
|
||||||
status=createdAction.get("status", TaskStatus.PENDING),
|
|
||||||
error=createdAction.get("error"),
|
|
||||||
retryCount=createdAction.get("retryCount", 0),
|
|
||||||
retryMax=createdAction.get("retryMax", 3),
|
|
||||||
processingTime=createdAction.get("processingTime"),
|
|
||||||
timestamp=parseTimestamp(createdAction.get("timestamp"), default=self.services.utils.timestampGetUtc()),
|
|
||||||
result=createdAction.get("result"),
|
|
||||||
resultDocuments=createdAction.get("resultDocuments", []),
|
|
||||||
userMessage=createdAction.get("userMessage")
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating task action: {str(e)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,22 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from modules.datamodels.datamodelChat import TaskStep, ActionResult, Observation
|
from modules.datamodels.datamodelChat import TaskStep, ActionResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class TaskExecutionState:
|
class TaskExecutionState:
|
||||||
"""Manages execution state for a task with retry logic"""
|
"""Manages execution state for a task with retry logic"""
|
||||||
|
|
||||||
def __init__(self, task_step: TaskStep):
|
def __init__(self, taskStep: TaskStep):
|
||||||
self.task_step = task_step
|
self.task_step = taskStep
|
||||||
self.successful_actions: List[ActionResult] = [] # Preserved across retries
|
self.successful_actions: List[ActionResult] = []
|
||||||
self.failed_actions: List[ActionResult] = [] # For analysis
|
self.failed_actions: List[ActionResult] = []
|
||||||
self.current_action_index = 0
|
self.current_action_index = 0
|
||||||
self.retry_count = 0
|
self.retry_count = 0
|
||||||
self.max_retries = 3
|
self.max_retries = 3
|
||||||
# Iterative loop (dynamic mode)
|
|
||||||
self.current_step = 0
|
self.current_step = 0
|
||||||
self.max_steps = 0 # Will be overridden by workflow.maxSteps from workflowManager.py
|
self.max_steps = 0
|
||||||
|
|
||||||
def addSuccessfulAction(self, action_result: ActionResult):
|
def addSuccessfulAction(self, action_result: ActionResult):
|
||||||
"""Add a successful action to the state"""
|
"""Add a successful action to the state"""
|
||||||
|
|
@ -58,48 +57,25 @@ class TaskExecutionState:
|
||||||
patterns.append("permission_issues")
|
patterns.append("permission_issues")
|
||||||
return list(set(patterns))
|
return list(set(patterns))
|
||||||
|
|
||||||
def shouldContinue(observation: Optional[Observation], review=None, current_step: int = 0, max_steps: int = 1) -> bool:
|
def shouldContinue(observation=None, review=None, current_step: int = 0, max_steps: int = 1) -> bool:
|
||||||
"""Helper to decide if the iterative loop should continue
|
"""Helper to decide if the iterative loop should continue.
|
||||||
|
|
||||||
Args:
|
Returns False if max steps reached or review indicates 'stop'/'success'.
|
||||||
observation: Observation Pydantic model with action execution results
|
|
||||||
review: ReviewResult or dict with review decision (optional)
|
|
||||||
current_step: Current step number in the iteration
|
|
||||||
max_steps: Maximum allowed steps
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if loop should continue, False if should stop
|
|
||||||
|
|
||||||
Logic:
|
|
||||||
- Stop if max steps reached
|
|
||||||
- Stop if review indicates 'stop' or success criteria are met
|
|
||||||
- Continue if observation indicates failure but allow one more step (caller caps by max_steps)
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Stop if max steps reached
|
|
||||||
if current_step >= max_steps:
|
if current_step >= max_steps:
|
||||||
logger.info(f"Stopping workflow: reached max_steps limit ({current_step} >= {max_steps})")
|
logger.info(f"Stopping workflow: reached max_steps limit ({current_step} >= {max_steps})")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check review decision (can be ReviewResult model or dict)
|
|
||||||
if review:
|
if review:
|
||||||
if hasattr(review, 'status'):
|
if hasattr(review, 'status'):
|
||||||
# ReviewResult Pydantic model
|
|
||||||
if review.status in ('stop', 'success'):
|
if review.status in ('stop', 'success'):
|
||||||
return False
|
return False
|
||||||
elif isinstance(review, dict):
|
elif isinstance(review, dict):
|
||||||
# Legacy dict format
|
|
||||||
decision = review.get('decision') or review.get('status')
|
decision = review.get('decision') or review.get('status')
|
||||||
if decision in ('stop', 'success'):
|
if decision in ('stop', 'success'):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check observation: if hard failure with no documents, allow one more step
|
|
||||||
# The caller will enforce max_steps limit
|
|
||||||
if observation:
|
|
||||||
if observation.success is False and observation.documentsCount == 0:
|
|
||||||
# Allow next step once; the caller caps by max_steps
|
|
||||||
return True
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error in shouldContinue: {e}")
|
logger.warning(f"Error in shouldContinue: {e}")
|
||||||
|
|
|
||||||
|
|
@ -19,117 +19,57 @@ methods = {}
|
||||||
def discoverMethods(serviceCenter):
|
def discoverMethods(serviceCenter):
|
||||||
"""Dynamically discover all method classes and their actions in modules methods package.
|
"""Dynamically discover all method classes and their actions in modules methods package.
|
||||||
|
|
||||||
CRITICAL: If methods are already discovered, updates their Services reference to ensure
|
Always creates fresh method instances bound to the given serviceCenter,
|
||||||
they use the current workflow (self.services.workflow). This prevents stale workflow IDs
|
preventing stale or cross-workflow service references.
|
||||||
from being used when a new workflow starts.
|
|
||||||
"""
|
"""
|
||||||
|
global methods
|
||||||
try:
|
try:
|
||||||
# Import the methods package
|
|
||||||
methodsPackage = importlib.import_module('modules.workflows.methods')
|
methodsPackage = importlib.import_module('modules.workflows.methods')
|
||||||
|
|
||||||
# Discover all modules and packages in the methods package
|
# Clear and rebuild to prevent cross-workflow state contamination
|
||||||
|
methods.clear()
|
||||||
|
uniqueCount = 0
|
||||||
|
|
||||||
for _, name, isPkg in pkgutil.iter_modules(methodsPackage.__path__):
|
for _, name, isPkg in pkgutil.iter_modules(methodsPackage.__path__):
|
||||||
if name.startswith('method'):
|
if name.startswith('method'):
|
||||||
try:
|
try:
|
||||||
if isPkg:
|
module = importlib.import_module(f'modules.workflows.methods.{name}')
|
||||||
# Package (folder) - import __init__.py which exports the Method class
|
|
||||||
module = importlib.import_module(f'modules.workflows.methods.{name}')
|
|
||||||
else:
|
|
||||||
# Module (file) - import directly
|
|
||||||
module = importlib.import_module(f'modules.workflows.methods.{name}')
|
|
||||||
|
|
||||||
# Find all classes in the module that inherit from MethodBase
|
|
||||||
for itemName, item in inspect.getmembers(module):
|
for itemName, item in inspect.getmembers(module):
|
||||||
if (inspect.isclass(item) and
|
if (inspect.isclass(item) and
|
||||||
issubclass(item, MethodBase) and
|
issubclass(item, MethodBase) and
|
||||||
item != MethodBase):
|
item != MethodBase):
|
||||||
|
|
||||||
# Check if method already exists in cache
|
|
||||||
shortName = itemName.replace('Method', '').lower()
|
shortName = itemName.replace('Method', '').lower()
|
||||||
if itemName in methods or shortName in methods:
|
|
||||||
# Method already discovered - update Services reference to use current workflow
|
# Skip if already processed (via another module path)
|
||||||
existingMethodInfo = methods.get(itemName) or methods.get(shortName)
|
if itemName in methods:
|
||||||
if existingMethodInfo and existingMethodInfo.get('instance'):
|
continue
|
||||||
existingMethodInfo['instance'].services = serviceCenter
|
|
||||||
logger.debug(f"Updated Services reference for cached method {itemName} to use current workflow")
|
methodInstance = item(serviceCenter)
|
||||||
else:
|
actions = methodInstance.actions
|
||||||
# Method exists but instance is missing - recreate it
|
|
||||||
methodInstance = item(serviceCenter)
|
methodInfo = {
|
||||||
actions = methodInstance.actions
|
'instance': methodInstance,
|
||||||
methodInfo = {
|
'actions': actions,
|
||||||
'instance': methodInstance,
|
'description': item.__doc__ or f"Method {itemName}"
|
||||||
'actions': actions,
|
}
|
||||||
'description': item.__doc__ or f"Method {itemName}"
|
|
||||||
}
|
methods[itemName] = methodInfo
|
||||||
methods[itemName] = methodInfo
|
methods[shortName] = methodInfo
|
||||||
methods[shortName] = methodInfo
|
uniqueCount += 1
|
||||||
logger.info(f"Recreated method {itemName} (short: {shortName}) with {len(actions)} actions")
|
|
||||||
else:
|
logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions")
|
||||||
# Method not discovered yet - create new instance
|
|
||||||
methodInstance = item(serviceCenter)
|
|
||||||
|
|
||||||
# Use the actions property from MethodBase which handles WorkflowActionDefinition
|
|
||||||
actions = methodInstance.actions
|
|
||||||
|
|
||||||
# Create method info
|
|
||||||
methodInfo = {
|
|
||||||
'instance': methodInstance,
|
|
||||||
'actions': actions,
|
|
||||||
'description': item.__doc__ or f"Method {itemName}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Store the method with full class name
|
|
||||||
methods[itemName] = methodInfo
|
|
||||||
|
|
||||||
# Also store with short name for action executor access
|
|
||||||
methods[shortName] = methodInfo
|
|
||||||
|
|
||||||
logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error discovering method {name}: {str(e)}")
|
logger.error(f"Error discovering method {name}: {str(e)}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Discovered/updated {len(methods)} method entries total")
|
logger.info(f"Discovered {uniqueCount} unique methods ({len(methods)} entries with aliases)")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error discovering methods: {str(e)}")
|
logger.error(f"Error discovering methods: {str(e)}")
|
||||||
|
|
||||||
def getMethodsList(serviceCenter):
|
|
||||||
"""Get a list of available methods with their signatures"""
|
|
||||||
if not methods:
|
|
||||||
discoverMethods(serviceCenter)
|
|
||||||
|
|
||||||
methodsList = []
|
|
||||||
for methodName, methodInfo in methods.items():
|
|
||||||
methodDescription = methodInfo['description']
|
|
||||||
actionsList = []
|
|
||||||
|
|
||||||
for actionName, actionInfo in methodInfo['actions'].items():
|
|
||||||
actionDescription = actionInfo['description']
|
|
||||||
parameters = actionInfo['parameters']
|
|
||||||
|
|
||||||
# Build parameter signature
|
|
||||||
paramSig = []
|
|
||||||
for paramName, paramInfo in parameters.items():
|
|
||||||
paramType = paramInfo['type']
|
|
||||||
paramRequired = paramInfo['required']
|
|
||||||
paramDefault = paramInfo['default']
|
|
||||||
|
|
||||||
if paramRequired:
|
|
||||||
paramSig.append(f"{paramName}: {paramType}")
|
|
||||||
else:
|
|
||||||
defaultStr = f" = {paramDefault}" if paramDefault is not None else " = None"
|
|
||||||
paramSig.append(f"{paramName}: {paramType}{defaultStr}")
|
|
||||||
|
|
||||||
paramSignature = f"({', '.join(paramSig)})" if paramSig else "()"
|
|
||||||
actionsList.append(f"- {actionName}{paramSignature}: {actionDescription}")
|
|
||||||
|
|
||||||
actionsStr = "\n".join(actionsList)
|
|
||||||
methodsList.append(f"**{methodName}**: {methodDescription}\n{actionsStr}")
|
|
||||||
|
|
||||||
return "\n\n".join(methodsList)
|
|
||||||
|
|
||||||
def getActionParameterList(methodName: str, actionName: str, methods: Dict[str, Any]) -> str:
|
def getActionParameterList(methodName: str, actionName: str, methods: Dict[str, Any]) -> str:
|
||||||
"""Get action parameter list from WorkflowActionParameter structure for AI parameter generation (list only)."""
|
"""Get action parameter list from WorkflowActionParameter structure for AI parameter generation (list only)."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,26 @@ from typing import Dict, Any, List
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
from modules.workflows.processing.shared.methodDiscovery import (methods, discoverMethods)
|
from modules.workflows.processing.shared.methodDiscovery import (methods, discoverMethods)
|
||||||
|
from modules.datamodels.datamodelChat import Observation
|
||||||
|
|
||||||
|
|
||||||
|
def _observationToDict(obs) -> dict:
|
||||||
|
"""Convert an Observation (Pydantic model or dict) to a plain dict."""
|
||||||
|
if isinstance(obs, dict):
|
||||||
|
return obs.copy()
|
||||||
|
if hasattr(obs, 'model_dump'):
|
||||||
|
return obs.model_dump(exclude_none=True)
|
||||||
|
if hasattr(obs, 'dict'):
|
||||||
|
return obs.dict()
|
||||||
|
return {"raw": str(obs)}
|
||||||
|
|
||||||
|
|
||||||
|
def _redactSnippets(obsDict: dict):
|
||||||
|
"""Replace large snippet strings with a metadata indicator."""
|
||||||
|
if 'previews' in obsDict and isinstance(obsDict['previews'], list):
|
||||||
|
for preview in obsDict['previews']:
|
||||||
|
if isinstance(preview, dict) and 'snippet' in preview:
|
||||||
|
preview['snippet'] = f"[Content: {len(preview.get('snippet', ''))} characters]"
|
||||||
|
|
||||||
def extractUserPrompt(context: Any) -> str:
|
def extractUserPrompt(context: Any) -> str:
|
||||||
"""Extract user prompt from context. Maps to {{KEY:USER_PROMPT}}.
|
"""Extract user prompt from context. Maps to {{KEY:USER_PROMPT}}.
|
||||||
|
|
@ -71,22 +91,17 @@ def extractUserPrompt(context: Any) -> str:
|
||||||
def extractNormalizedRequest(services: Any) -> str:
|
def extractNormalizedRequest(services: Any) -> str:
|
||||||
"""Extract normalized user request from services. Maps to {{KEY:NORMALIZED_REQUEST}}.
|
"""Extract normalized user request from services. Maps to {{KEY:NORMALIZED_REQUEST}}.
|
||||||
Returns the full normalized request from user input analysis (preserves all constraints and details).
|
Returns the full normalized request from user input analysis (preserves all constraints and details).
|
||||||
CRITICAL: Must return the actual normalizedRequest from analysis, NOT intent.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get normalized request from currentUserPromptNormalized (stores the normalizedRequest from analysis)
|
|
||||||
if services and getattr(services, 'currentUserPromptNormalized', None):
|
if services and getattr(services, 'currentUserPromptNormalized', None):
|
||||||
normalized = services.currentUserPromptNormalized
|
normalized = services.currentUserPromptNormalized
|
||||||
# Validate that it's not the intent (which is shorter and less detailed)
|
|
||||||
# Intent is typically a concise objective, normalized request should be longer and more detailed
|
|
||||||
workflowIntent = getattr(services.workflow, '_workflowIntent', {}) if hasattr(services, 'workflow') and services.workflow else {}
|
workflowIntent = getattr(services.workflow, '_workflowIntent', {}) if hasattr(services, 'workflow') and services.workflow else {}
|
||||||
intent = workflowIntent.get('intent', '')
|
intent = workflowIntent.get('intent', '')
|
||||||
|
|
||||||
# If normalized matches intent exactly, it's wrong - log warning
|
|
||||||
if intent and normalized == intent:
|
if intent and normalized == intent:
|
||||||
logger.warning(f"extractNormalizedRequest: normalized request matches intent - this is incorrect! normalized={normalized[:100]}...")
|
logger.warning(f"extractNormalizedRequest: normalized request matches intent - this is incorrect! normalized={normalized[:100]}...")
|
||||||
# Try to get from workflow intent or return error message
|
# Fall back to intent rather than injecting an error string into the LLM prompt
|
||||||
return f"ERROR: Normalized request not properly stored. Expected detailed request, got intent: {intent}"
|
return intent
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
@ -346,49 +361,12 @@ def extractReviewContent(context: Any) -> str:
|
||||||
|
|
||||||
return result_summary
|
return result_summary
|
||||||
elif hasattr(context, 'observation') and context.observation:
|
elif hasattr(context, 'observation') and context.observation:
|
||||||
# For observation data, show full content but handle documents specially
|
obs_dict = _observationToDict(context.observation)
|
||||||
# Handle both Pydantic Observation model and dict format
|
_redactSnippets(obs_dict)
|
||||||
from modules.datamodels.datamodelChat import Observation
|
|
||||||
|
|
||||||
if isinstance(context.observation, Observation):
|
|
||||||
# Convert Pydantic model to dict
|
|
||||||
obs_dict = context.observation.model_dump(exclude_none=True) if hasattr(context.observation, 'model_dump') else context.observation.dict()
|
|
||||||
elif isinstance(context.observation, dict):
|
|
||||||
obs_dict = context.observation.copy()
|
|
||||||
else:
|
|
||||||
# Fallback: try to serialize as-is
|
|
||||||
obs_dict = context.observation.model_dump(exclude_none=True) if hasattr(context.observation, 'model_dump') else context.observation.dict()
|
|
||||||
|
|
||||||
# If there are previews with documents, show only metadata
|
|
||||||
if 'previews' in obs_dict and isinstance(obs_dict['previews'], list):
|
|
||||||
for preview in obs_dict['previews']:
|
|
||||||
if isinstance(preview, dict) and 'snippet' in preview:
|
|
||||||
# Replace snippet with metadata indicator
|
|
||||||
preview['snippet'] = f"[Content: {len(preview.get('snippet', ''))} characters]"
|
|
||||||
|
|
||||||
return json.dumps(obs_dict, indent=2, ensure_ascii=False)
|
return json.dumps(obs_dict, indent=2, ensure_ascii=False)
|
||||||
elif hasattr(context, 'stepResult') and context.stepResult and 'observation' in context.stepResult:
|
elif hasattr(context, 'stepResult') and context.stepResult and 'observation' in context.stepResult:
|
||||||
# For observation data in stepResult, show full content but handle documents specially
|
obs_dict = _observationToDict(context.stepResult['observation'])
|
||||||
observation = context.stepResult['observation']
|
_redactSnippets(obs_dict)
|
||||||
# Handle both Pydantic Observation model and dict format
|
|
||||||
from modules.datamodels.datamodelChat import Observation
|
|
||||||
|
|
||||||
if isinstance(observation, Observation):
|
|
||||||
# Convert Pydantic model to dict
|
|
||||||
obs_dict = observation.model_dump(exclude_none=True) if hasattr(observation, 'model_dump') else observation.dict()
|
|
||||||
elif isinstance(observation, dict):
|
|
||||||
obs_dict = observation.copy()
|
|
||||||
else:
|
|
||||||
# Fallback: try to serialize
|
|
||||||
obs_dict = observation.model_dump(exclude_none=True) if hasattr(observation, 'model_dump') else observation.dict()
|
|
||||||
|
|
||||||
# If there are previews with documents, show only metadata
|
|
||||||
if 'previews' in obs_dict and isinstance(obs_dict['previews'], list):
|
|
||||||
for preview in obs_dict['previews']:
|
|
||||||
if isinstance(preview, dict) and 'snippet' in preview:
|
|
||||||
# Replace snippet with metadata indicator
|
|
||||||
preview['snippet'] = f"[Content: {len(preview.get('snippet', ''))} characters]"
|
|
||||||
|
|
||||||
return json.dumps(obs_dict, indent=2, ensure_ascii=False)
|
return json.dumps(obs_dict, indent=2, ensure_ascii=False)
|
||||||
else:
|
else:
|
||||||
return "No review content available"
|
return "No review content available"
|
||||||
|
|
@ -449,41 +427,22 @@ def extractLatestRefinementFeedback(context: Any) -> str:
|
||||||
CRITICAL: If ERROR level logs are found, refinement should stop processing.
|
CRITICAL: If ERROR level logs are found, refinement should stop processing.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# First check for ERROR level logs in workflow
|
|
||||||
if hasattr(context, 'workflow') and context.workflow:
|
|
||||||
try:
|
|
||||||
import modules.interfaces.interfaceDbChat as interfaceDbChat
|
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
||||||
rootInterface = getRootInterface()
|
|
||||||
interfaceDbChat = interfaceDbChat.getInterface(rootInterface.currentUser)
|
|
||||||
|
|
||||||
# Get workflow logs
|
|
||||||
chatData = interfaceDbChat.getUnifiedChatData(context.workflow.id, None)
|
|
||||||
logs = chatData.get("logs", [])
|
|
||||||
|
|
||||||
# Check for ERROR level logs
|
|
||||||
for log in logs:
|
|
||||||
if isinstance(log, dict):
|
|
||||||
log_level = log.get("level", "").upper()
|
|
||||||
log_message = str(log.get("message", ""))
|
|
||||||
if log_level == "ERROR" or "ERROR" in log_message.upper():
|
|
||||||
return f"CRITICAL: Processing stopped due to ERROR in logs: {log_message[:200]}"
|
|
||||||
except Exception as log_check_error:
|
|
||||||
# If we can't check logs, continue with normal feedback extraction
|
|
||||||
logger.warning(f"Could not check for ERROR logs: {str(log_check_error)}")
|
|
||||||
|
|
||||||
if not hasattr(context, 'previousReviewResult') or not context.previousReviewResult or not isinstance(context.previousReviewResult, list):
|
if not hasattr(context, 'previousReviewResult') or not context.previousReviewResult or not isinstance(context.previousReviewResult, list):
|
||||||
return "No previous refinement feedback available"
|
return "No previous refinement feedback available"
|
||||||
|
|
||||||
# Get the most recent refinement decision
|
# Get the most recent refinement decision (supports both ReviewResult objects and dicts)
|
||||||
latest_decision = context.previousReviewResult[-1]
|
latest_decision = context.previousReviewResult[-1]
|
||||||
if not isinstance(latest_decision, dict):
|
|
||||||
|
# Normalize to dict if it's a Pydantic model (e.g. ReviewResult)
|
||||||
|
if hasattr(latest_decision, 'model_dump'):
|
||||||
|
latest_decision = latest_decision.model_dump()
|
||||||
|
elif not isinstance(latest_decision, dict):
|
||||||
return "No previous refinement feedback available"
|
return "No previous refinement feedback available"
|
||||||
|
|
||||||
feedback_parts = []
|
feedback_parts = []
|
||||||
|
|
||||||
# Add decision and reason
|
# Add decision and reason (ReviewResult uses 'status', legacy uses 'decision')
|
||||||
decision = latest_decision.get('decision', 'unknown')
|
decision = latest_decision.get('status') or latest_decision.get('decision', 'unknown')
|
||||||
reason = latest_decision.get('reason', 'No reason provided')
|
reason = latest_decision.get('reason', 'No reason provided')
|
||||||
feedback_parts.append(f"Latest Decision: {decision}")
|
feedback_parts.append(f"Latest Decision: {decision}")
|
||||||
feedback_parts.append(f"Reason: {reason}")
|
feedback_parts.append(f"Reason: {reason}")
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,19 @@ def generateDynamicPlanSelectionPrompt(services, context: Any, learningEngine=No
|
||||||
adaptiveContext = learningEngine.getAdaptiveContextForActionSelection(workflowId, userPrompt)
|
adaptiveContext = learningEngine.getAdaptiveContextForActionSelection(workflowId, userPrompt)
|
||||||
|
|
||||||
if adaptiveContext:
|
if adaptiveContext:
|
||||||
# Add learning-aware placeholders
|
|
||||||
placeholders.extend([
|
placeholders.extend([
|
||||||
PromptPlaceholder(label="ADAPTIVE_GUIDANCE", content=adaptiveContext.get('adaptiveGuidance', ''), summaryAllowed=True),
|
PromptPlaceholder(label="ADAPTIVE_GUIDANCE", content=adaptiveContext.get('adaptiveGuidance', ''), summaryAllowed=True),
|
||||||
PromptPlaceholder(label="FAILURE_ANALYSIS", content=json.dumps(adaptiveContext.get('failureAnalysis', {}), indent=2), summaryAllowed=True),
|
PromptPlaceholder(label="FAILURE_ANALYSIS", content=json.dumps(adaptiveContext.get('failureAnalysis', {}), indent=2), summaryAllowed=True),
|
||||||
PromptPlaceholder(label="ESCALATION_LEVEL", content=adaptiveContext.get('escalationLevel', 'low'), summaryAllowed=False),
|
PromptPlaceholder(label="ESCALATION_LEVEL", content=adaptiveContext.get('escalationLevel', 'low'), summaryAllowed=False),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# Always provide these placeholders so template tokens don't leak into the LLM prompt
|
||||||
|
if not adaptiveContext:
|
||||||
|
placeholders.extend([
|
||||||
|
PromptPlaceholder(label="ADAPTIVE_GUIDANCE", content="", summaryAllowed=True),
|
||||||
|
PromptPlaceholder(label="FAILURE_ANALYSIS", content="", summaryAllowed=True),
|
||||||
|
PromptPlaceholder(label="ESCALATION_LEVEL", content="low", summaryAllowed=False),
|
||||||
|
])
|
||||||
|
|
||||||
template = """Select exactly one next action to advance the task incrementally.
|
template = """Select exactly one next action to advance the task incrementally.
|
||||||
|
|
||||||
|
|
@ -60,7 +67,8 @@ CONTEXT: {{KEY:OVERALL_TASK_CONTEXT}}
|
||||||
OBJECTIVE: {{KEY:TASK_OBJECTIVE}}
|
OBJECTIVE: {{KEY:TASK_OBJECTIVE}}
|
||||||
|
|
||||||
=== AVAILABLE RESOURCES ===
|
=== AVAILABLE RESOURCES ===
|
||||||
AVAILABLE_DOCUMENTS_INDEX: {{KEY:AVAILABLE_DOCUMENTS_SUMMARY}}
|
AVAILABLE_DOCUMENTS_SUMMARY: {{KEY:AVAILABLE_DOCUMENTS_SUMMARY}}
|
||||||
|
AVAILABLE_DOCUMENTS_INDEX:
|
||||||
{{KEY:AVAILABLE_DOCUMENTS_INDEX}}
|
{{KEY:AVAILABLE_DOCUMENTS_INDEX}}
|
||||||
AVAILABLE_CONNECTIONS_INDEX:
|
AVAILABLE_CONNECTIONS_INDEX:
|
||||||
{{KEY:AVAILABLE_CONNECTIONS_INDEX}}
|
{{KEY:AVAILABLE_CONNECTIONS_INDEX}}
|
||||||
|
|
@ -227,6 +235,13 @@ Excludes documents/connections/history entirely.
|
||||||
PromptPlaceholder(label="ATTEMPT_NUMBER", content=str(adaptiveContext.get('attemptNumber', 1)), summaryAllowed=False),
|
PromptPlaceholder(label="ATTEMPT_NUMBER", content=str(adaptiveContext.get('attemptNumber', 1)), summaryAllowed=False),
|
||||||
PromptPlaceholder(label="FAILURE_ANALYSIS", content=json.dumps(adaptiveContext.get('failureAnalysis', {}), indent=2), summaryAllowed=True),
|
PromptPlaceholder(label="FAILURE_ANALYSIS", content=json.dumps(adaptiveContext.get('failureAnalysis', {}), indent=2), summaryAllowed=True),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
if not adaptiveContext:
|
||||||
|
placeholders.extend([
|
||||||
|
PromptPlaceholder(label="PARAMETER_GUIDANCE", content="", summaryAllowed=True),
|
||||||
|
PromptPlaceholder(label="ATTEMPT_NUMBER", content="1", summaryAllowed=False),
|
||||||
|
PromptPlaceholder(label="FAILURE_ANALYSIS", content="", summaryAllowed=True),
|
||||||
|
])
|
||||||
|
|
||||||
template = """You are a parameter generator. Set the parameters for this specific action.
|
template = """You are a parameter generator. Set the parameters for this specific action.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,8 +141,9 @@ class WorkflowProcessor:
|
||||||
# Delegate to the appropriate mode
|
# Delegate to the appropriate mode
|
||||||
result = await self.mode.executeTask(taskStep, workflow, context)
|
result = await self.mode.executeTask(taskStep, workflow, context)
|
||||||
|
|
||||||
# Complete progress tracking
|
# Complete progress tracking based on actual result
|
||||||
self.services.chat.progressLogFinish(operationId, True)
|
taskSuccess = result.success if hasattr(result, 'success') else True
|
||||||
|
self.services.chat.progressLogFinish(operationId, taskSuccess)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -329,7 +330,7 @@ class WorkflowProcessor:
|
||||||
return handoverData
|
return handoverData
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in prepareTaskHandover: {str(e)}")
|
logger.error(f"Error in prepareTaskHandover: {str(e)}")
|
||||||
return {'error': str(e)}
|
raise
|
||||||
|
|
||||||
# Fast Path Implementation
|
# Fast Path Implementation
|
||||||
|
|
||||||
|
|
@ -379,10 +380,7 @@ class WorkflowProcessor:
|
||||||
"################ USER INPUT START #################\n"
|
"################ USER INPUT START #################\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add sanitized user input with clear delimiters
|
complexityPrompt += f"{prompt or ''}\n"
|
||||||
# Escape curly braces for f-string safety, but preserve format (no quote wrapping)
|
|
||||||
sanitizedPrompt = prompt.replace('{', '{{').replace('}', '}}') if prompt else ""
|
|
||||||
complexityPrompt += f"{sanitizedPrompt}\n"
|
|
||||||
|
|
||||||
complexityPrompt += "################ USER INPUT FINISH #################\n\n"
|
complexityPrompt += "################ USER INPUT FINISH #################\n\n"
|
||||||
|
|
||||||
|
|
@ -469,17 +467,14 @@ class WorkflowProcessor:
|
||||||
"Format your response as plain text (no markdown code blocks unless showing code examples)."
|
"Format your response as plain text (no markdown code blocks unless showing code examples)."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prepare AI call options for fast path (balanced, fast processing)
|
|
||||||
|
|
||||||
options = AiCallOptions(
|
options = AiCallOptions(
|
||||||
operationType=OperationTypeEnum.DATA_ANALYSE,
|
operationType=OperationTypeEnum.DATA_ANALYSE,
|
||||||
priority=PriorityEnum.BALANCED,
|
priority=PriorityEnum.BALANCED,
|
||||||
processingMode=ProcessingModeEnum.BASIC,
|
processingMode=ProcessingModeEnum.BASIC,
|
||||||
maxCost=0.10, # Low cost for simple requests
|
maxCost=0.10,
|
||||||
maxProcessingTime=15 # Fast path should complete in 15s
|
maxProcessingTime=15
|
||||||
)
|
)
|
||||||
|
|
||||||
# Call AI via callAi() to ensure stats are stored
|
|
||||||
aiRequest = AiCallRequest(
|
aiRequest = AiCallRequest(
|
||||||
prompt=fastPathPrompt,
|
prompt=fastPathPrompt,
|
||||||
context="",
|
context="",
|
||||||
|
|
@ -630,17 +625,23 @@ class WorkflowProcessor:
|
||||||
chatDocuments = []
|
chatDocuments = []
|
||||||
if taskResult.actionResult and taskResult.actionResult.documents:
|
if taskResult.actionResult and taskResult.actionResult.documents:
|
||||||
for actionDoc in taskResult.actionResult.documents:
|
for actionDoc in taskResult.actionResult.documents:
|
||||||
if hasattr(actionDoc, 'documentData') and actionDoc.documentData:
|
if hasattr(actionDoc, 'documentData') and actionDoc.documentData is not None:
|
||||||
# Create file in component storage
|
rawData = actionDoc.documentData
|
||||||
|
if isinstance(rawData, bytes):
|
||||||
|
contentBytes = rawData
|
||||||
|
elif isinstance(rawData, str):
|
||||||
|
contentBytes = rawData.encode('utf-8')
|
||||||
|
else:
|
||||||
|
contentBytes = json.dumps(rawData, ensure_ascii=False).encode('utf-8')
|
||||||
|
|
||||||
fileItem = self.services.interfaceDbComponent.createFile(
|
fileItem = self.services.interfaceDbComponent.createFile(
|
||||||
name=actionDoc.documentName if hasattr(actionDoc, 'documentName') else f"task_{taskResult.taskId}_result.txt",
|
name=actionDoc.documentName if hasattr(actionDoc, 'documentName') else f"task_{taskResult.taskId}_result.txt",
|
||||||
mimeType=actionDoc.mimeType if hasattr(actionDoc, 'mimeType') else "text/plain",
|
mimeType=actionDoc.mimeType if hasattr(actionDoc, 'mimeType') else "text/plain",
|
||||||
content=actionDoc.documentData if isinstance(actionDoc.documentData, bytes) else actionDoc.documentData.encode('utf-8')
|
content=contentBytes
|
||||||
)
|
)
|
||||||
# Persist file data
|
|
||||||
self.services.interfaceDbComponent.createFileData(
|
self.services.interfaceDbComponent.createFileData(
|
||||||
fileItem.id,
|
fileItem.id,
|
||||||
actionDoc.documentData if isinstance(actionDoc.documentData, bytes) else actionDoc.documentData.encode('utf-8')
|
contentBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get file info
|
# Get file info
|
||||||
|
|
@ -651,7 +652,7 @@ class WorkflowProcessor:
|
||||||
chatDoc = {
|
chatDoc = {
|
||||||
"fileId": fileItem.id,
|
"fileId": fileItem.id,
|
||||||
"fileName": fileInfo.get("fileName", actionDoc.documentName) if fileInfo else actionDoc.documentName,
|
"fileName": fileInfo.get("fileName", actionDoc.documentName) if fileInfo else actionDoc.documentName,
|
||||||
"fileSize": fileInfo.get("size", len(actionDoc.documentData) if isinstance(actionDoc.documentData, bytes) else len(actionDoc.documentData.encode('utf-8'))) if fileInfo else (len(actionDoc.documentData) if isinstance(actionDoc.documentData, bytes) else len(actionDoc.documentData.encode('utf-8'))),
|
"fileSize": fileInfo.get("size", len(contentBytes)) if fileInfo else len(contentBytes),
|
||||||
"mimeType": fileInfo.get("mimeType", actionDoc.mimeType) if fileInfo else actionDoc.mimeType,
|
"mimeType": fileInfo.get("mimeType", actionDoc.mimeType) if fileInfo else actionDoc.mimeType,
|
||||||
"roundNumber": workflow.currentRound,
|
"roundNumber": workflow.currentRound,
|
||||||
"taskNumber": workflow.getTaskIndex(),
|
"taskNumber": workflow.getTaskIndex(),
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import json
|
||||||
|
|
||||||
from modules.datamodels.datamodelChat import (
|
from modules.datamodels.datamodelChat import (
|
||||||
UserInputRequest,
|
UserInputRequest,
|
||||||
ChatMessage,
|
|
||||||
ChatWorkflow,
|
ChatWorkflow,
|
||||||
ChatDocument,
|
ChatDocument,
|
||||||
WorkflowModeEnum
|
WorkflowModeEnum
|
||||||
|
|
@ -44,11 +43,6 @@ class WorkflowManager:
|
||||||
# Store workflow in services for reference (this is the ChatWorkflow object)
|
# Store workflow in services for reference (this is the ChatWorkflow object)
|
||||||
self.services.workflow = workflow
|
self.services.workflow = workflow
|
||||||
|
|
||||||
# CRITICAL: Update all method instances to use the current Services object with the correct workflow
|
|
||||||
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
|
|
||||||
discoverMethods(self.services)
|
|
||||||
logger.debug(f"Updated method instances to use workflow {self.services.workflow.id}")
|
|
||||||
|
|
||||||
if workflow.status == "running":
|
if workflow.status == "running":
|
||||||
logger.info(f"Stopping running workflow {workflowId} before processing new prompt")
|
logger.info(f"Stopping running workflow {workflowId} before processing new prompt")
|
||||||
workflow.status = "stopped"
|
workflow.status = "stopped"
|
||||||
|
|
@ -57,12 +51,13 @@ class WorkflowManager:
|
||||||
"status": "stopped",
|
"status": "stopped",
|
||||||
"lastActivity": currentTime
|
"lastActivity": currentTime
|
||||||
})
|
})
|
||||||
self.services.chat.storeLog(workflow, {
|
if workflow.status == "stopped":
|
||||||
"message": "Workflow stopped for new prompt",
|
self.services.chat.storeLog(workflow, {
|
||||||
"type": "info",
|
"message": "Workflow stopped for new prompt",
|
||||||
"status": "stopped",
|
"type": "info",
|
||||||
"progress": 1.0
|
"status": "stopped",
|
||||||
})
|
"progress": 1.0
|
||||||
|
})
|
||||||
|
|
||||||
newRound = workflow.currentRound + 1
|
newRound = workflow.currentRound + 1
|
||||||
self.services.chat.updateWorkflow(workflowId, {
|
self.services.chat.updateWorkflow(workflowId, {
|
||||||
|
|
@ -170,7 +165,10 @@ class WorkflowManager:
|
||||||
self.services.currentUserPrompt = userInput.prompt
|
self.services.currentUserPrompt = userInput.prompt
|
||||||
|
|
||||||
# Reset progress logger for new workflow
|
# Reset progress logger for new workflow
|
||||||
self.services.chat._progressLogger = None
|
if hasattr(self.services.chat, 'resetProgressLogger'):
|
||||||
|
self.services.chat.resetProgressLogger()
|
||||||
|
else:
|
||||||
|
self.services.chat._progressLogger = None
|
||||||
|
|
||||||
# Reset workflow history flag at start of each workflow
|
# Reset workflow history flag at start of each workflow
|
||||||
setattr(self.services, '_needsWorkflowHistory', False)
|
setattr(self.services, '_needsWorkflowHistory', False)
|
||||||
|
|
@ -565,9 +563,10 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
|
|
||||||
logger.info(f"Fast path completed successfully, response length: {len(responseText)} chars")
|
logger.info(f"Fast path completed successfully, response length: {len(responseText)} chars")
|
||||||
|
|
||||||
|
except WorkflowStoppedException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in _executeFastPath: {str(e)}")
|
logger.error(f"Error in _executeFastPath: {str(e)}")
|
||||||
# Fall back to full workflow on error
|
|
||||||
logger.info("Falling back to full workflow due to fast path error")
|
logger.info("Falling back to full workflow due to fast path error")
|
||||||
taskPlan = await self._planTasks(userInput)
|
taskPlan = await self._planTasks(userInput)
|
||||||
await self._executeTasks(taskPlan)
|
await self._executeTasks(taskPlan)
|
||||||
|
|
@ -897,8 +896,8 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
failedActions=[],
|
failedActions=[],
|
||||||
successfulActions=[],
|
successfulActions=[],
|
||||||
criteriaProgress={
|
criteriaProgress={
|
||||||
'met_criteria': set(),
|
'met_criteria': [],
|
||||||
'unmet_criteria': set(),
|
'unmet_criteria': [],
|
||||||
'attempt_history': []
|
'attempt_history': []
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -1021,11 +1020,11 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
elif workflow.status == 'failed':
|
elif workflow.status == 'failed':
|
||||||
# Create error message
|
lastError = getattr(workflow, '_lastError', None) or "Processing failed"
|
||||||
errorMessage = {
|
errorMessage = {
|
||||||
"workflowId": workflow.id,
|
"workflowId": workflow.id,
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"message": f"Workflow failed: {'Unknown error'}",
|
"message": f"Workflow failed: {lastError}",
|
||||||
"status": "last",
|
"status": "last",
|
||||||
"sequenceNr": len(workflow.messages) + 1,
|
"sequenceNr": len(workflow.messages) + 1,
|
||||||
"publishedAt": self.services.utils.timestampGetUtc(),
|
"publishedAt": self.services.utils.timestampGetUtc(),
|
||||||
|
|
@ -1051,9 +1050,8 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
"totalActions": workflow.totalActions
|
"totalActions": workflow.totalActions
|
||||||
})
|
})
|
||||||
|
|
||||||
# Add failed log entry
|
|
||||||
self.services.chat.storeLog(workflow, {
|
self.services.chat.storeLog(workflow, {
|
||||||
"message": "Workflow failed: Unknown error",
|
"message": f"Workflow failed: {lastError}",
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"progress": 1.0
|
"progress": 1.0
|
||||||
|
|
@ -1155,7 +1153,6 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
"""Generate feedback message for workflow completion"""
|
"""Generate feedback message for workflow completion"""
|
||||||
try:
|
try:
|
||||||
workflow = self.services.workflow
|
workflow = self.services.workflow
|
||||||
checkWorkflowStopped(self.services)
|
|
||||||
|
|
||||||
# Count messages by role
|
# Count messages by role
|
||||||
userMessages = [msg for msg in workflow.messages if msg.role == 'user']
|
userMessages = [msg for msg in workflow.messages if msg.role == 'user']
|
||||||
|
|
@ -1227,7 +1224,6 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
workflow = self.services.workflow
|
workflow = self.services.workflow
|
||||||
logger.error(f"Workflow processing error: {str(error)}")
|
logger.error(f"Workflow processing error: {str(error)}")
|
||||||
|
|
||||||
# Update workflow status to failed
|
|
||||||
workflow.status = "failed"
|
workflow.status = "failed"
|
||||||
workflow.lastActivity = self.services.utils.timestampGetUtc()
|
workflow.lastActivity = self.services.utils.timestampGetUtc()
|
||||||
self.services.chat.updateWorkflow(workflow.id, {
|
self.services.chat.updateWorkflow(workflow.id, {
|
||||||
|
|
@ -1237,11 +1233,10 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
"totalActions": workflow.totalActions
|
"totalActions": workflow.totalActions
|
||||||
})
|
})
|
||||||
|
|
||||||
# Create error message
|
|
||||||
error_message = {
|
error_message = {
|
||||||
"workflowId": workflow.id,
|
"workflowId": workflow.id,
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"message": f"Workflow processing failed: {str(error)}",
|
"message": "Workflow processing encountered an error. Please try again.",
|
||||||
"status": "last",
|
"status": "last",
|
||||||
"sequenceNr": len(workflow.messages) + 1,
|
"sequenceNr": len(workflow.messages) + 1,
|
||||||
"publishedAt": self.services.utils.timestampGetUtc(),
|
"publishedAt": self.services.utils.timestampGetUtc(),
|
||||||
|
|
@ -1257,15 +1252,12 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
}
|
}
|
||||||
self.services.chat.storeMessageWithDocuments(workflow, error_message, [])
|
self.services.chat.storeMessageWithDocuments(workflow, error_message, [])
|
||||||
|
|
||||||
# Add error log entry
|
|
||||||
self.services.chat.storeLog(workflow, {
|
self.services.chat.storeLog(workflow, {
|
||||||
"message": f"Workflow failed: {str(error)}",
|
"message": f"Workflow failed: {str(error)}",
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"progress": 1.0
|
"progress": 1.0
|
||||||
})
|
})
|
||||||
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _processFileIds(self, fileIds: List[str], messageId: str = None) -> List[ChatDocument]:
|
async def _processFileIds(self, fileIds: List[str], messageId: str = None) -> List[ChatDocument]:
|
||||||
"""Process file IDs from existing files and return ChatDocument objects.
|
"""Process file IDs from existing files and return ChatDocument objects.
|
||||||
|
|
@ -1365,21 +1357,3 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
# Return original content on error
|
# Return original content on error
|
||||||
return contentBytes
|
return contentBytes
|
||||||
|
|
||||||
def _checkIfHistoryAvailable(self) -> bool:
|
|
||||||
"""Check if workflow history is available (previous rounds exist).
|
|
||||||
|
|
||||||
Returns True if there are previous workflow rounds with messages.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from modules.workflows.processing.shared.placeholderFactory import getPreviousRoundContext
|
|
||||||
|
|
||||||
history = getPreviousRoundContext(self.services)
|
|
||||||
|
|
||||||
# Check if history contains actual content (not just "No previous round context available")
|
|
||||||
if history and history != "No previous round context available":
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking if history is available: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue