Skip to content

feat: question-understanding pipeline with intent classification, clarifying questions, and suggested follow-ups - #81

Open
Joyful12-tech wants to merge 1 commit into
Deen-Bridge:devfrom
Joyful12-tech:feat/question-understanding-pipeline
Open

feat: question-understanding pipeline with intent classification, clarifying questions, and suggested follow-ups#81
Joyful12-tech wants to merge 1 commit into
Deen-Bridge:devfrom
Joyful12-tech:feat/question-understanding-pipeline

Conversation

@Joyful12-tech

@Joyful12-tech Joyful12-tech commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Implements a complete question-understanding pipeline for the /chat endpoint that classifies every message by intent, calibrates answer shape and length per intent, asks clarifying questions before guessing on ambiguous questions, and returns 2-3 suggested follow-up questions.

Changes

  • New: intent.py — Intent taxonomy (6 intents), deterministic short-circuit for salam/greetings, Gemini-based classifier for non-trivial messages, per-intent generation config table, ambiguity detection, follow-up parsing with defensive parse-or-degrade pattern, and no-double-clarification guard
  • Modified: main.py — Intent classification integrated into /chat flow, clarifying-question early return, per-intent generation config replaces hardcoded values, follow-up extraction and stripping, intent metadata in ChatResponse
  • Modified: CI — intent.py added to lint and syntax checks, tests/test_intent.py added to test suite
  • New: tests/test_intent.py — 53 tests covering short-circuit, classification, per-intent configs, follow-up parsing, no-double-clarification guard, ambiguity detection, and end-to-end flow

Acceptance criteria

  • "Assalamu alaykum" classified as greeting without LLM call
  • Classification adds at most one lightweight model call with deterministic short-circuit for trivial messages
  • Ambiguous questions trigger clarifying question with needs_clarification: true; follow-up does not loop
  • suggested_followups contains 2-3 items for knowledge intents, empty on parse failure, raw block never leaks
  • intent present in response metadata; hardcoded generation config replaced by per-intent table
  • All 53 new tests pass; linting clean

Closes #42

Summary by CodeRabbit

  • New Features

    • Added intent-aware chat responses for greetings, factual questions, religious rulings, personal guidance, platform questions, and out-of-scope requests.
    • Added clarifying questions for ambiguous messages, with safeguards against repeated clarification prompts.
    • Added intent-specific response behavior and generation settings.
    • Added suggested follow-up questions, displayed separately from the main answer.
    • Chat responses now include detected intent and clarification details.
  • Bug Fixes

    • Improved handling of malformed or unexpected classification responses with safe fallbacks.
  • Tests

    • Added comprehensive automated coverage for intent classification, ambiguity handling, and follow-up suggestions.

… clarifying questions, and suggested follow-ups
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Intent-aware chat pipeline

Layer / File(s) Summary
Intent classification and ambiguity decisions
intent.py, tests/test_intent.py
Adds intent/result models, greeting short-circuiting, LLM classification, ambiguity thresholds, safe fallbacks, and related tests.
Intent configuration and follow-up processing
intent.py, tests/test_intent.py
Adds per-intent generation settings, instruction snippets, follow-up extraction/stripping, clarification guards, and validation tests.
Chat endpoint integration
main.py
Applies classification during /chat, supports clarification responses, intent-specific generation, follow-up metadata, updated confidence signals, cache metadata, and session cleanup.
CI and end-to-end intent validation
.github/workflows/ci.yml, tests/test_intent.py
Adds intent linting, syntax compilation, dedicated pytest execution, edge-case coverage, and an integrated intent-flow test.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ChatHandler
  participant classify_intent
  participant Gemini
  Client->>ChatHandler: Submit chat message
  ChatHandler->>classify_intent: Classify intent
  classify_intent->>Gemini: Request structured classification
  Gemini-->>classify_intent: Return intent and ambiguity
  ChatHandler->>ChatHandler: Apply clarification guard
  ChatHandler->>Gemini: Generate configured response
  Gemini-->>ChatHandler: Return answer and follow-ups
  ChatHandler-->>Client: Return ChatResponse metadata
