Spaces:
Sleeping
Sleeping
| # 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" | |
| } | |
| 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)) |