| """ |
| 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 |
|
|
| |
| |
| |
|
|
| _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_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) |
|
|
|
|
| |
| |
| |
|
|
| _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), |
| ] |
|
|
| |
| |
| _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 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) |
| return topics |
|
|
|
|
| |
| |
| |
|
|
| 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() |
| |
| |
| |
| topic_core = re.sub(r"\s*\([^)]*\)\s*$", "", topic_lower).strip() |
|
|
| |
| if topic_lower in query_lower or (topic_core and topic_core in query_lower): |
| found.append(topic) |
| continue |
|
|
| |
| |
| 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 |
|
|
| |
| |
| 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 |
|
|
| |
| seen = set() |
| deduped = [] |
| for t in found: |
| if t not in seen: |
| seen.add(t) |
| deduped.append(t) |
| return deduped |
|
|
|
|