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))