Riley Claude commited on
Commit
cf9b3dc
·
1 Parent(s): d5ef6d3

feat: Merge GPT-5 enhancements with working c72a240 base + restore crawler

Browse files

Major update combining the stable c72a240 Hugging Face deployment base
with GPT-5 improvements from 057c21e.

## Core Enhancements Added:

### GPT-5 Model Support (Step 1 of 15)
- Three-tier model system: nano/mini/main for different use cases
- model_router (gpt-5-nano): Fast routing and classification
- model_translator (gpt-5-mini): Translations and summaries
- model_analyzer (gpt-5): Complex analysis and comparisons
- Verbosity control: low/medium/high response lengths
- Reasoning effort: minimal/medium/high thinking depth
- Task-specific presets for optimal parameters

### Files Modified:
- analyzer/llm_client.py: Added GPT-5 support with model types
- analyzer/config.py: Added GPT-5 model configurations
- src/analyzer/*: Synced all files to match analyzer/ base

### Crawler Restored:
- analyzer/crawler/snapshot.py: Grant page scraping
- analyzer/crawler/discover_grants.py: Grant URL discovery
- Both restored from 057c21e commit

### Preserved from c72a240 Working Base:
- ✓ 7 preset questions (including past winners)
- ✓ Async batch processing
- ✓ All optimizations (caching, context reduction)
- ✓ Clean UI (no emojis)
- ✓ Reliable regex-based query routing
- ✓ 36 grants loading correctly

### Tests Passed:
- All imports successful
- 36 grants loaded
- GPT-5 models configured correctly
- Crawler modules working
- LLM client ready

Both analyzer/ and src/analyzer/ directories are now identical
and contain the working c72a240 base + GPT-5 enhancements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

analyzer/config.py CHANGED
@@ -32,7 +32,7 @@ from typing import Literal, Optional
32
  Provider = Literal["openai", "anthropic"]
33
 
34
  DEFAULT_MODELS = {
35
- "openai": "gpt-4.1-mini", # safe default; override to gpt-5-mini if enabled for your key
36
  "anthropic": "claude-3-5-haiku-20241022", # fast, low-cost Claude
37
  }
38
 
@@ -45,6 +45,11 @@ class Config:
45
  openai_api_key: Optional[str] = None
46
  anthropic_api_key: Optional[str] = None
47
 
 
 
 
 
 
48
  temperature: float = 0.2
49
  max_output_tokens: int = 800
50
  timeout_s: float = 30.0
@@ -86,6 +91,9 @@ def load_config() -> Config:
86
  model=model,
87
  openai_api_key=_env("OPENAI_API_KEY"),
88
  anthropic_api_key=_env("ANTHROPIC_API_KEY"),
 
 
 
89
  temperature=float(_env("LLM_TEMPERATURE", "0.2")),
90
  max_output_tokens=int(_env("LLM_MAX_OUTPUT_TOKENS", "800")),
91
  timeout_s=float(_env("LLM_TIMEOUT_S", "30")),
 
32
  Provider = Literal["openai", "anthropic"]
33
 
34
  DEFAULT_MODELS = {
35
+ "openai": "gpt-5-mini", # GPT-5 family: nano/mini/main for different use cases
36
  "anthropic": "claude-3-5-haiku-20241022", # fast, low-cost Claude
37
  }
38
 
 
45
  openai_api_key: Optional[str] = None
46
  anthropic_api_key: Optional[str] = None
47
 
48
+ # Model-specific configurations for different use cases (GPT-5 family)
49
+ model_router: str = "gpt-5-nano" # Fast routing and classification
50
+ model_translator: str = "gpt-5-mini" # Translations and summaries
51
+ model_analyzer: str = "gpt-5" # Complex analysis and comparisons
52
+
53
  temperature: float = 0.2
54
  max_output_tokens: int = 800
55
  timeout_s: float = 30.0
 
91
  model=model,
92
  openai_api_key=_env("OPENAI_API_KEY"),
93
  anthropic_api_key=_env("ANTHROPIC_API_KEY"),
94
+ model_router=_env("LLM_MODEL_ROUTER", "gpt-5-nano"),
95
+ model_translator=_env("LLM_MODEL_TRANSLATOR", "gpt-5-mini"),
96
+ model_analyzer=_env("LLM_MODEL_ANALYZER", "gpt-5"),
97
  temperature=float(_env("LLM_TEMPERATURE", "0.2")),
98
  max_output_tokens=int(_env("LLM_MAX_OUTPUT_TOKENS", "800")),
99
  timeout_s=float(_env("LLM_TIMEOUT_S", "30")),
analyzer/crawler/discover_grants.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Discover and fetch new grants from Innovate UK website.
3
+
4
+ This module:
5
+ 1. Fetches the Innovate UK competition search/listing page
6
+ 2. Extracts all available grant URLs
7
+ 3. Fetches each grant's details using snapshot.py
8
+ 4. Saves to snapshots directory
9
+ """
10
+
11
+ import logging
12
+ import asyncio
13
+ import json
14
+ import re
15
+ from pathlib import Path
16
+ from typing import List, Tuple, Optional, Set
17
+ from urllib.parse import urljoin, urlparse
18
+ from datetime import datetime, UTC
19
+ from playwright.async_api import async_playwright
20
+ from bs4 import BeautifulSoup
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Innovate UK service base URL
25
+ IUK_BASE = "https://apply-for-innovation-funding.service.gov.uk"
26
+ COMPETITIONS_URL = f"{IUK_BASE}/competition/search"
27
+
28
+
29
+ async def discover_grant_urls(max_retries: int = 2) -> List[str]:
30
+ """
31
+ Discover all available grant overview URLs from Innovate UK.
32
+
33
+ Returns:
34
+ List of grant overview URLs
35
+ """
36
+ logger.info(f"Discovering grants from {COMPETITIONS_URL}")
37
+
38
+ async with async_playwright() as pw:
39
+ browser = await pw.chromium.launch(
40
+ headless=True,
41
+ args=["--disable-dev-shm-usage"]
42
+ )
43
+ context = await browser.new_context(
44
+ user_agent=(
45
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
46
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
47
+ ),
48
+ locale="en-GB",
49
+ timezone_id="Europe/London",
50
+ )
51
+ page = await context.new_page()
52
+ page.set_default_timeout(30000)
53
+
54
+ for attempt in range(max_retries):
55
+ try:
56
+ await page.goto(COMPETITIONS_URL, wait_until="domcontentloaded")
57
+ await page.wait_for_load_state("networkidle")
58
+ break
59
+ except Exception as e:
60
+ if attempt == max_retries - 1:
61
+ logger.error(f"Failed to fetch competitions page: {e}")
62
+ await context.close()
63
+ await browser.close()
64
+ return []
65
+ logger.warning(f"Attempt {attempt + 1} failed, retrying...")
66
+
67
+ html = await page.content()
68
+ await context.close()
69
+ await browser.close()
70
+
71
+ # Parse URLs from HTML
72
+ urls = _extract_grant_urls(html)
73
+ logger.info(f"Discovered {len(urls)} grants")
74
+
75
+ return urls
76
+
77
+
78
+ def _extract_grant_urls(html: str) -> List[str]:
79
+ """
80
+ Extract all grant overview URLs from the competitions listing page.
81
+
82
+ Looks for links matching pattern: /competition/{id}/overview/{uuid}
83
+ """
84
+ soup = BeautifulSoup(html, "lxml")
85
+ urls = []
86
+
87
+ # Find all links that match the overview pattern
88
+ pattern = re.compile(r'/competition/(\d+)/overview/([0-9a-f\-]{8,})', re.I)
89
+
90
+ for link in soup.find_all("a", href=True):
91
+ href = link.get("href", "")
92
+ if pattern.search(href):
93
+ # Make absolute URL
94
+ full_url = urljoin(IUK_BASE, href)
95
+ if full_url not in urls:
96
+ urls.append(full_url)
97
+
98
+ # Also check for links in data attributes or javascript
99
+ for elem in soup.find_all(["a", "div", "li"], {"data-href": True}):
100
+ href = elem.get("data-href", "")
101
+ if pattern.search(href):
102
+ full_url = urljoin(IUK_BASE, href)
103
+ if full_url not in urls:
104
+ urls.append(full_url)
105
+
106
+ return urls
107
+
108
+
109
+ async def fetch_grant_snapshot(url: str, output_dir: Path) -> Optional[str]:
110
+ """
111
+ Fetch a single grant's snapshot and save to JSON.
112
+
113
+ Args:
114
+ url: Grant overview URL
115
+ output_dir: Directory to save snapshot JSON
116
+
117
+ Returns:
118
+ Filename of saved snapshot, or None if failed
119
+ """
120
+ try:
121
+ from .snapshot import fetch_sections_from_overview, parse_deeplink
122
+
123
+ # Extract grant ID from URL
124
+ match = re.search(r'/competition/(\d+)/', url)
125
+ if not match:
126
+ logger.warning(f"Could not extract grant ID from {url}")
127
+ return None
128
+
129
+ grant_id = match.group(1)
130
+ output_path = output_dir / f"competition-{grant_id}.json"
131
+
132
+ # Skip if already exists
133
+ if output_path.exists():
134
+ logger.debug(f"Grant {grant_id} already exists, skipping")
135
+ return None
136
+
137
+ logger.info(f"Fetching grant {grant_id}...")
138
+ html, sections = await fetch_sections_from_overview(url)
139
+
140
+ # Parse dates and funding from the fetched content
141
+ from .snapshot import (
142
+ parse_dates_singleline,
143
+ extract_funding,
144
+ _find_duration_months,
145
+ pick_open_close_from_milestones,
146
+ derive_aux_dates,
147
+ clean_title
148
+ )
149
+
150
+ # Parse dates
151
+ dates_text = sections.get("dates_raw", "") or ""
152
+ milestones = parse_dates_singleline(dates_text)
153
+
154
+ # Derive open/close from milestones
155
+ open_date, close_date = pick_open_close_from_milestones(milestones)
156
+
157
+ # If either missing, try scanning all text
158
+ if not open_date or not close_date:
159
+ all_text = " ".join(v for v in sections.values() if isinstance(v, str) and v)
160
+ extra = parse_dates_singleline(all_text)
161
+ seen = {(m["label_raw"], m["date_iso"], m.get("date_iso_end")) for m in milestones}
162
+ for m2 in extra:
163
+ key = (m2["label_raw"], m2["date_iso"], m2.get("date_iso_end"))
164
+ if key not in seen:
165
+ milestones.append(m2)
166
+ seen.add(key)
167
+ od2, cd2 = pick_open_close_from_milestones(milestones)
168
+ open_date = open_date or od2
169
+ close_date = close_date or cd2
170
+
171
+ notify_date, project_start_from = derive_aux_dates(milestones)
172
+
173
+ # Parse funding
174
+ funding = extract_funding(sections)
175
+
176
+ # Parse duration
177
+ dur_min, dur_max = _find_duration_months(sections.get("eligibility_raw", "") or "")
178
+ if dur_min is None or dur_max is None:
179
+ all_text_for_duration = " ".join(v for v in sections.values() if isinstance(v, str) and v)
180
+ dur_min, dur_max = _find_duration_months(all_text_for_duration)
181
+ duration_months = {"min": dur_min, "max": dur_max}
182
+
183
+ # Extract title
184
+ raw_title = sections.get("summary_raw", "").split("\n")[0] if sections.get("summary_raw") else ""
185
+ title = clean_title(raw_title)
186
+
187
+ # Create snapshot
188
+ snapshot = {
189
+ "id": f"competition-{grant_id}",
190
+ "competition_id": grant_id,
191
+ "url": url,
192
+ "title": title,
193
+ "programme": "",
194
+ "round": "",
195
+ "open_date": open_date,
196
+ "close_date": close_date,
197
+ "notify_date": notify_date,
198
+ "project_start_from": project_start_from,
199
+ "funding": funding,
200
+ "duration_months": duration_months,
201
+ "sections": sections,
202
+ "pdfs": [],
203
+ "summaries": {},
204
+ "extracted": {"milestones": milestones},
205
+ "wonky": {"score": 0.0, "reasons": []},
206
+ "prev_round_refs": [],
207
+ "diff_summary": "",
208
+ "history_stats": {},
209
+ "created_at": datetime.now(UTC).isoformat(),
210
+ "updated_at": datetime.now(UTC).isoformat(),
211
+ }
212
+
213
+ # Save snapshot
214
+ output_path.write_text(
215
+ json.dumps(snapshot, indent=2),
216
+ encoding="utf-8"
217
+ )
218
+ logger.info(f"Saved grant {grant_id} snapshot")
219
+
220
+ return output_path.name
221
+
222
+ except Exception as e:
223
+ logger.error(f"Failed to fetch grant from {url}: {e}")
224
+ return None
225
+
226
+
227
+ async def discover_and_fetch_grants(
228
+ output_dir: Path,
229
+ skip_existing: bool = True
230
+ ) -> Tuple[int, int, List[str]]:
231
+ """
232
+ Discover all grants and fetch new ones.
233
+
234
+ Args:
235
+ output_dir: Directory to save snapshots
236
+ skip_existing: Skip grants that already exist
237
+
238
+ Returns:
239
+ Tuple of (total_discovered, newly_fetched, new_filenames)
240
+ """
241
+ output_dir.mkdir(parents=True, exist_ok=True)
242
+
243
+ # Step 1: Discover all grant URLs
244
+ urls = await discover_grant_urls()
245
+ if not urls:
246
+ logger.warning("No grants discovered")
247
+ return 0, 0, []
248
+
249
+ # Step 2: Get existing grant IDs if skipping
250
+ existing_ids: Set[str] = set()
251
+ if skip_existing:
252
+ for json_file in output_dir.glob("competition-*.json"):
253
+ match = re.search(r'competition-(\d+)', json_file.name)
254
+ if match:
255
+ existing_ids.add(match.group(1))
256
+
257
+ # Step 3: Fetch new grants concurrently
258
+ new_files = []
259
+ tasks = []
260
+
261
+ for url in urls:
262
+ match = re.search(r'/competition/(\d+)/', url)
263
+ if match and match.group(1) in existing_ids:
264
+ logger.debug(f"Grant {match.group(1)} already exists")
265
+ continue
266
+
267
+ tasks.append(fetch_grant_snapshot(url, output_dir))
268
+
269
+ if tasks:
270
+ logger.info(f"Fetching {len(tasks)} new grants concurrently...")
271
+ results = await asyncio.gather(*tasks, return_exceptions=False)
272
+ new_files = [f for f in results if f is not None]
273
+
274
+ logger.info(
275
+ f"Discovery complete: {len(urls)} total, "
276
+ f"{len(new_files)} newly fetched"
277
+ )
278
+
279
+ return len(urls), len(new_files), new_files
280
+
281
+
282
+ def main_sync(
283
+ output_dir: str = "data/snapshots",
284
+ skip_existing: bool = True
285
+ ) -> Tuple[int, int, List[str]]:
286
+ """
287
+ Synchronous wrapper for discovering and fetching grants.
288
+ """
289
+ output_path = Path(output_dir)
290
+ return asyncio.run(
291
+ discover_and_fetch_grants(output_path, skip_existing)
292
+ )
293
+
294
+
295
+ if __name__ == "__main__":
296
+ logging.basicConfig(
297
+ level=logging.INFO,
298
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
299
+ )
300
+
301
+ total, new, files = main_sync()
302
+ print(f"\n✓ Discovery complete:")
303
+ print(f" Total discovered: {total}")
304
+ print(f" Newly fetched: {new}")
305
+ if files:
306
+ print(f" Files: {', '.join(files)}")
analyzer/crawler/snapshot.py ADDED
@@ -0,0 +1,626 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ import pathlib
4
+ import asyncio
5
+ from urllib.parse import urlparse
6
+ from datetime import datetime, UTC
7
+ from typing import List, Optional, Tuple, Dict
8
+ from playwright.async_api import async_playwright
9
+ from bs4 import BeautifulSoup, Tag, NavigableString
10
+ import typer
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # HELPERS
14
+ # ---------------------------------------------------------------------------
15
+
16
+ EXPECTED_TITLES = [
17
+ "Summary",
18
+ "Eligibility",
19
+ "Scope",
20
+ "Dates",
21
+ "How to apply",
22
+ "Supporting information",
23
+ ]
24
+
25
+ # Accept close-enough labels and alias them to canonical 6
26
+ SECTION_ALIASES = {
27
+ "who can apply": "Eligibility",
28
+ "who’s eligible": "Eligibility",
29
+ "who is eligible": "Eligibility",
30
+ "applicant eligibility": "Eligibility",
31
+ "what we ask you": "How to apply",
32
+ "apply": "How to apply",
33
+ "application process": "How to apply",
34
+ "supporting info": "Supporting information",
35
+ "key dates": "Dates",
36
+ "timeline": "Dates",
37
+ "competition dates": "Dates",
38
+ "overview": "Summary",
39
+ "summary": "Summary",
40
+ "scope": "Scope",
41
+ }
42
+
43
+ def canonical_label(label: str) -> Optional[str]:
44
+ l = label.strip().lower()
45
+ for t in EXPECTED_TITLES:
46
+ if l == t.lower():
47
+ return t
48
+ return SECTION_ALIASES.get(l, None)
49
+
50
+ def norm_key(label: str) -> str:
51
+ return re.sub(r"[^\w\s-]", "", label).strip().lower().replace(" ", "_") + "_raw"
52
+
53
+ def parse_deeplink(url: str):
54
+ u = urlparse(url)
55
+ m = re.search(r"/competition/(\d+)/overview/([0-9a-f-]{8,})", u.path, re.I)
56
+ if not m:
57
+ raise ValueError("Expected: .../competition/{id}/overview/{uuid}")
58
+ return f"{u.scheme}://{u.netloc}", m.group(1), m.group(2)
59
+
60
+ def get_competition_nav_anchors(soup: BeautifulSoup) -> List[tuple[str, str]]:
61
+ anchors: List[tuple[str, str]] = []
62
+
63
+ headings = soup.find_all(["h2", "h3", "h4"], string=lambda s: isinstance(s, str) and "competition sections" in s.lower())
64
+ nav_root: Optional[Tag] = None
65
+ for h in headings:
66
+ for sib in h.next_siblings:
67
+ if isinstance(sib, Tag) and sib.name in ("nav", "ul", "ol", "div"):
68
+ nav_root = sib
69
+ break
70
+ if nav_root:
71
+ break
72
+
73
+ if not nav_root:
74
+ for candidate in soup.find_all("nav"):
75
+ if candidate.find("a", href=True):
76
+ nav_root = candidate
77
+ break
78
+
79
+ if nav_root:
80
+ seen = set()
81
+ for a in nav_root.find_all("a", href=True):
82
+ href = a.get("href", "")
83
+ if not href.startswith("#"):
84
+ continue
85
+ frag = href[1:].strip()
86
+ raw = (a.get_text(" ", strip=True) or "").strip()
87
+ if not frag or not raw:
88
+ continue
89
+ canon = canonical_label(raw) or raw
90
+ if canon in EXPECTED_TITLES and frag not in seen:
91
+ anchors.append((canon, frag))
92
+ seen.add(frag)
93
+
94
+ if not anchors:
95
+ anchors = [
96
+ ("Summary", "summary"),
97
+ ("Eligibility", "eligibility"),
98
+ ("Scope", "scope"),
99
+ ("Dates", "dates"),
100
+ ("How to apply", "how-to-apply"),
101
+ ("Supporting information", "supporting-information"),
102
+ ]
103
+ return anchors
104
+
105
+ # -----------------------------
106
+ # Footer / cookie / consent trimmer
107
+ # -----------------------------
108
+
109
+ _FOOTER_STOPS = [
110
+ "Need help with this service?",
111
+ "Support links",
112
+ "GOV.UK uses cookies",
113
+ "Create one update function for each consent parameter",
114
+ "© Crown copyright",
115
+ "All content is available under the Open Government Licence",
116
+ ]
117
+
118
+ def trim_footer(text: str) -> str:
119
+ if not text:
120
+ return text
121
+ for marker in _FOOTER_STOPS:
122
+ i = text.find(marker)
123
+ if i != -1:
124
+ return text[:i].rstrip()
125
+ return text
126
+
127
+ def _strip_boilerplate(soup: BeautifulSoup):
128
+ selectors = [
129
+ "#global-cookie-message", ".cookie-banner", "#ccc-notify", "#onetrust-banner-sdk",
130
+ "footer", ".govuk-footer",
131
+ ".govuk-prototype-kit-warning",
132
+ ]
133
+ for sel in selectors:
134
+ for el in soup.select(sel):
135
+ el.decompose()
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # TITLE
139
+ # ---------------------------------------------------------------------------
140
+
141
+ def clean_title(raw: str) -> str:
142
+ if not raw:
143
+ return raw
144
+ raw = raw.strip()
145
+ return re.sub(r"^\s*Funding competition\s+", "", raw, flags=re.I).strip()
146
+
147
+ def extract_between_ids(soup: BeautifulSoup, start_id: str, end_id: Optional[str]) -> str:
148
+ start = soup.find(id=start_id)
149
+ if not start:
150
+ return ""
151
+ out_chunks: List[str] = []
152
+ for el in start.next_elements:
153
+ if isinstance(el, Tag):
154
+ if end_id and el.get("id") == end_id:
155
+ break
156
+ if el.name in ("script", "style", "noscript"):
157
+ continue
158
+ if isinstance(el, NavigableString):
159
+ txt = el.strip()
160
+ if txt:
161
+ out_chunks.append(txt)
162
+ text = " ".join(out_chunks)
163
+ text = re.sub(r"\s+", " ", text).strip()
164
+ text = trim_footer(text)
165
+ return text
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # PARSING
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def slice_by_anchors(html: str) -> dict:
172
+ soup = BeautifulSoup(html, "html.parser")
173
+ _strip_boilerplate(soup)
174
+ anchors = get_competition_nav_anchors(soup)
175
+ ids_in_order = [aid for _, aid in anchors]
176
+ id_to_next = {ids_in_order[i]: (ids_in_order[i + 1] if i + 1 < len(ids_in_order) else None)
177
+ for i in range(len(ids_in_order))}
178
+ out = {}
179
+ for label, start_id in anchors:
180
+ next_id = id_to_next.get(start_id)
181
+ key = norm_key(label)
182
+ out[key] = extract_between_ids(soup, start_id, next_id)
183
+ return out
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # MAIN SCRAPER
187
+ # ---------------------------------------------------------------------------
188
+
189
+ async def fetch_sections_from_overview(url: str) -> tuple[str, dict]:
190
+ scheme_host, comp_id, uuid = parse_deeplink(url)
191
+ overview_url = f"{scheme_host}/competition/{comp_id}/overview/{uuid}"
192
+
193
+ async with async_playwright() as pw:
194
+ browser = await pw.chromium.launch(headless=True, args=["--disable-dev-shm-usage"])
195
+ context = await browser.new_context(
196
+ user_agent=("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
197
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
198
+ locale="en-GB",
199
+ timezone_id="Europe/London",
200
+ )
201
+ page = await context.new_page()
202
+ page.set_default_timeout(30000)
203
+
204
+ for attempt in range(2):
205
+ try:
206
+ await page.goto(overview_url, wait_until="domcontentloaded")
207
+ await page.wait_for_load_state("networkidle")
208
+ break
209
+ except Exception:
210
+ if attempt == 1:
211
+ raise
212
+ html = await page.content()
213
+ await context.close()
214
+ await browser.close()
215
+
216
+ return html, slice_by_anchors(html)
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # DATE PARSING — SINGLE-LINE CHUNKS
220
+ # ---------------------------------------------------------------------------
221
+
222
+ # tokens
223
+ _DATE_WORD = r"(?:\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}|[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4})"
224
+ _TIME_WORD = r"(?:\d{1,2}:\d{2}\s*[ap]m|\d{1,2}\s*[ap]m)"
225
+
226
+ _DATE_ONLY_RX = re.compile(_DATE_WORD, re.I)
227
+ _TIME_RX = re.compile(_TIME_WORD, re.I)
228
+
229
+ # e.g. "9 to 20 March 2026", "9–20 March 2026", "9 - 20 March 2026"
230
+ _DATE_RANGE_RX = re.compile(r"(\d{1,2})\s*(?:to|-|–)\s*(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})", re.I)
231
+
232
+ _MONTHS = {m.lower(): i for i, m in enumerate(
233
+ ["January","February","March","April","May","June","July","August","September","October","November","December"], 1
234
+ )}
235
+
236
+ _EXCLUDE_SENTENCE_CUES = ["briefing event", "briefing", "webinar", "register to attend", "register", "info session"]
237
+
238
+ _LABEL_RULES = [
239
+ ("opens", ["competition opens", "opens"]),
240
+ ("closes", ["competition closes", "closes", "deadline"]),
241
+ ("notify", ["applicants notified", "applicants will be notified", "notification"]),
242
+ ("project_start", ["project start from", "project starts from", "project start date", "project start"]),
243
+ ("assessment", ["interview", "assessment", "panel"]),
244
+ ("results", ["results published", "winners announced"]),
245
+ ("eligibility_cutoff", ["eligibility closes", "registration closes"]),
246
+ ("info_session", ["briefing", "webinar", "register"]),
247
+ ]
248
+
249
+ def _classify_label(sent_lower: str) -> str:
250
+ for norm, cues in _LABEL_RULES:
251
+ if any(c in sent_lower for c in cues):
252
+ return norm
253
+ return "other"
254
+
255
+ def _parse_single_date(token: str, time_hint: Optional[str]) -> Optional[str]:
256
+ token = token.strip()
257
+ m_comma = re.match(r"([A-Za-z]{3,9})\s+(\d{1,2}),?\s+(\d{4})", token)
258
+ if m_comma:
259
+ month_name, day, year = m_comma.groups()
260
+ else:
261
+ m = re.match(r"(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})", token)
262
+ if not m:
263
+ return None
264
+ day, month_name, year = m.groups()
265
+ month = _MONTHS.get(month_name.lower())
266
+ if not month:
267
+ return None
268
+ if time_hint:
269
+ t = time_hint.lower().replace(" ", "")
270
+ mm = re.match(r"(\d{1,2})(?::(\d{2}))?([ap]m)", t)
271
+ if mm:
272
+ hh = int(mm.group(1))
273
+ mins = int(mm.group(2) or 0)
274
+ ampm = mm.group(3)
275
+ if ampm == "pm" and hh != 12: hh += 12
276
+ if ampm == "am" and hh == 12: hh = 0
277
+ return f"{int(year):04d}-{month:02d}-{int(day):02d}T{hh:02d}:{mins:02d}:00"
278
+ return f"{int(year):04d}-{month:02d}-{int(day):02d}"
279
+
280
+ def parse_dates_singleline(text: str) -> List[dict]:
281
+ """
282
+ Split the Dates section into *one milestone per line/chunk*:
283
+ chunk := from each DATE token up to the next DATE token (or end).
284
+ Keeps the entire chunk in label_raw.
285
+ """
286
+ milestones: List[dict] = []
287
+ if not text:
288
+ return milestones
289
+
290
+ # find all date token positions
291
+ matches = list(_DATE_ONLY_RX.finditer(text))
292
+ if not matches:
293
+ return milestones
294
+
295
+ spans = []
296
+ for i, m in enumerate(matches):
297
+ start = m.start()
298
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
299
+ spans.append((start, end))
300
+
301
+ for (start, end) in spans:
302
+ chunk = text[start:end].strip()
303
+ if not chunk:
304
+ continue
305
+ low = chunk.lower()
306
+ excluded = any(k in low for k in _EXCLUDE_SENTENCE_CUES)
307
+
308
+ # primary date + optional time in this chunk
309
+ first_date = _DATE_ONLY_RX.search(chunk)
310
+ time_hint_match = _TIME_RX.search(chunk)
311
+ iso = _parse_single_date(first_date.group(0), time_hint_match.group(0) if time_hint_match else None) if first_date else None
312
+
313
+ # optional same-month day range inside the chunk
314
+ r = _DATE_RANGE_RX.search(chunk)
315
+ date_end_iso = None
316
+ if r:
317
+ d1, d2, mon_name, year = r.groups()
318
+ month = _MONTHS.get(mon_name.lower())
319
+ if month:
320
+ date_end_iso = f"{int(year):04d}-{month:02d}-{int(d2):02d}"
321
+ # if the start of the range equals first_date, keep iso as start;
322
+ # otherwise we still keep iso from first_date (which begins the chunk)
323
+
324
+ if iso:
325
+ milestones.append({
326
+ "label_raw": chunk,
327
+ "label_norm": _classify_label(low),
328
+ "date_iso": iso,
329
+ "date_iso_end": date_end_iso,
330
+ "has_time": bool(time_hint_match),
331
+ "excluded_from_open_close": excluded,
332
+ })
333
+
334
+ return milestones
335
+
336
+ def pick_open_close_from_milestones(milestones: List[dict]) -> Tuple[Optional[str], Optional[str]]:
337
+ open_iso = close_iso = None
338
+ for m in milestones:
339
+ if m["excluded_from_open_close"]:
340
+ continue
341
+ if m["label_norm"] == "opens" and open_iso is None:
342
+ open_iso = m["date_iso"].split("T")[0]
343
+ if m["label_norm"] == "closes" and close_iso is None:
344
+ close_iso = m["date_iso"]
345
+ if open_iso is None:
346
+ for m in milestones:
347
+ if m["label_norm"] == "opens":
348
+ open_iso = m["date_iso"].split("T")[0]
349
+ break
350
+ if close_iso is None:
351
+ for m in milestones:
352
+ if m["label_norm"] == "closes":
353
+ close_iso = m["date_iso"]
354
+ break
355
+ return open_iso, close_iso
356
+
357
+ def derive_aux_dates(milestones: List[dict]) -> Tuple[Optional[str], Optional[str]]:
358
+ notify = project_start_from = None
359
+ for m in milestones:
360
+ if notify is None and m["label_norm"] == "notify":
361
+ notify = m["date_iso"].split("T")[0]
362
+ if project_start_from is None and m["label_norm"] == "project_start":
363
+ project_start_from = m["date_iso"].split("T")[0]
364
+ if notify and project_start_from:
365
+ break
366
+ return notify, project_start_from
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # FUNDING / COMPENSATION PARSING
370
+ # ---------------------------------------------------------------------------
371
+
372
+ _MONEY_TOKEN = re.compile(r"(£|\bGBP\s*)([\d,]+(?:\.\d+)?)(?:\s*(million|m|billion|bn|k))?", re.I)
373
+
374
+ def _money_to_int(sign: str, num_str: str, mag: Optional[str]) -> int:
375
+ val = float(num_str.replace(",", ""))
376
+ if mag:
377
+ m = mag.lower()
378
+ if m in ("million", "m"):
379
+ val *= 1_000_000
380
+ elif m in ("billion", "bn"):
381
+ val *= 1_000_000_000
382
+ elif m in ("k",):
383
+ val *= 1_000
384
+ return int(round(val))
385
+
386
+ _TOTAL_CUES = [
387
+ "total prize fund", "total prize pot", "total funding available", "available in total",
388
+ "total pot", "prize fund", "funding pot", "overall budget", "total budget",
389
+ "total allocation", "in total across", "total amount available",
390
+ ]
391
+ _AWARD_CUES = [
392
+ "per project", "each project", "you can apply for", "can apply for", "apply for up to",
393
+ "grant of up to", "awards of up to", "awards between", "awards of between",
394
+ "fund between", "we will fund", "we can fund", "project costs between",
395
+ "total eligible project costs between", "your project must have total costs between",
396
+ "maximum grant", "minimum grant", "maximum funding", "minimum funding",
397
+ "up to", "no more than", "at least",
398
+ "grant funding request", "eligible grant funding", "eligible grant", "funding request must be between",
399
+ ]
400
+ _EXCLUDE_CUES = [
401
+ "market", "industry", "global", "worldwide", "valuation", "addressable", "gdp",
402
+ "economy", "sector value", "turnover", "revenue", "jobs", "headcount",
403
+ ]
404
+
405
+ _RANGE_PATTERNS = [
406
+ re.compile(rf"(?:between|from)\s+{_MONEY_TOKEN.pattern}\s+(?:and|to)\s+{_MONEY_TOKEN.pattern}", re.I),
407
+ re.compile(rf"{_MONEY_TOKEN.pattern}\s*(?:to|-)\s*{_MONEY_TOKEN.pattern}", re.I),
408
+ ]
409
+ _MAX_PATTERNS = [
410
+ re.compile(rf"(?:up to|no more than|max(?:imum)?(?:\s+grant|\s+funding)?)\s+{_MONEY_TOKEN.pattern}", re.I),
411
+ ]
412
+ _MIN_PATTERNS = [
413
+ re.compile(rf"(?:at least|minimum(?:\s+grant|\s+funding)?)\s+{_MONEY_TOKEN.pattern}", re.I),
414
+ ]
415
+
416
+ def _contains_any(text: str, cues: List[str]) -> bool:
417
+ low = text.lower()
418
+ return any(c in low for c in cues)
419
+
420
+ def _is_excluded_sentence(sent: str) -> bool:
421
+ return _contains_any(sent, _EXCLUDE_CUES)
422
+
423
+ def _split_sentences_generic(text: str) -> List[str]:
424
+ parts = re.split(r"(?:\n+|(?<=[\.\!\?])\s+)", text)
425
+ return [p.strip() for p in parts if p and p.strip()]
426
+
427
+ def _find_total_pot(text: str) -> Optional[int]:
428
+ if not text:
429
+ return None
430
+ best = None
431
+ for sent in _split_sentences_generic(text):
432
+ if _is_excluded_sentence(sent):
433
+ continue
434
+ if _contains_any(sent, _TOTAL_CUES):
435
+ vals = []
436
+ for m in _MONEY_TOKEN.finditer(sent):
437
+ _, num_str, mag = m.groups()
438
+ vals.append(_money_to_int("£", num_str, mag))
439
+ if vals:
440
+ v = max(vals)
441
+ best = v if best is None or v > best else best
442
+ return best
443
+
444
+ def _find_award_range(text: str) -> Tuple[Optional[int], Optional[int]]:
445
+ if not text:
446
+ return None, None
447
+
448
+ # Strong: explicit ranges in a sentence that has award cues
449
+ for sent in _split_sentences_generic(text):
450
+ if _is_excluded_sentence(sent):
451
+ continue
452
+ if not _contains_any(sent, _AWARD_CUES):
453
+ continue
454
+ for rx in _RANGE_PATTERNS:
455
+ m = rx.search(sent)
456
+ if not m:
457
+ continue
458
+ monies = list(_MONEY_TOKEN.finditer(m.group(0)))
459
+ if len(monies) >= 2:
460
+ v1 = _money_to_int(*("£", monies[-2].group(2), monies[-2].group(3)))
461
+ v2 = _money_to_int(*("£", monies[-1].group(2), monies[-1].group(3)))
462
+ lo, hi = sorted([v1, v2])
463
+ return lo, hi
464
+
465
+ # Next: max-only / min-only with cues
466
+ chosen_min = None
467
+ chosen_max = None
468
+ for sent in _split_sentences_generic(text):
469
+ if _is_excluded_sentence(sent):
470
+ continue
471
+ if not _contains_any(sent, _AWARD_CUES):
472
+ continue
473
+
474
+ if chosen_max is None:
475
+ for rx in _MAX_PATTERNS:
476
+ m = rx.search(sent)
477
+ if m:
478
+ money = _MONEY_TOKEN.search(m.group(0))
479
+ if money:
480
+ chosen_max = _money_to_int(*("£", money.group(2), money.group(3)))
481
+ break
482
+
483
+ if chosen_min is None:
484
+ for rx in _MIN_PATTERNS:
485
+ m = rx.search(sent)
486
+ if m:
487
+ money = _MONEY_TOKEN.search(m.group(0))
488
+ if money:
489
+ chosen_min = _money_to_int(*("£", money.group(2), money.group(3)))
490
+ break
491
+
492
+ if chosen_min is not None and chosen_max is not None:
493
+ break
494
+
495
+ return chosen_min, chosen_max
496
+
497
+ # NEW: funding rates & duration
498
+ _RATE_LINE = re.compile(
499
+ r"up to\s*(\d{1,3})%\s*if you are a\s*(?:micro|small).*?up to\s*(\d{1,3})%\s*if you are a\s*medium.*?up to\s*(\d{1,3})%\s*if you are a\s*large",
500
+ re.I | re.S,
501
+ )
502
+ def _find_funding_rates(text: str) -> Optional[dict]:
503
+ if not text:
504
+ return None
505
+ m = _RATE_LINE.search(text)
506
+ if not m:
507
+ return None
508
+ small, medium, large = map(int, m.groups())
509
+ return {"micro_small": small, "medium": medium, "large": large}
510
+
511
+ _DURATION_RX = re.compile(r"last\s+between\s+(\d{1,3})\s*(?:and|to|–|-)\s*(\d{1,3})\s+months", re.I)
512
+ def _find_duration_months(text: str) -> Tuple[Optional[int], Optional[int]]:
513
+ if not text:
514
+ return None, None
515
+ m = _DURATION_RX.search(text)
516
+ if not m:
517
+ return None, None
518
+ lo, hi = map(int, m.groups())
519
+ return (lo if lo <= hi else hi), (hi if hi >= lo else lo)
520
+
521
+ def extract_funding(sections: dict) -> dict:
522
+ summary = sections.get("summary_raw", "") or ""
523
+ support = sections.get("supporting_information_raw", "") or ""
524
+ scope = sections.get("scope_raw", "") or ""
525
+ eligibility = sections.get("eligibility_raw", "") or ""
526
+ all_text = " ".join(v for v in sections.values() if isinstance(v, str) and v)
527
+
528
+ total_pot = _find_total_pot(summary) or _find_total_pot(support) or _find_total_pot(all_text)
529
+
530
+ min_award = max_award = None
531
+ for candidate in (summary, eligibility, support, scope, all_text):
532
+ lo, hi = _find_award_range(candidate)
533
+ if lo is not None or hi is not None:
534
+ if lo is not None: min_award = lo
535
+ if hi is not None: max_award = hi
536
+ break
537
+
538
+ rates = None
539
+ for candidate in (eligibility, support, all_text):
540
+ rates = _find_funding_rates(candidate or "")
541
+ if rates:
542
+ break
543
+
544
+ return {"min": min_award, "max": max_award, "total_pot": total_pot, "rates": rates}
545
+
546
+ # ---------------------------------------------------------------------------
547
+ # MAIN
548
+ # ---------------------------------------------------------------------------
549
+
550
+ async def _main_async(url: str):
551
+ m = re.search(r"/competition/(\d+)", url)
552
+ slug = f"competition-{m.group(1)}" if m else re.sub(r"[^a-z0-9]+", "-", url.lower()).strip("-")[-60:]
553
+ out_path = pathlib.Path("data/snapshots") / f"{slug}.json"
554
+ out_path.parent.mkdir(parents=True, exist_ok=True)
555
+
556
+ html, sections = await fetch_sections_from_overview(url)
557
+ soup = BeautifulSoup(html, "html.parser")
558
+ h1 = soup.find("h1")
559
+ raw_title = h1.get_text(" ", strip=True) if h1 else ""
560
+ title = clean_title(raw_title)
561
+
562
+ # Dates -> single-line chunks
563
+ dates_text = sections.get("dates_raw", "") or ""
564
+ milestones = parse_dates_singleline(dates_text)
565
+
566
+ # Derive open/close from milestones (with exclusions for briefing lines)
567
+ open_date, close_date = pick_open_close_from_milestones(milestones)
568
+
569
+ # If either missing, try scanning all text but still as single-line chunks
570
+ if not open_date or not close_date:
571
+ all_text = " ".join(v for v in sections.values() if isinstance(v, str) and v)
572
+ extra = parse_dates_singleline(all_text)
573
+ # merge de-duped by (label_raw, date_iso, date_iso_end)
574
+ seen = {(m["label_raw"], m["date_iso"], m.get("date_iso_end")) for m in milestones}
575
+ for m2 in extra:
576
+ key = (m2["label_raw"], m2["date_iso"], m2.get("date_iso_end"))
577
+ if key not in seen:
578
+ milestones.append(m2)
579
+ seen.add(key)
580
+ od2, cd2 = pick_open_close_from_milestones(milestones)
581
+ open_date = open_date or od2
582
+ close_date = close_date or cd2
583
+
584
+ notify_date, project_start_from = derive_aux_dates(milestones)
585
+
586
+ # Funding
587
+ funding = extract_funding(sections)
588
+
589
+ # Duration months
590
+ dur_min, dur_max = _find_duration_months(sections.get("eligibility_raw", "") or "")
591
+ if dur_min is None or dur_max is None:
592
+ all_text_for_duration = " ".join(v for v in sections.values() if isinstance(v, str) and v)
593
+ dur_min, dur_max = _find_duration_months(all_text_for_duration)
594
+ duration_months = {"min": dur_min, "max": dur_max}
595
+
596
+ snapshot = {
597
+ "url": url,
598
+ "title": title,
599
+ "programme": "",
600
+ "round": "",
601
+ "open_date": open_date,
602
+ "close_date": close_date,
603
+ "notify_date": notify_date,
604
+ "project_start_from": project_start_from,
605
+ "funding": funding,
606
+ "duration_months": duration_months,
607
+ "sections": sections,
608
+ "pdfs": [],
609
+ "summaries": {},
610
+ "extracted": {"milestones": milestones},
611
+ "wonky": {"score": 0.0, "reasons": []},
612
+ "prev_round_refs": [],
613
+ "diff_summary": "",
614
+ "history_stats": {},
615
+ "created_at": datetime.now(UTC).isoformat(),
616
+ "updated_at": datetime.now(UTC).isoformat(),
617
+ }
618
+
619
+ out_path.write_text(json.dumps(snapshot, indent=2), encoding="utf-8")
620
+ print(f"Snapshot saved to {out_path}")
621
+
622
+ def main(url: str = typer.Argument(..., help="IFS overview URL e.g. .../competition/{id}/overview/{uuid}")):
623
+ asyncio.run(_main_async(url))
624
+
625
+ if __name__ == "__main__":
626
+ typer.run(main)
analyzer/llm_client.py CHANGED
@@ -3,10 +3,14 @@ from __future__ import annotations
3
  import logging
4
  import os
5
  import time
6
- from typing import Any, Dict, List, Optional, Callable
7
 
8
  from .utils.errors import LLMError, ConfigError
9
 
 
 
 
 
10
  try:
11
  from openai import OpenAI
12
  import httpx
@@ -28,9 +32,14 @@ class LLMClient:
28
  def __init__(self, cfg: Any):
29
  # Extract config (supports both dict and object)
30
  self.provider = self._get_cfg(cfg, "provider", "openai")
31
- self.model = self._get_cfg(cfg, "model", "gpt-4o-mini")
32
  self.disable_llm = self._get_cfg(cfg, "disable_llm", False)
33
 
 
 
 
 
 
34
  api_key = self._get_cfg(cfg, "api_key") or os.getenv("OPENAI_API_KEY")
35
  base_url = self._get_cfg(cfg, "base_url") or os.getenv(
36
  "OPENAI_API_BASE",
@@ -85,6 +94,37 @@ class LLMClient:
85
  """Check if LLM client is ready to use."""
86
  return self.client is not None and not self.disable_llm
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  def _retry_with_backoff(
89
  self,
90
  fn: Callable[[], Any],
@@ -143,9 +183,12 @@ class LLMClient:
143
  temperature: float = 0.2,
144
  top_p: float = 1.0,
145
  stream: bool = False,
 
 
 
146
  ) -> str:
147
  """
148
- Single chat completion call with optional streaming.
149
 
150
  Args:
151
  messages: List of message dicts with 'role' and 'content'
@@ -153,6 +196,15 @@ class LLMClient:
153
  temperature: Sampling temperature (0-2)
154
  top_p: Nucleus sampling parameter
155
  stream: If True, returns generator yielding tokens (else full response)
 
 
 
 
 
 
 
 
 
156
 
157
  Returns:
158
  Generated text response (or generator if stream=True)
@@ -169,15 +221,27 @@ class LLMClient:
169
  )
170
 
171
  def _make_call():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  try:
173
- resp = self.client.chat.completions.create(
174
- model=self.model,
175
- messages=messages,
176
- temperature=temperature,
177
- top_p=top_p,
178
- max_tokens=max_tokens,
179
- stream=stream,
180
- )
181
  except Exception as e:
182
  # Handle httpx exceptions if available
183
  if httpx and isinstance(e, httpx.TimeoutException):
 
3
  import logging
4
  import os
5
  import time
6
+ from typing import Any, Dict, List, Optional, Callable, Literal
7
 
8
  from .utils.errors import LLMError, ConfigError
9
 
10
+ ModelType = Literal["router", "translator", "analyzer"]
11
+ VerbosityLevel = Literal["low", "medium", "high"]
12
+ ReasoningEffort = Literal["minimal", "medium", "high"]
13
+
14
  try:
15
  from openai import OpenAI
16
  import httpx
 
32
  def __init__(self, cfg: Any):
33
  # Extract config (supports both dict and object)
34
  self.provider = self._get_cfg(cfg, "provider", "openai")
35
+ self.model = self._get_cfg(cfg, "model", "gpt-5-mini")
36
  self.disable_llm = self._get_cfg(cfg, "disable_llm", False)
37
 
38
+ # Store model variants for different use cases (GPT-5 family)
39
+ self.model_router = self._get_cfg(cfg, "model_router", "gpt-5-nano")
40
+ self.model_translator = self._get_cfg(cfg, "model_translator", "gpt-5-mini")
41
+ self.model_analyzer = self._get_cfg(cfg, "model_analyzer", "gpt-5")
42
+
43
  api_key = self._get_cfg(cfg, "api_key") or os.getenv("OPENAI_API_KEY")
44
  base_url = self._get_cfg(cfg, "base_url") or os.getenv(
45
  "OPENAI_API_BASE",
 
94
  """Check if LLM client is ready to use."""
95
  return self.client is not None and not self.disable_llm
96
 
97
+ def _get_model_for_type(self, model_type: Optional[ModelType] = None) -> str:
98
+ """Get the appropriate model for the given type."""
99
+ if model_type == "router":
100
+ return self.model_router
101
+ elif model_type == "translator":
102
+ return self.model_translator
103
+ elif model_type == "analyzer":
104
+ return self.model_analyzer
105
+ else:
106
+ return self.model
107
+
108
+ @staticmethod
109
+ def get_recommended_params(task_type: str) -> Dict[str, Any]:
110
+ """
111
+ Get recommended verbosity and reasoning_effort for common tasks.
112
+
113
+ Args:
114
+ task_type: One of "translation", "analysis", "routing", "summary", "comparison"
115
+
116
+ Returns:
117
+ Dict with verbosity and reasoning_effort settings
118
+ """
119
+ presets = {
120
+ "translation": {"verbosity": "medium", "reasoning_effort": "minimal"},
121
+ "analysis": {"verbosity": "high", "reasoning_effort": "high"},
122
+ "routing": {"verbosity": "low", "reasoning_effort": "minimal"},
123
+ "summary": {"verbosity": "medium", "reasoning_effort": "medium"},
124
+ "comparison": {"verbosity": "high", "reasoning_effort": "high"}
125
+ }
126
+ return presets.get(task_type, {"verbosity": "medium", "reasoning_effort": "medium"})
127
+
128
  def _retry_with_backoff(
129
  self,
130
  fn: Callable[[], Any],
 
183
  temperature: float = 0.2,
184
  top_p: float = 1.0,
185
  stream: bool = False,
186
+ model_type: Optional[ModelType] = None,
187
+ verbosity: Optional[VerbosityLevel] = None,
188
+ reasoning_effort: Optional[ReasoningEffort] = None,
189
  ) -> str:
190
  """
191
+ Single chat completion call with optional streaming and GPT-5 features.
192
 
193
  Args:
194
  messages: List of message dicts with 'role' and 'content'
 
196
  temperature: Sampling temperature (0-2)
197
  top_p: Nucleus sampling parameter
198
  stream: If True, returns generator yielding tokens (else full response)
199
+ model_type: Type of model to use ('router', 'translator', or 'analyzer')
200
+ verbosity: Response length control (GPT-5 feature)
201
+ - 'low': Brief, concise responses
202
+ - 'medium': Standard length responses
203
+ - 'high': Detailed, comprehensive responses
204
+ reasoning_effort: Thinking time control (GPT-5 feature)
205
+ - 'minimal': Quick, straightforward responses
206
+ - 'medium': Moderate analysis and reasoning
207
+ - 'high': Deep analysis and careful reasoning
208
 
209
  Returns:
210
  Generated text response (or generator if stream=True)
 
221
  )
222
 
223
  def _make_call():
224
+ # Select model based on model_type
225
+ model = self._get_model_for_type(model_type)
226
+
227
+ # Build API parameters
228
+ api_params = {
229
+ "model": model,
230
+ "messages": messages,
231
+ "temperature": temperature,
232
+ "top_p": top_p,
233
+ "max_tokens": max_tokens,
234
+ "stream": stream,
235
+ }
236
+
237
+ # Add GPT-5 specific parameters if provided
238
+ if verbosity is not None:
239
+ api_params["verbosity"] = verbosity
240
+ if reasoning_effort is not None:
241
+ api_params["reasoning_effort"] = reasoning_effort
242
+
243
  try:
244
+ resp = self.client.chat.completions.create(**api_params)
 
 
 
 
 
 
 
245
  except Exception as e:
246
  # Handle httpx exceptions if available
247
  if httpx and isinstance(e, httpx.TimeoutException):
src/__pycache__/__init__.cpython-312.pyc CHANGED
Binary files a/src/__pycache__/__init__.cpython-312.pyc and b/src/__pycache__/__init__.cpython-312.pyc differ
 
src/analyzer/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (155 Bytes)
 
src/analyzer/__pycache__/config.cpython-312.pyc DELETED
Binary file (5.49 kB)
 
src/analyzer/__pycache__/llm_client.cpython-312.pyc DELETED
Binary file (15.7 kB)
 
src/analyzer/chat/chat_tools.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import asyncio
4
  import logging
5
  import re
 
6
  from dataclasses import dataclass
7
  from datetime import datetime
8
  from pathlib import Path
@@ -55,6 +56,51 @@ def _norm(s: Any) -> str:
55
  return clean(str(s)) # Use utils.text.clean() for final normalization
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  # ---------------------------------------------------------------------
59
  # Main ChatTools class
60
  # ---------------------------------------------------------------------
@@ -86,17 +132,6 @@ class ChatTools:
86
  # Initialize cache for summaries (NEW: Optimized caching)
87
  self.summary_cache = SummaryCache(ttl_seconds=3600)
88
 
89
- # -----------------------------------------------------------------
90
- # Cache stats logging helper
91
- # -----------------------------------------------------------------
92
- def _log_cache_stats(self) -> None:
93
- """Log cache statistics for monitoring."""
94
- stats = self.summary_cache.stats()
95
- logging.info(
96
- f"📊 Cache stats: {stats['valid']}/{stats['cached']} valid entries "
97
- f"({stats['valid']/max(stats['cached'], 1)*100:.1f}% hit rate)"
98
- )
99
-
100
  # -----------------------------------------------------------------
101
  # Status calculation (NEW)
102
  # -----------------------------------------------------------------
@@ -154,7 +189,8 @@ class ChatTools:
154
  results = []
155
  for r in self.current:
156
  txt = _norm(r)
157
- if kw and kw not in txt.lower():
 
158
  continue
159
  if max_award is not None:
160
  ma = to_number(r.get("max_award") or r.get("funding_max"))
@@ -183,6 +219,76 @@ class ChatTools:
183
  return results
184
  return results[:limit]
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  # -----------------------------------------------------------------
187
  # Retrieve a single grant
188
  # -----------------------------------------------------------------
@@ -254,13 +360,8 @@ class ChatTools:
254
  # Yield each result as it's ready
255
  for result in results:
256
  yield result
257
-
258
- # Log cache stats after batch completion
259
- self._log_cache_stats()
260
  except Exception as e:
261
  logging.error(f"Batch summarization failed: {e}")
262
- # Log cache stats even on error
263
- self._log_cache_stats()
264
  raise
265
 
266
  async def get_all_grant_summaries(self, batch_size: int = 5):
@@ -292,20 +393,16 @@ class ChatTools:
292
  async for result in self.summarize_grants_batch(all_grant_ids, batch_size=batch_size):
293
  yield result
294
 
295
- # Log final cache stats
296
- self._log_cache_stats()
297
-
298
  # -----------------------------------------------------------------
299
  # Summarize a grant
300
  # -----------------------------------------------------------------
301
- def summarize_grant(self, gid: str, include_supporting: bool = True, summary_type: str = "layman") -> Dict[str, Any]:
302
  """
303
- Summarize a grant using LLM with MongoDB and memory caching.
304
 
305
  Args:
306
  gid: Grant ID
307
  include_supporting: If True, include supporting PDFs and materials in context
308
- summary_type: Type of summary to retrieve ("layman", "technical", "exec")
309
 
310
  Returns:
311
  Dict with summary_md, title, id
@@ -314,27 +411,10 @@ class ChatTools:
314
  title = row.get("title", "(untitled)")
315
  grant_id = row.get("id") or gid
316
 
317
- # NEW: Check MongoDB first for pre-computed summaries
318
- try:
319
- from ...database import SummaryStore
320
- summary_store = SummaryStore()
321
- mongodb_summary = summary_store.get_summary(grant_id, summary_type)
322
-
323
- if mongodb_summary:
324
- logging.info(f"📦 MongoDB HIT for grant {grant_id} ({summary_type})")
325
- return {
326
- "summary_md": mongodb_summary,
327
- "title": title,
328
- "id": grant_id,
329
- }
330
- except Exception as e:
331
- logging.warning(f"MongoDB lookup failed for {grant_id}: {e}")
332
- # Continue to memory cache/LLM fallback
333
-
334
- # Check memory cache
335
  cached_summary = self.summary_cache.get(row)
336
  if cached_summary:
337
- logging.info("📦 Memory cache HIT for grant %s", grant_id)
338
  return {
339
  "summary_md": cached_summary,
340
  "title": title,
@@ -372,9 +452,6 @@ class ChatTools:
372
  # NEW: Cache the summary
373
  self.summary_cache.set(row, text)
374
 
375
- # Log cache stats
376
- self._log_cache_stats()
377
-
378
  return {"summary_md": text, "title": title, "id": grant_id}
379
 
380
  # -----------------------------------------------------------------
@@ -405,150 +482,6 @@ class ChatTools:
405
  parts.append(f"{k.upper()}:\n{_norm(v)}")
406
  return "\n".join(parts)
407
 
408
- # -----------------------------------------------------------------
409
- # Batch process multiple grants
410
- # -----------------------------------------------------------------
411
- def batch_process_grants(
412
- self,
413
- grant_ids: List[str],
414
- operation_type: str = "summarize",
415
- batch_size: int = 5
416
- ) -> Dict[str, str]:
417
- """
418
- Batch process multiple grants with a single LLM call per batch.
419
-
420
- This method groups grants into batches and sends them as a single prompt
421
- with numbered sections, then parses the response to extract individual results.
422
-
423
- Args:
424
- grant_ids: List of grant IDs to process
425
- operation_type: Type of operation - "summarize", "translate", "simplify", etc.
426
- batch_size: Number of grants per batch (default 5)
427
-
428
- Returns:
429
- Dictionary mapping grant_id to result text
430
-
431
- Example:
432
- results = tools.batch_process_grants(
433
- ["competition-2315", "competition-2316"],
434
- operation_type="summarize"
435
- )
436
- # Returns: {"competition-2315": "summary text...", "competition-2316": "..."}
437
- """
438
- import hashlib
439
-
440
- if not grant_ids:
441
- return {}
442
-
443
- if not self.client or not self.client.is_ready():
444
- logging.warning("LLM client not available for batch processing")
445
- return {gid: "LLM unavailable" for gid in grant_ids}
446
-
447
- # Build operation-specific instruction
448
- operation_instructions = {
449
- "summarize": "Provide a concise summary highlighting key information, deadlines, and funding details.",
450
- "translate": "Translate the grant information into simple, everyday language that anyone can understand.",
451
- "simplify": "Explain this grant in layman's terms, avoiding technical jargon.",
452
- "analyze": "Analyze this grant's strengths, requirements, and suitability for different applicants.",
453
- }
454
- instruction = operation_instructions.get(operation_type, "Process this grant information.")
455
-
456
- results = {}
457
-
458
- # Process grants in batches
459
- for batch_start in range(0, len(grant_ids), batch_size):
460
- batch_ids = grant_ids[batch_start:batch_start + batch_size]
461
-
462
- # Build batch prompt with numbered sections
463
- prompt_parts = [
464
- f"Process the following {len(batch_ids)} grants. {instruction}",
465
- "\nFor each grant, start your response with '### Grant N:' where N is the grant number.",
466
- "\n---\n"
467
- ]
468
-
469
- # Add each grant with its context
470
- grant_contexts = []
471
- for idx, grant_id in enumerate(batch_ids, 1):
472
- try:
473
- grant = self.get_grant(grant_id)
474
- # Use extract_minimal_context for efficiency
475
- from ..summarizer_optimized import extract_minimal_context
476
- context = extract_minimal_context(grant)
477
-
478
- prompt_parts.append(f"### Grant {idx}:")
479
- prompt_parts.append(f"ID: {grant_id}")
480
- prompt_parts.append(context)
481
- prompt_parts.append("\n---\n")
482
-
483
- grant_contexts.append((idx, grant_id))
484
-
485
- except Exception as e:
486
- logging.error(f"Failed to load grant {grant_id}: {e}")
487
- results[grant_id] = f"Error loading grant: {e}"
488
-
489
- if not grant_contexts:
490
- continue
491
-
492
- # Build final prompt
493
- full_prompt = "\n".join(prompt_parts)
494
-
495
- # Check cache for batch
496
- cache_key = hashlib.md5(full_prompt.encode()).hexdigest()[:12]
497
- cache_dict = {"id": f"batch_{operation_type}_{cache_key}"}
498
- cached_response = self.summary_cache.get(cache_dict)
499
-
500
- if cached_response:
501
- logging.info(f"📦 Cache HIT for batch {cache_key}")
502
- response_text = cached_response
503
- else:
504
- # Call LLM with batch prompt
505
- try:
506
- messages = [
507
- {"role": "system", "content": "You are a grant analyst. Process each grant separately and clearly mark each response with the grant number."},
508
- {"role": "user", "content": full_prompt}
509
- ]
510
- response_text = self.client.chat(
511
- messages,
512
- max_tokens=batch_size * 400, # ~400 tokens per grant
513
- temperature=0.3
514
- )
515
-
516
- # Cache the response
517
- self.summary_cache.set(cache_dict, response_text)
518
- logging.info(f"✅ Batch processed {len(batch_ids)} grants")
519
-
520
- except Exception as e:
521
- logging.error(f"Batch processing failed: {e}")
522
- for _, grant_id in grant_contexts:
523
- results[grant_id] = f"Batch processing error: {e}"
524
- continue
525
-
526
- # Parse response to extract individual results
527
- # Use regex to split by "### Grant N:" markers
528
- import re
529
- pattern = r'### Grant (\d+):(.*?)(?=### Grant \d+:|$)'
530
- matches = re.findall(pattern, response_text, re.DOTALL)
531
-
532
- # Map results back to grant IDs
533
- for grant_num, result_text in matches:
534
- grant_idx = int(grant_num)
535
- # Find corresponding grant_id
536
- for idx, grant_id in grant_contexts:
537
- if idx == grant_idx:
538
- results[grant_id] = result_text.strip()
539
- break
540
-
541
- # Handle any grants that didn't get matched
542
- for idx, grant_id in grant_contexts:
543
- if grant_id not in results:
544
- logging.warning(f"No result found for grant {grant_id} (index {idx})")
545
- results[grant_id] = "No response generated"
546
-
547
- # Log cache stats
548
- self._log_cache_stats()
549
-
550
- return results
551
-
552
  # -----------------------------------------------------------------
553
  # Compare two grants (deterministic)
554
  # -----------------------------------------------------------------
@@ -617,30 +550,18 @@ class ChatTools:
617
 
618
  insight = ""
619
  if self.client and self.client.is_ready() and context_text.strip():
620
- # Create a cache key for comparison (use sorted grant IDs to ensure consistency)
621
- cache_key = {"id": f"compare_{min(grant_id_a, grant_id_b)}_{max(grant_id_a, grant_id_b)}"}
622
-
623
- # Check cache first
624
- cached_insight = self.summary_cache.get(cache_key)
625
- if cached_insight:
626
- logging.info("📦 Cache HIT for comparison %s vs %s", grant_id_a, grant_id_b)
627
- insight = cached_insight
628
- else:
629
- try:
630
- prompt = (
631
- "Given the factual table and context below, write 3-5 bullet points "
632
- "highlighting *meaningful differences* that matter to SMEs (funding size, "
633
- "duration, eligibility, etc.). Do not restate identical facts.\n\n"
634
- + "\n".join(table)
635
- + "\n\n"
636
- + context_text
637
- )
638
- insight = self.client.summarize(prompt)
639
- # Cache the insight
640
- self.summary_cache.set(cache_key, insight)
641
- logging.info("✅ Generated and cached comparison insight")
642
- except Exception as e:
643
- logging.warning("compare_grants insight failed: %s", e)
644
 
645
  md = [
646
  "### Comparison",
@@ -650,9 +571,6 @@ class ChatTools:
650
  if insight:
651
  md += ["\n### Key differences", insight]
652
 
653
- # Log cache stats
654
- self._log_cache_stats()
655
-
656
  return {"comparison_md": "\n".join(md)}
657
 
658
  # -----------------------------------------------------------------
@@ -818,22 +736,8 @@ RECOMMENDED GRANTS:
818
  - Why: [1-2 sentence explanation of fit]
819
  """
820
 
821
- # Create cache key for company analysis (hash the URL)
822
- import hashlib
823
- url_hash = hashlib.md5(company_url.encode()).hexdigest()[:12]
824
- cache_key = {"id": f"company_{url_hash}"}
825
-
826
- # Check cache first
827
- cached_analysis = self.summary_cache.get(cache_key)
828
- if cached_analysis:
829
- logging.info("📦 Cache HIT for company analysis: %s", company_url)
830
- analysis_text = cached_analysis
831
- else:
832
- # Get LLM analysis
833
- analysis_text = self.client.summarize(prompt)
834
- # Cache the analysis
835
- self.summary_cache.set(cache_key, analysis_text)
836
- logging.info("✅ Generated and cached company analysis")
837
 
838
  # Extract recommended grant IDs from the response
839
  recommended_ids = []
@@ -861,9 +765,6 @@ RECOMMENDED GRANTS:
861
  except KeyError:
862
  continue
863
 
864
- # Log cache stats
865
- self._log_cache_stats()
866
-
867
  return {
868
  "company_url": company_url,
869
  "analysis": analysis_text,
@@ -872,8 +773,6 @@ RECOMMENDED GRANTS:
872
 
873
  except Exception as e:
874
  logging.error(f"Failed to analyze company: {e}")
875
- # Log cache stats even on error
876
- self._log_cache_stats()
877
  return {
878
  "error": f"Analysis failed: {str(e)}",
879
  "company_url": company_url,
 
3
  import asyncio
4
  import logging
5
  import re
6
+ import difflib
7
  from dataclasses import dataclass
8
  from datetime import datetime
9
  from pathlib import Path
 
56
  return clean(str(s)) # Use utils.text.clean() for final normalization
57
 
58
 
59
+ # Fuzzy matching helper - supports partial/typo matches
60
+ def _fuzzy_match(keyword: str, text: str, threshold: float = 0.6) -> bool:
61
+ """
62
+ Check if keyword matches text using fuzzy matching.
63
+
64
+ Returns True if:
65
+ - Exact substring match (e.g., "ai" in "agentic ai")
66
+ - Fuzzy word match with high similarity (e.g., "agent" vs "agentic" @ 86%)
67
+ - Any word in text starts with the keyword
68
+
69
+ Args:
70
+ keyword: The search keyword
71
+ text: The text to search in
72
+ threshold: Minimum similarity score (0-1) for fuzzy match
73
+
74
+ Returns:
75
+ True if match found, False otherwise
76
+ """
77
+ keyword_lower = keyword.lower()
78
+ text_lower = text.lower()
79
+
80
+ # Exact substring match (fastest, most common case)
81
+ if keyword_lower in text_lower:
82
+ return True
83
+
84
+ # Split into words and try fuzzy matching on individual words
85
+ text_words = text_lower.split()
86
+ keyword_words = keyword_lower.split()
87
+
88
+ for kw_word in keyword_words:
89
+ # Check if any text word starts with the keyword word
90
+ for text_word in text_words:
91
+ # Allow leading punctuation in text_word
92
+ clean_text_word = re.sub(r'^[^a-z0-9]+', '', text_word)
93
+ if clean_text_word.startswith(kw_word):
94
+ return True
95
+
96
+ # Fuzzy match single words (e.g., "agent" vs "agentic")
97
+ ratio = difflib.SequenceMatcher(None, kw_word, clean_text_word).ratio()
98
+ if ratio >= threshold:
99
+ return True
100
+
101
+ return False
102
+
103
+
104
  # ---------------------------------------------------------------------
105
  # Main ChatTools class
106
  # ---------------------------------------------------------------------
 
132
  # Initialize cache for summaries (NEW: Optimized caching)
133
  self.summary_cache = SummaryCache(ttl_seconds=3600)
134
 
 
 
 
 
 
 
 
 
 
 
 
135
  # -----------------------------------------------------------------
136
  # Status calculation (NEW)
137
  # -----------------------------------------------------------------
 
189
  results = []
190
  for r in self.current:
191
  txt = _norm(r)
192
+ # Use fuzzy matching instead of exact substring match
193
+ if kw and not _fuzzy_match(kw, txt):
194
  continue
195
  if max_award is not None:
196
  ma = to_number(r.get("max_award") or r.get("funding_max"))
 
219
  return results
220
  return results[:limit]
221
 
222
+ # -----------------------------------------------------------------
223
+ # Search past winners
224
+ # -----------------------------------------------------------------
225
+ def search_past_winners(
226
+ self,
227
+ keyword: Optional[str] = None,
228
+ competition: Optional[str] = None,
229
+ limit: Optional[int] = None,
230
+ ) -> List[Dict[str, Any]]:
231
+ """
232
+ Search past winners by keyword or competition name.
233
+
234
+ Args:
235
+ keyword: Filter by keyword in project title, organization, or description
236
+ competition: Filter by competition/programme name
237
+ limit: Max results to return. If None, returns ALL matching winners.
238
+
239
+ Returns:
240
+ List of past winner records sorted by year (newest first)
241
+ """
242
+ kw = (keyword or "").lower()
243
+ comp = (competition or "").lower()
244
+ results = []
245
+
246
+ for r in self.past:
247
+ # Filter by keyword (searches project title, org name, and description)
248
+ if kw:
249
+ searchable_text = _norm({
250
+ "project_title": r.get("project_title"),
251
+ "lead_org": r.get("lead_org"),
252
+ "participant_name": r.get("participant_name"),
253
+ "public_description": r.get("public_description"),
254
+ "abstract": r.get("abstract"),
255
+ })
256
+ # Use fuzzy matching instead of exact substring match
257
+ if not _fuzzy_match(kw, searchable_text):
258
+ continue
259
+
260
+ # Filter by competition name
261
+ if comp:
262
+ comp_text = _norm({
263
+ "competition": r.get("competition"),
264
+ "competition_title": r.get("competition_title"),
265
+ "programme_title": r.get("programme_title"),
266
+ })
267
+ # Use fuzzy matching instead of exact substring match
268
+ if not _fuzzy_match(comp, comp_text):
269
+ continue
270
+
271
+ # Extract key fields for display
272
+ results.append({
273
+ "project_title": r.get("project_title") or "(untitled)",
274
+ "lead_org": r.get("lead_org") or r.get("participant_name") or "(unknown)",
275
+ "competition": r.get("competition_title") or r.get("competition") or "(unknown)",
276
+ "award_amount": r.get("award_amount"),
277
+ "year": r.get("year") or r.get("project_start_date"),
278
+ "abstract": r.get("public_description") or r.get("abstract"),
279
+ })
280
+
281
+ # Sort by year (newest first)
282
+ results.sort(
283
+ key=lambda x: _parse_date(x.get("year")) or datetime.min,
284
+ reverse=True
285
+ )
286
+
287
+ # Return all results if limit is None
288
+ if limit is None:
289
+ return results
290
+ return results[:limit]
291
+
292
  # -----------------------------------------------------------------
293
  # Retrieve a single grant
294
  # -----------------------------------------------------------------
 
360
  # Yield each result as it's ready
361
  for result in results:
362
  yield result
 
 
 
363
  except Exception as e:
364
  logging.error(f"Batch summarization failed: {e}")
 
 
365
  raise
366
 
367
  async def get_all_grant_summaries(self, batch_size: int = 5):
 
393
  async for result in self.summarize_grants_batch(all_grant_ids, batch_size=batch_size):
394
  yield result
395
 
 
 
 
396
  # -----------------------------------------------------------------
397
  # Summarize a grant
398
  # -----------------------------------------------------------------
399
+ def summarize_grant(self, gid: str, include_supporting: bool = True) -> Dict[str, Any]:
400
  """
401
+ Summarize a grant using LLM with caching.
402
 
403
  Args:
404
  gid: Grant ID
405
  include_supporting: If True, include supporting PDFs and materials in context
 
406
 
407
  Returns:
408
  Dict with summary_md, title, id
 
411
  title = row.get("title", "(untitled)")
412
  grant_id = row.get("id") or gid
413
 
414
+ # NEW: Check cache first
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  cached_summary = self.summary_cache.get(row)
416
  if cached_summary:
417
+ logging.info("📦 Cache HIT for grant %s", grant_id)
418
  return {
419
  "summary_md": cached_summary,
420
  "title": title,
 
452
  # NEW: Cache the summary
453
  self.summary_cache.set(row, text)
454
 
 
 
 
455
  return {"summary_md": text, "title": title, "id": grant_id}
456
 
457
  # -----------------------------------------------------------------
 
482
  parts.append(f"{k.upper()}:\n{_norm(v)}")
483
  return "\n".join(parts)
484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  # -----------------------------------------------------------------
486
  # Compare two grants (deterministic)
487
  # -----------------------------------------------------------------
 
550
 
551
  insight = ""
552
  if self.client and self.client.is_ready() and context_text.strip():
553
+ try:
554
+ prompt = (
555
+ "Given the factual table and context below, write 3-5 bullet points "
556
+ "highlighting *meaningful differences* that matter to SMEs (funding size, "
557
+ "duration, eligibility, etc.). Do not restate identical facts.\n\n"
558
+ + "\n".join(table)
559
+ + "\n\n"
560
+ + context_text
561
+ )
562
+ insight = self.client.summarize(prompt)
563
+ except Exception as e:
564
+ logging.warning("compare_grants insight failed: %s", e)
 
 
 
 
 
 
 
 
 
 
 
 
565
 
566
  md = [
567
  "### Comparison",
 
571
  if insight:
572
  md += ["\n### Key differences", insight]
573
 
 
 
 
574
  return {"comparison_md": "\n".join(md)}
575
 
576
  # -----------------------------------------------------------------
 
736
  - Why: [1-2 sentence explanation of fit]
737
  """
738
 
739
+ # Get LLM analysis
740
+ analysis_text = self.client.summarize(prompt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
 
742
  # Extract recommended grant IDs from the response
743
  recommended_ids = []
 
765
  except KeyError:
766
  continue
767
 
 
 
 
768
  return {
769
  "company_url": company_url,
770
  "analysis": analysis_text,
 
773
 
774
  except Exception as e:
775
  logging.error(f"Failed to analyze company: {e}")
 
 
776
  return {
777
  "error": f"Analysis failed: {str(e)}",
778
  "company_url": company_url,
src/analyzer/chat/company_analyzer.py DELETED
@@ -1,384 +0,0 @@
1
- """
2
- Enhanced company analysis for grant matching v2.
3
-
4
- This module provides intelligent company profiling and grant matching based on:
5
- - Technology stack and industry sector
6
- - Company size and stage
7
- - Location and eligibility requirements
8
- - Funding amounts and project types
9
- """
10
- from __future__ import annotations
11
-
12
- import logging
13
- import re
14
- from dataclasses import dataclass
15
- from datetime import datetime
16
- from typing import Dict, List, Optional, Set, Any
17
-
18
- from ..net.fetcher import fetch_link
19
- from ..utils.text import clean
20
- from ..utils.dates import parse_date
21
-
22
- logger = logging.getLogger(__name__)
23
-
24
-
25
- # Technology keywords for sector detection
26
- TECH_KEYWORDS = {
27
- "ai_ml": ["ai", "artificial intelligence", "machine learning", "ml", "deep learning",
28
- "neural network", "llm", "gpt", "nlp", "computer vision", "agentic"],
29
- "battery_ev": ["battery", "batteries", "electric vehicle", "ev", "electrification",
30
- "energy storage", "lithium", "zero emission"],
31
- "biotech": ["biotech", "pharmaceutical", "drug discovery", "clinical", "medical device",
32
- "diagnostic", "therapeutic", "genomic", "bioinformatics"],
33
- "manufacturing": ["manufacturing", "production", "factory", "industrial", "assembly",
34
- "automation", "robotics", "supply chain"],
35
- "software": ["software", "saas", "platform", "application", "app", "digital", "cloud"],
36
- "green_tech": ["sustainability", "renewable", "green energy", "climate", "carbon",
37
- "environmental", "circular economy", "net zero"],
38
- "aerospace": ["aerospace", "aviation", "aircraft", "satellite", "space", "drone"],
39
- "quantum": ["quantum computing", "quantum", "qubit"],
40
- }
41
-
42
- # Company size indicators
43
- SIZE_INDICATORS = {
44
- "startup": ["startup", "founded in 202", "seed", "pre-seed", "early stage"],
45
- "scale_up": ["scale-up", "scaleup", "series a", "series b", "growing", "expansion"],
46
- "sme": ["small business", "sme", "small to medium", "limited", "ltd"],
47
- "enterprise": ["enterprise", "corporation", "plc", "publicly traded", "fortune"],
48
- }
49
-
50
- # Location keywords for UK eligibility
51
- UK_LOCATIONS = [
52
- "uk", "united kingdom", "london", "manchester", "birmingham", "glasgow", "edinburgh",
53
- "bristol", "leeds", "liverpool", "cardiff", "belfast", "scotland", "wales", "england",
54
- "northern ireland", "britain", "british"
55
- ]
56
-
57
-
58
- @dataclass
59
- class CompanyProfile:
60
- """Extracted company profile for grant matching."""
61
- url: str
62
- text_content: str
63
-
64
- # Detected attributes
65
- sectors: Set[str]
66
- tech_stack: Set[str]
67
- company_size: Optional[str]
68
- is_uk_based: bool
69
- keywords: Set[str]
70
-
71
- # Inferred characteristics
72
- appears_r_and_d_focused: bool
73
- mentions_funding: bool
74
-
75
- def to_dict(self) -> Dict[str, Any]:
76
- """Convert to dictionary for JSON serialization."""
77
- return {
78
- "url": self.url,
79
- "sectors": list(self.sectors),
80
- "tech_stack": list(self.tech_stack),
81
- "company_size": self.company_size,
82
- "is_uk_based": self.is_uk_based,
83
- "appears_r_and_d_focused": self.appears_r_and_d_focused,
84
- "mentions_funding": self.mentions_funding,
85
- "keywords": list(self.keywords)[:20], # Limit for readability
86
- }
87
-
88
-
89
- def extract_company_profile(company_url: str) -> Optional[CompanyProfile]:
90
- """
91
- Extract detailed company profile from website.
92
-
93
- Args:
94
- company_url: URL of company website
95
-
96
- Returns:
97
- CompanyProfile with extracted attributes, or None if fetch fails
98
- """
99
- logger.info(f"Extracting company profile from: {company_url}")
100
-
101
- # Use the existing fetcher
102
- result = fetch_link(company_url)
103
-
104
- if not result.get("ok"):
105
- logger.error(f"Failed to fetch {company_url}: {result.get('error')}")
106
- return None
107
-
108
- text = result.get("text", "")
109
- text_lower = text.lower()
110
-
111
- # Detect technology sectors
112
- sectors = set()
113
- tech_stack = set()
114
- for sector, keywords in TECH_KEYWORDS.items():
115
- for keyword in keywords:
116
- if keyword in text_lower:
117
- sectors.add(sector)
118
- tech_stack.add(keyword)
119
-
120
- # Detect company size
121
- company_size = None
122
- for size_type, indicators in SIZE_INDICATORS.items():
123
- for indicator in indicators:
124
- if indicator in text_lower:
125
- company_size = size_type
126
- break
127
- if company_size:
128
- break
129
-
130
- # Check UK location
131
- is_uk_based = any(loc in text_lower for loc in UK_LOCATIONS)
132
-
133
- # Extract meaningful keywords (simple approach)
134
- words = re.findall(r'\b[a-z]{4,}\b', text_lower)
135
- # Filter out common words
136
- common_words = {"about", "their", "with", "from", "that", "this", "have", "more",
137
- "what", "when", "where", "which", "they", "would", "could", "should"}
138
- keywords = set(w for w in words if w not in common_words)
139
-
140
- # Detect R&D focus
141
- r_and_d_indicators = ["research", "development", "innovation", "r&d", "patent",
142
- "prototype", "pilot", "feasibility", "experimental"]
143
- appears_r_and_d_focused = any(ind in text_lower for ind in r_and_d_indicators)
144
-
145
- # Check if they mention funding
146
- funding_indicators = ["funding", "investment", "grant", "raise", "capital", "finance"]
147
- mentions_funding = any(ind in text_lower for ind in funding_indicators)
148
-
149
- profile = CompanyProfile(
150
- url=company_url,
151
- text_content=text[:5000], # Keep first 5000 chars for analysis
152
- sectors=sectors,
153
- tech_stack=tech_stack,
154
- company_size=company_size,
155
- is_uk_based=is_uk_based,
156
- keywords=keywords,
157
- appears_r_and_d_focused=appears_r_and_d_focused,
158
- mentions_funding=mentions_funding,
159
- )
160
-
161
- logger.info(f"Profile extracted: {len(sectors)} sectors, size={company_size}, UK={is_uk_based}")
162
- return profile
163
-
164
-
165
- @dataclass
166
- class GrantMatch:
167
- """Represents a grant match with scoring and reasoning."""
168
- grant_id: str
169
- grant_title: str
170
- match_score: float # 0-100
171
- match_category: str # "perfect", "strong", "potential"
172
- reasons: List[str]
173
- concerns: List[str]
174
- deadline: Optional[str]
175
- funding_max: Optional[float]
176
-
177
- def to_dict(self) -> Dict[str, Any]:
178
- """Convert to dictionary."""
179
- return {
180
- "grant_id": self.grant_id,
181
- "grant_title": self.grant_title,
182
- "match_score": round(self.match_score, 1),
183
- "match_category": self.match_category,
184
- "reasons": self.reasons,
185
- "concerns": self.concerns if self.concerns else None,
186
- "deadline": self.deadline,
187
- "funding_max": self.funding_max,
188
- }
189
-
190
-
191
- def calculate_grant_status(grant: Dict[str, Any]) -> str:
192
- """Calculate grant status based on dates."""
193
- today = datetime.now()
194
- close_date = parse_date(grant.get("close_date") or grant.get("deadline"))
195
- open_date = parse_date(grant.get("open_date"))
196
-
197
- if not close_date:
198
- return "unknown"
199
- if close_date < today:
200
- return "closed"
201
- if open_date and open_date > today:
202
- return "upcoming"
203
- return "open"
204
-
205
-
206
- def score_grant_match(
207
- profile: CompanyProfile,
208
- grant: Dict[str, Any]
209
- ) -> GrantMatch:
210
- """
211
- Score how well a grant matches a company profile.
212
-
213
- Returns:
214
- GrantMatch with score, category, reasons, and concerns
215
- """
216
- grant_id = grant.get("id") or grant.get("competition_id") or "unknown"
217
- grant_title = grant.get("title", "(untitled)")
218
-
219
- # Prepare grant text for analysis
220
- grant_text = " ".join([
221
- str(grant.get("title", "")),
222
- str(grant.get("summary", "")),
223
- str(grant.get("scope", "")),
224
- str(grant.get("eligibility", "")),
225
- ]).lower()
226
-
227
- score = 0.0
228
- reasons = []
229
- concerns = []
230
-
231
- # 1. Sector/Technology alignment (40 points max)
232
- sector_matches = []
233
- for sector in profile.sectors:
234
- sector_keywords = TECH_KEYWORDS.get(sector, [])
235
- for keyword in sector_keywords:
236
- if keyword in grant_text:
237
- sector_matches.append(keyword)
238
-
239
- if sector_matches:
240
- sector_score = min(40, len(sector_matches) * 10)
241
- score += sector_score
242
- reasons.append(f"Strong sector alignment: {', '.join(set(sector_matches[:3]))}")
243
-
244
- # 2. UK eligibility (20 points if UK-based)
245
- grant_status = calculate_grant_status(grant)
246
-
247
- if profile.is_uk_based:
248
- score += 20
249
- reasons.append("UK-based company (eligible for Innovate UK)")
250
- else:
251
- concerns.append("Company may not be UK-based (verify eligibility)")
252
-
253
- # 3. Grant status (20 points if open)
254
- if grant_status == "open":
255
- score += 20
256
- reasons.append(f"Grant is currently open")
257
- elif grant_status == "upcoming":
258
- score += 15
259
- reasons.append(f"Grant opens soon")
260
- elif grant_status == "closed":
261
- score -= 30
262
- concerns.append("Grant deadline has passed")
263
-
264
- # 4. Company size/stage fit (10 points)
265
- eligibility_text = str(grant.get("eligibility", "")).lower()
266
- if profile.company_size:
267
- size_mentioned = profile.company_size in eligibility_text
268
- if "sme" in eligibility_text or "small" in eligibility_text:
269
- if profile.company_size in ["startup", "sme", "scale_up"]:
270
- score += 10
271
- reasons.append(f"Good fit for {profile.company_size}s")
272
- elif size_mentioned:
273
- score += 10
274
- reasons.append(f"Mentions {profile.company_size}s")
275
-
276
- # 5. R&D focus alignment (10 points)
277
- if profile.appears_r_and_d_focused:
278
- r_and_d_in_grant = any(word in grant_text for word in ["research", "development", "innovation", "r&d"])
279
- if r_and_d_in_grant:
280
- score += 10
281
- reasons.append("R&D-focused opportunity (matches company profile)")
282
-
283
- # 6. Funding amount considerations
284
- funding_max = grant.get("funding_max") or grant.get("max_award")
285
- if funding_max:
286
- try:
287
- funding_val = float(str(funding_max).replace(",", "").replace("£", ""))
288
- if funding_val > 1000000: # £1M+
289
- if profile.company_size == "startup":
290
- concerns.append(f"Large grant (£{funding_val:,.0f}) - may require significant match funding")
291
- except:
292
- pass
293
-
294
- # Determine match category
295
- if score >= 70:
296
- category = "perfect"
297
- elif score >= 50:
298
- category = "strong"
299
- elif score >= 30:
300
- category = "potential"
301
- else:
302
- category = "weak"
303
-
304
- return GrantMatch(
305
- grant_id=grant_id,
306
- grant_title=grant_title,
307
- match_score=score,
308
- match_category=category,
309
- reasons=reasons,
310
- concerns=concerns if concerns else [],
311
- deadline=grant.get("deadline") or grant.get("close_date"),
312
- funding_max=funding_max,
313
- )
314
-
315
-
316
- def analyze_company_for_grants_v2(
317
- company_url: str,
318
- grants: List[Dict[str, Any]],
319
- limit: int = 10
320
- ) -> Dict[str, Any]:
321
- """
322
- Enhanced company analysis with smart grant matching.
323
-
324
- Args:
325
- company_url: URL of company website
326
- grants: List of available grants to match against
327
- limit: Maximum number of recommendations per category
328
-
329
- Returns:
330
- Dict with company profile, perfect matches, strong matches, and exclusion reasons
331
- """
332
- logger.info(f"Starting enhanced company analysis for: {company_url}")
333
-
334
- # Extract company profile
335
- profile = extract_company_profile(company_url)
336
- if not profile:
337
- return {
338
- "error": "Failed to extract company profile from URL",
339
- "company_url": company_url,
340
- }
341
-
342
- # Score all grants
343
- all_matches = []
344
- for grant in grants:
345
- match = score_grant_match(profile, grant)
346
- all_matches.append(match)
347
-
348
- # Sort by score
349
- all_matches.sort(key=lambda m: m.match_score, reverse=True)
350
-
351
- # Categorize matches
352
- perfect_matches = [m for m in all_matches if m.match_category == "perfect"][:limit]
353
- strong_matches = [m for m in all_matches if m.match_category == "strong"][:limit]
354
- potential_matches = [m for m in all_matches if m.match_category == "potential"][:limit]
355
-
356
- # Explain why others were excluded (top reasons)
357
- excluded = [m for m in all_matches if m.match_category == "weak"]
358
- exclusion_reasons = {}
359
- for match in excluded[:10]: # Analyze top 10 excluded
360
- for concern in match.concerns:
361
- if concern not in exclusion_reasons:
362
- exclusion_reasons[concern] = 0
363
- exclusion_reasons[concern] += 1
364
-
365
- # Sort exclusion reasons by frequency
366
- top_exclusions = sorted(exclusion_reasons.items(), key=lambda x: x[1], reverse=True)[:5]
367
-
368
- return {
369
- "company_url": company_url,
370
- "company_profile": profile.to_dict(),
371
- "perfect_matches": [m.to_dict() for m in perfect_matches],
372
- "strong_matches": [m.to_dict() for m in strong_matches],
373
- "worth_considering": [m.to_dict() for m in potential_matches],
374
- "why_not_others": {
375
- "top_exclusion_reasons": [reason for reason, count in top_exclusions],
376
- "total_excluded": len(excluded),
377
- },
378
- "summary": {
379
- "total_analyzed": len(all_matches),
380
- "perfect_matches_count": len(perfect_matches),
381
- "strong_matches_count": len(strong_matches),
382
- "potential_matches_count": len(potential_matches),
383
- }
384
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/analyzer/chat/demo_app.py CHANGED
@@ -23,13 +23,6 @@ except ImportError:
23
  print("ERROR: Gradio not installed. Install with: pip install gradio")
24
  sys.exit(1)
25
 
26
- try:
27
- import httpx
28
- except ImportError:
29
- print("ERROR: httpx not installed. Install with: pip install httpx")
30
- httpx = None
31
-
32
-
33
  from ..config import load_config
34
  from ..data_loader import load_current_grants, load_past_winners
35
  from ..llm_client import LLMClient
@@ -47,7 +40,9 @@ PRESET_QUESTIONS = {
47
  "Show upcoming deadlines": "What are the upcoming grant deadlines?",
48
  "Compare two grants": "Compare competition-2313 and competition-2314",
49
  "Grant details": "Tell me about competition-2317 in detail",
50
- "SME funding options": "What grants are available for SMEs with funding over £100k?"
 
 
51
  }
52
 
53
 
@@ -92,7 +87,7 @@ class GrantAnalystDemo:
92
  # Initialize LLM
93
  self.llm_client = LLMClient(self.cfg)
94
  if not self.llm_client.is_ready():
95
- return False, "ERROR: LLM client not ready. Check API key configuration."
96
 
97
  # Load index
98
  try:
@@ -122,38 +117,50 @@ class GrantAnalystDemo:
122
  {
123
  "role": "system",
124
  "content": (
125
- "You are an expert UK grant analyst assistant. Your PRIMARY DIRECTIVE is to EXECUTE user requests.\n\n"
126
- "WHEN USER ASKS FOR:\n"
127
- "- 'description/summaries of all/every grant' IMMEDIATELY call get_all_grant_summaries (ONE SINGLE TOOL CALL)\n"
128
- "- 'description/summaries of grants' IMMEDIATELY call summarize_grants_batch\n"
129
- "- 'list all grants' (NO descriptions) IMMEDIATELY call list_grants with limit=None\n"
130
- "- 'find grants about [topic]' IMMEDIATELY call search_grants\n"
131
- "- ANY REQUEST FOR INFORMATION DO NOT DESCRIBE WHAT YOU WILL DO, JUST DO IT\n\n"
 
 
 
 
 
 
 
 
132
  "CRITICAL RULES:\n"
133
- "- DO NOT make multiple tool calls. Make ONE tool call and wait for results.\n"
134
- "- DO NOT return raw JSON lists when user asks for descriptions/summaries\n"
135
- "- When user asks for 'all grants', use get_all_grant_summaries (NOT list_grants + summarize)\n"
136
- "- DO NOT say 'I will do X' and then stop. ACTUALLY CALL THE TOOL.\n"
137
- "- DO NOT provide preliminary responses. CALL TOOLS FIRST, THEN RESPOND.\n"
138
- "- If user asks for information, ALWAYS use tools - NEVER make up answers.\n"
139
- "- Never promise to do something later. Do it immediately.\n\n"
140
- "SPECIFIC TOOL USAGE:\n"
141
- "- get_all_grant_summaries: For 'all grants', 'every grant', 'all grant opportunities' (ONE SINGLE CALL - most efficient)\n"
142
- "- summarize_grants_batch: For summaries/descriptions of specific grant groups\n"
143
- "- summarize_grant: Only for single grant details\n"
144
- "- list_grants: To get IDs/titles only (NOT for descriptions)\n"
145
- "- search_grants: For finding grants by topic/keyword\n"
146
- "- get_grant: For full structured data on one grant\n"
147
- "- compare_grants: For side-by-side comparisons\n\n"
 
148
  "RESPONSE FORMAT:\n"
149
- "- ALWAYS include complete tool results in your response\n"
150
- "- Do NOT paraphrase or summarize tool results - display them exactly as provided\n"
151
- "- Use markdown formatting (headers, numbered lists, tables)\n"
152
- "- When displaying lists of grants, ALWAYS use numbered format (1., 2., 3., etc.) NOT bullet points\n"
153
- "- Include all details: funding, eligibility, deadlines, scope\n"
154
- "- No length limits - be comprehensive\n"
155
- "- NEVER omit tool results from your response\n\n"
156
- "Current date: 2025-10-27"
 
 
 
157
  )
158
  }
159
  ]
@@ -189,9 +196,9 @@ class GrantAnalystDemo:
189
  batch_size = tool_args.get("batch_size", 5)
190
 
191
  if not grant_ids:
192
- return "ERROR: No grant IDs provided for batch summarization"
193
 
194
- logging.info(f"Starting batch summarization of {len(grant_ids)} grants")
195
 
196
  # Collect results from async generator
197
  results = []
@@ -221,7 +228,7 @@ class GrantAnalystDemo:
221
 
222
  # Format results for display
223
  if not results:
224
- return "ERROR: No grants could be summarized"
225
 
226
  formatted = f"Batch summarization complete for {len(results)} grants:\n\n"
227
  for i, result in enumerate(results, 1):
@@ -238,7 +245,7 @@ class GrantAnalystDemo:
238
  # Get summaries of ALL grants in one batch
239
  batch_size = tool_args.get("batch_size", 5)
240
 
241
- logging.info(f"Starting to get summaries for ALL grants (batch_size={batch_size})")
242
 
243
  # Collect results from async generator
244
  results = []
@@ -263,7 +270,7 @@ class GrantAnalystDemo:
263
 
264
  # Format results for display
265
  if not results:
266
- return "ERROR: No grants could be summarized"
267
 
268
  formatted = f"Summaries for ALL {len(results)} grants:\n\n"
269
  for i, result in enumerate(results, 1):
@@ -309,6 +316,37 @@ class GrantAnalystDemo:
309
  ])
310
  return formatted
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  elif tool_name == "fetch_link":
313
  # NEW: Handle external link fetching
314
  try:
@@ -340,168 +378,9 @@ class GrantAnalystDemo:
340
  logging.error(f"Tool {tool_name} failed: {e}", exc_info=True)
341
  return {"error": str(e)}
342
 
343
- def chat_stream(self, user_message: str, history: List, use_sse: bool = False, use_websocket: bool = False):
344
- """
345
- Process a chat message with optional SSE or WebSocket streaming.
346
-
347
- Args:
348
- user_message: User's input
349
- history: Gradio chat history
350
- use_sse: If True, use SSE streaming from API endpoint
351
- use_websocket: If True, use WebSocket streaming (takes precedence over SSE)
352
-
353
- Yields:
354
- Updated history for streaming response
355
- """
356
- import time
357
- import json
358
-
359
- if not self.initialized:
360
- yield history + [[user_message, "WARNING: System not initialized. Please restart the app."]]
361
- return
362
-
363
- # WebSocket streaming takes precedence
364
- if use_websocket and HAS_WEBSOCKET:
365
- try:
366
- accumulated_response = ""
367
- intent = None
368
- citations = []
369
-
370
- # Connect to WebSocket endpoint
371
- ws = websocket.create_connection("ws://localhost:8000/ws/query", timeout=60)
372
-
373
- # Send query
374
- message = {
375
- "query": user_message,
376
- "session_id": "gradio_session"
377
- }
378
- ws.send(json.dumps(message))
379
-
380
- # Receive and process stream
381
- while True:
382
- try:
383
- msg = ws.recv()
384
- data = json.loads(msg)
385
- msg_type = data.get("type")
386
-
387
- if msg_type == "metadata":
388
- # Initial metadata received
389
- logging.info(f"WebSocket session: {data.get('session_id')}")
390
-
391
- elif msg_type == "intent":
392
- intent = data.get("intent")
393
- # Show intent in response
394
- status_msg = f"*Detected intent: {intent}*\n\n"
395
- yield history + [[user_message, status_msg]]
396
-
397
- elif msg_type == "token":
398
- # Stream token to UI
399
- token = data.get("token", "")
400
- accumulated_response += token
401
- # Yield updated history with partial response
402
- status_prefix = f"*Intent: {intent}*\n\n" if intent else ""
403
- yield history + [[user_message, status_prefix + accumulated_response]]
404
-
405
- elif msg_type == "citations":
406
- citations = data.get("citations", [])
407
-
408
- elif msg_type == "done":
409
- latency_ms = data.get("latency_ms")
410
- logging.info(f"WebSocket stream completed in {latency_ms}ms")
411
- break
412
-
413
- elif msg_type == "error":
414
- error_msg = data.get("error", "Unknown error")
415
- yield history + [[user_message, f"ERROR: Error: {error_msg}"]]
416
- ws.close()
417
- return
418
-
419
- except websocket.WebSocketTimeoutException:
420
- logging.warning("WebSocket timeout")
421
- break
422
- except json.JSONDecodeError as e:
423
- logging.error(f"Failed to parse WebSocket message: {e}")
424
- continue
425
-
426
- ws.close()
427
-
428
- # Add citations to final response
429
- if citations:
430
- accumulated_response += "\n\n**Citations:**\n"
431
- for cite in citations:
432
- title = cite.get("title", "Unknown")
433
- grant_id = cite.get("grant_id", "N/A")
434
- accumulated_response += f"- **{title}** (ID: {grant_id})\n"
435
-
436
- # Add intent badge if available
437
- if intent:
438
- final_response = f"*Intent: {intent}*\n\n{accumulated_response}"
439
- else:
440
- final_response = accumulated_response
441
-
442
- yield history + [[user_message, final_response]]
443
-
444
- except Exception as e:
445
- logging.error(f"WebSocket streaming error: {e}")
446
- yield history + [[user_message, f"ERROR: WebSocket error: {e}"]]
447
- return
448
-
449
- if use_sse and httpx:
450
- # Use SSE streaming from API endpoint
451
- try:
452
- accumulated_response = ""
453
- citations = []
454
-
455
- with httpx.Client(timeout=60.0) as client:
456
- with client.stream(
457
- "POST",
458
- "http://localhost:8000/qa/stream", # Adjust URL as needed
459
- json={"query": user_message, "use_llm_routing": True}
460
- ) as response:
461
- for line in response.iter_lines():
462
- if line.startswith("data: "):
463
- data_str = line[6:] # Remove "data: " prefix
464
- try:
465
- data = json.loads(data_str)
466
- event_type = data.get("type")
467
-
468
- if event_type == "token":
469
- token = data.get("token", "")
470
- accumulated_response += token
471
- # Yield updated history with partial response
472
- yield history + [[user_message, accumulated_response]]
473
-
474
- elif event_type == "citations":
475
- citations = data.get("citations", [])
476
-
477
- elif event_type == "error":
478
- error = data.get("error", "Unknown error")
479
- yield history + [[user_message, f"ERROR: Error: {error}"]]
480
- return
481
-
482
- except json.JSONDecodeError:
483
- continue
484
-
485
- # Add citations to final response
486
- if citations:
487
- accumulated_response += "\n\n**Citations:**\n"
488
- for cite in citations:
489
- accumulated_response += f"- {cite.get('title')} (ID: {cite.get('grant_id')})\n"
490
-
491
- yield history + [[user_message, accumulated_response]]
492
-
493
- except Exception as e:
494
- logging.error(f"SSE streaming error: {e}")
495
- yield history + [[user_message, f"ERROR: Streaming error: {e}"]]
496
- return
497
-
498
- # Fallback to original non-streaming chat
499
- response, updated_history = self.chat(user_message, history)
500
- yield updated_history
501
-
502
  def chat(self, user_message: str, history: List) -> Tuple[str, List]:
503
  """
504
- Process a chat message (non-streaming).
505
 
506
  Args:
507
  user_message: User's input
@@ -513,7 +392,7 @@ class GrantAnalystDemo:
513
  import time
514
 
515
  if not self.initialized:
516
- return "WARNING: System not initialized. Please restart the app.", history
517
 
518
  start_time = time.time()
519
  tools_called = []
@@ -530,7 +409,7 @@ class GrantAnalystDemo:
530
  messages=self.messages,
531
  tools=self.available_tools,
532
  tool_choice="auto",
533
- temperature=0.5, # INCREASED from 0.1 to allow more thorough, creative responses
534
  max_tokens=4096, # INCREASED to allow detailed summaries without truncation
535
  )
536
  timing_info["llm_call"] = time.time() - llm_start
@@ -557,7 +436,7 @@ class GrantAnalystDemo:
557
  tool_start = time.time()
558
  tool_result = self._dispatch_tool(function_name, function_args)
559
  tool_time = time.time() - tool_start
560
- logging.info(f"{function_name} took {tool_time:.2f}s")
561
  timing_info[f"tool_{function_name}"] = tool_time
562
 
563
  # Add tool result
@@ -573,7 +452,7 @@ class GrantAnalystDemo:
573
  final_response = self.llm_client.client.chat.completions.create(
574
  model=self.llm_client.model,
575
  messages=self.messages,
576
- temperature=0.5, # INCREASED from 0.1 for thorough final responses
577
  max_tokens=4096, # INCREASED to allow complete answers without truncation
578
  )
579
  timing_info["final_llm_call"] = time.time() - final_start
@@ -588,7 +467,7 @@ class GrantAnalystDemo:
588
  # Log timing info
589
  response_time_ms = int((time.time() - start_time) * 1000)
590
  timing_str = " | ".join([f"{k}:{v:.2f}s" for k, v in timing_info.items()])
591
- logging.info(f"Total: {response_time_ms}ms | {timing_str}")
592
 
593
  # Direct logging to CSV (simpler, more reliable)
594
  try:
@@ -630,12 +509,12 @@ class GrantAnalystDemo:
630
  logging.info(f"Query logged to {csv_path}")
631
 
632
  except Exception as log_error:
633
- logging.error(f"ERROR: Logging failed: {log_error}", exc_info=True)
634
 
635
  return assistant_message, history + [[user_message, assistant_message]]
636
 
637
  except Exception as e:
638
- error_msg = f"ERROR: Error: {e}"
639
  logging.error(f"Chat error: {e}", exc_info=True)
640
 
641
  # Log failed interactions too
@@ -681,7 +560,7 @@ class GrantAnalystDemo:
681
  logging.info(f"Failed query logged to {csv_path}")
682
 
683
  except Exception as log_error:
684
- logging.error(f"ERROR: Logging failed: {log_error}", exc_info=True)
685
 
686
  return error_msg, history + [[user_message, error_msg]]
687
 
@@ -693,8 +572,17 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
693
  title="Grant Analyst Demo",
694
  theme=gr.themes.Soft(),
695
  css="""
696
- .contain { max-width: 1200px; margin: auto; }
 
 
 
 
 
 
 
697
  #status-box { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
 
 
698
  """
699
  ) as app:
700
 
@@ -720,7 +608,7 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
720
  if success:
721
  return f"**System Ready** — {msg}"
722
  else:
723
- return f"ERROR: **Initialization Failed** — {msg}"
724
 
725
  # Chatbot interface
726
  with gr.Row():
@@ -739,8 +627,7 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
739
  )
740
  send_btn = gr.Button("Send", scale=1, variant="primary")
741
 
742
- with gr.Row():
743
- clear_btn = gr.Button("Clear Conversation")
744
 
745
  with gr.Column(scale=1):
746
  gr.Markdown("### Preset Questions")
@@ -778,27 +665,15 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
778
 
779
  # Event handlers
780
  def respond(message, chat_history):
781
- """Handle user message."""
782
- # Use non-streaming response
783
  bot_response, updated_history = demo.chat(message, chat_history)
784
- yield "", updated_history
785
 
786
  def use_preset(question, chat_history):
787
- """Handle preset question click."""
788
- for result in respond(question, chat_history):
789
- yield result
790
 
791
  # Wire up events
792
- msg_input.submit(
793
- respond,
794
- [msg_input, chatbot],
795
- [msg_input, chatbot]
796
- )
797
- send_btn.click(
798
- respond,
799
- [msg_input, chatbot],
800
- [msg_input, chatbot]
801
- )
802
  clear_btn.click(lambda: [], None, chatbot)
803
 
804
  for btn, question in preset_btns:
@@ -816,12 +691,16 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
816
 
817
  def main():
818
  """Launch the demo application."""
 
819
 
820
  parser = argparse.ArgumentParser(description="Grant Analyst Demo App")
821
  parser.add_argument("--share", action="store_true", help="Create public shareable link")
822
- parser.add_argument("--port", type=int, default=7860, help="Port to run on")
823
  args = parser.parse_args()
824
 
 
 
 
825
  # Setup logging (create log directory if needed)
826
  log_handlers = [logging.StreamHandler(sys.stderr)]
827
  try:
@@ -845,12 +724,12 @@ def main():
845
  app = create_demo_ui(demo)
846
 
847
  print("\n" + "="*60)
848
- print("Launching Grant Analyst Demo...")
849
  print("="*60)
850
 
851
  app.launch(
852
  server_name="0.0.0.0",
853
- server_port=args.port,
854
  share=args.share,
855
  show_error=True,
856
  )
 
23
  print("ERROR: Gradio not installed. Install with: pip install gradio")
24
  sys.exit(1)
25
 
 
 
 
 
 
 
 
26
  from ..config import load_config
27
  from ..data_loader import load_current_grants, load_past_winners
28
  from ..llm_client import LLMClient
 
40
  "Show upcoming deadlines": "What are the upcoming grant deadlines?",
41
  "Compare two grants": "Compare competition-2313 and competition-2314",
42
  "Grant details": "Tell me about competition-2317 in detail",
43
+ "SME funding options": "What grants are available for SMEs with funding over £100k?",
44
+ "Past winners of open grants": "Of the grants that are currently open, are there any past winners listed as reference?",
45
+ "Past AI winners": "Show me past winners related to AI projects"
46
  }
47
 
48
 
 
87
  # Initialize LLM
88
  self.llm_client = LLMClient(self.cfg)
89
  if not self.llm_client.is_ready():
90
+ return False, " LLM client not ready. Check API key configuration."
91
 
92
  # Load index
93
  try:
 
117
  {
118
  "role": "system",
119
  "content": (
120
+ "You are an expert UK grant analyst assistant. Your PRIMARY DIRECTIVE is to PROVIDE COMPLETE, THOROUGH RESPONSES.\n\n"
121
+ "CORE BEHAVIOR:\n"
122
+ "- When user asks about a grant, ALWAYS call tools to get the information\n"
123
+ "- When tools return data, ALWAYS present ALL the data to the user\n"
124
+ "- NEVER say 'I don't have information' without first calling tools\n"
125
+ "- NEVER give partial answers - if more information exists, include it\n"
126
+ "- If user challenges your answer, you likely gave incomplete information - call tools again\n\n"
127
+ "TOOL USAGE - WHEN USER ASKS FOR:\n"
128
+ "- 'tell me about [grant name]' → call search_grants with the grant name, then summarize_grant with the ID\n"
129
+ "- 'biomedical catalyst grant' → call search_grants with query='biomedical catalyst'\n"
130
+ "- 'description/summaries of all/every grant' → call get_all_grant_summaries\n"
131
+ "- 'list all grants' (NO descriptions) → call list_grants with limit=None\n"
132
+ "- 'find grants about [topic]' → call search_grants with query=[topic]\n"
133
+ "- 'who won', 'past winners' → call search_past_winners\n"
134
+ "- 'competition-XXXX details' → call get_grant then summarize_grant\n\n"
135
  "CRITICAL RULES:\n"
136
+ "- ALWAYS call tools first, respond second\n"
137
+ "- If tool returns data, present ALL of it - don't truncate or paraphrase\n"
138
+ "- If user asks 'tell me more' or 'is that all', you missed information - call tools again\n"
139
+ "- search_grants finds grants by fuzzy matching - use it liberally\n"
140
+ "- If search finds 0 results, try a simpler/shorter query\n"
141
+ "- NEVER assume a grant doesn't exist - always search first\n\n"
142
+ "AVAILABLE TOOLS:\n"
143
+ "- search_grants: Find grants by keyword/topic (fuzzy matching, searches titles + descriptions)\n"
144
+ "- list_grants: List all grants with optional filters (keyword, status, max_award)\n"
145
+ "- get_grant: Get full structured data for one grant ID\n"
146
+ "- summarize_grant: Get AI-generated summary of one grant\n"
147
+ "- summarize_grants_batch: Get summaries for multiple grants\n"
148
+ "- get_all_grant_summaries: Get summaries for ALL grants at once\n"
149
+ "- compare_grants: Side-by-side comparison of two grants\n"
150
+ "- search_past_winners: Search historical winners (10+ years of data)\n"
151
+ "- deadlines_overview: Show upcoming deadlines\n\n"
152
  "RESPONSE FORMAT:\n"
153
+ "- Use markdown: headers (##), numbered lists (1. 2. 3.), bold (**text**)\n"
154
+ "- Include ALL details from tool results: funding amounts, dates, eligibility, scope\n"
155
+ "- Be comprehensive and thorough - no length limits\n"
156
+ "- Present complete information on first response, not piecemeal\n\n"
157
+ "WRITING STYLE:\n"
158
+ "- Write in a tight, conversational voice — confident, warm, and clear\n"
159
+ "- Avoid fluff, filler, or over-formality\n"
160
+ "- Skip headings, bullet points, or 'as an AI' disclaimers\n"
161
+ "- Prefer short, active sentences\n"
162
+ "- Be bold, human, and efficient\n\n"
163
+ "Current date: 2025-11-05"
164
  )
165
  }
166
  ]
 
196
  batch_size = tool_args.get("batch_size", 5)
197
 
198
  if not grant_ids:
199
+ return " No grant IDs provided for batch summarization"
200
 
201
+ logging.info(f"📦 Starting batch summarization of {len(grant_ids)} grants")
202
 
203
  # Collect results from async generator
204
  results = []
 
228
 
229
  # Format results for display
230
  if not results:
231
+ return "No grants could be summarized"
232
 
233
  formatted = f"Batch summarization complete for {len(results)} grants:\n\n"
234
  for i, result in enumerate(results, 1):
 
245
  # Get summaries of ALL grants in one batch
246
  batch_size = tool_args.get("batch_size", 5)
247
 
248
+ logging.info(f"📦 Starting to get summaries for ALL grants (batch_size={batch_size})")
249
 
250
  # Collect results from async generator
251
  results = []
 
270
 
271
  # Format results for display
272
  if not results:
273
+ return "No grants could be summarized"
274
 
275
  formatted = f"Summaries for ALL {len(results)} grants:\n\n"
276
  for i, result in enumerate(results, 1):
 
316
  ])
317
  return formatted
318
 
319
+ elif tool_name == "search_past_winners":
320
+ results = self.tools.search_past_winners(
321
+ keyword=tool_args.get("keyword"),
322
+ competition=tool_args.get("competition"),
323
+ limit=tool_args.get("limit") # If None, returns ALL
324
+ )
325
+ # Format results for better conversation flow
326
+ if not results:
327
+ return "No past winners found matching those criteria."
328
+
329
+ if len(results) > 50:
330
+ # If too many, return summary + first 20
331
+ summary = f"Found {len(results)} past winners matching the criteria. Showing first 20:\n\n"
332
+ display = results[:20]
333
+ else:
334
+ summary = f"Found {len(results)} past winner(s):\n\n"
335
+ display = results
336
+
337
+ formatted_items = []
338
+ for i, r in enumerate(display, 1):
339
+ item = f"{i}. **{r['project_title']}**\n"
340
+ item += f" Organization: {r['lead_org']}\n"
341
+ item += f" Competition: {r['competition']}\n"
342
+ if r.get('award_amount'):
343
+ item += f" Award: £{r['award_amount']}\n"
344
+ if r.get('year'):
345
+ item += f" Year: {r['year']}\n"
346
+ formatted_items.append(item)
347
+
348
+ return summary + "\n".join(formatted_items)
349
+
350
  elif tool_name == "fetch_link":
351
  # NEW: Handle external link fetching
352
  try:
 
378
  logging.error(f"Tool {tool_name} failed: {e}", exc_info=True)
379
  return {"error": str(e)}
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  def chat(self, user_message: str, history: List) -> Tuple[str, List]:
382
  """
383
+ Process a chat message.
384
 
385
  Args:
386
  user_message: User's input
 
392
  import time
393
 
394
  if not self.initialized:
395
+ return "⚠️ System not initialized. Please restart the app.", history
396
 
397
  start_time = time.time()
398
  tools_called = []
 
409
  messages=self.messages,
410
  tools=self.available_tools,
411
  tool_choice="auto",
412
+ temperature=0.2, # Lower temperature for more consistent, thorough responses
413
  max_tokens=4096, # INCREASED to allow detailed summaries without truncation
414
  )
415
  timing_info["llm_call"] = time.time() - llm_start
 
436
  tool_start = time.time()
437
  tool_result = self._dispatch_tool(function_name, function_args)
438
  tool_time = time.time() - tool_start
439
+ logging.info(f"⏱️ {function_name} took {tool_time:.2f}s")
440
  timing_info[f"tool_{function_name}"] = tool_time
441
 
442
  # Add tool result
 
452
  final_response = self.llm_client.client.chat.completions.create(
453
  model=self.llm_client.model,
454
  messages=self.messages,
455
+ temperature=0.2, # Lower temperature for more complete, consistent responses
456
  max_tokens=4096, # INCREASED to allow complete answers without truncation
457
  )
458
  timing_info["final_llm_call"] = time.time() - final_start
 
467
  # Log timing info
468
  response_time_ms = int((time.time() - start_time) * 1000)
469
  timing_str = " | ".join([f"{k}:{v:.2f}s" for k, v in timing_info.items()])
470
+ logging.info(f"⏱️ Total: {response_time_ms}ms | {timing_str}")
471
 
472
  # Direct logging to CSV (simpler, more reliable)
473
  try:
 
509
  logging.info(f"Query logged to {csv_path}")
510
 
511
  except Exception as log_error:
512
+ logging.error(f"Logging failed: {log_error}", exc_info=True)
513
 
514
  return assistant_message, history + [[user_message, assistant_message]]
515
 
516
  except Exception as e:
517
+ error_msg = f" Error: {e}"
518
  logging.error(f"Chat error: {e}", exc_info=True)
519
 
520
  # Log failed interactions too
 
560
  logging.info(f"Failed query logged to {csv_path}")
561
 
562
  except Exception as log_error:
563
+ logging.error(f"Logging failed: {log_error}", exc_info=True)
564
 
565
  return error_msg, history + [[user_message, error_msg]]
566
 
 
572
  title="Grant Analyst Demo",
573
  theme=gr.themes.Soft(),
574
  css="""
575
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
576
+
577
+ * {
578
+ font-family: 'Inter', system-ui, -apple-system, sans-serif !important;
579
+ }
580
+ .gradio-container { max-width: 100% !important; padding-left: 0 !important; padding-right: 0 !important; }
581
+ .main { max-width: 100% !important; }
582
+ .contain { max-width: 100% !important; }
583
  #status-box { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
584
+ .chatbot { min-height: 500px; }
585
+ code, pre { font-family: 'Fira Code', 'Consolas', monospace !important; }
586
  """
587
  ) as app:
588
 
 
608
  if success:
609
  return f"**System Ready** — {msg}"
610
  else:
611
+ return f"**Initialization Failed** — {msg}"
612
 
613
  # Chatbot interface
614
  with gr.Row():
 
627
  )
628
  send_btn = gr.Button("Send", scale=1, variant="primary")
629
 
630
+ clear_btn = gr.Button("Clear Conversation")
 
631
 
632
  with gr.Column(scale=1):
633
  gr.Markdown("### Preset Questions")
 
665
 
666
  # Event handlers
667
  def respond(message, chat_history):
 
 
668
  bot_response, updated_history = demo.chat(message, chat_history)
669
+ return "", updated_history
670
 
671
  def use_preset(question, chat_history):
672
+ return respond(question, chat_history)
 
 
673
 
674
  # Wire up events
675
+ msg_input.submit(respond, [msg_input, chatbot], [msg_input, chatbot])
676
+ send_btn.click(respond, [msg_input, chatbot], [msg_input, chatbot])
 
 
 
 
 
 
 
 
677
  clear_btn.click(lambda: [], None, chatbot)
678
 
679
  for btn, question in preset_btns:
 
691
 
692
  def main():
693
  """Launch the demo application."""
694
+ import os
695
 
696
  parser = argparse.ArgumentParser(description="Grant Analyst Demo App")
697
  parser.add_argument("--share", action="store_true", help="Create public shareable link")
698
+ parser.add_argument("--port", type=int, default=None, help="Port to run on")
699
  args = parser.parse_args()
700
 
701
+ # Use PORT environment variable if set (for AWS/cloud deployments), otherwise use CLI arg or default
702
+ port = int(os.environ.get("PORT", args.port or 7860))
703
+
704
  # Setup logging (create log directory if needed)
705
  log_handlers = [logging.StreamHandler(sys.stderr)]
706
  try:
 
724
  app = create_demo_ui(demo)
725
 
726
  print("\n" + "="*60)
727
+ print("🚀 Launching Grant Analyst Demo...")
728
  print("="*60)
729
 
730
  app.launch(
731
  server_name="0.0.0.0",
732
+ server_port=port,
733
  share=args.share,
734
  show_error=True,
735
  )
src/analyzer/chat/query_router.py CHANGED
@@ -13,15 +13,9 @@ This helps reduce redundant information requests and improves UX.
13
  from __future__ import annotations
14
  from dataclasses import dataclass
15
  from typing import Dict, Optional, Tuple, List
16
- import json, re, hashlib, time, logging
17
 
18
- logger = logging.getLogger(__name__)
19
-
20
- _INTENTS = {"list", "summarize", "compare", "deadlines", "search", "general", "get_grant", "list_grants"}
21
-
22
- # Routing cache with 24-hour TTL
23
- _routing_cache: Dict[str, Tuple[Dict, float]] = {}
24
- _CACHE_TTL = 86400 # 24 hours in seconds
25
 
26
  # Accept "competition-2315", "2315", "comp-2315"
27
  _ID_RE = re.compile(r"(?:comp(?:etition)?-)?([0-9]{3,7})", re.IGNORECASE)
@@ -112,93 +106,14 @@ def _detect_status_filter(text: str) -> Optional[str]:
112
  return None
113
 
114
 
115
- def classify_complexity(query: str) -> str:
116
- """
117
- Classify query complexity to select appropriate model.
118
-
119
- Returns:
120
- 'simple': Use gpt-5-mini for simple translation/explanation tasks
121
- 'complex': Use gpt-5 for detailed analysis/comparisons
122
- 'medium': Default, use gpt-5-mini
123
- """
124
- low = query.lower()
125
-
126
- # Simple queries: translation, explanation, layman's terms
127
- simple_keywords = {"translate", "explain", "simple", "layman", "what is", "define"}
128
- if any(kw in low for kw in simple_keywords):
129
- return 'simple'
130
-
131
- # Complex queries: detailed analysis, comparisons
132
- complex_keywords = {"compare", "analyze", "detailed", "comprehensive", "in-depth"}
133
- if any(kw in low for kw in complex_keywords):
134
- return 'complex'
135
-
136
- # Default to medium complexity
137
- return 'medium'
138
-
139
-
140
- def _route_with_llm(text: str) -> Optional[str]:
141
  """
142
- Route query using GPT-5-nano for intelligent intent classification.
143
-
144
- Returns:
145
- Intent string or None if LLM fails
146
- """
147
- try:
148
- from ..llm_client import LLMClient
149
- from ..config import load_config
150
-
151
- # Initialize LLM client with router model
152
- config = load_config()
153
- client = LLMClient(config)
154
-
155
- if not client.is_ready():
156
- return None
157
-
158
- # Build routing prompt
159
- prompt = f"""Classify this query into ONE of these intents: search, summarize, compare, get_grant, list_grants, deadlines, general.
160
-
161
- Query: {text}
162
-
163
- Respond with ONLY the intent word, nothing else."""
164
-
165
- # Get LLM classification with routing parameters
166
- messages = [
167
- {"role": "system", "content": "You are a query intent classifier. Respond with only one word."},
168
- {"role": "user", "content": prompt}
169
- ]
170
-
171
- response = client.chat(
172
- messages,
173
- model_type="router", # Use gpt-5-nano
174
- verbosity="low",
175
- reasoning_effort="minimal",
176
- max_tokens=10,
177
- temperature=0.1
178
- )
179
-
180
- # Parse single word response
181
- intent = response.strip().lower()
182
-
183
- # Validate intent
184
- if intent in _INTENTS:
185
- logger.info(f"🤖 LLM routing: '{text[:50]}...' -> {intent}")
186
- return intent
187
- else:
188
- logger.warning(f"LLM returned invalid intent: {intent}")
189
- return None
190
-
191
- except Exception as e:
192
- logger.warning(f"LLM routing failed: {e}")
193
- return None
194
-
195
-
196
- def _route_with_regex(text: str) -> Dict:
197
- """
198
- Fallback regex-based routing (original implementation).
199
 
200
- Returns:
201
- Routed dict with intent, args, and confidence
 
 
202
  """
203
  t = text.strip()
204
  low = t.lower()
@@ -212,9 +127,10 @@ def _route_with_regex(text: str) -> Dict:
212
  kw = t.split(" ", 1)[1].strip() if " " in t else ""
213
 
214
  # Remove punctuation
 
215
  kw = re.sub(r'[?!.,;:]', '', kw).strip()
216
 
217
- # Clean up filler words
218
  filler = {
219
  "me", "please", "show", "list", "all", "available", "open", "closed", "upcoming",
220
  "grants", "grant", "opportunities", "opportunity", "funding", "calls", "call",
@@ -224,112 +140,51 @@ def _route_with_regex(text: str) -> Dict:
224
  kw = " ".join(kw_tokens) if kw_tokens else ""
225
 
226
  args = {}
 
227
  if kw:
228
  args["keyword"] = kw
 
229
  if status_filter:
230
  args["status"] = status_filter
231
 
 
232
  args["limit"] = None
233
- return Routed("list", args, 0.75).to_dict()
234
 
235
  if low.startswith("summarize") or low.startswith("summarise"):
236
  ids, _ = _extract_ids(t)
237
  if len(ids) >= 2:
238
- return Routed("compare", {"grant_id_a": f"competition-{ids[0]}", "grant_id_b": f"competition-{ids[1]}"}, 0.85).to_dict()
239
  if len(ids) == 1:
240
- return Routed("summarize", {"grant_id": f"competition-{ids[0]}"}, 0.85).to_dict()
 
241
  kw = t.split(" ", 1)[1].strip() if " " in t else ""
242
- return Routed("search", {"keyword": kw, "limit": None}, 0.6).to_dict()
243
 
244
  # Deadline queries
245
  if any(p in low for p in ["deadline", "close date", "when is it due", "when do i need to apply", "application deadline"]):
246
- return Routed("deadlines", {"n": None}, 0.75).to_dict()
247
 
248
  # compare / vs / versus / between → compare two grants
249
  if "compare" in low or " vs " in low or "versus" in low or "between" in low:
250
  ids, residual = _extract_ids(t)
 
251
  if len(ids) >= 2:
252
  return Routed(
253
  "compare",
254
  {"grant_id_a": f"competition-{ids[0]}", "grant_id_b": f"competition-{ids[1]}"},
255
- 0.82
256
  ).to_dict()
257
  if len(ids) == 1:
258
  return Routed("summarize", {"grant_id": f"competition-{ids[0]}"}, 0.7).to_dict()
259
 
260
- # Natural search
261
  if any(w in low for w in ["grant", "funding", "competition", "call", "apply", "what", "which", "find", "search"]):
262
  kw = _keywords_from_question(t)
263
- return Routed("search", {"keyword": kw, "limit": None}, 0.65).to_dict()
264
 
265
  return Routed("general", {"question": t}, 0.5).to_dict()
266
 
267
-
268
- def route(text: str, *, use_llm: bool = True) -> Dict:
269
- """
270
- Route a user query to the appropriate intent handler.
271
-
272
- Uses GPT-5-nano for intelligent routing with regex fallback.
273
- Results are cached for 24 hours for performance.
274
-
275
- Args:
276
- text: User query text
277
- use_llm: If True, use GPT-5-nano for routing (default: True)
278
-
279
- Returns:
280
- Dict with intent, args, and confidence
281
- """
282
- t = text.strip()
283
-
284
- # Check cache first
285
- cache_key = hashlib.md5(t.lower().encode()).hexdigest()
286
- if cache_key in _routing_cache:
287
- cached_result, cached_time = _routing_cache[cache_key]
288
- if time.time() - cached_time < _CACHE_TTL:
289
- logger.debug(f"📦 Cache HIT for routing: '{t[:50]}...'")
290
- return cached_result
291
-
292
- # Try LLM routing first (if enabled)
293
- result = None
294
- if use_llm:
295
- llm_intent = _route_with_llm(t)
296
- if llm_intent:
297
- # Extract args based on intent
298
- args = {}
299
- ids, _ = _extract_ids(t)
300
-
301
- if llm_intent == "summarize" and len(ids) == 1:
302
- args["grant_id"] = f"competition-{ids[0]}"
303
- elif llm_intent == "compare" and len(ids) >= 2:
304
- args["grant_id_a"] = f"competition-{ids[0]}"
305
- args["grant_id_b"] = f"competition-{ids[1]}"
306
- elif llm_intent in ("search", "list_grants"):
307
- kw = _keywords_from_question(t)
308
- if kw:
309
- args["keyword"] = kw
310
- status = _detect_status_filter(t)
311
- if status:
312
- args["status"] = status
313
- args["limit"] = None
314
- elif llm_intent == "get_grant" and len(ids) == 1:
315
- args["grant_id"] = f"competition-{ids[0]}"
316
- elif llm_intent == "deadlines":
317
- args["n"] = None
318
- else:
319
- args["question"] = t
320
-
321
- result = Routed(llm_intent, args, 0.95).to_dict()
322
-
323
- # Fall back to regex if LLM failed or disabled
324
- if result is None:
325
- logger.debug(f"🔧 Using regex fallback for: '{t[:50]}...'")
326
- result = _route_with_regex(t)
327
-
328
- # Cache the result
329
- _routing_cache[cache_key] = (result, time.time())
330
-
331
- return result
332
-
333
  # Self-test
334
  if __name__ == "__main__":
335
  tests = [
 
13
  from __future__ import annotations
14
  from dataclasses import dataclass
15
  from typing import Dict, Optional, Tuple, List
16
+ import json, re
17
 
18
+ _INTENTS = {"list", "summarize", "compare", "deadlines", "search", "general"}
 
 
 
 
 
 
19
 
20
  # Accept "competition-2315", "2315", "comp-2315"
21
  _ID_RE = re.compile(r"(?:comp(?:etition)?-)?([0-9]{3,7})", re.IGNORECASE)
 
106
  return None
107
 
108
 
109
+ def route(text: str, *, use_llm: bool = False) -> Dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  """
111
+ Route a user query to the appropriate intent handler.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
+ Improved to handle:
114
+ - Synonyms (grants = calls = opportunities = funding)
115
+ - Status filters (open, closed, upcoming)
116
+ - Variations of the same intent
117
  """
118
  t = text.strip()
119
  low = t.lower()
 
127
  kw = t.split(" ", 1)[1].strip() if " " in t else ""
128
 
129
  # Remove punctuation
130
+ import re
131
  kw = re.sub(r'[?!.,;:]', '', kw).strip()
132
 
133
+ # Clean up filler words like "me", "please", "all", "available", "is", "are"
134
  filler = {
135
  "me", "please", "show", "list", "all", "available", "open", "closed", "upcoming",
136
  "grants", "grant", "opportunities", "opportunity", "funding", "calls", "call",
 
140
  kw = " ".join(kw_tokens) if kw_tokens else ""
141
 
142
  args = {}
143
+ # Only add keyword if we have a real keyword (not just grant-related filler)
144
  if kw:
145
  args["keyword"] = kw
146
+
147
  if status_filter:
148
  args["status"] = status_filter
149
 
150
+ # Return ALL grants if just listing (no limit = all)
151
  args["limit"] = None
152
+ return Routed("list", args, 0.95).to_dict()
153
 
154
  if low.startswith("summarize") or low.startswith("summarise"):
155
  ids, _ = _extract_ids(t)
156
  if len(ids) >= 2:
157
+ return Routed("compare", {"grant_id_a": f"competition-{ids[0]}", "grant_id_b": f"competition-{ids[1]}"}, 0.95).to_dict()
158
  if len(ids) == 1:
159
+ return Routed("summarize", {"grant_id": f"competition-{ids[0]}"}, 0.95).to_dict()
160
+ # no IDs → treat remainder as search
161
  kw = t.split(" ", 1)[1].strip() if " " in t else ""
162
+ return Routed("search", {"keyword": kw, "limit": None}, 0.6).to_dict() # FIXED: No limit = return all
163
 
164
  # Deadline queries
165
  if any(p in low for p in ["deadline", "close date", "when is it due", "when do i need to apply", "application deadline"]):
166
+ return Routed("deadlines", {"n": None}, 0.85).to_dict() # Return ALL deadlines
167
 
168
  # compare / vs / versus / between → compare two grants
169
  if "compare" in low or " vs " in low or "versus" in low or "between" in low:
170
  ids, residual = _extract_ids(t)
171
+ facet = residual.strip()
172
  if len(ids) >= 2:
173
  return Routed(
174
  "compare",
175
  {"grant_id_a": f"competition-{ids[0]}", "grant_id_b": f"competition-{ids[1]}"},
176
+ 0.92
177
  ).to_dict()
178
  if len(ids) == 1:
179
  return Routed("summarize", {"grant_id": f"competition-{ids[0]}"}, 0.7).to_dict()
180
 
181
+ # Natural search (lower confidence, but still strong)
182
  if any(w in low for w in ["grant", "funding", "competition", "call", "apply", "what", "which", "find", "search"]):
183
  kw = _keywords_from_question(t)
184
+ return Routed("search", {"keyword": kw, "limit": None}, 0.75).to_dict() # FIXED: No limit = return all
185
 
186
  return Routed("general", {"question": t}, 0.5).to_dict()
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  # Self-test
189
  if __name__ == "__main__":
190
  tests = [
src/analyzer/chat/run_chat_llm.py CHANGED
@@ -108,6 +108,16 @@ def _dispatch_tool_call(tools: ChatTools, tool_name: str, tool_args: Dict[str, A
108
  "query": tool_args.get("query")
109
  }
110
 
 
 
 
 
 
 
 
 
 
 
111
  else:
112
  return {"error": f"Unknown tool: {tool_name}"}
113
 
 
108
  "query": tool_args.get("query")
109
  }
110
 
111
+ elif tool_name == "search_past_winners":
112
+ return {
113
+ "results": tools.search_past_winners(
114
+ keyword=tool_args.get("keyword"),
115
+ competition=tool_args.get("competition"),
116
+ limit=tool_args.get("limit")
117
+ ),
118
+ "query": tool_args.get("keyword") or tool_args.get("competition") or "all"
119
+ }
120
+
121
  else:
122
  return {"error": f"Unknown tool: {tool_name}"}
123
 
src/analyzer/chat/tool_schemas.py CHANGED
@@ -92,9 +92,10 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
92
  "function": {
93
  "name": "list_grants",
94
  "description": (
95
- "List all grants with optional filters by keyword, funding, status, or audience. "
96
- "Returns ALL matching grants if limit is not specified. "
97
- "Prefer 'search_grants' for natural language; use this for precise filtering."
 
98
  ),
99
  "parameters": {
100
  "type": "object",
@@ -125,6 +126,36 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
125
  },
126
  },
127
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  {
129
  "type": "function",
130
  "function": {
@@ -203,9 +234,10 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
203
  "function": {
204
  "name": "get_all_grant_summaries",
205
  "description": (
206
- "Get detailed summaries of ALL available grants in a single efficient batch operation. "
207
- "Perfect when user asks 'describe all grants', 'summaries of every grant', 'all grant opportunities', etc. "
208
- "Processes all grants in parallel batches for speed."
 
209
  ),
210
  "parameters": {
211
  "type": "object",
@@ -394,7 +426,7 @@ def get_tools(provider: str = "openai", *, extended: bool = False) -> List[Dict[
394
  Example:
395
  tools = get_tools("openai", extended=True)
396
  response = client.chat.completions.create(
397
- model="gpt-5",
398
  messages=[...],
399
  tools=tools
400
  )
 
92
  "function": {
93
  "name": "list_grants",
94
  "description": (
95
+ "List all grant IDs and titles (no descriptions) with optional filters. "
96
+ "Use this when user asks 'list all grants' or 'show all grants'. "
97
+ "Returns simple list with ID, title, deadline, status ONLY (fast, no summaries). "
98
+ "For full grant descriptions/summaries, use get_all_grant_summaries instead."
99
  ),
100
  "parameters": {
101
  "type": "object",
 
126
  },
127
  },
128
  },
129
+ {
130
+ "type": "function",
131
+ "function": {
132
+ "name": "search_past_winners",
133
+ "description": (
134
+ "Search past winners from 10+ years of UK innovation funding history. "
135
+ "Use this to find previous winners of a grant or search by organization/project name. "
136
+ "Returns past projects with their funding amounts and winning organizations. "
137
+ "If limit is not specified, returns ALL matching winners."
138
+ ),
139
+ "parameters": {
140
+ "type": "object",
141
+ "properties": {
142
+ "keyword": {
143
+ "type": "string",
144
+ "description": "Search by project title, organization name, or description."
145
+ },
146
+ "competition": {
147
+ "type": "string",
148
+ "description": "Filter by grant/competition name to find past winners of a specific grant."
149
+ },
150
+ "limit": {
151
+ "type": "integer",
152
+ "description": "Maximum results to return. If omitted, returns all matching winners."
153
+ },
154
+ },
155
+ "required": [],
156
+ },
157
+ },
158
+ },
159
  {
160
  "type": "function",
161
  "function": {
 
234
  "function": {
235
  "name": "get_all_grant_summaries",
236
  "description": (
237
+ "Get detailed summaries and descriptions of ALL grants. "
238
+ "Use ONLY when user asks 'describe all grants', 'summarize all grants', or wants 'details/descriptions'. "
239
+ "Do NOT use for simple 'list all grants' requests (use list_grants instead). "
240
+ "Returns comprehensive summaries with descriptions and analysis for each grant."
241
  ),
242
  "parameters": {
243
  "type": "object",
 
426
  Example:
427
  tools = get_tools("openai", extended=True)
428
  response = client.chat.completions.create(
429
+ model="gpt-4",
430
  messages=[...],
431
  tools=tools
432
  )
src/analyzer/config.py CHANGED
@@ -32,7 +32,7 @@ from typing import Literal, Optional
32
  Provider = Literal["openai", "anthropic"]
33
 
34
  DEFAULT_MODELS = {
35
- "openai": "gpt-5-mini",
36
  "anthropic": "claude-3-5-haiku-20241022", # fast, low-cost Claude
37
  }
38
 
@@ -45,10 +45,10 @@ class Config:
45
  openai_api_key: Optional[str] = None
46
  anthropic_api_key: Optional[str] = None
47
 
48
- # Model-specific configurations for different use cases
49
- model_router: str = "gpt-5-nano"
50
- model_translator: str = "gpt-5-mini"
51
- model_analyzer: str = "gpt-5"
52
 
53
  temperature: float = 0.2
54
  max_output_tokens: int = 800
 
32
  Provider = Literal["openai", "anthropic"]
33
 
34
  DEFAULT_MODELS = {
35
+ "openai": "gpt-5-mini", # GPT-5 family: nano/mini/main for different use cases
36
  "anthropic": "claude-3-5-haiku-20241022", # fast, low-cost Claude
37
  }
38
 
 
45
  openai_api_key: Optional[str] = None
46
  anthropic_api_key: Optional[str] = None
47
 
48
+ # Model-specific configurations for different use cases (GPT-5 family)
49
+ model_router: str = "gpt-5-nano" # Fast routing and classification
50
+ model_translator: str = "gpt-5-mini" # Translations and summaries
51
+ model_analyzer: str = "gpt-5" # Complex analysis and comparisons
52
 
53
  temperature: float = 0.2
54
  max_output_tokens: int = 800
src/analyzer/crawler/scheduler.py CHANGED
@@ -198,147 +198,6 @@ def rebuild_search_index(
198
  return False
199
 
200
 
201
- def generate_summaries_for_grants(
202
- snapshots_dir: Path,
203
- grant_ids: Optional[list] = None
204
- ) -> int:
205
- """
206
- Generate layman, technical, and executive summaries for grants.
207
-
208
- Args:
209
- snapshots_dir: Directory containing grant JSON files
210
- grant_ids: Optional list of grant IDs to process (defaults to all)
211
-
212
- Returns:
213
- Number of grants summarized
214
- """
215
- import json
216
- import asyncio
217
-
218
- try:
219
- from ...database import SummaryStore
220
- from ...analyzer.config import load_config
221
- from ...analyzer.llm_client import LLMClient
222
- from ...analyzer.summarizer_optimized import extract_minimal_context
223
- except ImportError as e:
224
- logger.error(f"Required modules not available: {e}")
225
- return 0
226
-
227
- if not snapshots_dir.exists():
228
- return 0
229
-
230
- # Initialize MongoDB and LLM
231
- try:
232
- summary_store = SummaryStore()
233
- config = load_config()
234
- # Override to use gpt-5-mini for overnight batch processing
235
- config.model = "gpt-5-mini"
236
- llm_client = LLMClient(config)
237
- except Exception as e:
238
- logger.error(f"Failed to initialize summary generation: {e}")
239
- return 0
240
-
241
- # Load grants to summarize
242
- grants_to_process = []
243
- for json_file in snapshots_dir.glob("*.json"):
244
- try:
245
- with open(json_file, "r", encoding="utf-8") as f:
246
- grant = json.load(f)
247
-
248
- grant_id = grant.get("id") or json_file.stem
249
-
250
- # Filter by grant_ids if provided
251
- if grant_ids and grant_id not in grant_ids:
252
- continue
253
-
254
- grants_to_process.append(grant)
255
- except Exception as e:
256
- logger.warning(f"Failed to read {json_file}: {e}")
257
-
258
- if not grants_to_process:
259
- logger.info("No grants to summarize")
260
- return 0
261
-
262
- logger.info(f"Generating summaries for {len(grants_to_process)} grants...")
263
-
264
- # Generate summaries in parallel batches with bulk write
265
- async def generate_all_summaries():
266
- tasks = []
267
- for grant in grants_to_process:
268
- tasks.append(generate_grant_summaries(grant, llm_client))
269
-
270
- # Process in parallel (batches of 10)
271
- all_summaries = []
272
- for i in range(0, len(tasks), 10):
273
- batch = tasks[i:i+10]
274
- batch_results = await asyncio.gather(*batch, return_exceptions=True)
275
-
276
- # Collect all successful summaries for bulk write
277
- for result in batch_results:
278
- if isinstance(result, list):
279
- all_summaries.extend(result)
280
-
281
- # Bulk write all summaries at once (more efficient)
282
- if all_summaries:
283
- saved_count = summary_store.bulk_save_summaries(all_summaries)
284
- logger.info(f"Bulk saved {saved_count} summaries")
285
- return saved_count // 3 # Divide by 3 since we generate 3 types per grant
286
-
287
- return 0
288
-
289
- async def generate_grant_summaries(grant, client):
290
- """Generate all 3 summary types for a single grant (returns list of summaries)."""
291
- grant_id = grant.get("id", "unknown")
292
-
293
- try:
294
- # Extract minimal context
295
- context = extract_minimal_context(grant)
296
-
297
- # Generate 3 summary types in parallel
298
- summary_types = [
299
- ("layman", "Explain this grant in simple, everyday language that anyone can understand."),
300
- ("technical", "Provide a detailed technical summary of this grant, including eligibility criteria and funding details."),
301
- ("exec", "Provide a concise executive summary highlighting key points and deadlines.")
302
- ]
303
-
304
- async def generate_typed_summary(summary_type, instruction):
305
- prompt = f"{instruction}\n\n{context}"
306
-
307
- try:
308
- # Use streaming=False for batch processing
309
- summary = client.summarize(prompt, max_tokens=300)
310
- return {
311
- "grant_id": grant_id,
312
- "summary_type": summary_type,
313
- "summary_text": summary,
314
- "metadata": {"model": client.model, "context_length": len(context)}
315
- }
316
- except Exception as e:
317
- logger.error(f"Failed to generate {summary_type} summary for {grant_id}: {e}")
318
- return None
319
-
320
- # Generate all 3 types in parallel
321
- results = await asyncio.gather(*[
322
- generate_typed_summary(stype, instruction)
323
- for stype, instruction in summary_types
324
- ], return_exceptions=True)
325
-
326
- # Filter out None results
327
- return [r for r in results if r is not None and isinstance(r, dict)]
328
-
329
- except Exception as e:
330
- logger.error(f"Failed to process grant {grant_id}: {e}")
331
- return []
332
-
333
- try:
334
- summarized_count = asyncio.run(generate_all_summaries())
335
- logger.info(f"Successfully generated summaries for {summarized_count} grants")
336
- return summarized_count
337
- except Exception as e:
338
- logger.error(f"Summary generation failed: {e}", exc_info=True)
339
- return 0
340
-
341
-
342
  def run_crawl_cycle() -> CrawlResult:
343
  """
344
  Run a complete crawl and maintenance cycle.
@@ -353,7 +212,6 @@ def run_crawl_cycle() -> CrawlResult:
353
  try:
354
  # Step 1: Discover and fetch new grants from Innovate UK
355
  new_grants = 0
356
- new_grant_ids = []
357
  try:
358
  from ...crawler.discover_grants import discover_and_fetch_grants
359
 
@@ -362,7 +220,6 @@ def run_crawl_cycle() -> CrawlResult:
362
  discover_and_fetch_grants(SNAPSHOTS_DIR, skip_existing=True)
363
  )
364
  new_grants = newly_fetched
365
- new_grant_ids = [f.replace(".json", "") for f in new_files]
366
  logger.info(
367
  f"Grant discovery: {total_discovered} total, "
368
  f"{newly_fetched} newly fetched"
@@ -373,32 +230,19 @@ def run_crawl_cycle() -> CrawlResult:
373
  logger.error(f"Grant discovery failed: {e}", exc_info=True)
374
  # Continue with other steps even if discovery fails
375
 
376
- # Step 2: Generate summaries for new grants (using gpt-5-mini)
377
- summaries_generated = 0
378
- if new_grant_ids:
379
- try:
380
- logger.info(f"Generating layman summaries for {len(new_grant_ids)} new grants...")
381
- summaries_generated = generate_summaries_for_grants(
382
- SNAPSHOTS_DIR,
383
- grant_ids=new_grant_ids
384
- )
385
- logger.info(f"Generated summaries for {summaries_generated} grants")
386
- except Exception as e:
387
- logger.error(f"Summary generation failed: {e}", exc_info=True)
388
-
389
- # Step 3: Check for duplicates
390
  duplicates = deduplicate_grants(SNAPSHOTS_DIR)
391
 
392
- # Step 4: Mark closed grants
393
  closed = mark_closed_grants(SNAPSHOTS_DIR)
394
 
395
- # Step 5: Rebuild index
396
  index_rebuilt = rebuild_search_index()
397
 
398
  result = CrawlResult(
399
  timestamp=start_time,
400
  new_grants=new_grants,
401
- updated_grants=summaries_generated, # Track summaries in updated_grants
402
  closed_grants=closed,
403
  duplicates_removed=duplicates,
404
  index_rebuilt=index_rebuilt,
 
198
  return False
199
 
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  def run_crawl_cycle() -> CrawlResult:
202
  """
203
  Run a complete crawl and maintenance cycle.
 
212
  try:
213
  # Step 1: Discover and fetch new grants from Innovate UK
214
  new_grants = 0
 
215
  try:
216
  from ...crawler.discover_grants import discover_and_fetch_grants
217
 
 
220
  discover_and_fetch_grants(SNAPSHOTS_DIR, skip_existing=True)
221
  )
222
  new_grants = newly_fetched
 
223
  logger.info(
224
  f"Grant discovery: {total_discovered} total, "
225
  f"{newly_fetched} newly fetched"
 
230
  logger.error(f"Grant discovery failed: {e}", exc_info=True)
231
  # Continue with other steps even if discovery fails
232
 
233
+ # Step 2: Check for duplicates
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  duplicates = deduplicate_grants(SNAPSHOTS_DIR)
235
 
236
+ # Step 3: Mark closed grants
237
  closed = mark_closed_grants(SNAPSHOTS_DIR)
238
 
239
+ # Step 4: Rebuild index
240
  index_rebuilt = rebuild_search_index()
241
 
242
  result = CrawlResult(
243
  timestamp=start_time,
244
  new_grants=new_grants,
245
+ updated_grants=0,
246
  closed_grants=closed,
247
  duplicates_removed=duplicates,
248
  index_rebuilt=index_rebuilt,
src/analyzer/data_loader.py CHANGED
@@ -117,14 +117,61 @@ def _load_past_winners_from_json_dir(json_dir: Path) -> List[Dict[str, Any]]:
117
  return records
118
 
119
 
120
- def load_past_winners(history_xlsx: Path | str | None = None,
121
- history_json_dir: Path | str | None = None) -> List[Dict[str, Any]]:
122
- """Load past winners from either Excel (preferred) or a JSON folder.
123
 
124
- Priority order: JSON dir (if provided) > Excel (if provided).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  If neither exists, returns an empty list.
126
  """
127
- # JSON dir first if present
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  if history_json_dir is not None:
129
  jdir = Path(history_json_dir)
130
  if jdir.exists():
@@ -135,7 +182,7 @@ def load_past_winners(history_xlsx: Path | str | None = None,
135
  else:
136
  logger.info("No JSON past winners found under %s", jdir)
137
 
138
- # Excel next
139
  if history_xlsx is not None:
140
  xlsx = Path(history_xlsx)
141
  if xlsx.exists():
 
117
  return records
118
 
119
 
120
+ def _load_past_winners_from_jsonl(jsonl_path: Path) -> List[Dict[str, Any]]:
121
+ """Load past winners from JSONL file (optionally gzipped)."""
122
+ import gzip
123
 
124
+ records: List[Dict[str, Any]] = []
125
+
126
+ # Check if file is gzipped
127
+ open_func = gzip.open if str(jsonl_path).endswith('.gz') else open
128
+
129
+ try:
130
+ with open_func(jsonl_path, 'rt', encoding='utf-8') as f:
131
+ for line in f:
132
+ line = line.strip()
133
+ if not line:
134
+ continue
135
+ try:
136
+ rec = json.loads(line)
137
+ if isinstance(rec, dict):
138
+ records.append(rec)
139
+ except json.JSONDecodeError:
140
+ continue
141
+ return records
142
+ except Exception as e:
143
+ logger.warning(f"Failed to load JSONL from {jsonl_path}: {e}")
144
+ return []
145
+
146
+
147
+ def load_past_winners(
148
+ history_xlsx: Path | str | None = None,
149
+ history_json_dir: Path | str | None = None,
150
+ history_jsonl: Path | str | None = None
151
+ ) -> List[Dict[str, Any]]:
152
+ """Load past winners from Excel, JSONL, or JSON directory.
153
+
154
+ Priority order: JSONL > JSON dir > Excel.
155
  If neither exists, returns an empty list.
156
  """
157
+ # Try JSONL first (most efficient for large datasets)
158
+ if history_jsonl is not None:
159
+ jsonl = Path(history_jsonl)
160
+ if jsonl.exists():
161
+ recs = _load_past_winners_from_jsonl(jsonl)
162
+ if recs:
163
+ logger.info("Loaded %d past winners from JSONL: %s", len(recs), jsonl)
164
+ return recs
165
+
166
+ # Also check for past_winners.jsonl.gz in default location (for HF deployment)
167
+ default_jsonl = Path("data/past_winners.jsonl.gz")
168
+ if default_jsonl.exists() and history_jsonl is None:
169
+ recs = _load_past_winners_from_jsonl(default_jsonl)
170
+ if recs:
171
+ logger.info("Loaded %d past winners from default JSONL: %s", len(recs), default_jsonl)
172
+ return recs
173
+
174
+ # JSON dir next
175
  if history_json_dir is not None:
176
  jdir = Path(history_json_dir)
177
  if jdir.exists():
 
182
  else:
183
  logger.info("No JSON past winners found under %s", jdir)
184
 
185
+ # Excel last
186
  if history_xlsx is not None:
187
  xlsx = Path(history_xlsx)
188
  if xlsx.exists():
src/analyzer/llm_client.py CHANGED
@@ -35,7 +35,7 @@ class LLMClient:
35
  self.model = self._get_cfg(cfg, "model", "gpt-5-mini")
36
  self.disable_llm = self._get_cfg(cfg, "disable_llm", False)
37
 
38
- # Store model variants for different use cases
39
  self.model_router = self._get_cfg(cfg, "model_router", "gpt-5-nano")
40
  self.model_translator = self._get_cfg(cfg, "model_translator", "gpt-5-mini")
41
  self.model_analyzer = self._get_cfg(cfg, "model_analyzer", "gpt-5")
@@ -106,39 +106,22 @@ class LLMClient:
106
  return self.model
107
 
108
  @staticmethod
109
- def get_recommended_params(
110
- task_type: str
111
- ) -> Dict[str, Any]:
112
  """
113
  Get recommended verbosity and reasoning_effort for common tasks.
114
 
115
  Args:
116
- task_type: One of "translation", "analysis", "routing", "summary"
117
 
118
  Returns:
119
  Dict with verbosity and reasoning_effort settings
120
  """
121
  presets = {
122
- "translation": {
123
- "verbosity": "medium",
124
- "reasoning_effort": "minimal"
125
- },
126
- "analysis": {
127
- "verbosity": "high",
128
- "reasoning_effort": "high"
129
- },
130
- "routing": {
131
- "verbosity": "low",
132
- "reasoning_effort": "minimal"
133
- },
134
- "summary": {
135
- "verbosity": "medium",
136
- "reasoning_effort": "medium"
137
- },
138
- "comparison": {
139
- "verbosity": "high",
140
- "reasoning_effort": "high"
141
- }
142
  }
143
  return presets.get(task_type, {"verbosity": "medium", "reasoning_effort": "medium"})
144
 
@@ -205,7 +188,7 @@ class LLMClient:
205
  reasoning_effort: Optional[ReasoningEffort] = None,
206
  ) -> str:
207
  """
208
- Single chat completion call with optional streaming.
209
 
210
  Args:
211
  messages: List of message dicts with 'role' and 'content'
@@ -223,11 +206,6 @@ class LLMClient:
223
  - 'medium': Moderate analysis and reasoning
224
  - 'high': Deep analysis and careful reasoning
225
 
226
- Recommended combinations:
227
- - Translations: verbosity='medium', reasoning_effort='minimal'
228
- - Complex analysis: verbosity='high', reasoning_effort='high'
229
- - Routing decisions: verbosity='low', reasoning_effort='minimal'
230
-
231
  Returns:
232
  Generated text response (or generator if stream=True)
233
 
@@ -242,28 +220,28 @@ class LLMClient:
242
  "LLM client not initialized. Check API key and configuration."
243
  )
244
 
245
- # Select model based on type
246
- selected_model = self._get_model_for_type(model_type)
247
-
248
  def _make_call():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  try:
250
- # Build API call parameters
251
- params = {
252
- "model": selected_model,
253
- "messages": messages,
254
- "temperature": temperature,
255
- "top_p": top_p,
256
- "max_tokens": max_tokens,
257
- "stream": stream,
258
- }
259
-
260
- # Add GPT-5 specific parameters if provided
261
- if verbosity is not None:
262
- params["verbosity"] = verbosity
263
- if reasoning_effort is not None:
264
- params["reasoning_effort"] = reasoning_effort
265
-
266
- resp = self.client.chat.completions.create(**params)
267
  except Exception as e:
268
  # Handle httpx exceptions if available
269
  if httpx and isinstance(e, httpx.TimeoutException):
@@ -288,19 +266,6 @@ class LLMClient:
288
  if not content:
289
  raise LLMError("LLM returned empty response")
290
 
291
- # Record metrics (token usage and model distribution)
292
- try:
293
- from src.monitoring import record_tokens, record_model_use
294
- if hasattr(resp, 'usage') and resp.usage:
295
- record_tokens(
296
- prompt_tokens=resp.usage.prompt_tokens,
297
- completion_tokens=resp.usage.completion_tokens
298
- )
299
- record_model_use(selected_model)
300
- except Exception as e:
301
- # Don't fail the request if metrics recording fails
302
- logging.warning(f"Failed to record metrics: {e}")
303
-
304
  return content.strip()
305
 
306
  return self._retry_with_backoff(_make_call, max_attempts=self.max_retries)
@@ -327,8 +292,6 @@ class LLMClient:
327
  system_text: str = "You are an expert UK grant analyst. Be precise, grounded, concise.",
328
  max_tokens: int = 900,
329
  temperature: float = 0.2,
330
- verbosity: Optional[VerbosityLevel] = None,
331
- reasoning_effort: Optional[ReasoningEffort] = None,
332
  ) -> str:
333
  """
334
  Convenience: single-turn chat.
@@ -338,8 +301,6 @@ class LLMClient:
338
  system_text: System prompt
339
  max_tokens: Maximum tokens
340
  temperature: Sampling temperature
341
- verbosity: Response length control (GPT-5)
342
- reasoning_effort: Thinking time control (GPT-5)
343
 
344
  Returns:
345
  Generated summary
@@ -351,13 +312,7 @@ class LLMClient:
351
  {"role": "system", "content": system_text},
352
  {"role": "user", "content": user_text},
353
  ]
354
- return self.chat(
355
- messages,
356
- max_tokens=max_tokens,
357
- temperature=temperature,
358
- verbosity=verbosity,
359
- reasoning_effort=reasoning_effort
360
- )
361
 
362
  def summarize_long(
363
  self,
 
35
  self.model = self._get_cfg(cfg, "model", "gpt-5-mini")
36
  self.disable_llm = self._get_cfg(cfg, "disable_llm", False)
37
 
38
+ # Store model variants for different use cases (GPT-5 family)
39
  self.model_router = self._get_cfg(cfg, "model_router", "gpt-5-nano")
40
  self.model_translator = self._get_cfg(cfg, "model_translator", "gpt-5-mini")
41
  self.model_analyzer = self._get_cfg(cfg, "model_analyzer", "gpt-5")
 
106
  return self.model
107
 
108
  @staticmethod
109
+ def get_recommended_params(task_type: str) -> Dict[str, Any]:
 
 
110
  """
111
  Get recommended verbosity and reasoning_effort for common tasks.
112
 
113
  Args:
114
+ task_type: One of "translation", "analysis", "routing", "summary", "comparison"
115
 
116
  Returns:
117
  Dict with verbosity and reasoning_effort settings
118
  """
119
  presets = {
120
+ "translation": {"verbosity": "medium", "reasoning_effort": "minimal"},
121
+ "analysis": {"verbosity": "high", "reasoning_effort": "high"},
122
+ "routing": {"verbosity": "low", "reasoning_effort": "minimal"},
123
+ "summary": {"verbosity": "medium", "reasoning_effort": "medium"},
124
+ "comparison": {"verbosity": "high", "reasoning_effort": "high"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  }
126
  return presets.get(task_type, {"verbosity": "medium", "reasoning_effort": "medium"})
127
 
 
188
  reasoning_effort: Optional[ReasoningEffort] = None,
189
  ) -> str:
190
  """
191
+ Single chat completion call with optional streaming and GPT-5 features.
192
 
193
  Args:
194
  messages: List of message dicts with 'role' and 'content'
 
206
  - 'medium': Moderate analysis and reasoning
207
  - 'high': Deep analysis and careful reasoning
208
 
 
 
 
 
 
209
  Returns:
210
  Generated text response (or generator if stream=True)
211
 
 
220
  "LLM client not initialized. Check API key and configuration."
221
  )
222
 
 
 
 
223
  def _make_call():
224
+ # Select model based on model_type
225
+ model = self._get_model_for_type(model_type)
226
+
227
+ # Build API parameters
228
+ api_params = {
229
+ "model": model,
230
+ "messages": messages,
231
+ "temperature": temperature,
232
+ "top_p": top_p,
233
+ "max_tokens": max_tokens,
234
+ "stream": stream,
235
+ }
236
+
237
+ # Add GPT-5 specific parameters if provided
238
+ if verbosity is not None:
239
+ api_params["verbosity"] = verbosity
240
+ if reasoning_effort is not None:
241
+ api_params["reasoning_effort"] = reasoning_effort
242
+
243
  try:
244
+ resp = self.client.chat.completions.create(**api_params)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  except Exception as e:
246
  # Handle httpx exceptions if available
247
  if httpx and isinstance(e, httpx.TimeoutException):
 
266
  if not content:
267
  raise LLMError("LLM returned empty response")
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  return content.strip()
270
 
271
  return self._retry_with_backoff(_make_call, max_attempts=self.max_retries)
 
292
  system_text: str = "You are an expert UK grant analyst. Be precise, grounded, concise.",
293
  max_tokens: int = 900,
294
  temperature: float = 0.2,
 
 
295
  ) -> str:
296
  """
297
  Convenience: single-turn chat.
 
301
  system_text: System prompt
302
  max_tokens: Maximum tokens
303
  temperature: Sampling temperature
 
 
304
 
305
  Returns:
306
  Generated summary
 
312
  {"role": "system", "content": system_text},
313
  {"role": "user", "content": user_text},
314
  ]
315
+ return self.chat(messages, max_tokens=max_tokens, temperature=temperature)
 
 
 
 
 
 
316
 
317
  def summarize_long(
318
  self,
src/analyzer/prompt_templates.py CHANGED
@@ -14,7 +14,19 @@ from typing import Dict
14
  _SYSTEM_DEFAULT = (
15
  "You are an expert grant analyst. Produce crisp, factual executive summaries "
16
  "for UK innovation funding calls using ONLY the provided context. "
17
- "For vague or messy user requests, call the tool `search_grants` with `query` set to the raw user text and include any obvious `filters` you can infer (e.g., status=\"open\", audience=\"SME\", theme like \"battery\", or timeframe). "
 
 
 
 
 
 
 
 
 
 
 
 
18
  "Be precise, avoid hype, and NEVER invent facts. If a detail is missing, say so briefly. "
19
  "Prefer bullet points. Keep to 250–400 words."
20
  )
@@ -59,8 +71,12 @@ def build_prompt(provider: str, context_text: str, *, style: str = "default") ->
59
  OPEN_SYSTEM = (
60
  "You are a UK grant analyst and research copilot.\n"
61
  "- Prefer grounded answers using the provided context/snippets when available.\n"
62
- "- If a detail isnt in the context, say so briefly or mark it as uncertain.\n"
63
- "- Choose the clearest format for the user’s ask (short answer, bullets, table, or brief narrative) — your call.\n"
 
 
 
 
64
  "- Be concise by default; expand only if asked.\n"
65
  "- Never fabricate URLs or specific numbers not present in context."
66
  )
 
14
  _SYSTEM_DEFAULT = (
15
  "You are an expert grant analyst. Produce crisp, factual executive summaries "
16
  "for UK innovation funding calls using ONLY the provided context. "
17
+ "\n"
18
+ "## Tool usage guide:\n"
19
+ "- For searching CURRENT GRANTS: Use `search_grants` or `list_grants` with queries like 'AI', 'battery', 'net zero', etc.\n"
20
+ "- For finding PAST WINNERS: Use `search_past_winners` to find previous winners by project name, organization, or grant name.\n"
21
+ "- If user asks about 'who won' or 'previous winners': ALWAYS use `search_past_winners`.\n"
22
+ "- If user asks about 'funding opportunities' or 'apply for': ALWAYS use `search_grants` or `list_grants`.\n"
23
+ "- For vague user requests, call `search_grants` with `query` set to the raw user text and include filters (e.g., status, audience, theme).\n"
24
+ "\n"
25
+ "## About grants vs prizes:\n"
26
+ "- GRANTS: Traditional funding for projects/research (current opportunities in the database).\n"
27
+ "- PRIZES: Competition-based funding with winners (e.g., 'Agentic AI Pioneers Prize').\n"
28
+ "- Both can be found! If you find a prize when searching for a grant, note that it's a prize and explain the difference.\n"
29
+ "\n"
30
  "Be precise, avoid hype, and NEVER invent facts. If a detail is missing, say so briefly. "
31
  "Prefer bullet points. Keep to 250–400 words."
32
  )
 
71
  OPEN_SYSTEM = (
72
  "You are a UK grant analyst and research copilot.\n"
73
  "- Prefer grounded answers using the provided context/snippets when available.\n"
74
+ "- If a detail isn't in the context, say so briefly or mark it as uncertain.\n"
75
+ "- Use tools proactively:\n"
76
+ " * `search_grants`/`list_grants` for funding opportunities\n"
77
+ " * `search_past_winners` for previous winners and past projects\n"
78
+ "- Note distinctions: GRANTS are traditional funding, PRIZES are competition-based (e.g., 'Agentic AI Pioneers Prize').\n"
79
+ "- Choose the clearest format for the user's ask (short answer, bullets, table, or brief narrative) — your call.\n"
80
  "- Be concise by default; expand only if asked.\n"
81
  "- Never fabricate URLs or specific numbers not present in context."
82
  )
src/analyzer/summarizer_optimized.py CHANGED
@@ -3,9 +3,9 @@ summarizer_optimized.py — High-performance grant summarization with:
3
  - PARALLELIZATION: asyncio.gather() for concurrent processing
4
  - STREAMING: Yield results as batches complete (for async contexts)
5
  - BATCH PROCESSING: 5 grants per API call via clever prompting
6
- - SMART CONTEXT: Extract only essential fields (~200 tokens per grant)
7
  - CACHING: In-memory cache with 1-hour TTL (Redis-ready pattern)
8
- - MODEL OPTIMIZATION: gpt-5-mini for basic summaries (fast and cost-effective)
9
 
10
  Performance target: <30 seconds for 30 grants (vs. 7 minutes sequential)
11
 
@@ -31,14 +31,6 @@ from .llm_client import LLMClient
31
 
32
  logger = logging.getLogger(__name__)
33
 
34
- # Import tiktoken for token counting
35
- try:
36
- import tiktoken
37
- HAS_TIKTOKEN = True
38
- except ImportError:
39
- HAS_TIKTOKEN = False
40
- logger.warning("tiktoken not available - token counting disabled")
41
-
42
 
43
  # ================================= CACHING LAYER =================================
44
 
@@ -86,55 +78,21 @@ class SummaryCache:
86
 
87
  # ================================= CONTEXT EXTRACTION =================================
88
 
89
- def _get_first_sentences(text: str, n: int = 3) -> str:
90
- """Extract first N sentences from text."""
91
- if not text:
92
- return ""
93
- sentences = text.split('. ')
94
- return '. '.join(sentences[:n]).strip() + ('.' if len(sentences) > n else '')
95
-
96
-
97
- def _count_tokens(text: str) -> int:
98
- """Count tokens in text using tiktoken (if available)."""
99
- if not HAS_TIKTOKEN:
100
- # Rough approximation: 1 token ≈ 4 characters
101
- return len(text) // 4
102
-
103
- try:
104
- # Use o200k_base encoding for GPT-5 models (fallback to cl100k_base for GPT-4)
105
- try:
106
- encoding = tiktoken.get_encoding("o200k_base")
107
- except:
108
- encoding = tiktoken.get_encoding("cl100k_base")
109
- return len(encoding.encode(text))
110
- except Exception:
111
- # Fallback to character approximation
112
- return len(text) // 4
113
-
114
-
115
  def extract_minimal_context(grant: Dict[str, Any], past_winners: Optional[List[Dict[str, Any]]] = None) -> str:
116
  """
117
- Extract only essential fields (~200 tokens per grant).
118
-
119
- Aggressive reduction strategy:
120
- - Title: max 100 chars
121
- - Deadline: as-is
122
- - Funding: as-is
123
- - Summary: first 3 sentences only
124
- - Eligibility: first 2 sentences only
125
- - NO past winners (saves ~50-100 tokens)
126
 
127
- Target: <200 tokens per grant for efficient batch processing
 
128
  """
129
  parts = []
130
 
131
- # Title (truncate to 100 chars)
132
  title = grant.get("title") or grant.get("name") or grant.get("competition_title") or "(untitled)"
133
- title = title[:100]
134
  parts.append(f"TITLE: {title}")
135
 
136
  # Deadline
137
- deadline = grant.get("deadline") or grant.get("close_date")
138
  if deadline:
139
  parts.append(f"DEADLINE: {deadline}")
140
 
@@ -143,7 +101,7 @@ def extract_minimal_context(grant: Dict[str, Any], past_winners: Optional[List[D
143
  if funding:
144
  parts.append(f"FUNDING: {funding}")
145
 
146
- # Summary/Description (first 3 sentences only)
147
  summary_raw = None
148
  for field in ["summary_raw", "summary", "description", "overview"]:
149
  if grant.get("sections", {}).get(field):
@@ -151,14 +109,11 @@ def extract_minimal_context(grant: Dict[str, Any], past_winners: Optional[List[D
151
  break
152
 
153
  if summary_raw:
154
- # Extract first 3 sentences
155
- summary_short = _get_first_sentences(summary_raw, 3)
156
- # Further truncate to 200 chars if needed
157
- if len(summary_short) > 200:
158
- summary_short = summary_short[:200] + "..."
159
- parts.append(f"SUMMARY: {summary_short}")
160
-
161
- # Eligibility (first 2 sentences only)
162
  eligibility = None
163
  for field in ["eligibility_raw", "eligibility", "who_can_apply"]:
164
  if grant.get("sections", {}).get(field):
@@ -166,24 +121,19 @@ def extract_minimal_context(grant: Dict[str, Any], past_winners: Optional[List[D
166
  break
167
 
168
  if eligibility:
169
- # Extract first 2 sentences
170
- eligibility_short = _get_first_sentences(eligibility, 2)
171
- # Further truncate to 150 chars if needed
172
- if len(eligibility_short) > 150:
173
- eligibility_short = eligibility_short[:150] + "..."
174
- parts.append(f"ELIGIBILITY: {eligibility_short}")
175
-
176
- # Build final context
177
- context = "\n".join(parts)
178
-
179
- # Token count check (for logging)
180
- token_count = _count_tokens(context)
181
- if token_count > 200:
182
- logger.debug(
183
- f"Context for '{title[:30]}...' is {token_count} tokens (target: 200)"
184
- )
185
-
186
- return context
187
 
188
 
189
  # ================================= BATCH SUMMARIZATION =================================
 
3
  - PARALLELIZATION: asyncio.gather() for concurrent processing
4
  - STREAMING: Yield results as batches complete (for async contexts)
5
  - BATCH PROCESSING: 5 grants per API call via clever prompting
6
+ - SMART CONTEXT: Extract only essential fields (~500 tokens per grant)
7
  - CACHING: In-memory cache with 1-hour TTL (Redis-ready pattern)
8
+ - MODEL OPTIMIZATION: gpt-3.5-turbo for basic summaries (10x cheaper, 2x faster)
9
 
10
  Performance target: <30 seconds for 30 grants (vs. 7 minutes sequential)
11
 
 
31
 
32
  logger = logging.getLogger(__name__)
33
 
 
 
 
 
 
 
 
 
34
 
35
  # ================================= CACHING LAYER =================================
36
 
 
78
 
79
  # ================================= CONTEXT EXTRACTION =================================
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  def extract_minimal_context(grant: Dict[str, Any], past_winners: Optional[List[Dict[str, Any]]] = None) -> str:
82
  """
83
+ Extract only essential fields (~500 tokens per grant).
 
 
 
 
 
 
 
 
84
 
85
+ Instead of full HTML, extract:
86
+ - title, deadline, max_funding, brief description (first 200 words), eligibility
87
  """
88
  parts = []
89
 
90
+ # Title
91
  title = grant.get("title") or grant.get("name") or grant.get("competition_title") or "(untitled)"
 
92
  parts.append(f"TITLE: {title}")
93
 
94
  # Deadline
95
+ deadline = grant.get("deadline")
96
  if deadline:
97
  parts.append(f"DEADLINE: {deadline}")
98
 
 
101
  if funding:
102
  parts.append(f"FUNDING: {funding}")
103
 
104
+ # Brief summary/description (first 200 words)
105
  summary_raw = None
106
  for field in ["summary_raw", "summary", "description", "overview"]:
107
  if grant.get("sections", {}).get(field):
 
109
  break
110
 
111
  if summary_raw:
112
+ # Truncate to ~200 words
113
+ words = summary_raw.split()[:200]
114
+ parts.append(f"DESCRIPTION: {' '.join(words)}")
115
+
116
+ # Eligibility
 
 
 
117
  eligibility = None
118
  for field in ["eligibility_raw", "eligibility", "who_can_apply"]:
119
  if grant.get("sections", {}).get(field):
 
121
  break
122
 
123
  if eligibility:
124
+ words = eligibility.split()[:150]
125
+ parts.append(f"ELIGIBILITY: {' '.join(words)}")
126
+
127
+ # Past winners (if provided)
128
+ if past_winners:
129
+ parts.append(f"\nPAST WINNERS ({len(past_winners)} records):")
130
+ for winner in past_winners[:3]: # Only first 3 to save tokens
131
+ org = winner.get("lead_org", "Unknown")
132
+ amount = winner.get("award_amount", "Unknown")
133
+ title_w = winner.get("project_title", "Unknown")
134
+ parts.append(f" • {org}: {title_w} ({amount})")
135
+
136
+ return "\n".join(parts)
 
 
 
 
 
137
 
138
 
139
  # ================================= BATCH SUMMARIZATION =================================
src/analyzer/telemetry/logger.py CHANGED
@@ -5,7 +5,7 @@ Usage:
5
  from analyzer.telemetry.logger import QALogger
6
  log = QALogger("logs/chat.jsonl")
7
  log.write(user="find farming grants", intent="search", args={"keyword":"farming"},
8
- answer_md="...", ok=True, latency_ms=321, meta={"model":"gpt-5-mini"})
9
  """
10
  from __future__ import annotations
11
  from dataclasses import asdict, dataclass, field
 
5
  from analyzer.telemetry.logger import QALogger
6
  log = QALogger("logs/chat.jsonl")
7
  log.write(user="find farming grants", intent="search", args={"keyword":"farming"},
8
+ answer_md="...", ok=True, latency_ms=321, meta={"model":"gpt-4.1-mini"})
9
  """
10
  from __future__ import annotations
11
  from dataclasses import asdict, dataclass, field
src/analyzer/utils/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (161 Bytes)
 
src/analyzer/utils/__pycache__/errors.cpython-312.pyc DELETED
Binary file (2.68 kB)
 
src/analyzer/utils/query_logger.py CHANGED
@@ -81,7 +81,7 @@ class QueryLogger:
81
  success: Whether the interaction was successful
82
  rating: Optional 1-5 rating (for RLHF)
83
  feedback: Optional text feedback (for RLHF)
84
- model: Model name used (e.g., "gpt-5-mini")
85
  tokens_used: Number of tokens consumed
86
  metadata: Additional metadata to log
87
  """
@@ -305,7 +305,7 @@ if __name__ == "__main__":
305
  success=True,
306
  rating=5,
307
  feedback="Very helpful!",
308
- model="gpt-5-mini"
309
  )
310
 
311
  # Get stats
 
81
  success: Whether the interaction was successful
82
  rating: Optional 1-5 rating (for RLHF)
83
  feedback: Optional text feedback (for RLHF)
84
+ model: Model name used (e.g., "gpt-4-mini")
85
  tokens_used: Number of tokens consumed
86
  metadata: Additional metadata to log
87
  """
 
305
  success=True,
306
  rating=5,
307
  feedback="Very helpful!",
308
+ model="gpt-4-mini"
309
  )
310
 
311
  # Get stats
src/analyzer/utils/text.py CHANGED
@@ -34,7 +34,7 @@ def safe_truncate_chars(s: str, n: int) -> str:
34
  return s
35
  return s[: max(0, n - 1)] + "…"
36
 
37
- def safe_truncate_tokens(s: str, max_tokens: int, *, model: str = "gpt-5-mini") -> str:
38
  """
39
  Best effort token truncation. If tiktoken is available, use it;
40
  otherwise approximate by ~4 chars/token heuristic.
@@ -42,11 +42,7 @@ def safe_truncate_tokens(s: str, max_tokens: int, *, model: str = "gpt-5-mini")
42
  s = s or ""
43
  try:
44
  import tiktoken # type: ignore
45
- # Use o200k_base encoding for GPT-5 models (fallback to cl100k_base for GPT-4)
46
- try:
47
- enc = tiktoken.get_encoding("o200k_base")
48
- except:
49
- enc = tiktoken.get_encoding("cl100k_base")
50
  toks = enc.encode(s)
51
  if len(toks) <= max_tokens:
52
  return s
 
34
  return s
35
  return s[: max(0, n - 1)] + "…"
36
 
37
+ def safe_truncate_tokens(s: str, max_tokens: int, *, model: str = "gpt-4o-mini") -> str:
38
  """
39
  Best effort token truncation. If tiktoken is available, use it;
40
  otherwise approximate by ~4 chars/token heuristic.
 
42
  s = s or ""
43
  try:
44
  import tiktoken # type: ignore
45
+ enc = tiktoken.encoding_for_model(model)
 
 
 
 
46
  toks = enc.encode(s)
47
  if len(toks) <= max_tokens:
48
  return s