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

fix: Complete restoration of analyzer/ directory from c72a240

Browse files

Restored the ENTIRE analyzer/ directory from commit c72a240, which was
the last known fully working Hugging Face Spaces deployment.

This includes:
- All chat tools and UI (demo_app.py with 7 preset questions)
- LLM client, config, data loaders
- Crawler/scraper tools
- All utilities and supporting modules

Every file is now exactly as it was in the working c72a240 deployment,
removing all changes that happened during the problematic merge/push cycle.

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

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

analyzer/.DS_Store ADDED
Binary file (8.2 kB). View file
 
analyzer/chat/.DS_Store ADDED
Binary file (6.15 kB). View file
 
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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = [
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
 
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,11 +45,6 @@ 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
55
  timeout_s: float = 30.0
@@ -91,9 +86,6 @@ def load_config() -> Config:
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")),
 
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
  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
  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")),
analyzer/crawler/discover_grants.py DELETED
@@ -1,306 +0,0 @@
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/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,
analyzer/crawler/snapshot.py DELETED
@@ -1,626 +0,0 @@
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/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():
analyzer/llm_client.py CHANGED
@@ -3,14 +3,10 @@ from __future__ import annotations
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,14 +28,9 @@ class LLMClient:
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
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,54 +85,6 @@ class LLMClient:
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(
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
-
145
  def _retry_with_backoff(
146
  self,
147
  fn: Callable[[], Any],
@@ -200,9 +143,6 @@ class LLMClient:
200
  temperature: float = 0.2,
201
  top_p: float = 1.0,
202
  stream: bool = False,
203
- model_type: Optional[ModelType] = None,
204
- verbosity: Optional[VerbosityLevel] = None,
205
- reasoning_effort: Optional[ReasoningEffort] = None,
206
  ) -> str:
207
  """
208
  Single chat completion call with optional streaming.
@@ -213,20 +153,6 @@ class LLMClient:
213
  temperature: Sampling temperature (0-2)
214
  top_p: Nucleus sampling parameter
215
  stream: If True, returns generator yielding tokens (else full response)
216
- model_type: Type of model to use ('router', 'translator', or 'analyzer')
217
- verbosity: Response length control (GPT-5 feature)
218
- - 'low': Brief, concise responses
219
- - 'medium': Standard length responses
220
- - 'high': Detailed, comprehensive responses
221
- reasoning_effort: Thinking time control (GPT-5 feature)
222
- - 'minimal': Quick, straightforward responses
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)
@@ -242,28 +168,16 @@ 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 +202,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 +228,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 +237,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 +248,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,
 
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
  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
  """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
  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.
 
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)
 
168
  "LLM client not initialized. Check API key and configuration."
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):
 
202
  if not content:
203
  raise LLMError("LLM returned empty response")
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  return content.strip()
206
 
207
  return self._retry_with_backoff(_make_call, max_attempts=self.max_retries)
 
228
  system_text: str = "You are an expert UK grant analyst. Be precise, grounded, concise.",
229
  max_tokens: int = 900,
230
  temperature: float = 0.2,
 
 
231
  ) -> str:
232
  """
233
  Convenience: single-turn chat.
 
237
  system_text: System prompt
238
  max_tokens: Maximum tokens
239
  temperature: Sampling temperature
 
 
240
 
241
  Returns:
242
  Generated summary
 
248
  {"role": "system", "content": system_text},
249
  {"role": "user", "content": user_text},
250
  ]
251
+ return self.chat(messages, max_tokens=max_tokens, temperature=temperature)
 
 
 
 
 
 
252
 
253
  def summarize_long(
254
  self,
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
  )
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 =================================
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
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
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