Spaces:
Sleeping
Sleeping
Riley Claude commited on
Commit ·
d5ef6d3
1
Parent(s): 477e08a
fix: Complete restoration of analyzer/ directory from c72a240
Browse filesRestored 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 +0 -0
- analyzer/chat/.DS_Store +0 -0
- analyzer/chat/company_analyzer.py +0 -384
- analyzer/chat/query_router.py +23 -168
- analyzer/chat/run_chat_llm.py +10 -0
- analyzer/config.py +1 -9
- analyzer/crawler/discover_grants.py +0 -306
- analyzer/crawler/scheduler.py +4 -160
- analyzer/crawler/snapshot.py +0 -626
- analyzer/data_loader.py +53 -6
- analyzer/llm_client.py +11 -120
- analyzer/prompt_templates.py +19 -3
- analyzer/summarizer_optimized.py +26 -76
- analyzer/telemetry/logger.py +1 -1
- analyzer/utils/query_logger.py +2 -2
- analyzer/utils/text.py +2 -6
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
|
| 17 |
|
| 18 |
-
|
| 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
|
| 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
|
| 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 |
-
|
| 201 |
-
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 239 |
if len(ids) == 1:
|
| 240 |
-
return Routed("summarize", {"grant_id": f"competition-{ids[0]}"}, 0.
|
|
|
|
| 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.
|
| 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.
|
| 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.
|
| 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-
|
| 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:
|
| 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
|
| 393 |
closed = mark_closed_grants(SNAPSHOTS_DIR)
|
| 394 |
|
| 395 |
-
# Step
|
| 396 |
index_rebuilt = rebuild_search_index()
|
| 397 |
|
| 398 |
result = CrawlResult(
|
| 399 |
timestamp=start_time,
|
| 400 |
new_grants=new_grants,
|
| 401 |
-
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
|
| 121 |
-
|
| 122 |
-
|
| 123 |
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
If neither exists, returns an empty list.
|
| 126 |
"""
|
| 127 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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-
|
| 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 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 isn
|
| 63 |
-
"-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (~
|
| 7 |
- CACHING: In-memory cache with 1-hour TTL (Redis-ready pattern)
|
| 8 |
-
- MODEL OPTIMIZATION: gpt-5-
|
| 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 (~
|
| 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 |
-
|
|
|
|
| 128 |
"""
|
| 129 |
parts = []
|
| 130 |
|
| 131 |
-
# Title
|
| 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")
|
| 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 |
-
#
|
| 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 |
-
#
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 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 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
parts.append(f"
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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 |
-
|
| 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
|