gateway/modules/features/commcoach/serviceCommcoachContextRetrieval.py
2026-04-26 18:11:42 +02:00

245 lines
7.7 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
CommCoach Context Retrieval.
Intent detection, retrieval strategies, and context assembly for intelligent session continuity.
"""
import re
import logging
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List, Tuple
from enum import Enum
logger = logging.getLogger(__name__)
# Retrieval config
PREVIOUS_SESSION_SUMMARIES_COUNT = 5
ROLLING_OVERVIEW_SESSION_THRESHOLD = 10
ROLLING_OVERVIEW_EVERY_N_SESSIONS = 10
TOPIC_SEARCH_MAX_RESULTS = 5
class RetrievalIntent(str, Enum):
NORMAL = "normal"
SUMMARIZE_ALL = "summarize_all"
RECALL_SESSION = "recall_session"
RECALL_TOPIC = "recall_topic"
def detectIntent(userMessage: str) -> RetrievalIntent:
"""
Lightweight intent detection from user message.
Uses keyword/regex heuristics.
"""
text = (userMessage or "").strip().lower()
if len(text) < 3:
return RetrievalIntent.NORMAL
summarizePatterns = [
r"\b(fasse|zusammenfass|zusammenfassung|ueberblick|gesamte?r?\s*chat|alles\s+zusammen)\b",
r"\b(summarize|summary\s+of\s+all|complete\s+summary)\b",
r"zusammenfassung\s+(des\s+)?gesamten",
r"gesamten\s+chat",
]
for p in summarizePatterns:
if re.search(p, text, re.IGNORECASE):
return RetrievalIntent.SUMMARIZE_ALL
datePatterns = [
r"\b(session|gespraech|besprechung)\s+(vom|am|vom)\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})",
r"\b(am|vom)\s*(\d{1,2})\.(\d{1,2})\.(\d{2,4})",
r"\b(letzte\s+woche|voriger\s+monat|gestern)\b",
r"\b(session|gespraech)\s+(vom|from)\s+(\d{4}-\d{2}-\d{2})",
]
for p in datePatterns:
if re.search(p, text, re.IGNORECASE):
return RetrievalIntent.RECALL_SESSION
recallTopicPatterns = [
r"\b(erinnerst\s+du\s+dich|damals\s+als|thema\s+.*\s+von|ueber\s+was\s+haben\s+wir)\b",
r"\b(was\s+war\s+.*\s+nochmal|thema\s+.*\s+besprochen)\b",
r"\b(recall|remember|vor\s+\d+\s+sessions?)\b",
]
for p in recallTopicPatterns:
if re.search(p, text, re.IGNORECASE):
return RetrievalIntent.RECALL_TOPIC
return RetrievalIntent.NORMAL
def _parseDateFromMessage(text: str) -> Optional[datetime]:
"""Extract date from user message. Returns date or None."""
text = text.strip()
patterns = [
(r"(\d{1,2})\.(\d{1,2})\.(\d{2,4})", lambda m: (int(m[1]), int(m[2]), int(m[3]))),
(r"(\d{4})-(\d{2})-(\d{2})", lambda m: (int(m[3]), int(m[2]), int(m[1]))),
]
for pattern, extractor in patterns:
match = re.search(pattern, text)
if match:
try:
day, month, year = extractor(match)
if year < 100:
year += 2000
return datetime(year, month, day)
except (ValueError, IndexError):
pass
return None
def findSessionByDate(
sessions: List[Dict[str, Any]],
targetDate: Optional[datetime],
) -> Optional[Dict[str, Any]]:
"""
Find session closest to targetDate.
sessions: list of session dicts with startedAt/endedAt.
"""
if not targetDate or not sessions:
return None
targetDateOnly = targetDate.date()
bestMatch = None
bestDiff = None
for s in sessions:
if s.get("status") != "completed":
continue
startedAt = s.get("startedAt") or s.get("endedAt")
if not startedAt:
continue
dt = datetime.fromtimestamp(startedAt, tz=timezone.utc)
sessionDate = dt.date()
diff = abs((sessionDate - targetDateOnly).days)
if bestDiff is None or diff < bestDiff:
bestDiff = diff
bestMatch = s
return bestMatch
def searchSessionsByTopic(
sessions: List[Dict[str, Any]],
query: str,
maxResults: int = TOPIC_SEARCH_MAX_RESULTS,
) -> List[Dict[str, Any]]:
"""
Topic search over sessions.
Phase 5: Keyword-based (keyTopics + summary).
Phase 7: Falls back to embedding search when available; for now uses keyword only.
"""
if not query or not sessions:
return []
queryWords = set(re.findall(r"\w+", query.lower()))
if not queryWords:
return []
scored = []
for s in sessions:
if s.get("status") != "completed":
continue
score = 0
summary = (s.get("summary") or "").lower()
keyTopicsRaw = s.get("keyTopics")
keyTopics = []
if keyTopicsRaw:
try:
import json
parsed = json.loads(keyTopicsRaw) if isinstance(keyTopicsRaw, str) else keyTopicsRaw
keyTopics = [t.lower() if isinstance(t, str) else str(t).lower() for t in parsed] if isinstance(parsed, list) else []
except Exception:
pass
for word in queryWords:
if len(word) < 3:
continue
if word in summary:
score += 1
for topic in keyTopics:
if word in topic:
score += 2
if score > 0:
scored.append((score, s))
scored.sort(key=lambda x: -x[0])
return [s for _, s in scored[:maxResults]]
def searchSessionsByTopicRag(
query: str,
userId: str,
instanceId: str,
mandateId: str = None,
queryVector: List[float] = None,
) -> List[Dict[str, Any]]:
"""Search using platform RAG (semantic search across mandate-wide knowledge data).
Requires a pre-computed queryVector (embedding). The caller is responsible
for generating the embedding via AiService.callEmbedding before invoking this.
"""
if not queryVector:
logger.warning("searchSessionsByTopicRag called without queryVector, skipping RAG search")
return []
try:
from modules.interfaces.interfaceDbKnowledge import getInterface as _getKnowledgeInterface
knowledgeDb = _getKnowledgeInterface()
results = knowledgeDb.semanticSearch(
queryVector=queryVector,
userId=userId,
featureInstanceId=instanceId,
mandateId=mandateId,
isSysAdmin=False,
limit=TOPIC_SEARCH_MAX_RESULTS,
)
formatted = []
for r in (results or []):
rData = r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else {}
contextRef = rData.get("contextRef") or {}
formatted.append({
"source": "rag",
"content": rData.get("data") or rData.get("summary") or "",
"fileName": contextRef.get("containerPath") or "RAG-Ergebnis",
"score": rData.get("_score") or 0,
})
return formatted
except Exception as e:
logger.warning(f"RAG search failed for query '{query[:50]}': {e}")
return []
def buildSessionSummariesForPrompt(
sessions: List[Dict[str, Any]],
excludeSessionId: Optional[str] = None,
limit: int = PREVIOUS_SESSION_SUMMARIES_COUNT,
) -> List[Dict[str, Any]]:
"""
Build list of session summaries with date for prompt.
Each item: {summary, date, sessionId, keyTopics}.
"""
completed = [
s for s in sessions
if s.get("status") == "completed"
and s.get("summary")
and s.get("id") != excludeSessionId
]
completed.sort(key=lambda x: x.get("startedAt") or 0, reverse=True)
result = []
for s in completed[:limit]:
startedAt = s.get("startedAt")
dateStr = ""
if startedAt:
dt = datetime.fromtimestamp(startedAt, tz=timezone.utc)
dateStr = dt.strftime("%d.%m.%Y")
result.append({
"summary": s.get("summary", ""),
"date": dateStr,
"sessionId": s.get("id"),
"keyTopics": s.get("keyTopics"),
})
return result