Loading

Possibly related PRs

Suggested reviewers: zeemscript, fury03

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the new intent classification, clarification, and follow-up pipeline.
Linked Issues check ✅ Passed The PR implements the requested intent pipeline, clarification guard, follow-up parsing, metadata, tests, and CI coverage.
Out of Scope Changes check ✅ Passed The changes stay focused on the question-understanding pipeline and supporting test/CI updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@zeemscript

Copy link
Copy Markdown
Contributor

@Joyful12-tech fix conflicts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
main.py (1)

248-341: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Intent classification blocks the event loop, runs before the cache check, and rebuilds a model client on every request.

Three compounding issues in this block:

  1. Blocking call inside an async endpoint. classifier_model.generate_content is called synchronously and un-awaited (line 261-264) inside async def chat(...). This is a real (blocking) network round trip to Gemini that stalls the event loop for every concurrent request being served by this worker, not just the current one. The codebase already has an established pattern for offloading this kind of call — await safety_pipeline.run_async(request.prompt, generate) (line 382) — but this new call bypasses it entirely.
  2. Runs unconditionally before the semantic-cache lookup. Classification (248-271) happens before is_cacheable is computed (308-313) and before the cache-hit check (318-341). That means every cache-HIT request — which should be near-instant — now also pays for a full Gemini round trip it doesn't need, undermining the point of the cache.
  3. A fresh GenerativeModel is instantiated per request (254-260) instead of being created once and reused.
🐛 Suggested direction
-        import time
-        _intent_start = time.monotonic()
-        classifier_model = genai.GenerativeModel(
-            model_name="gemini-1.5-flash",
-            generation_config=genai.types.GenerationConfig(
-                temperature=0.1,
-                max_output_tokens=256,
-            ),
-        )
-        classification = classify_intent(
-            request.prompt,
-            model_callable=classifier_model.generate_content,
-        )
+        _intent_start = time.monotonic()
+        classification = await asyncio.to_thread(
+            classify_intent,
+            request.prompt,
+            model_callable=_CLASSIFIER_MODEL.generate_content,
+        )

Hoist _CLASSIFIER_MODEL = genai.GenerativeModel(...) to module scope, move import time/import asyncio to the top of the file, and consider moving this whole block below the semantic-cache hit check so cached answers skip it entirely.

Separately (root-caused in intent.py): because _llm_classify doesn't catch generic exceptions from the model callable, any transient failure of this "lightweight" classifier currently fails the whole chat turn via the outer except Exception handler — worth fixing there alongside offloading this call.

As per path instructions, "Flag blocking calls inside async endpoints (network calls should be awaited or offloaded)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.py` around lines 248 - 341, Refactor the chat endpoint’s
intent-classification flow around classify_intent so cacheable semantic-cache
hits return before classification, while preserving classification for
non-cached requests. Create the classifier GenerativeModel once at module scope,
move time/asyncio imports to module scope, and offload the synchronous
generate_content call through the established async execution pattern so it
never blocks the event loop. Update intent.py’s _llm_classify to catch generic
model-call exceptions and use its existing fallback behavior instead of failing
the chat turn.

Source: Path instructions

🧹 Nitpick comments (3)
tests/test_intent.py (2)

108-157: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a regression test for the _GREETING_WORDS false-positive.

