fix(MAT-166): robust option matching + language-aware UI for article summary

Replace brittle exact-string matching with keyword/substring classifier
that handles edge cases (punctuation, partial matches, German variants).
Detect article language and present all prompts in the users language.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-03-10 14:41:54 +02:00
parent 62cc2a92fe
commit 21b8a4efb1
2 changed files with 141 additions and 25 deletions

View File

@@ -23,6 +23,14 @@ URL_PATTERN = re.compile(r'https?://[^\s\)>\]"]+')
CANCEL_WORDS = {"cancel", "stop", "abbrechen", "abbruch", "nevermind"}
# Keyword sets for robust option matching (substring search, not exact match)
_DISCUSS_KW = {"discuss", "diskutieren", "besprechen", "reden", "talk", "chat"}
_TEXT_KW = {"text", "zusammenfassung", "summary", "lesen", "read", "schriftlich", "written"}
_AUDIO_KW = {"audio", "mp3", "anhören", "vorlesen", "hören", "listen", "blinkist", "abspielen", "podcast"}
# Simple German detection: common words that appear frequently in German text
_DE_INDICATORS = {"der", "die", "das", "und", "ist", "ein", "eine", "für", "mit", "auf", "den", "dem", "sich", "nicht", "von", "wird", "auch", "nach", "wie", "aber"}
LANGUAGE_OPTIONS = {
"1": ("en", "English"),
"2": ("de", "German"),
@@ -43,6 +51,44 @@ DURATION_OPTIONS = {
}
def _detect_content_lang(text: str) -> str:
"""Detect language from text content. Returns 'de' or 'en'."""
words = set(re.findall(r'\b\w+\b', text.lower()))
de_hits = len(words & _DE_INDICATORS)
return "de" if de_hits >= 4 else "en"
def _classify_choice(body: str) -> str | None:
"""Classify user's action choice from free-form text.
Returns 'discuss', 'text', 'audio', or None (unrecognized).
"""
# Normalize: lowercase, strip punctuation around digits
raw = body.strip().lower()
# Extract bare number if message is just "3." or "3!" or "nummer 3" etc.
num_match = re.search(r'\b([123])\b', raw)
bare_num = num_match.group(1) if num_match else None
# Number-only messages (highest priority — unambiguous)
stripped = re.sub(r'[^\w\s]', '', raw).strip()
if stripped in ("1", "2", "3"):
return {"1": "discuss", "2": "text", "3": "audio"}[stripped]
# Keyword search (substring matching)
if any(kw in raw for kw in _AUDIO_KW):
return "audio"
if any(kw in raw for kw in _TEXT_KW):
return "text"
if any(kw in raw for kw in _DISCUSS_KW):
return "discuss"
# "nummer 3" / "option 3" / "3. bitte" — number in context
if bare_num:
return {"1": "discuss", "2": "text", "3": "audio"}[bare_num]
return None
class ArticleSummaryHandler:
"""Handles the interactive article summary conversation flow."""
@@ -76,8 +122,9 @@ class ArticleSummaryHandler:
# Cancel from any active state
if session.state != ArticleState.IDLE and body_lower in CANCEL_WORDS:
ui_de = session.ui_language == "de"
self.sessions.reset(sender, room_id)
return "Summary cancelled."
return "Zusammenfassung abgebrochen." if ui_de else "Summary cancelled."
# Route based on current state
if session.state == ArticleState.IDLE:
@@ -100,6 +147,8 @@ class ArticleSummaryHandler:
return self._on_topics(room_id, sender, body)
elif session.state == ArticleState.GENERATING:
if session.ui_language == "de":
return "Zusammenfassung wird noch erstellt, bitte warten..."
return "Still generating your summary, please wait..."
elif session.state == ArticleState.COMPLETE:
@@ -145,6 +194,19 @@ class ArticleSummaryHandler:
if session.detected_topics:
topics_hint = f"\nTopics: {', '.join(session.detected_topics)}"
# Detect article language for localized UI
lang = _detect_content_lang(session.content[:2000])
session.ui_language = lang
if lang == "de":
return (
f"**Gefunden:** {session.title} (~{read_time} min Lesezeit){topics_hint}\n\n"
f"Was möchtest du damit machen?\n"
f"1\ufe0f\u20e3 **Diskutieren** \u2014 Ich lese den Artikel und wir reden darüber\n"
f"2\ufe0f\u20e3 **Textzusammenfassung** \u2014 Kurze schriftliche Zusammenfassung\n"
f"3\ufe0f\u20e3 **Audiozusammenfassung** \u2014 Blinkist-Style MP3\n\n"
f"_(oder schreib einfach weiter \u2014 ich unterbreche nicht)_"
)
return (
f"**Found:** {session.title} (~{read_time} min read){topics_hint}\n\n"
f"What would you like to do?\n"
@@ -159,14 +221,24 @@ class ArticleSummaryHandler:
) -> str | None:
"""Handle language selection."""
lang = LANGUAGE_OPTIONS.get(choice)
session = self.sessions.get(sender, room_id)
ui_de = session.ui_language == "de"
if not lang:
if ui_de:
return "Bitte wähle eine Sprache: **1** für Englisch, **2** für Deutsch."
return "Please pick a language: **1** for English, **2** for German."
session = self.sessions.get(sender, room_id)
session.language = lang[0]
session.state = ArticleState.LANGUAGE
self.sessions.touch(sender, room_id)
if ui_de:
return (
f"Sprache: **{lang[1]}**. Wie lang soll die Zusammenfassung sein?\n"
f"1⃣ 5 Min (kurz)\n"
f"2⃣ 10 Min (standard)\n"
f"3⃣ 15 Min (ausführlich)"
)
return (
f"Language: **{lang[1]}**. How long should the summary be?\n"
f"1⃣ 5 min (short)\n"
@@ -179,10 +251,13 @@ class ArticleSummaryHandler:
) -> str | None:
"""Handle duration selection."""
duration = DURATION_OPTIONS.get(choice)
session = self.sessions.get(sender, room_id)
ui_de = session.ui_language == "de"
if not duration:
if ui_de:
return "Bitte wähle: **1** (5 Min), **2** (10 Min) oder **3** (15 Min)."
return "Please pick: **1** (5 min), **2** (10 min), or **3** (15 min)."
session = self.sessions.get(sender, room_id)
session.duration_minutes = duration
session.state = ArticleState.DURATION
self.sessions.touch(sender, room_id)
@@ -191,12 +266,23 @@ class ArticleSummaryHandler:
topic_list = "\n".join(
f"{t}" for t in session.detected_topics
)
if ui_de:
return (
f"Dauer: **{duration} Min**. Auf welche Themen fokussieren?\n"
f"{topic_list}\n\n"
f"Antworte mit Themennummern (kommagetrennt), bestimmten Themen oder **alle**."
)
return (
f"Duration: **{duration} min**. Focus on which topics?\n"
f"{topic_list}\n\n"
f"Reply with topic numbers (comma-separated), specific topics, or **all**."
)
else:
if ui_de:
return (
f"Dauer: **{duration} Min**. Bestimmte Themen im Fokus?\n"
f"Antworte mit Themen (kommagetrennt) oder **alle** für eine allgemeine Zusammenfassung."
)
return (
f"Duration: **{duration} min**. Any specific topics to focus on?\n"
f"Reply with topics (comma-separated) or **all** for a general summary."
@@ -234,23 +320,21 @@ class ArticleSummaryHandler:
) -> str | None:
"""Handle user's choice after URL detection: discuss, text summary, or audio."""
session = self.sessions.get(sender, room_id)
choice = _classify_choice(body)
# Option 1: Discuss — reset FSM, return article context for AI handler
if body_lower in ("1", "discuss", "diskutieren", "besprechen"):
if choice == "discuss":
article_context = session.content[:8000]
title = session.title
self.sessions.reset(sender, room_id)
return f"__DISCUSS__{title}\n{article_context}"
# Option 2: Text summary — generate and return text, no TTS
if body_lower in ("2", "text", "text summary", "zusammenfassung", "textzusammenfassung"):
if choice == "text":
return await self._generate_text_summary(room_id, sender)
# Option 3: Audio summary — enter language selection (existing flow)
if body_lower in ("3", "audio", "audio summary", "audiozusammenfassung", "audio zusammenfassung"):
if choice == "audio":
return self._prompt_language(room_id, sender)
# Anything else — user is just chatting, reset and pass through with article context
# Unrecognized — user is just chatting, pass through with article context
article_context = session.content[:8000]
title = session.title
self.sessions.reset(sender, room_id)
@@ -261,6 +345,12 @@ class ArticleSummaryHandler:
session = self.sessions.get(sender, room_id)
session.state = ArticleState.AWAITING_LANGUAGE
self.sessions.touch(sender, room_id)
if session.ui_language == "de":
return (
"In welcher Sprache soll die Audiozusammenfassung sein?\n"
"1\ufe0f\u20e3 Englisch\n"
"2\ufe0f\u20e3 Deutsch"
)
return (
"What language for the audio summary?\n"
"1\ufe0f\u20e3 English\n"
@@ -293,6 +383,11 @@ class ArticleSummaryHandler:
session.summary_text = summary
session.state = ArticleState.COMPLETE
self.sessions.touch(sender, room_id)
if session.ui_language == "de":
return (
f"**Zusammenfassung: {session.title}**\n\n{summary}\n\n"
f"_Stelle Folgefragen oder teile einen neuen Link._"
)
return (
f"**Summary: {session.title}**\n\n{summary}\n\n"
f"_Ask follow-up questions or share a new link._"
@@ -306,12 +401,20 @@ class ArticleSummaryHandler:
"""Run the full pipeline: summarize → TTS → upload MP3."""
session = self.sessions.get(sender, room_id)
topics_str = ", ".join(session.topics) if session.topics else "all topics"
await bot._send_text(
room_id,
f"Generating {session.duration_minutes}-min {session.language.upper()} "
f"summary of **{session.title}** (focus: {topics_str})...",
)
ui_de = session.ui_language == "de"
topics_str = ", ".join(session.topics) if session.topics else ("alle Themen" if ui_de else "all topics")
if ui_de:
await bot._send_text(
room_id,
f"Erstelle {session.duration_minutes}-Min {session.language.upper()} "
f"Zusammenfassung von **{session.title}** (Fokus: {topics_str})...",
)
else:
await bot._send_text(
room_id,
f"Generating {session.duration_minutes}-min {session.language.upper()} "
f"summary of **{session.title}** (focus: {topics_str})...",
)
try:
# Step 1: Summarize
@@ -343,20 +446,32 @@ class ArticleSummaryHandler:
transcript_preview = summary[:500]
if len(summary) > 500:
transcript_preview += "..."
await bot._send_text(
room_id,
f"**Summary of:** {session.title}\n\n{transcript_preview}\n\n"
f"_You can ask follow-up questions about this article._",
)
if ui_de:
await bot._send_text(
room_id,
f"**Zusammenfassung von:** {session.title}\n\n{transcript_preview}\n\n"
f"_Du kannst Folgefragen zu diesem Artikel stellen._",
)
else:
await bot._send_text(
room_id,
f"**Summary of:** {session.title}\n\n{transcript_preview}\n\n"
f"_You can ask follow-up questions about this article._",
)
session.state = ArticleState.COMPLETE
self.sessions.touch(sender, room_id)
except Exception:
logger.exception("Article summary pipeline failed for %s", session.url)
await bot._send_text(
room_id, "Sorry, I couldn't generate the audio summary. Please try again."
)
if ui_de:
await bot._send_text(
room_id, "Entschuldigung, die Audiozusammenfassung konnte nicht erstellt werden. Bitte versuche es erneut."
)
else:
await bot._send_text(
room_id, "Sorry, I couldn't generate the audio summary. Please try again."
)
self.sessions.reset(sender, room_id)
async def _on_followup(