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

500 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
CommCoach AI Service.
Handles system prompts, diagnostic question generation, session summarization, and scoring.
"""
import logging
import json
from typing import Optional, Dict, Any, List, Tuple
logger = logging.getLogger(__name__)
# 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
def buildResumeGreetingPrompt(messages: List[Dict[str, Any]], contextTitle: str) -> str:
"""Build prompt for AI to generate a follow-up greeting when user returns to session."""
recent = messages[-6:] if len(messages) > 6 else messages
conversation = ""
for msg in recent:
role = "Benutzer" if msg.get("role") == "user" else "Coach"
conversation += f"\n{role}: {msg.get('content', '')[:200]}"
return f"""Der User kehrt zur laufenden Coaching-Session zum Thema "{contextTitle}" zurück.
Bisheriger Verlauf:
{conversation}
Erstelle eine kurze, freundliche Begrüssung für den Wiedereinstieg (2-3 Sätze):
- Begrüsse den User zurück
- Fasse in einem Satz zusammen, worum es zuletzt ging
- Lade ein, dort weiterzumachen oder eine neue Frage zu stellen
Antworte NUR mit der Begrüssung, keine Erklärungen."""
def buildEarlierConversationSummaryPrompt(messages: List[Dict[str, Any]]) -> str:
"""Build prompt to summarize older messages for long-session compression."""
conversation = ""
for msg in messages:
role = "Benutzer" if msg.get("role") == "user" else "Coach"
conversation += f"\n{role}: {msg.get('content', '')}"
return f"""Fasse das folgende Coaching-Gespräch in 4-6 Sätzen zusammen.
Behalte: Kernthemen, wichtige Erkenntnisse, erwähnte Aufgaben, emotionale Wendepunkte, Fortschritte.
Entferne Wiederholungen und Fülltext.
Antworte NUR mit der Zusammenfassung, keine Erklärungen.
Gespräch:
{conversation}"""
def prepareMessagesForPrompt(
messages: List[Dict[str, Any]],
compressedSummary: Optional[str],
compressedUpToCount: Optional[int],
) -> Tuple[Optional[str], List[Dict[str, Any]]]:
"""
Prepare message history for the coaching prompt.
Returns (earlierSummary, recentMessages).
If messages <= THRESHOLD: (None, messages).
If messages > THRESHOLD: (summary or None, last RECENT_COUNT messages).
Cached summary is reused when compressedUpToCount >= len(toSummarize).
"""
if len(messages) <= COMPRESSION_MESSAGE_THRESHOLD:
return None, messages
toSummarizeCount = len(messages) - COMPRESSION_RECENT_COUNT
if toSummarizeCount <= 0:
return None, messages
toSummarize = messages[:toSummarizeCount]
recent = messages[-COMPRESSION_RECENT_COUNT:]
try:
upTo = int(compressedUpToCount) if compressedUpToCount is not None else None
except (TypeError, ValueError):
upTo = None
if compressedSummary and upTo is not None and upTo >= toSummarizeCount:
return compressedSummary, recent
return None, messages
def buildCoachingSystemPrompt(
context: Dict[str, Any],
previousMessages: List[Dict[str, Any]],
tasks: List[Dict[str, Any]],
previousSessionSummaries: Optional[List[Dict[str, Any]]] = None,
earlierSummary: Optional[str] = None,
rollingOverview: Optional[str] = None,
retrievedSession: Optional[Dict[str, Any]] = None,
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")
contextCategory = context.get("category", "custom")
contextDescription = context.get("description", "")
goalsRaw = context.get("goals")
insightsRaw = context.get("insights")
goals = _parseJsonField(goalsRaw, [])
insights = _parseJsonField(insightsRaw, [])
openTasks = [t for t in tasks if t.get("status") in ("open", "inProgress")]
doneTasks = [t for t in tasks if t.get("status") == "done"]
summaries = previousSessionSummaries or []
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:
- Stelle gezielte diagnostische Rückfragen, um das Problem/Thema besser zu verstehen
- Gib konkrete, praxisnahe Tipps und Übungen
- Baue auf frühere Sessions auf (Kontext-Kontinuität)
- Erkenne Fortschritte und benenne sie
- Schlage am Ende der Session konkrete nächste Schritte vor (als Tasks)
- 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:
- Duze den Benutzer
- Sei direkt aber wertschätzend
- Verwende keine Emojis
- Antworte in der Sprache des Benutzers
- Halte Chat-Antworten (text/speech) fokussiert. Dokumente duerfen ausfuehrlich sein."""
prompt += """
Handlungsprinzip:
- Wenn der Benutzer dich bittet, etwas zu erstellen (Dokument, Präsentation, Checkliste, Plan), dann TU ES SOFORT. Frage NICHT nochmals nach Bestätigung.
- Verwende alle verfügbaren Informationen aus dem Chat-Verlauf, den Dokumenten und dem Kontext.
- Wenn der Benutzer sagt "erstelle", "mach", "schreib", dann liefere das fertige Ergebnis — keine Aufzählung von Punkten, die du "gleich umsetzen wirst".
- Dir wird automatisch relevanter Kontext aus früheren Sessions bereitgestellt (Relevant Knowledge). Nutze diesen für Kontinuität und Bezugnahme auf frühere Gespräche.
Antwortformat:
- Antworte direkt als Freitext (KEIN JSON). Markdown-Formatierung ist erlaubt.
- Halte Antworten gesprächig und kurz (2-6 Sätze im Normalfall), wie in einem echten Coaching-Gespräch.
- Bei komplexen Themen oder wenn der Benutzer Details anfragt, darf die Antwort ausführlicher sein.
- Dein Text wird sowohl angezeigt als auch vorgelesen schreibe daher natürlich und gut sprechbar.
Tool-Nutzung:
- Du hast Zugriff auf Tools (Dateien lesen, Web-Suche, Datenquellen abfragen) wenn der Benutzer Dateien/Quellen angehängt hat oder Recherche benötigt.
- Nutze Tools NUR wenn nötig. Für normales Coaching-Gespräch: antworte direkt ohne Tools.
- Wenn du ein Tool nutzt, erkläre kurz was du tust."""
if contextDescription:
prompt += f"\n\nKontext-Beschreibung: {contextDescription}"
if goals:
goalTexts = [g.get("text", g) if isinstance(g, dict) else str(g) for g in goals]
prompt += f"\n\nZiele des Benutzers:\n" + "\n".join(f"- {g}" for g in goalTexts)
if insights:
insightTexts = [i.get("text", i) if isinstance(i, dict) else str(i) for i in insights[-5:]]
prompt += f"\n\nBisherige Erkenntnisse:\n" + "\n".join(f"- {i}" for i in insightTexts)
if rollingOverview:
prompt += f"\n\nGesamtüberblick bisheriger Sessions:\n{rollingOverview[:600]}"
if summaries:
prompt += "\n\nBisherige Sessions (Zusammenfassungen):"
for s in summaries[-5:]:
summary = s.get("summary", s.get("text", ""))
dateStr = s.get("date", "")
prefix = f"[{dateStr}] " if dateStr else ""
if summary:
prompt += f"\n- {prefix}{summary[:350]}"
if retrievedSession:
dateStr = ""
startedAt = retrievedSession.get("startedAt")
if startedAt:
from datetime import datetime, timezone
dt = datetime.fromtimestamp(startedAt, tz=timezone.utc)
dateStr = dt.strftime("%d.%m.%Y")
prompt += f"\n\nVom Benutzer angefragte Session ({dateStr}):"
prompt += f"\n{retrievedSession.get('summary', '')[:500]}"
if retrievedByTopic:
prompt += "\n\nRelevante Sessions und Mandantenwissen zum angefragten Thema:"
for s in retrievedByTopic[:5]:
summary = s.get("summary", s.get("content", ""))
if not summary:
continue
dateStr = s.get("date", "")
if s.get("source") == "rag":
label = s.get("ragSourceLabel") or "Mandantenwissen"
prompt += f"\n- [Wissen: {label}] {summary[:320]}"
else:
prefix = f"[{dateStr}] " if dateStr else ""
prompt += f"\n- {prefix}{summary[:300]}"
if openTasks:
prompt += "\n\nOffene Aufgaben:"
for t in openTasks:
prompt += f"\n- [{t.get('status')}] {t.get('title')}"
if doneTasks:
prompt += f"\n\nAbgeschlossene Aufgaben: {len(doneTasks)}"
if earlierSummary:
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 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:]:
role = msg.get("role", "user")
content = msg.get("content", "")[:400]
prompt += f"\n[{role}]: {content}"
return prompt
def buildSummaryPrompt(messages: List[Dict[str, Any]], contextTitle: str) -> str:
"""Build a prompt to generate a session summary plus structured email content."""
conversation = ""
for msg in messages:
role = "Benutzer" if msg.get("role") == "user" else "Coach"
conversation += f"\n{role}: {msg.get('content', '')}"
return f"""Erstelle eine Zusammenfassung dieser Coaching-Session zum Thema "{contextTitle}".
Antworte AUSSCHLIESSLICH als JSON im folgenden Format:
{{
"summary": "Kompakte Plaintext-Zusammenfassung fuer die App. Struktur: Kernthema, Erkenntnisse, Naechste Schritte, Fortschritt.",
"email": {{
"headline": "Kurze, professionelle Titelzeile fuer die E-Mail",
"intro": "1-2 Saetze, die den Kern der Session auf den Punkt bringen",
"coreTopic": "Das zentrale Thema in einem praezisen Satz",
"insights": ["Erkenntnis 1", "Erkenntnis 2"],
"nextSteps": ["Naechster Schritt 1", "Naechster Schritt 2"],
"progress": ["Fortschritt 1", "Fortschritt 2"]
}}
}}
Regeln:
- KEIN HTML erzeugen.
- "summary" ist reiner Plaintext ohne Markdown.
- "headline" kurz und professionell.
- "intro" in natuerlichem Business-Deutsch.
- "insights", "nextSteps" und "progress" jeweils als kurze Stichpunkte.
- Maximal 4 Eintraege pro Liste.
- Wenn eine Liste leer ist, gib [] zurueck.
Gespräch:
{conversation}
Antworte auf Deutsch, sachlich, klar und kompakt. NUR JSON, keine Erklaerungen."""
def buildScoringPrompt(messages: List[Dict[str, Any]], contextCategory: str) -> str:
"""Build a prompt to evaluate competence dimensions after a session."""
conversation = ""
for msg in messages:
role = "Benutzer" if msg.get("role") == "user" else "Coach"
conversation += f"\n{role}: {msg.get('content', '')}"
return f"""Bewerte die Kommunikationskompetenz des Benutzers basierend auf dieser Coaching-Session.
Kategorie: {contextCategory}
Bewerte folgende Dimensionen auf einer Skala von 0-100:
- empathy: Einfühlungsvermögen
- clarity: Klarheit der Kommunikation
- assertiveness: Durchsetzungsfähigkeit
- listening: Zuhörfähigkeit
- selfReflection: Selbstreflexion
Antworte AUSSCHLIESSLICH als JSON-Array:
[
{{"dimension": "empathy", "score": 65, "trend": "improving", "evidence": "Zeigt zunehmendes Verständnis..."}},
{{"dimension": "clarity", "score": 70, "trend": "stable", "evidence": "..."}}
]
Trend: "improving", "stable", oder "declining" basierend auf dem Gesprächsverlauf.
Gespräch:
{conversation}"""
def buildKeyTopicsExtractionPrompt(summary: str, messages: List[Dict[str, Any]]) -> str:
"""Extract 2-5 key topics from session for indexing."""
return f"""Extrahiere 2-5 Kernthemen aus dieser Coaching-Session.
Antworte AUSSCHLIESSLICH als JSON-Array von Strings:
["Thema 1", "Thema 2", "Thema 3"]
Zusammenfassung: {summary[:500]}
Nur konkrete Themen (z.B. Delegation, Feedback-Gespräch, Konflikt mit Vorgesetztem)."""
def buildFullContextSummaryPrompt(
sessionSummaries: List[Dict[str, Any]],
currentSessionSummary: Optional[str],
currentSessionMessages: List[Dict[str, Any]],
contextTitle: str,
) -> str:
"""Build prompt for full context summary (summarize_all intent)."""
parts = []
for s in sessionSummaries:
dateStr = s.get("date", "")
summary = s.get("summary", "")
if summary:
parts.append(f"Session {dateStr}: {summary}")
if currentSessionSummary:
parts.append(f"Aktuelle Session (zusammengefasst): {currentSessionSummary}")
recent = "\n".join(
f"{m.get('role','user')}: {m.get('content','')[:200]}"
for m in currentSessionMessages[-10:]
)
if recent:
parts.append(f"Aktuelle Session (letzte Nachrichten):\n{recent}")
combined = "\n\n".join(parts)
return f"""Erstelle eine kompakte Gesamtzusammenfassung aller Coaching-Sessions zum Thema "{contextTitle}".
Struktur:
1. **Gesamtüberblick**: Was wurde über alle Sessions hinweg besprochen
2. **Entwicklung**: Wie hat sich das Thema/thematische Schwerpunkte entwickelt
3. **Offene Punkte**: Was steht noch aus
4. **Empfehlung**: Kurzer nächster Fokus
Inhalt:
{combined[:6000]}
Antworte auf Deutsch, sachlich, 4-6 Absätze."""
def buildRollingOverviewPrompt(sessionSummaries: List[Dict[str, Any]], contextTitle: str) -> str:
"""Build prompt for rolling overview (compress many sessions)."""
parts = []
for s in sessionSummaries:
dateStr = s.get("date", "")
summary = s.get("summary", "")
if summary:
parts.append(f"- {dateStr}: {summary[:300]}")
combined = "\n".join(parts)
return f"""Fasse die folgenden Coaching-Sessions zum Thema "{contextTitle}" in 4-6 Sätzen zusammen.
Behalte: Kernthemen, Fortschritte, wichtige Erkenntnisse, offene Punkte.
Entferne Wiederholungen.
Sessions:
{combined}
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:
"""Build a prompt to extract actionable tasks from a session."""
recentForTasks = messages[-25:] if len(messages) > 25 else messages
conversation = ""
for msg in recentForTasks:
role = "Benutzer" if msg.get("role") == "user" else "Coach"
conversation += f"\n{role}: {msg.get('content', '')}"
return f"""Extrahiere konkrete Aufgaben/nächste Schritte aus diesem Coaching-Gespräch.
Nur Aufgaben, die der Benutzer selbst umsetzen soll.
Antworte AUSSCHLIESSLICH als JSON-Array:
[
{{"title": "Aufgabentitel", "description": "Kurze Beschreibung", "priority": "medium"}}
]
priority: "low", "medium", oder "high"
Maximal 3 Aufgaben. Wenn keine klar erkennbar: leeres Array [].
Gespräch:
{conversation}"""
def parseJsonResponse(responseText: str, fallback: Any = None) -> Any:
"""Parse a JSON response from AI, handling markdown code blocks."""
text = responseText.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"Failed to parse AI JSON response: {text[:200]}")
return fallback
_parseAiJsonSafe = parseJsonResponse
def _parseJsonField(value: Optional[str], fallback: Any = None) -> Any:
if not value:
return fallback
if isinstance(value, (list, dict)):
return value
try:
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}}"""