# src/analyzer/chat/query_router.py """ Query routing: detect user intent from natural language questions. Supports synonyms and variations: - "What grants are available?" = "List all grants" - "Show me funding opportunities" = "List all grants" - "List all open grants" = "Filter by status=open" - "What can I apply for?" = "List available opportunities" This helps reduce redundant information requests and improves UX. """ from __future__ import annotations from dataclasses import dataclass from typing import Dict, Optional, Tuple, List import json, re _INTENTS = {"list", "summarize", "compare", "deadlines", "search", "general"} # Accept "competition-2315", "2315", "comp-2315" _ID_RE = re.compile(r"(?:comp(?:etition)?-)?([0-9]{3,7})", re.IGNORECASE) # Synonym groups for better intent detection GRANT_SYNONYMS = { "grants", "grant", "calls", "call", "opportunities", "opportunity", "funding", "competitions", "competition", "schemes", "scheme" } LIST_INTENT_SYNONYMS = { "list", "show", "display", "what", "which", "all", "available", "open", "upcoming", "closed", "find", "get" } STOPWORDS = { "what","which","are","is","the","a","an","for","about","of","to","in","on","and","with", "available","there","any","please","show","me","find","search","grants","grant","calls", "opportunities","funding","compare","vs","versus","between","two","both", "can", "i", "me", "my", "your", "apply", "get" } @dataclass class Routed: intent: str args: Dict confidence: float def to_dict(self) -> Dict: return {"intent": self.intent, "args": self.args, "confidence": self.confidence} def _extract_ids(text: str) -> Tuple[List[str], str]: ids = [m.group(1) for m in _ID_RE.finditer(text)] residual = _ID_RE.sub("", text).strip() return ids, residual def _keywords_from_question(t: str) -> str: # pull phrase after 'for ' or 'in ' if present, else keep content tokens m = re.search(r"(?:for|in)\s+([A-Za-z0-9\- ][A-Za-z0-9\-\s]+)\??$", t, flags=re.IGNORECASE) phrase = (m.group(1) if m else t).strip() tokens = [w.lower() for w in re.findall(r"[A-Za-z0-9\-]+", phrase) if w.lower() not in STOPWORDS] return " ".join(tokens[-4:]) if tokens else phrase.lower() def _detect_list_intent(text: str) -> bool: """ Detect if user wants to list/view all grants. Recognizes patterns like: - "What grants are available?" - "Show me funding opportunities" - "List all open grants" - "What can I apply for?" """ low = text.lower() # Explicit list/show commands if low.startswith(("list", "show", "display", "get me")): return True # "What/which ... grants/opportunities/funding" patterns list_patterns = [ "what grants", "what funding", "what opportunities", "what calls", "which grants", "which funding", "which opportunities", "which calls", "what can i apply for", "what's available", "what's open", "show me grants", "show me funding", "show me opportunities", "show me calls", "what opportunities", "what calls" ] if any(p in low for p in list_patterns): return True # "All grants" or "all open/closed grants" if "all " in low and any(w in low for w in GRANT_SYNONYMS): return True return False def _detect_status_filter(text: str) -> Optional[str]: """Extract status filter from question if present.""" low = text.lower() if "open" in low and ("grants" in low or "opportunities" in low or "calls" in low): return "open" if "closed" in low and any(w in low for w in GRANT_SYNONYMS): return "closed" if "upcoming" in low and any(w in low for w in GRANT_SYNONYMS): return "upcoming" return None def route(text: str, *, use_llm: bool = False) -> Dict: """ Route a user query to the appropriate intent handler. Improved to handle: - Synonyms (grants = calls = opportunities = funding) - Status filters (open, closed, upcoming) - Variations of the same intent """ t = text.strip() low = t.lower() # Check for status-specific listing first (higher priority) status_filter = _detect_status_filter(t) # Explicit list/show commands (highest confidence) if low.startswith("list") or low.startswith("show") or _detect_list_intent(t): # Extract keyword, but be smart about filler words kw = t.split(" ", 1)[1].strip() if " " in t else "" # Remove punctuation import re kw = re.sub(r'[?!.,;:]', '', kw).strip() # Clean up filler words like "me", "please", "all", "available", "is", "are" filler = { "me", "please", "show", "list", "all", "available", "open", "closed", "upcoming", "grants", "grant", "opportunities", "opportunity", "funding", "calls", "call", "is", "are", "the", "a", "an", "and", "or", "for", "to", "in", "on", "with" } kw_tokens = [w for w in kw.lower().split() if w not in filler] kw = " ".join(kw_tokens) if kw_tokens else "" args = {} # Only add keyword if we have a real keyword (not just grant-related filler) if kw: args["keyword"] = kw if status_filter: args["status"] = status_filter # Return ALL grants if just listing (no limit = all) args["limit"] = None return Routed("list", args, 0.95).to_dict() if low.startswith("summarize") or low.startswith("summarise"): ids, _ = _extract_ids(t) if len(ids) >= 2: return Routed("compare", {"grant_id_a": f"competition-{ids[0]}", "grant_id_b": f"competition-{ids[1]}"}, 0.95).to_dict() if len(ids) == 1: return Routed("summarize", {"grant_id": f"competition-{ids[0]}"}, 0.95).to_dict() # no IDs → treat remainder as search kw = t.split(" ", 1)[1].strip() if " " in t else "" return Routed("search", {"keyword": kw, "limit": None}, 0.6).to_dict() # FIXED: No limit = return all # Deadline queries if any(p in low for p in ["deadline", "close date", "when is it due", "when do i need to apply", "application deadline"]): return Routed("deadlines", {"n": None}, 0.85).to_dict() # Return ALL deadlines # compare / vs / versus / between → compare two grants if "compare" in low or " vs " in low or "versus" in low or "between" in low: ids, residual = _extract_ids(t) facet = residual.strip() if len(ids) >= 2: return Routed( "compare", {"grant_id_a": f"competition-{ids[0]}", "grant_id_b": f"competition-{ids[1]}"}, 0.92 ).to_dict() if len(ids) == 1: return Routed("summarize", {"grant_id": f"competition-{ids[0]}"}, 0.7).to_dict() # Natural search (lower confidence, but still strong) if any(w in low for w in ["grant", "funding", "competition", "call", "apply", "what", "which", "find", "search"]): kw = _keywords_from_question(t) return Routed("search", {"keyword": kw, "limit": None}, 0.75).to_dict() # FIXED: No limit = return all return Routed("general", {"question": t}, 0.5).to_dict() # Self-test if __name__ == "__main__": tests = [ "list AI calls", "summarize competition-2316", "summarize 2315 2318", "compare 2315 vs 2318 for total funding per project and start-by", "find grants for battery feasibility studies", "what funding is available for hydrogen?", "when is the deadline?", ] for s in tests: print(s, "->", route(s))