Spaces:
Sleeping
Sleeping
File size: 7,656 Bytes
19de729 bfcc872 59ebe66 19de729 cf9b3dc 59ebe66 cf9b3dc 59ebe66 19de729 59ebe66 bfcc872 59ebe66 bfcc872 59ebe66 19de729 59ebe66 19de729 59ebe66 19de729 59ebe66 19de729 59ebe66 bfcc872 cf9b3dc 057c21e cf9b3dc 057c21e cf9b3dc bfcc872 59ebe66 bfcc872 19de729 bfcc872 cf9b3dc bfcc872 cf9b3dc bfcc872 cf9b3dc bfcc872 cf9b3dc bfcc872 cf9b3dc bfcc872 cf9b3dc 59ebe66 19de729 cf9b3dc 19de729 cf9b3dc 19de729 cf9b3dc 19de729 bfcc872 cf9b3dc 19de729 bfcc872 19de729 cf9b3dc 19de729 bfcc872 cf9b3dc 19de729 cf9b3dc 19de729 59ebe66 cf9b3dc 59ebe66 19de729 59ebe66 19de729 59ebe66 19de729 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | # 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)) |