chatbot / agents /intent_classifier.py
anris05's picture
updates
e5a0a2f
Raw
History Blame Contribute Delete
8.04 kB
"""
Intent classification helpers used by the agent router.
This module is intentionally rule-based (regex + fuzzy topic-name matching)
rather than LLM-based, since the LLM provider abstraction (llm/) hasn't
been wired in yet. It's isolated behind plain functions so it can be
swapped for an LLM-based classifier later without changing router.py's
interface — router.py only calls extract_topics(), is_comparison_query(),
and is_followup_query().
No keyword-based *retrieval* happens here — this only decides ROUTING.
Actual knowledge retrieval is always embedding-based (see rag/retriever.py).
"""
import difflib
import json
import re
import config
# ---------------------------------------------------------------------------
# Topic vocabulary (loaded once from topic_metadata.json)
# ---------------------------------------------------------------------------
_FUZZY_MATCH_CUTOFF = 0.85
def _load_known_topics() -> list:
"""Returns the list of canonical topic names, e.g. ['Binary Search', ...]."""
with open(config.TOPIC_METADATA_PATH, "r", encoding="utf-8") as f:
metadata = json.load(f)
return sorted({entry["topic"] for entry in metadata.values() if entry.get("topic")})
_KNOWN_TOPICS = None
def get_known_topics() -> list:
global _KNOWN_TOPICS
if _KNOWN_TOPICS is None:
_KNOWN_TOPICS = _load_known_topics()
return _KNOWN_TOPICS
def refresh_known_topics() -> None:
"""Call after topic_metadata.json changes (e.g. after a re-index) to
force reloading the topic vocabulary on next use."""
global _KNOWN_TOPICS
_KNOWN_TOPICS = None
# ---------------------------------------------------------------------------
# Comparison detection
# ---------------------------------------------------------------------------
_COMPARISON_PATTERNS = [
re.compile(r"\bdifference between\b", re.IGNORECASE),
re.compile(r"\bvs\.?\b", re.IGNORECASE),
re.compile(r"\bversus\b", re.IGNORECASE),
re.compile(r"\bcompare(d|s)?\b", re.IGNORECASE),
re.compile(r"\bwhich (one )?is (better|faster|more efficient)\b", re.IGNORECASE),
re.compile(r"\bpros and cons of\b", re.IGNORECASE),
]
def is_comparison_query(query: str) -> bool:
return any(p.search(query) for p in _COMPARISON_PATTERNS)
# ---------------------------------------------------------------------------
# Follow-up detection
# ---------------------------------------------------------------------------
_FOLLOWUP_PATTERNS = [
re.compile(r"^\s*(explain|say|show)?\s*(that|this|it)\b.*again\b", re.IGNORECASE),
re.compile(r"^\s*what (do you mean|about (that|it|this))\b", re.IGNORECASE),
re.compile(r"^\s*(can you|could you)\s+(clarify|elaborate|expand)\b", re.IGNORECASE),
re.compile(r"^\s*(and|so)\b", re.IGNORECASE),
re.compile(r"^\s*(why|how) (is that|does that work)\b", re.IGNORECASE),
re.compile(r"\b(more|another) example\b", re.IGNORECASE),
re.compile(r"^\s*(simpler|simplify|dumb it down|eli5)\b", re.IGNORECASE),
]
# Bare pronoun references with no topic name mentioned, e.g. "explain it",
# "why is that", "tell me more"
_PRONOUN_ONLY_PATTERN = re.compile(
r"\b(it|that|this|those|these)\b", re.IGNORECASE
)
_WORKED_EXAMPLE_PATTERN = re.compile(
r"\d+(?:\s*,\s*\d+){1,}.*\b(this|that|same|additional info|additional information)\b.*\b(search|sort|algorithm|method|approach|traversal)\b",
re.IGNORECASE,
)
_CODE_REQUEST_PATTERN = re.compile(r"\b(code|implementation|logic)\b", re.IGNORECASE)
def is_followup_query(query: str, has_conversation_history: bool) -> bool:
"""
A query is treated as a follow-up when:
1. There IS prior conversation to follow up on, AND
2. Either it matches a known follow-up phrasing pattern, OR
it's short, contains a bare pronoun reference, and mentions no
known topic by name (i.e. it can't stand on its own).
"""
if not has_conversation_history:
return False
if any(p.search(query) for p in _FOLLOWUP_PATTERNS):
return True
if _WORKED_EXAMPLE_PATTERN.search(query):
return True
word_count = len(query.strip().split())
has_pronoun = bool(_PRONOUN_ONLY_PATTERN.search(query)) or bool(_CODE_REQUEST_PATTERN.search(query))
mentions_topic = bool(extract_topics(query))
# if has_pronoun and not mentions_topic and word_count <= 8:
# return True
if not mentions_topic and word_count <= 8 and has_conversation_history:
return True
return False
_GENERIC_COMPARISON_PATTERN = re.compile(
r"\b(compare|comparison|vs\.?|versus|difference|different from|how does .* (differ|compare))\b.*"
r"\b(other|similar|related|different|alternative)\b",
re.IGNORECASE,
)
def is_generic_comparison_followup(query: str) -> bool:
"""
Matches comparison phrasing that references *other* algorithms without
naming a second one explicitly, e.g. "compare with other graph
algorithms", "how does this differ from similar approaches".
"""
return bool(_GENERIC_COMPARISON_PATTERN.search(query))
def topics_in_recent_history(recent_messages: list) -> list:
"""
Scans recent conversation turns (both user and assistant messages) for
known topic mentions. Order preserved, most-recently-mentioned last.
"""
topics = []
for message in recent_messages:
for topic in extract_topics(message.get("content", "")):
if topic not in topics:
topics.append(topic)
else:
topics.remove(topic)
topics.append(topic) # bump to most-recent position
return topics
# ---------------------------------------------------------------------------
# Topic extraction (fuzzy match against known topic names)
# ---------------------------------------------------------------------------
def extract_topics(query: str) -> list:
"""
Returns the list of known topic names mentioned in the query, matched
via substring + fuzzy matching (handles typos/casing/partial names like
"binary search" or "BFS traversal").
This is NOT used for retrieval (retrieval stays purely embedding-based)
— it's only used for ROUTING decisions: deciding single-topic vs.
comparison, and picking which topics a comparison should retrieve.
"""
query_lower = query.lower()
found = []
for topic in get_known_topics():
topic_lower = topic.lower()
# Strip a trailing parenthetical, e.g. "Kruskal's Algorithm (MST)"
# -> "kruskal's algorithm", since people ask using the plain name,
# not the parenthetical qualifier.
topic_core = re.sub(r"\s*\([^)]*\)\s*$", "", topic_lower).strip()
# Direct substring match (full name or core name without qualifier)
if topic_lower in query_lower or (topic_core and topic_core in query_lower):
found.append(topic)
continue
# Acronym match, e.g. "Depth-First Search" -> "DFS", "Breadth-First
# Search" -> "BFS". Common in how people actually ask DSA questions.
acronym = "".join(w[0] for w in re.split(r"[\s\-]+", topic) if w).lower()
if len(acronym) >= 3 and re.search(rf"\b{re.escape(acronym)}\b", query_lower):
found.append(topic)
continue
# Fuzzy match against each sliding n-gram window of the query,
# sized to the topic's word count, to catch typos/near-matches.
topic_words = topic_lower.split()
query_words = query_lower.split()
window = len(topic_words)
for i in range(len(query_words) - window + 1):
candidate = " ".join(query_words[i : i + window])
ratio = difflib.SequenceMatcher(None, candidate, topic_lower).ratio()
if ratio >= _FUZZY_MATCH_CUTOFF:
found.append(topic)
break
# De-duplicate while preserving order
seen = set()
deduped = []
for t in found:
if t not in seen:
seen.add(t)
deduped.append(t)
return deduped