Given the "good"/"morning"/"afternoon"/"evening" standalone-token issue flagged in intent.py (lines 68-102), a case like _is_trivial_greeting("Good, thanks!") or _is_trivial_greeting("Morning, quick question") asserting False would pin the fix and prevent regression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_intent.py` around lines 108 - 157, Add regression coverage to
TestDeterministicShortCircuit for standalone greeting-word false positives:
assert _is_trivial_greeting returns False for substantive messages such as
“Good, thanks!” and “Morning, quick question”, preserving the existing greeting
and short-circuit tests.

164-241: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding coverage for model_callable raising and for non-dict JSON output.

Tied to the _llm_classify exception-handling gap flagged in intent.py (lines 196-251): a test where model_callable raises (simulating a network/API error) and one where the fixture returns valid JSON that isn't an object (e.g. json.dumps([1, 2])) would both currently propagate an unhandled exception rather than falling back gracefully — worth locking in the fix with tests once it lands.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_intent.py` around lines 164 - 241, Extend TestLLMClassification
with coverage for _llm_classify failures: use a model_callable that raises an
exception and assert classify_intent returns the safe factual-knowledge
fallback, then use FakeModel with JSON representing a non-dict value such as a
list and assert the same graceful fallback without propagating an exception.
intent.py (1)

259-289: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

effective_params() env-var keys collide across intents sharing the same max_output_tokens, and the method is currently unused.

Keys are derived from self.max_output_tokens (e.g. f"TEMP_{self.max_output_tokens}"), not from the intent's identity. FACTUAL_KNOWLEDGE and FIQH_RULING both default to 2048 → they'd both read/write TEMP_2048/TOP_P_2048/etc., even though their base temperatures differ (0.7 vs 0.6). Same collision for PERSONAL_GUIDANCE/PLATFORM_QUESTION (both 1024). Separately, main.py reads intent_config.temperature/.top_p/.top_k/.max_output_tokens directly and never calls effective_params(), so the "env-overridable per intent" behavior promised in the docstring (line 268) doesn't actually apply today.

Recommend keying by intent name instead (and deciding whether main.py should actually call this method), so this doesn't silently misbehave if/when it gets wired up.

