| """ |
| Agent router. |
| |
| Decides how each incoming query should be processed and gathers the |
| context needed for the corresponding prompt builder (llm/prompts.py, |
| built in a later layer). This module does NOT call the LLM — it only |
| classifies + retrieves. |
| |
| Routes: |
| SINGLE_TOPIC - e.g. "Explain Merge Sort" -> retrieve one topic |
| COMPARISON - e.g. "Difference between BFS/DFS" -> retrieve multiple topics |
| FOLLOWUP - e.g. "Can you explain that again?" -> reuse conversation context |
| OUT_OF_SCOPE - e.g. "Who won the World Cup?" -> reject politely |
| """ |
|
|
| from dataclasses import dataclass, field |
| from enum import Enum |
|
|
| from agents.comparison import gather_comparison_context, has_sufficient_context |
| from agents.followup import gather_followup_context |
| from agents.intent_classifier import ( |
| extract_topics, |
| is_comparison_query, |
| is_followup_query, |
| is_generic_comparison_followup, |
| topics_in_recent_history, |
| ) |
| from agents.topic_categories import get_sibling_topics |
| from logs.logger import get_logger |
|
|
| logger = get_logger(__name__) |
|
|
|
|
| class RouteType(str, Enum): |
| SINGLE_TOPIC = "single_topic" |
| COMPARISON = "comparison" |
| FOLLOWUP = "followup" |
| OUT_OF_SCOPE = "out_of_scope" |
|
|
|
|
| @dataclass |
| class RouteDecision: |
| route_type: RouteType |
| query: str |
| topics: list = field(default_factory=list) |
| |
| single_chunks: list = field(default_factory=list) |
| comparison_context: dict = field(default_factory=dict) |
| followup_context: dict = field(default_factory=dict) |
|
|
| def to_log_dict(self) -> dict: |
| return { |
| "route_type": self.route_type.value, |
| "query": self.query, |
| "topics": self.topics, |
| } |
|
|
|
|
| def classify(query: str, has_conversation_history: bool = False, recent_topics: list = None) -> RouteDecision: |
| """ |
| Pure classification step (no retrieval yet). Order matters: |
| 1. Follow-up check first — a short pronoun-based query with existing |
| history should be treated as a follow-up even if it superficially |
| contains comparison words. |
| 2. Comparison check — requires comparison phrasing AND 2+ topics. |
| 3. Single topic — exactly one recognized topic mentioned. |
| 4. Otherwise — out of scope. |
| """ |
| recent_topics = recent_topics or [] |
|
|
| if is_followup_query(query, has_conversation_history): |
| decision = RouteDecision(route_type=RouteType.FOLLOWUP, query=query) |
| logger.info("Routed to FOLLOWUP: %r", query) |
| return decision |
|
|
| topics = extract_topics(query) |
|
|
| if is_comparison_query(query) and len(topics) >= 2: |
| decision = RouteDecision(route_type=RouteType.COMPARISON, query=query, topics=topics) |
| logger.info("Routed to COMPARISON: %r | topics=%s", query, topics) |
| return decision |
|
|
| |
| |
| if is_generic_comparison_followup(query) and recent_topics: |
| anchor_topic = recent_topics[-1] |
| siblings = get_sibling_topics(anchor_topic, exclude=topics, limit=2) |
| combined = list(dict.fromkeys(topics + [anchor_topic] + siblings)) |
| if len(combined) >= 2: |
| decision = RouteDecision(route_type=RouteType.COMPARISON, query=query, topics=combined) |
| logger.info( |
| "Routed to COMPARISON (category follow-up): %r | anchor=%s | topics=%s", |
| query, anchor_topic, combined, |
| ) |
| return decision |
|
|
| if len(topics) >= 1: |
| decision = RouteDecision(route_type=RouteType.SINGLE_TOPIC, query=query, topics=topics[:1]) |
| logger.info("Routed to SINGLE_TOPIC: %r | topic=%s", query, topics[0]) |
| return decision |
|
|
| decision = RouteDecision(route_type=RouteType.OUT_OF_SCOPE, query=query) |
| logger.info("Routed to OUT_OF_SCOPE: %r", query) |
| return decision |
|
|
|
|
| def gather_context( |
| decision: RouteDecision, |
| retriever, |
| recent_messages: list = None, |
| ) -> RouteDecision: |
| """ |
| Populates the appropriate context field on `decision` based on its |
| route_type. Mutates and returns the same decision object. |
| |
| Args: |
| decision: output of classify(). |
| retriever: a rag.retriever.Retriever instance. |
| recent_messages: required for FOLLOWUP routes — recent conversation |
| turns, oldest -> newest. |
| """ |
| recent_messages = recent_messages or [] |
|
|
| if decision.route_type == RouteType.SINGLE_TOPIC: |
| topic = decision.topics[0] if decision.topics else None |
| decision.single_chunks = retriever.retrieve( |
| query=decision.query, |
| topic_filter=[topic] if topic else None, |
| bypass_threshold_if_single_topic=True, |
| ) |
| if not decision.single_chunks: |
| logger.warning( |
| "SINGLE_TOPIC query had no chunks pass the similarity " |
| "threshold: %r", |
| decision.query, |
| ) |
|
|
| elif decision.route_type == RouteType.COMPARISON: |
| decision.comparison_context = gather_comparison_context(decision.topics, retriever) |
| if not has_sufficient_context(decision.comparison_context): |
| logger.warning( |
| "COMPARISON query missing sufficient context for one or " |
| "more topics: %s", |
| decision.topics, |
| ) |
|
|
| elif decision.route_type == RouteType.FOLLOWUP: |
| decision.followup_context = gather_followup_context( |
| query=decision.query, |
| recent_messages=recent_messages, |
| retriever=retriever, |
| ) |
|
|
| |
|
|
| return decision |
|
|
| def route(query: str, retriever, recent_messages: list = None) -> RouteDecision: |
| """ |
| Convenience entrypoint combining classify() + gather_context(). |
| |
| recent_messages: recent conversation turns (oldest -> newest). Presence |
| of any messages here signals "has_conversation_history=True" for |
| follow-up detection. |
| """ |
| recent_messages = recent_messages or [] |
| recent_topics = topics_in_recent_history(recent_messages) |
| decision = classify( |
| query, |
| has_conversation_history=len(recent_messages) > 0, |
| recent_topics=recent_topics, |
| ) |
| return gather_context(decision, retriever, recent_messages=recent_messages) |