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:
@@ -23,6 +23,14 @@ URL_PATTERN = re.compile(r'https?://[^\s\)>\]"]+')
|
|||||||
|
|
||||||
CANCEL_WORDS = {"cancel", "stop", "abbrechen", "abbruch", "nevermind"}
|
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 = {
|
LANGUAGE_OPTIONS = {
|
||||||
"1": ("en", "English"),
|
"1": ("en", "English"),
|
||||||
"2": ("de", "German"),
|
"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:
|
class ArticleSummaryHandler:
|
||||||
"""Handles the interactive article summary conversation flow."""
|
"""Handles the interactive article summary conversation flow."""
|
||||||
|
|
||||||
@@ -76,8 +122,9 @@ class ArticleSummaryHandler:
|
|||||||
|
|
||||||
# Cancel from any active state
|
# Cancel from any active state
|
||||||
if session.state != ArticleState.IDLE and body_lower in CANCEL_WORDS:
|
if session.state != ArticleState.IDLE and body_lower in CANCEL_WORDS:
|
||||||
|
ui_de = session.ui_language == "de"
|
||||||
self.sessions.reset(sender, room_id)
|
self.sessions.reset(sender, room_id)
|
||||||
return "Summary cancelled."
|
return "Zusammenfassung abgebrochen." if ui_de else "Summary cancelled."
|
||||||
|
|
||||||
# Route based on current state
|
# Route based on current state
|
||||||
if session.state == ArticleState.IDLE:
|
if session.state == ArticleState.IDLE:
|
||||||
@@ -100,6 +147,8 @@ class ArticleSummaryHandler:
|
|||||||
return self._on_topics(room_id, sender, body)
|
return self._on_topics(room_id, sender, body)
|
||||||
|
|
||||||
elif session.state == ArticleState.GENERATING:
|
elif session.state == ArticleState.GENERATING:
|
||||||
|
if session.ui_language == "de":
|
||||||
|
return "Zusammenfassung wird noch erstellt, bitte warten..."
|
||||||
return "Still generating your summary, please wait..."
|
return "Still generating your summary, please wait..."
|
||||||
|
|
||||||
elif session.state == ArticleState.COMPLETE:
|
elif session.state == ArticleState.COMPLETE:
|
||||||
@@ -145,6 +194,19 @@ class ArticleSummaryHandler:
|
|||||||
if session.detected_topics:
|
if session.detected_topics:
|
||||||
topics_hint = f"\nTopics: {', '.join(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 (
|
return (
|
||||||
f"**Found:** {session.title} (~{read_time} min read){topics_hint}\n\n"
|
f"**Found:** {session.title} (~{read_time} min read){topics_hint}\n\n"
|
||||||
f"What would you like to do?\n"
|
f"What would you like to do?\n"
|
||||||
@@ -159,14 +221,24 @@ class ArticleSummaryHandler:
|
|||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Handle language selection."""
|
"""Handle language selection."""
|
||||||
lang = LANGUAGE_OPTIONS.get(choice)
|
lang = LANGUAGE_OPTIONS.get(choice)
|
||||||
|
session = self.sessions.get(sender, room_id)
|
||||||
|
ui_de = session.ui_language == "de"
|
||||||
if not lang:
|
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."
|
return "Please pick a language: **1** for English, **2** for German."
|
||||||
|
|
||||||
session = self.sessions.get(sender, room_id)
|
|
||||||
session.language = lang[0]
|
session.language = lang[0]
|
||||||
session.state = ArticleState.LANGUAGE
|
session.state = ArticleState.LANGUAGE
|
||||||
self.sessions.touch(sender, room_id)
|
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 (
|
return (
|
||||||
f"Language: **{lang[1]}**. How long should the summary be?\n"
|
f"Language: **{lang[1]}**. How long should the summary be?\n"
|
||||||
f"1️⃣ 5 min (short)\n"
|
f"1️⃣ 5 min (short)\n"
|
||||||
@@ -179,10 +251,13 @@ class ArticleSummaryHandler:
|
|||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Handle duration selection."""
|
"""Handle duration selection."""
|
||||||
duration = DURATION_OPTIONS.get(choice)
|
duration = DURATION_OPTIONS.get(choice)
|
||||||
|
session = self.sessions.get(sender, room_id)
|
||||||
|
ui_de = session.ui_language == "de"
|
||||||
if not duration:
|
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)."
|
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.duration_minutes = duration
|
||||||
session.state = ArticleState.DURATION
|
session.state = ArticleState.DURATION
|
||||||
self.sessions.touch(sender, room_id)
|
self.sessions.touch(sender, room_id)
|
||||||
@@ -191,12 +266,23 @@ class ArticleSummaryHandler:
|
|||||||
topic_list = "\n".join(
|
topic_list = "\n".join(
|
||||||
f" • {t}" for t in session.detected_topics
|
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 (
|
return (
|
||||||
f"Duration: **{duration} min**. Focus on which topics?\n"
|
f"Duration: **{duration} min**. Focus on which topics?\n"
|
||||||
f"{topic_list}\n\n"
|
f"{topic_list}\n\n"
|
||||||
f"Reply with topic numbers (comma-separated), specific topics, or **all**."
|
f"Reply with topic numbers (comma-separated), specific topics, or **all**."
|
||||||
)
|
)
|
||||||
else:
|
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 (
|
return (
|
||||||
f"Duration: **{duration} min**. Any specific topics to focus on?\n"
|
f"Duration: **{duration} min**. Any specific topics to focus on?\n"
|
||||||
f"Reply with topics (comma-separated) or **all** for a general summary."
|
f"Reply with topics (comma-separated) or **all** for a general summary."
|
||||||
@@ -234,23 +320,21 @@ class ArticleSummaryHandler:
|
|||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Handle user's choice after URL detection: discuss, text summary, or audio."""
|
"""Handle user's choice after URL detection: discuss, text summary, or audio."""
|
||||||
session = self.sessions.get(sender, room_id)
|
session = self.sessions.get(sender, room_id)
|
||||||
|
choice = _classify_choice(body)
|
||||||
|
|
||||||
# Option 1: Discuss — reset FSM, return article context for AI handler
|
if choice == "discuss":
|
||||||
if body_lower in ("1", "discuss", "diskutieren", "besprechen"):
|
|
||||||
article_context = session.content[:8000]
|
article_context = session.content[:8000]
|
||||||
title = session.title
|
title = session.title
|
||||||
self.sessions.reset(sender, room_id)
|
self.sessions.reset(sender, room_id)
|
||||||
return f"__DISCUSS__{title}\n{article_context}"
|
return f"__DISCUSS__{title}\n{article_context}"
|
||||||
|
|
||||||
# Option 2: Text summary — generate and return text, no TTS
|
if choice == "text":
|
||||||
if body_lower in ("2", "text", "text summary", "zusammenfassung", "textzusammenfassung"):
|
|
||||||
return await self._generate_text_summary(room_id, sender)
|
return await self._generate_text_summary(room_id, sender)
|
||||||
|
|
||||||
# Option 3: Audio summary — enter language selection (existing flow)
|
if choice == "audio":
|
||||||
if body_lower in ("3", "audio", "audio summary", "audiozusammenfassung", "audio zusammenfassung"):
|
|
||||||
return self._prompt_language(room_id, sender)
|
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]
|
article_context = session.content[:8000]
|
||||||
title = session.title
|
title = session.title
|
||||||
self.sessions.reset(sender, room_id)
|
self.sessions.reset(sender, room_id)
|
||||||
@@ -261,6 +345,12 @@ class ArticleSummaryHandler:
|
|||||||
session = self.sessions.get(sender, room_id)
|
session = self.sessions.get(sender, room_id)
|
||||||
session.state = ArticleState.AWAITING_LANGUAGE
|
session.state = ArticleState.AWAITING_LANGUAGE
|
||||||
self.sessions.touch(sender, room_id)
|
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 (
|
return (
|
||||||
"What language for the audio summary?\n"
|
"What language for the audio summary?\n"
|
||||||
"1\ufe0f\u20e3 English\n"
|
"1\ufe0f\u20e3 English\n"
|
||||||
@@ -293,6 +383,11 @@ class ArticleSummaryHandler:
|
|||||||
session.summary_text = summary
|
session.summary_text = summary
|
||||||
session.state = ArticleState.COMPLETE
|
session.state = ArticleState.COMPLETE
|
||||||
self.sessions.touch(sender, room_id)
|
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 (
|
return (
|
||||||
f"**Summary: {session.title}**\n\n{summary}\n\n"
|
f"**Summary: {session.title}**\n\n{summary}\n\n"
|
||||||
f"_Ask follow-up questions or share a new link._"
|
f"_Ask follow-up questions or share a new link._"
|
||||||
@@ -306,12 +401,20 @@ class ArticleSummaryHandler:
|
|||||||
"""Run the full pipeline: summarize → TTS → upload MP3."""
|
"""Run the full pipeline: summarize → TTS → upload MP3."""
|
||||||
session = self.sessions.get(sender, room_id)
|
session = self.sessions.get(sender, room_id)
|
||||||
|
|
||||||
topics_str = ", ".join(session.topics) if session.topics else "all topics"
|
ui_de = session.ui_language == "de"
|
||||||
await bot._send_text(
|
topics_str = ", ".join(session.topics) if session.topics else ("alle Themen" if ui_de else "all topics")
|
||||||
room_id,
|
if ui_de:
|
||||||
f"Generating {session.duration_minutes}-min {session.language.upper()} "
|
await bot._send_text(
|
||||||
f"summary of **{session.title}** (focus: {topics_str})...",
|
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:
|
try:
|
||||||
# Step 1: Summarize
|
# Step 1: Summarize
|
||||||
@@ -343,20 +446,32 @@ class ArticleSummaryHandler:
|
|||||||
transcript_preview = summary[:500]
|
transcript_preview = summary[:500]
|
||||||
if len(summary) > 500:
|
if len(summary) > 500:
|
||||||
transcript_preview += "..."
|
transcript_preview += "..."
|
||||||
await bot._send_text(
|
if ui_de:
|
||||||
room_id,
|
await bot._send_text(
|
||||||
f"**Summary of:** {session.title}\n\n{transcript_preview}\n\n"
|
room_id,
|
||||||
f"_You can ask follow-up questions about this article._",
|
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
|
session.state = ArticleState.COMPLETE
|
||||||
self.sessions.touch(sender, room_id)
|
self.sessions.touch(sender, room_id)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Article summary pipeline failed for %s", session.url)
|
logger.exception("Article summary pipeline failed for %s", session.url)
|
||||||
await bot._send_text(
|
if ui_de:
|
||||||
room_id, "Sorry, I couldn't generate the audio summary. Please try again."
|
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)
|
self.sessions.reset(sender, room_id)
|
||||||
|
|
||||||
async def _on_followup(
|
async def _on_followup(
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ class ArticleSession:
|
|||||||
url: str = ""
|
url: str = ""
|
||||||
title: str = ""
|
title: str = ""
|
||||||
content: 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
|
duration_minutes: int = 10
|
||||||
topics: list[str] = field(default_factory=list)
|
topics: list[str] = field(default_factory=list)
|
||||||
detected_topics: list[str] = field(default_factory=list)
|
detected_topics: list[str] = field(default_factory=list)
|
||||||
|
|||||||
Reference in New Issue
Block a user