commcoach: backend abort handling + compression + document pre-AI-call + status events
TASK 5: Backend abort handling (cancel previous processMessage tasks) TASK 6: Compression thresholds lowered (25->15, 15->10) TASK 7: Combine pending user messages into single prompt TASK 8: Document handling with pre-AI-call intent detection TASK 9: Granular status events during AI processing Made-with: Cursor
This commit is contained in:
parent
e2c1c58442
commit
364e431749
4 changed files with 189 additions and 32 deletions
|
|
@ -289,6 +289,10 @@ class CommcoachObjects:
|
|||
data["createdAt"] = getIsoTimestamp()
|
||||
return self.db.recordCreate(CoachingDocument, data)
|
||||
|
||||
def updateDocument(self, documentId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
from .datamodelCommcoach import CoachingDocument
|
||||
return self.db.recordModify(CoachingDocument, documentId, updates)
|
||||
|
||||
def deleteDocument(self, documentId: str) -> bool:
|
||||
from .datamodelCommcoach import CoachingDocument
|
||||
return self.db.recordDelete(CoachingDocument, documentId)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEven
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_activeProcessTasks: dict = {}
|
||||
|
||||
|
||||
def _audit(context: RequestContext, action: str, resourceType: str = None, resourceId: str = None, details: str = ""):
|
||||
"""Log an audit event for CommCoach. Non-blocking, best-effort."""
|
||||
|
|
@ -500,10 +502,19 @@ async def sendMessageStream(
|
|||
contextId = session.get("contextId")
|
||||
service = CommcoachService(context.user, mandateId, instanceId)
|
||||
|
||||
# Process in background
|
||||
asyncio.create_task(
|
||||
existingTask = _activeProcessTasks.get(sessionId)
|
||||
if existingTask and not existingTask.done():
|
||||
existingTask.cancel()
|
||||
logger.info(f"Cancelled previous processMessage task for session {sessionId}")
|
||||
|
||||
def _onTaskDone(task):
|
||||
_activeProcessTasks.pop(sessionId, None)
|
||||
|
||||
task = asyncio.create_task(
|
||||
service.processMessage(sessionId, contextId, body.content, interface)
|
||||
)
|
||||
task.add_done_callback(_onTaskDone)
|
||||
_activeProcessTasks[sessionId] = task
|
||||
|
||||
# Stream events
|
||||
async def _eventGenerator():
|
||||
|
|
|
|||
|
|
@ -86,6 +86,32 @@ def cleanupSessionEvents(sessionId: str):
|
|||
CHUNK_WORD_SIZE = 4
|
||||
CHUNK_DELAY_SECONDS = 0.05
|
||||
|
||||
DOC_INTENT_MAX_DOCS = 3
|
||||
DOC_CONTENT_MAX_CHARS = 3000
|
||||
|
||||
|
||||
def _buildCombinedUserPrompt(messages: List[Dict[str, Any]]) -> str:
|
||||
"""Collect all user messages after the last assistant message into one combined prompt."""
|
||||
pending = []
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant":
|
||||
break
|
||||
if msg.get("role") == "user":
|
||||
pending.insert(0, msg.get("content", ""))
|
||||
return " ".join(pending).strip()
|
||||
|
||||
|
||||
def _stripPendingUserMessages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Return messages up to and including the last assistant message (remove trailing user-only tail)."""
|
||||
lastAssistantIdx = -1
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "assistant":
|
||||
lastAssistantIdx = i
|
||||
break
|
||||
if lastAssistantIdx < 0:
|
||||
return []
|
||||
return messages[:lastAssistantIdx + 1]
|
||||
|
||||
|
||||
def _parseAiJsonResponse(rawText: str) -> Dict[str, Any]:
|
||||
"""Parse the structured JSON response from AI. Strips optional markdown code fences."""
|
||||
|
|
@ -131,12 +157,13 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand
|
|||
logger.warning(f"TTS failed for session {sessionId}: {e}")
|
||||
|
||||
|
||||
async def _saveGeneratedDocument(doc: Dict[str, Any], contextId: str, userId: str,
|
||||
async def _saveOrUpdateDocument(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."""
|
||||
"""Save a new document or update an existing one. Stores file in Management DB."""
|
||||
from .datamodelCommcoach import CoachingDocument
|
||||
try:
|
||||
docId = doc.get("id")
|
||||
title = doc.get("title", "Dokument")
|
||||
content = doc.get("content", "")
|
||||
contentBytes = content.encode("utf-8")
|
||||
|
|
@ -152,8 +179,24 @@ async def _saveGeneratedDocument(doc: Dict[str, Any], contextId: str, userId: st
|
|||
mgmtInterface.createFileData(fileItem.id, contentBytes)
|
||||
fileRef = fileItem.id
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store generated document in file DB: {e}")
|
||||
logger.warning(f"Failed to store document in file DB: {e}")
|
||||
|
||||
if docId:
|
||||
updates = {
|
||||
"fileName": fileName,
|
||||
"extractedText": content,
|
||||
"summary": title,
|
||||
"fileSize": len(contentBytes),
|
||||
}
|
||||
if fileRef:
|
||||
updates["fileRef"] = fileRef
|
||||
updated = interface.updateDocument(docId, updates)
|
||||
if updated:
|
||||
await emitSessionEvent(sessionId, "documentUpdated", updated)
|
||||
logger.info(f"Document updated: {docId} ({title})")
|
||||
else:
|
||||
logger.warning(f"Document update failed, id not found: {docId}")
|
||||
else:
|
||||
docData = CoachingDocument(
|
||||
contextId=contextId,
|
||||
userId=userId,
|
||||
|
|
@ -169,7 +212,38 @@ async def _saveGeneratedDocument(doc: Dict[str, Any], contextId: str, userId: st
|
|||
created = interface.createDocument(docData)
|
||||
await emitSessionEvent(sessionId, "documentCreated", created)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save generated document: {e}")
|
||||
logger.warning(f"Failed to save/update document: {e}")
|
||||
|
||||
|
||||
async def _resolveDocumentIntent(combinedUserPrompt: str, docs: List[Dict[str, Any]], callAiFn) -> Dict[str, Any]:
|
||||
"""Pre-AI-call: identify which documents the user references and what action is needed."""
|
||||
if not docs:
|
||||
return {"read": [], "update": [], "create": [], "noDocumentAction": True}
|
||||
from . import serviceCommcoachAi as aiPrompts
|
||||
docCatalog = [{"id": d.get("id", ""), "title": d.get("summary") or d.get("fileName", ""), "summary": (d.get("summary") or "")[:100]} for d in docs]
|
||||
prompt = aiPrompts.buildDocumentIntentPrompt(combinedUserPrompt, docCatalog)
|
||||
try:
|
||||
response = await callAiFn("Du analysierst Dokumentreferenzen in Benutzeranfragen. Antworte NUR als JSON.", prompt)
|
||||
if response and response.errorCount == 0 and response.content:
|
||||
parsed = aiPrompts._parseAiJsonSafe(response.content.strip(), {"read": [], "update": [], "create": [], "noDocumentAction": True})
|
||||
return parsed
|
||||
except Exception as e:
|
||||
logger.warning(f"Document intent detection failed: {e}")
|
||||
return {"read": [], "update": [], "create": [], "noDocumentAction": True}
|
||||
|
||||
|
||||
def _loadDocumentContents(docIds: List[str], interface) -> List[Dict[str, Any]]:
|
||||
"""Load full extractedText for the given document IDs."""
|
||||
results = []
|
||||
for docId in docIds[:DOC_INTENT_MAX_DOCS]:
|
||||
doc = interface.getDocument(docId)
|
||||
if doc and doc.get("extractedText"):
|
||||
results.append({
|
||||
"id": doc.get("id", ""),
|
||||
"title": doc.get("summary") or doc.get("fileName", ""),
|
||||
"content": doc.get("extractedText", "")[:DOC_CONTENT_MAX_CHARS],
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
async def _emitChunkedResponse(sessionId: str, createdMsg: Dict[str, Any], fullText: str):
|
||||
|
|
@ -298,18 +372,39 @@ class CommcoachService:
|
|||
logger.warning(f"History compression failed for session {sessionId}: {e}")
|
||||
previousMessages = messages[-20:]
|
||||
|
||||
# Combine all pending user messages (after last assistant message) as the user prompt
|
||||
combinedUserPrompt = _buildCombinedUserPrompt(previousMessages)
|
||||
if not combinedUserPrompt:
|
||||
combinedUserPrompt = userContent
|
||||
|
||||
# Strip pending user messages from previousMessages to avoid redundancy in system prompt
|
||||
contextMessages = _stripPendingUserMessages(previousMessages)
|
||||
|
||||
tasks = interface.getTasks(contextId, self.userId)
|
||||
|
||||
await emitSessionEvent(sessionId, "status", {"label": "Kontext wird geladen..."})
|
||||
|
||||
retrievalResult = await self._buildRetrievalContext(
|
||||
contextId, sessionId, userContent, context, interface
|
||||
contextId, sessionId, combinedUserPrompt, context, interface
|
||||
)
|
||||
|
||||
persona = _resolvePersona(session, interface)
|
||||
documentSummaries = _getDocumentSummaries(contextId, self.userId, interface)
|
||||
|
||||
# Document intent detection (pre-AI-call)
|
||||
referencedDocumentContents = None
|
||||
allDocs = interface.getDocuments(contextId, self.userId) if documentSummaries else []
|
||||
if allDocs:
|
||||
await emitSessionEvent(sessionId, "status", {"label": "Dokumente werden geprueft..."})
|
||||
docIntent = await _resolveDocumentIntent(combinedUserPrompt, allDocs, self._callAi)
|
||||
if not docIntent.get("noDocumentAction"):
|
||||
docIdsToLoad = list(set((docIntent.get("read") or []) + (docIntent.get("update") or [])))
|
||||
if docIdsToLoad:
|
||||
referencedDocumentContents = _loadDocumentContents(docIdsToLoad, interface)
|
||||
|
||||
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
||||
context,
|
||||
previousMessages,
|
||||
contextMessages,
|
||||
tasks,
|
||||
previousSessionSummaries=retrievalResult.get("previousSessionSummaries"),
|
||||
earlierSummary=earlierSummary,
|
||||
|
|
@ -318,16 +413,20 @@ class CommcoachService:
|
|||
retrievedByTopic=retrievalResult.get("retrievedByTopic"),
|
||||
persona=persona,
|
||||
documentSummaries=documentSummaries,
|
||||
referencedDocumentContents=referencedDocumentContents,
|
||||
)
|
||||
|
||||
if retrievalResult.get("intent") == RetrievalIntent.SUMMARIZE_ALL:
|
||||
systemPrompt += "\n\nWICHTIG: Der Benutzer möchte eine Gesamtzusammenfassung. Erstelle eine umfassende Zusammenfassung aller genannten Sessions und der aktuellen Session."
|
||||
|
||||
# Call AI
|
||||
await emitSessionEvent(sessionId, "status", {"label": "Coach denkt nach..."})
|
||||
await emitSessionEvent(sessionId, "status", {"label": "Coach formuliert Antwort..."})
|
||||
|
||||
try:
|
||||
aiResponse = await self._callAi(systemPrompt, userContent)
|
||||
aiResponse = await self._callAi(systemPrompt, combinedUserPrompt)
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"processMessage cancelled for session {sessionId} (new message arrived)")
|
||||
return createdUserMsg
|
||||
except Exception as e:
|
||||
logger.error(f"AI call failed for session {sessionId}: {e}")
|
||||
await emitSessionEvent(sessionId, "error", {"message": f"AI error: {str(e)}"})
|
||||
|
|
@ -344,8 +443,12 @@ class CommcoachService:
|
|||
speechContent = parsed.get("speech", "")
|
||||
documents = parsed.get("documents", [])
|
||||
|
||||
if asyncio.current_task() and asyncio.current_task().cancelled():
|
||||
logger.info(f"processMessage cancelled before storing response for session {sessionId}")
|
||||
return createdUserMsg
|
||||
|
||||
for doc in documents:
|
||||
await _saveGeneratedDocument(doc, contextId, self.userId, self.mandateId, self.instanceId, interface, sessionId, user=self.currentUser)
|
||||
await _saveOrUpdateDocument(doc, contextId, self.userId, self.mandateId, self.instanceId, interface, sessionId, user=self.currentUser)
|
||||
|
||||
assistantMsg = CoachingMessage(
|
||||
sessionId=sessionId,
|
||||
|
|
@ -360,6 +463,8 @@ class CommcoachService:
|
|||
messages = interface.getMessages(sessionId)
|
||||
interface.updateSession(sessionId, {"messageCount": len(messages)})
|
||||
|
||||
await emitSessionEvent(sessionId, "status", {"label": "Antwort wird verarbeitet..."})
|
||||
|
||||
ttsTask = asyncio.create_task(
|
||||
_generateAndEmitTts(sessionId, speechContent, self.currentUser, self.mandateId, self.instanceId, interface)
|
||||
)
|
||||
|
|
@ -436,7 +541,7 @@ class CommcoachService:
|
|||
documents = parsed.get("documents", [])
|
||||
|
||||
for doc in documents:
|
||||
await _saveGeneratedDocument(doc, contextId, self.userId, self.mandateId, self.instanceId, interface, sessionId, user=self.currentUser)
|
||||
await _saveOrUpdateDocument(doc, contextId, self.userId, self.mandateId, self.instanceId, interface, sessionId, user=self.currentUser)
|
||||
|
||||
assistantMsg = CoachingMessage(
|
||||
sessionId=sessionId,
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ from typing import Optional, Dict, Any, List, Tuple
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Compression thresholds (Teamsbot-style)
|
||||
COMPRESSION_MESSAGE_THRESHOLD = 25
|
||||
COMPRESSION_RECENT_COUNT = 15
|
||||
# Compression thresholds — lowered for voice fragment pattern (multiple user msgs per turn)
|
||||
COMPRESSION_MESSAGE_THRESHOLD = 15
|
||||
COMPRESSION_RECENT_COUNT = 10
|
||||
COMPRESSION_MAX_MESSAGES_FETCH = 80
|
||||
|
||||
|
||||
|
|
@ -95,6 +95,7 @@ def buildCoachingSystemPrompt(
|
|||
retrievedByTopic: Optional[List[Dict[str, Any]]] = None,
|
||||
persona: Optional[Dict[str, Any]] = None,
|
||||
documentSummaries: Optional[List[str]] = None,
|
||||
referencedDocumentContents: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> str:
|
||||
"""Build the system prompt for a coaching session, including context history, tasks, and session continuity."""
|
||||
contextTitle = context.get("title", "General Coaching")
|
||||
|
|
@ -238,6 +239,16 @@ WICHTIG: Antworte NUR mit dem JSON-Objekt. Kein Text vor oder nach dem JSON."""
|
|||
for docSummary in documentSummaries[:5]:
|
||||
prompt += f"\n- {docSummary[:300]}"
|
||||
|
||||
if referencedDocumentContents:
|
||||
prompt += "\n\nReferenzierte Dokumente (vollstaendiger Inhalt):"
|
||||
for doc in referencedDocumentContents[:3]:
|
||||
prompt += f"\n\n=== {doc.get('title', 'Dokument')} (id: {doc.get('id', '')}) ===\n{doc.get('content', '')[:3000]}"
|
||||
prompt += """
|
||||
|
||||
Du kannst bestehende Dokumente aendern oder neue erstellen.
|
||||
Fuer UPDATE eines bestehenden Dokuments: {"id": "<doc-id>", "title": "...", "content": "...neuer vollstaendiger Inhalt..."}
|
||||
Fuer ein NEUES Dokument: {"title": "...", "content": "...Inhalt..."}"""
|
||||
|
||||
if previousMessages:
|
||||
prompt += "\n\nVorige Nachrichten dieser Session (Kontext):"
|
||||
for msg in previousMessages[-12:]:
|
||||
|
|
@ -418,7 +429,7 @@ def parseJsonResponse(responseText: str, fallback: Any = None) -> Any:
|
|||
text = responseText.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.split("\n")
|
||||
lines = lines[1:] # remove opening ```json
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines)
|
||||
|
|
@ -429,6 +440,8 @@ def parseJsonResponse(responseText: str, fallback: Any = None) -> Any:
|
|||
logger.warning(f"Failed to parse AI JSON response: {text[:200]}")
|
||||
return fallback
|
||||
|
||||
_parseAiJsonSafe = parseJsonResponse
|
||||
|
||||
|
||||
def _parseJsonField(value: Optional[str], fallback: Any = None) -> Any:
|
||||
if not value:
|
||||
|
|
@ -439,3 +452,27 @@ def _parseJsonField(value: Optional[str], fallback: Any = None) -> Any:
|
|||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return fallback
|
||||
|
||||
|
||||
def buildDocumentIntentPrompt(userInput: str, docCatalog: List[Dict[str, Any]]) -> str:
|
||||
"""Build a lightweight prompt for the pre-AI-call that identifies referenced documents."""
|
||||
catalogStr = ""
|
||||
for doc in docCatalog:
|
||||
catalogStr += f'\n- id: {doc.get("id", "")}, title: "{doc.get("title", "")}", summary: "{doc.get("summary", "")[:100]}"'
|
||||
|
||||
return f"""Analysiere den User-Input und die Dokumentliste.
|
||||
Welche Dokumente referenziert der User? Was soll damit passieren?
|
||||
|
||||
User-Input: "{userInput}"
|
||||
|
||||
Verfuegbare Dokumente:{catalogStr}
|
||||
|
||||
Antworte NUR als JSON:
|
||||
{{"read": ["doc-id-1"], "update": ["doc-id-2"], "create": ["Titel fuer neues Dokument"], "noDocumentAction": true/false}}
|
||||
|
||||
- "read": IDs von Dokumenten deren Inhalt der User lesen/besprechen will
|
||||
- "update": IDs von Dokumenten die geaendert/angepasst werden sollen
|
||||
- "create": Titel fuer neue Dokumente die erstellt werden sollen
|
||||
- "noDocumentAction": true wenn kein Dokument-Bezug erkannt wurde
|
||||
|
||||
Wenn kein Dokument-Bezug: {{"read": [], "update": [], "create": [], "noDocumentAction": true}}"""
|
||||
|
|
|
|||
Loading…
Reference in a new issue