♻️ Proposed direction
-    def effective_params(self) -> Dict[str, Any]:
+    def effective_params(self, intent_name: str) -> Dict[str, Any]:
         """Return generation config dict, respecting env overrides."""
         return {
             "temperature": float(os.getenv(
-                f"TEMP_{self.max_output_tokens}",
+                f"TEMP_{intent_name.upper()}",
                 str(self.temperature),
             )),
             ...
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@intent.py` around lines 259 - 289, Update IntentConfig.effective_params() to
derive environment-variable keys from each intent’s unique identity rather than
max_output_tokens, adding and populating that identity wherever IntentConfig
instances are defined. Then update the generation path in main.py to call
effective_params() so per-intent environment overrides are actually applied
while preserving configured defaults.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@intent.py`:
- Around line 196-251: Update _llm_classify to catch any exception from
model_callable, response parsing, and JSON decoding, returning the existing
factual-knowledge ClassificationResult fallback while logging the error. After
json.loads succeeds, validate that data is a dict before calling data.get;
otherwise log the non-object result and return the same fallback.
- Around line 68-102: Remove the standalone “good”, “morning”, “afternoon”, and
“evening” entries from _GREETING_WORDS so _is_trivial_greeting only
short-circuits on unambiguous greetings; retain the existing _SALAM_PATTERNS
handling for complete greeting phrases and leave other greeting words unchanged.

In `@main.py`:
- Around line 277-291: Update the _last_classifications assignments in the
clarification response path and normal response path so the stored
classification cannot carry needs_clarification=True when no clarifying question
was asked on the current turn. Preserve the existing should_clarify decision and
response fields, but store a classification with needs_clarification cleared
after a clarification is suppressed, ensuring the next turn can clarify again.

---

Outside diff comments:
In `@main.py`:
- Around line 248-341: Refactor the chat endpoint’s intent-classification flow
around classify_intent so cacheable semantic-cache hits return before
classification, while preserving classification for non-cached requests. Create
the classifier GenerativeModel once at module scope, move time/asyncio imports
to module scope, and offload the synchronous generate_content call through the
established async execution pattern so it never blocks the event loop. Update
intent.py’s _llm_classify to catch generic model-call exceptions and use its
existing fallback behavior instead of failing the chat turn.

---

Nitpick comments:
In `@intent.py`:
- Around line 259-289: Update IntentConfig.effective_params() to derive
environment-variable keys from each intent’s unique identity rather than
max_output_tokens, adding and populating that identity wherever IntentConfig
instances are defined. Then update the generation path in main.py to call
effective_params() so per-intent environment overrides are actually applied
while preserving configured defaults.

In `@tests/test_intent.py`:
- Around line 108-157: Add regression coverage to TestDeterministicShortCircuit
for standalone greeting-word false positives: assert _is_trivial_greeting
returns False for substantive messages such as “Good, thanks!” and “Morning,
quick question”, preserving the existing greeting and short-circuit tests.
- Around line 164-241: Extend TestLLMClassification with coverage for
_llm_classify failures: use a model_callable that raises an exception and assert
classify_intent returns the safe factual-knowledge fallback, then use FakeModel
with JSON representing a non-dict value such as a list and assert the same
graceful fallback without propagating an exception.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ca9c6201-3ad5-47e7-a1de-632a3cec105a

📥 Commits

Reviewing files that changed from the base of the PR and between 574380a and e06383f.

📒 Files selected for processing (4)
  • .github/workflows/ci.yml
  • intent.py
  • main.py
  • tests/test_intent.py

Comment thread intent.py
Comment on lines +68 to +102
_GREETING_WORDS = {
"hi", "hello", "hey", "salam", "salaam", "assalamu", "alaykum",
"assalamo", "alaikum", "as-salam", "marhaba", "ahlan",
"good", "morning", "afternoon", "evening", "peace",
}

# Short message length threshold — messages at or below this word count
# go through the deterministic check if they match greeting-like patterns.
_SHORT_MSG_THRESHOLD = 5


def _is_trivial_greeting(message: str) -> bool:
"""Return True if *message* is almost certainly a greeting / salam.

This is intentionally conservative — borderline cases fall through to
the LLM classifier so we never misclassify a real question as a greeting.
"""
stripped = message.strip()
if not stripped:
return False

words = stripped.split()
word_count = len(words)

# Very short pure-greeting messages
if word_count <= 3 and words[0].lower().strip("?!.,") in _GREETING_WORDS:
return True

# Salam pattern match
if word_count <= _SHORT_MSG_THRESHOLD:
for pat in _SALAM_PATTERNS:
if pat.match(stripped):
return True

return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

"good"/"morning"/"afternoon"/"evening" as standalone greeting words will misclassify real replies.

Any message with ≤3 words whose first word is "good", "morning", "afternoon", or "evening" short-circuits straight to GREETING_SMALLTALK — e.g. "Good, thanks!", "Morning, quick question", "Good idea". These aren't greetings, and _SALAM_PATTERNS (lines 61-62) already handles the actual "good morning/afternoon/evening/day" phrase, so the standalone tokens are both redundant and unsafe. This directly contradicts the function's own stated intent: "Return True if message is almost certainly a greeting / salam. This is intentionally conservative — borderline cases fall through to the LLM classifier so we never misclassify a real question as a greeting."

🐛 Proposed fix
 _GREETING_WORDS = {
     "hi", "hello", "hey", "salam", "salaam", "assalamu", "alaykum",
     "assalamo", "alaikum", "as-salam", "marhaba", "ahlan",
-    "good", "morning", "afternoon", "evening", "peace",
+    "peace",
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_GREETING_WORDS = {
"hi", "hello", "hey", "salam", "salaam", "assalamu", "alaykum",
"assalamo", "alaikum", "as-salam", "marhaba", "ahlan",
"good", "morning", "afternoon", "evening", "peace",
}
# Short message length threshold — messages at or below this word count
# go through the deterministic check if they match greeting-like patterns.
_SHORT_MSG_THRESHOLD = 5
def _is_trivial_greeting(message: str) -> bool:
"""Return True if *message* is almost certainly a greeting / salam.
This is intentionally conservativeborderline cases fall through to
the LLM classifier so we never misclassify a real question as a greeting.
"""
stripped = message.strip()
if not stripped:
return False
words = stripped.split()
word_count = len(words)
# Very short pure-greeting messages
if word_count <= 3 and words[0].lower().strip("?!.,") in _GREETING_WORDS:
return True
# Salam pattern match
if word_count <= _SHORT_MSG_THRESHOLD:
for pat in _SALAM_PATTERNS:
if pat.match(stripped):
return True
return False
_GREETING_WORDS = {
"hi", "hello", "hey", "salam", "salaam", "assalamu", "alaykum",
"assalamo", "alaikum", "as-salam", "marhaba", "ahlan",
"peace",
}
# Short message length threshold — messages at or below this word count
# go through the deterministic check if they match greeting-like patterns.
_SHORT_MSG_THRESHOLD = 5
def _is_trivial_greeting(message: str) -> bool:
"""Return True if *message* is almost certainly a greeting / salam.
This is intentionally conservativeborderline cases fall through to
the LLM classifier so we never misclassify a real question as a greeting.
"""
stripped = message.strip()
if not stripped:
return False
words = stripped.split()
word_count = len(words)
# Very short pure-greeting messages
if word_count <= 3 and words[0].lower().strip("?!.,") in _GREETING_WORDS:
return True
# Salam pattern match
if word_count <= _SHORT_MSG_THRESHOLD:
for pat in _SALAM_PATTERNS:
if pat.match(stripped):
return True
return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@intent.py` around lines 68 - 102, Remove the standalone “good”, “morning”,
“afternoon”, and “evening” entries from _GREETING_WORDS so _is_trivial_greeting
only short-circuits on unambiguous greetings; retain the existing
_SALAM_PATTERNS handling for complete greeting phrases and leave other greeting
words unchanged.

Comment thread intent.py
Comment on lines +196 to +251
def _llm_classify(message: str, model_callable) -> ClassificationResult:
"""Call the LLM to classify *message*."""
prompt = _CLASSIFIER_PROMPT.format(message=message)

if model_callable is None:
raise RuntimeError(
"classify_intent requires a model_callable for non-trivial messages."
)

try:
response = model_callable(prompt)
raw = response.text.strip()

# Strip markdown code fences if present
if raw.startswith("```"):
# Find the first ``` and the closing ```
start = raw.find("\n") + 1 if "\n" in raw else raw.find("```") + 3
end = raw.rfind("```")
if end > start:
raw = raw[start:end].strip()

data = json.loads(raw)
except (json.JSONDecodeError, AttributeError, ValueError) as exc:
logger.warning("Intent classifier returned unparseable output: %s", exc)
return ClassificationResult(
intent=Intent.FACTUAL_KNOWLEDGE, # safe fallback
)

# Extract and validate intent
intent_str = data.get("intent", "factual_knowledge")
try:
intent = Intent(intent_str)
except ValueError:
logger.warning("Unknown intent '%s'; falling back to factual_knowledge", intent_str)
intent = Intent.FACTUAL_KNOWLEDGE

# Ambiguity
needs_clarification = data.get("needs_clarification", False)
clarifying_question = data.get("clarifying_question", "")
ambiguity_confidence = float(data.get("ambiguity_confidence", 0.0))

# Apply threshold — if confidence is below threshold, don't clarify
if ambiguity_confidence < AMBIGUITY_THRESHOLD:
needs_clarification = False
clarifying_question = ""

# Fallback if needs_clarification is True but no question provided
if needs_clarification and not clarifying_question:
clarifying_question = _FALLBACK_CLARIFY

return ClassificationResult(
intent=intent,
needs_clarification=needs_clarification,
clarifying_question=clarifying_question,
ambiguity_confidence=ambiguity_confidence,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Exception handling in _llm_classify has two gaps that let a classifier hiccup escape the "safe fallback" contract.

  1. data.get(...) calls (lines 225, 233-235) sit outside the try block. If model_callable returns valid-but-non-dict JSON (e.g. "true", [1,2]), json.loads succeeds, but the first data.get(...) raises an uncaught AttributeError that propagates straight out of classify_intent.
  2. The except clause only catches (json.JSONDecodeError, AttributeError, ValueError) — it does not catch exceptions raised by model_callable(prompt) itself (network errors, timeouts, quota/API errors from Gemini). Any transient classifier failure propagates uncaught out of classify_intent.

Since main.py's /chat handler calls classify_intent(...) directly with no local try/except, either failure mode turns a hiccup in this "lightweight" pre-classification step into a full 500 for the whole chat turn — even when the primary answer-generation call would have succeeded fine. Given the PR's stated goal of "safe fallbacks," this deserves a broader catch plus a type check.

🛡️ Proposed fix
     try:
         response = model_callable(prompt)
         raw = response.text.strip()
 
         # Strip markdown code fences if present
         if raw.startswith("```"):
             ...
 
         data = json.loads(raw)
-    except (json.JSONDecodeError, AttributeError, ValueError) as exc:
+    except Exception as exc:
         logger.warning("Intent classifier returned unparseable output: %s", exc)
         return ClassificationResult(
             intent=Intent.FACTUAL_KNOWLEDGE,  # safe fallback
         )
 
+    if not isinstance(data, dict):
+        logger.warning("Intent classifier returned non-object JSON: %r", data)
+        return ClassificationResult(intent=Intent.FACTUAL_KNOWLEDGE)
+
     # Extract and validate intent
     intent_str = data.get("intent", "factual_knowledge")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _llm_classify(message: str, model_callable) -> ClassificationResult:
"""Call the LLM to classify *message*."""
prompt = _CLASSIFIER_PROMPT.format(message=message)
if model_callable is None:
raise RuntimeError(
"classify_intent requires a model_callable for non-trivial messages."
)
try:
response = model_callable(prompt)
raw = response.text.strip()
# Strip markdown code fences if present
if raw.startswith("```"):
# Find the first ``` and the closing ```
start = raw.find("\n") + 1 if "\n" in raw else raw.find("```") + 3
end = raw.rfind("```")
if end > start:
raw = raw[start:end].strip()
data = json.loads(raw)
except (json.JSONDecodeError, AttributeError, ValueError) as exc:
logger.warning("Intent classifier returned unparseable output: %s", exc)
return ClassificationResult(
intent=Intent.FACTUAL_KNOWLEDGE, # safe fallback
)
# Extract and validate intent
intent_str = data.get("intent", "factual_knowledge")
try:
intent = Intent(intent_str)
except ValueError:
logger.warning("Unknown intent '%s'; falling back to factual_knowledge", intent_str)
intent = Intent.FACTUAL_KNOWLEDGE
# Ambiguity
needs_clarification = data.get("needs_clarification", False)
clarifying_question = data.get("clarifying_question", "")
ambiguity_confidence = float(data.get("ambiguity_confidence", 0.0))
# Apply threshold — if confidence is below threshold, don't clarify
if ambiguity_confidence < AMBIGUITY_THRESHOLD:
needs_clarification = False
clarifying_question = ""
# Fallback if needs_clarification is True but no question provided
if needs_clarification and not clarifying_question:
clarifying_question = _FALLBACK_CLARIFY
return ClassificationResult(
intent=intent,
needs_clarification=needs_clarification,
clarifying_question=clarifying_question,
ambiguity_confidence=ambiguity_confidence,
)
def _llm_classify(message: str, model_callable) -> ClassificationResult:
"""Call the LLM to classify *message*."""
prompt = _CLASSIFIER_PROMPT.format(message=message)
if model_callable is None:
raise RuntimeError(
"classify_intent requires a model_callable for non-trivial messages."
)
try:
response = model_callable(prompt)
raw = response.text.strip()
# Strip markdown code fences if present
if raw.startswith("
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@intent.py` around lines 196 - 251, Update _llm_classify to catch any
exception from model_callable, response parsing, and JSON decoding, returning
the existing factual-knowledge ClassificationResult fallback while logging the
error. After json.loads succeeds, validate that data is a dict before calling
data.get; otherwise log the non-object result and return the same fallback.

Comment thread main.py
Comment on lines +277 to +291
last_cls = _last_classifications.get(chat_id)
if should_clarify(classification, last_cls):
_last_classifications[chat_id] = classification
clarifying_question = classification.clarifying_question or (
"Could you please provide more detail so I can help you better?"
)
return ChatResponse(
response=clarifying_question,
chat_id=chat_id,
history=[],
intent=classification.intent.value,
needs_clarification=True,
clarifying_question=clarifying_question,
)
_last_classifications[chat_id] = classification

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clarification suppression can cascade indefinitely instead of blocking just one turn.

Both branches unconditionally store the raw classification as _last_classifications[chat_id] (279, 291) — including its needs_clarification value — regardless of whether a clarifying question was actually asked this turn. Since classify_intent classifies each message in isolation (no history), a short reply like "Hanafi" answering a clarifying question can itself be independently flagged needs_clarification=True. That gets correctly suppressed this turn by should_clarify, but because the raw True is what gets remembered as "last", the next turn's guard also sees last_cls.needs_clarification=True and suppresses again — and so on. A run of consecutive ambiguous-in-isolation turns can permanently mute clarification for the rest of the session, well beyond the intended "never twice in a row."

🐛 Proposed fix
         last_cls = _last_classifications.get(chat_id)
         if should_clarify(classification, last_cls):
             _last_classifications[chat_id] = classification
             ...
             return ChatResponse(...)
-        _last_classifications[chat_id] = classification
+        # Remember only an *unresolved* ambiguous signal; once we've answered
+        # past it, don't let it keep suppressing clarification later.
+        _last_classifications[chat_id] = (
+            classification if not classification.needs_clarification
+            else ClassificationResult(intent=classification.intent)
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
last_cls = _last_classifications.get(chat_id)
if should_clarify(classification, last_cls):
_last_classifications[chat_id] = classification
clarifying_question = classification.clarifying_question or (
"Could you please provide more detail so I can help you better?"
)
return ChatResponse(
response=clarifying_question,
chat_id=chat_id,
history=[],
intent=classification.intent.value,
needs_clarification=True,
clarifying_question=clarifying_question,
)
_last_classifications[chat_id] = classification
last_cls = _last_classifications.get(chat_id)
if should_clarify(classification, last_cls):
_last_classifications[chat_id] = classification
clarifying_question = classification.clarifying_question or (
"Could you please provide more detail so I can help you better?"
)
return ChatResponse(
response=clarifying_question,
chat_id=chat_id,
history=[],
intent=classification.intent.value,
needs_clarification=True,
clarifying_question=clarifying_question,
)
# Remember only an *unresolved* ambiguous signal; once we've answered
# past it, don't let it keep suppressing clarification later.
_last_classifications[chat_id] = (
classification if not classification.needs_clarification
else ClassificationResult(intent=classification.intent)
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.py` around lines 277 - 291, Update the _last_classifications assignments
in the clarification response path and normal response path so the stored
classification cannot carry needs_clarification=True when no clarifying question
was asked on the current turn. Preserve the existing should_clarify decision and
response fields, but store a classification with needs_clarification cleared
after a clarification is suppressed, ensuring the next turn can clarify again.

@zeemscript

Copy link
Copy Markdown
Contributor

@Joyful12-tech pls fix conflicts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants