grant-radar / src /analyzer /chat /query_router.py
Riley
feat: Merge GPT-5 enhancements with working c72a240 base + restore crawler
cf9b3dc
Raw
History Blame Contribute Delete
7.66 kB
# 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))