gateway/modules/features/commcoach/serviceCommcoachAi.py

502 lines
20 KiB
Python

# 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".
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 die der Benutzer aufbewahren kann. Erstelle ein Dokument wenn: der Benutzer explizit darum bittet, du strukturierte Inhalte lieferst, oder Material zum Aufbewahren sinnvoll ist. Wenn keine: leeres Array [].
Dokument-Format:
{"title": "Dateiname_mit_Extension.html", "content": "...vollstaendiger Inhalt..."}
- Der Title IST der Dateiname inkl. Extension (.html, .md, .txt etc.)
- Fuer HTML-Dokumente: Erstelle VOLLSTAENDIGES, professionell gestyltes HTML mit inline CSS. Kein Markdown, sondern fertiges HTML mit Farben, Layout, Typografie.
- Fuer andere Dokumente: Verwende Markdown.
- WICHTIG: Der Content muss VOLLSTAENDIG und AUSFUEHRLICH sein. Keine Platzhalter, keine "hier kommt..."-Abschnitte. Schreibe echte, detaillierte Inhalte basierend auf allen verfuegbaren Informationen aus dem Chat und den Dokumenten.
- Laengenbeschraenkung fuer Dokumente: KEINE. Schreibe so viel wie noetig fuer ein vollstaendiges Ergebnis.
Kanalverteilung:
- Fakten, Listen, Übungen -> text
- Empathie, Einordnung, Nachfragen -> speech
- Erstellte Dateien, Materialien zum Aufbewahren -> documents
WICHTIG: Antworte NUR mit dem JSON-Objekt. Kein Text vor oder nach dem JSON."""
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") or retrievedSession.get("createdAt")
if startedAt:
try:
from datetime import datetime
dt = datetime.fromisoformat(str(startedAt).replace("Z", "+00:00"))
dateStr = dt.strftime("%d.%m.%Y")
except Exception:
pass
prompt += f"\n\nVom Benutzer angefragte Session ({dateStr}):"
prompt += f"\n{retrievedSession.get('summary', '')[:500]}"
if retrievedByTopic:
prompt += "\n\nRelevante Sessions zum angefragten Thema:"
for s in retrievedByTopic[:3]:
summary = s.get("summary", "")
dateStr = s.get("date", "")
if summary:
prompt += f"\n- [{dateStr}] {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 as JSON with plain text and styled HTML email."""
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 mit zwei Feldern:
{{
"summary": "Kompakte Zusammenfassung als Plaintext (fuer Anzeige in der App). Struktur: 1. Kernthema, 2. Erkenntnisse, 3. Naechste Schritte, 4. Fortschritt.",
"emailHtml": "<div>...</div>"
}}
Fuer "emailHtml": Erstelle ein professionell formatiertes HTML-Fragment (KEIN vollstaendiges HTML-Dokument, nur der Inhalt-Block).
Verwende inline CSS fuer schoene Darstellung in E-Mail-Clients:
- Verwende <h3> fuer Abschnitte (color: #1e40af; margin: 20px 0 8px; font-size: 16px)
- Verwende <ul>/<li> fuer Stichpunkte (margin: 4px 0; line-height: 1.6)
- Verwende <strong> fuer Hervorhebungen
- Verwende <p> fuer Fliesstext (color: #374151; line-height: 1.65; font-size: 15px)
- Verwende <hr style="border:none;border-top:1px solid #e5e7eb;margin:20px 0"> als Trenner
Fuer "summary": Kompakter Plaintext ohne HTML/Markdown. Abschnitte mit Zeilenumbruechen trennen.
Gespräch:
{conversation}
Antworte auf Deutsch, sachlich 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}}"""