From 21b8a4efb1fe3ceb076112121b17731204866f91 Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Tue, 10 Mar 2026 14:41:54 +0200 Subject: [PATCH] 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 --- article_summary/__init__.py | 163 ++++++++++++++++++++++++++++++------ article_summary/state.py | 3 +- 2 files changed, 141 insertions(+), 25 deletions(-) diff --git a/article_summary/__init__.py b/article_summary/__init__.py index 14b3ab3..a0dfe08 100644 --- a/article_summary/__init__.py +++ b/article_summary/__init__.py @@ -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( diff --git a/article_summary/state.py b/article_summary/state.py index 0c52b68..f5e8db8 100644 --- a/article_summary/state.py +++ b/article_summary/state.py @@ -24,7 +24,8 @@ class ArticleSession: url: str = "" title: str = "" content: str = "" - language: str = "" + language: str = "" # Audio output language (set by user choice) + ui_language: str = "en" # Detected from article content, used for UI strings duration_minutes: int = 10 topics: list[str] = field(default_factory=list) detected_topics: list[str] = field(default_factory=list)