Spaces:
Sleeping
feat: Merge GPT-5 enhancements with working c72a240 base + restore crawler
Browse filesMajor update combining the stable c72a240 Hugging Face deployment base
with GPT-5 improvements from 057c21e.
## Core Enhancements Added:
### GPT-5 Model Support (Step 1 of 15)
- Three-tier model system: nano/mini/main for different use cases
- model_router (gpt-5-nano): Fast routing and classification
- model_translator (gpt-5-mini): Translations and summaries
- model_analyzer (gpt-5): Complex analysis and comparisons
- Verbosity control: low/medium/high response lengths
- Reasoning effort: minimal/medium/high thinking depth
- Task-specific presets for optimal parameters
### Files Modified:
- analyzer/llm_client.py: Added GPT-5 support with model types
- analyzer/config.py: Added GPT-5 model configurations
- src/analyzer/*: Synced all files to match analyzer/ base
### Crawler Restored:
- analyzer/crawler/snapshot.py: Grant page scraping
- analyzer/crawler/discover_grants.py: Grant URL discovery
- Both restored from 057c21e commit
### Preserved from c72a240 Working Base:
- ✓ 7 preset questions (including past winners)
- ✓ Async batch processing
- ✓ All optimizations (caching, context reduction)
- ✓ Clean UI (no emojis)
- ✓ Reliable regex-based query routing
- ✓ 36 grants loading correctly
### Tests Passed:
- All imports successful
- 36 grants loaded
- GPT-5 models configured correctly
- Crawler modules working
- LLM client ready
Both analyzer/ and src/analyzer/ directories are now identical
and contain the working c72a240 base + GPT-5 enhancements.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- analyzer/config.py +9 -1
- analyzer/crawler/discover_grants.py +306 -0
- analyzer/crawler/snapshot.py +626 -0
- analyzer/llm_client.py +75 -11
- src/__pycache__/__init__.cpython-312.pyc +0 -0
- src/analyzer/__pycache__/__init__.cpython-312.pyc +0 -0
- src/analyzer/__pycache__/config.cpython-312.pyc +0 -0
- src/analyzer/__pycache__/llm_client.cpython-312.pyc +0 -0
- src/analyzer/chat/chat_tools.py +136 -237
- src/analyzer/chat/company_analyzer.py +0 -384
- src/analyzer/chat/demo_app.py +114 -235
- src/analyzer/chat/query_router.py +23 -168
- src/analyzer/chat/run_chat_llm.py +10 -0
- src/analyzer/chat/tool_schemas.py +39 -7
- src/analyzer/config.py +5 -5
- src/analyzer/crawler/scheduler.py +4 -160
- src/analyzer/data_loader.py +53 -6
- src/analyzer/llm_client.py +30 -75
- src/analyzer/prompt_templates.py +19 -3
- src/analyzer/summarizer_optimized.py +26 -76
- src/analyzer/telemetry/logger.py +1 -1
- src/analyzer/utils/__pycache__/__init__.cpython-312.pyc +0 -0
- src/analyzer/utils/__pycache__/errors.cpython-312.pyc +0 -0
- src/analyzer/utils/query_logger.py +2 -2
- src/analyzer/utils/text.py +2 -6
|
@@ -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,6 +45,11 @@ class Config:
|
|
| 45 |
openai_api_key: Optional[str] = None
|
| 46 |
anthropic_api_key: Optional[str] = None
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
temperature: float = 0.2
|
| 49 |
max_output_tokens: int = 800
|
| 50 |
timeout_s: float = 30.0
|
|
@@ -86,6 +91,9 @@ def load_config() -> Config:
|
|
| 86 |
model=model,
|
| 87 |
openai_api_key=_env("OPENAI_API_KEY"),
|
| 88 |
anthropic_api_key=_env("ANTHROPIC_API_KEY"),
|
|
|
|
|
|
|
|
|
|
| 89 |
temperature=float(_env("LLM_TEMPERATURE", "0.2")),
|
| 90 |
max_output_tokens=int(_env("LLM_MAX_OUTPUT_TOKENS", "800")),
|
| 91 |
timeout_s=float(_env("LLM_TIMEOUT_S", "30")),
|
|
|
|
| 32 |
Provider = Literal["openai", "anthropic"]
|
| 33 |
|
| 34 |
DEFAULT_MODELS = {
|
| 35 |
+
"openai": "gpt-5-mini", # GPT-5 family: nano/mini/main for different use cases
|
| 36 |
"anthropic": "claude-3-5-haiku-20241022", # fast, low-cost Claude
|
| 37 |
}
|
| 38 |
|
|
|
|
| 45 |
openai_api_key: Optional[str] = None
|
| 46 |
anthropic_api_key: Optional[str] = None
|
| 47 |
|
| 48 |
+
# Model-specific configurations for different use cases (GPT-5 family)
|
| 49 |
+
model_router: str = "gpt-5-nano" # Fast routing and classification
|
| 50 |
+
model_translator: str = "gpt-5-mini" # Translations and summaries
|
| 51 |
+
model_analyzer: str = "gpt-5" # Complex analysis and comparisons
|
| 52 |
+
|
| 53 |
temperature: float = 0.2
|
| 54 |
max_output_tokens: int = 800
|
| 55 |
timeout_s: float = 30.0
|
|
|
|
| 91 |
model=model,
|
| 92 |
openai_api_key=_env("OPENAI_API_KEY"),
|
| 93 |
anthropic_api_key=_env("ANTHROPIC_API_KEY"),
|
| 94 |
+
model_router=_env("LLM_MODEL_ROUTER", "gpt-5-nano"),
|
| 95 |
+
model_translator=_env("LLM_MODEL_TRANSLATOR", "gpt-5-mini"),
|
| 96 |
+
model_analyzer=_env("LLM_MODEL_ANALYZER", "gpt-5"),
|
| 97 |
temperature=float(_env("LLM_TEMPERATURE", "0.2")),
|
| 98 |
max_output_tokens=int(_env("LLM_MAX_OUTPUT_TOKENS", "800")),
|
| 99 |
timeout_s=float(_env("LLM_TIMEOUT_S", "30")),
|
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Discover and fetch new grants from Innovate UK website.
|
| 3 |
+
|
| 4 |
+
This module:
|
| 5 |
+
1. Fetches the Innovate UK competition search/listing page
|
| 6 |
+
2. Extracts all available grant URLs
|
| 7 |
+
3. Fetches each grant's details using snapshot.py
|
| 8 |
+
4. Saves to snapshots directory
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
import asyncio
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import List, Tuple, Optional, Set
|
| 17 |
+
from urllib.parse import urljoin, urlparse
|
| 18 |
+
from datetime import datetime, UTC
|
| 19 |
+
from playwright.async_api import async_playwright
|
| 20 |
+
from bs4 import BeautifulSoup
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# Innovate UK service base URL
|
| 25 |
+
IUK_BASE = "https://apply-for-innovation-funding.service.gov.uk"
|
| 26 |
+
COMPETITIONS_URL = f"{IUK_BASE}/competition/search"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def discover_grant_urls(max_retries: int = 2) -> List[str]:
|
| 30 |
+
"""
|
| 31 |
+
Discover all available grant overview URLs from Innovate UK.
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
List of grant overview URLs
|
| 35 |
+
"""
|
| 36 |
+
logger.info(f"Discovering grants from {COMPETITIONS_URL}")
|
| 37 |
+
|
| 38 |
+
async with async_playwright() as pw:
|
| 39 |
+
browser = await pw.chromium.launch(
|
| 40 |
+
headless=True,
|
| 41 |
+
args=["--disable-dev-shm-usage"]
|
| 42 |
+
)
|
| 43 |
+
context = await browser.new_context(
|
| 44 |
+
user_agent=(
|
| 45 |
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
| 46 |
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
| 47 |
+
),
|
| 48 |
+
locale="en-GB",
|
| 49 |
+
timezone_id="Europe/London",
|
| 50 |
+
)
|
| 51 |
+
page = await context.new_page()
|
| 52 |
+
page.set_default_timeout(30000)
|
| 53 |
+
|
| 54 |
+
for attempt in range(max_retries):
|
| 55 |
+
try:
|
| 56 |
+
await page.goto(COMPETITIONS_URL, wait_until="domcontentloaded")
|
| 57 |
+
await page.wait_for_load_state("networkidle")
|
| 58 |
+
break
|
| 59 |
+
except Exception as e:
|
| 60 |
+
if attempt == max_retries - 1:
|
| 61 |
+
logger.error(f"Failed to fetch competitions page: {e}")
|
| 62 |
+
await context.close()
|
| 63 |
+
await browser.close()
|
| 64 |
+
return []
|
| 65 |
+
logger.warning(f"Attempt {attempt + 1} failed, retrying...")
|
| 66 |
+
|
| 67 |
+
html = await page.content()
|
| 68 |
+
await context.close()
|
| 69 |
+
await browser.close()
|
| 70 |
+
|
| 71 |
+
# Parse URLs from HTML
|
| 72 |
+
urls = _extract_grant_urls(html)
|
| 73 |
+
logger.info(f"Discovered {len(urls)} grants")
|
| 74 |
+
|
| 75 |
+
return urls
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _extract_grant_urls(html: str) -> List[str]:
|
| 79 |
+
"""
|
| 80 |
+
Extract all grant overview URLs from the competitions listing page.
|
| 81 |
+
|
| 82 |
+
Looks for links matching pattern: /competition/{id}/overview/{uuid}
|
| 83 |
+
"""
|
| 84 |
+
soup = BeautifulSoup(html, "lxml")
|
| 85 |
+
urls = []
|
| 86 |
+
|
| 87 |
+
# Find all links that match the overview pattern
|
| 88 |
+
pattern = re.compile(r'/competition/(\d+)/overview/([0-9a-f\-]{8,})', re.I)
|
| 89 |
+
|
| 90 |
+
for link in soup.find_all("a", href=True):
|
| 91 |
+
href = link.get("href", "")
|
| 92 |
+
if pattern.search(href):
|
| 93 |
+
# Make absolute URL
|
| 94 |
+
full_url = urljoin(IUK_BASE, href)
|
| 95 |
+
if full_url not in urls:
|
| 96 |
+
urls.append(full_url)
|
| 97 |
+
|
| 98 |
+
# Also check for links in data attributes or javascript
|
| 99 |
+
for elem in soup.find_all(["a", "div", "li"], {"data-href": True}):
|
| 100 |
+
href = elem.get("data-href", "")
|
| 101 |
+
if pattern.search(href):
|
| 102 |
+
full_url = urljoin(IUK_BASE, href)
|
| 103 |
+
if full_url not in urls:
|
| 104 |
+
urls.append(full_url)
|
| 105 |
+
|
| 106 |
+
return urls
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def fetch_grant_snapshot(url: str, output_dir: Path) -> Optional[str]:
|
| 110 |
+
"""
|
| 111 |
+
Fetch a single grant's snapshot and save to JSON.
|
| 112 |
+
|
| 113 |
+
Args:
|
| 114 |
+
url: Grant overview URL
|
| 115 |
+
output_dir: Directory to save snapshot JSON
|
| 116 |
+
|
| 117 |
+
Returns:
|
| 118 |
+
Filename of saved snapshot, or None if failed
|
| 119 |
+
"""
|
| 120 |
+
try:
|
| 121 |
+
from .snapshot import fetch_sections_from_overview, parse_deeplink
|
| 122 |
+
|
| 123 |
+
# Extract grant ID from URL
|
| 124 |
+
match = re.search(r'/competition/(\d+)/', url)
|
| 125 |
+
if not match:
|
| 126 |
+
logger.warning(f"Could not extract grant ID from {url}")
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
grant_id = match.group(1)
|
| 130 |
+
output_path = output_dir / f"competition-{grant_id}.json"
|
| 131 |
+
|
| 132 |
+
# Skip if already exists
|
| 133 |
+
if output_path.exists():
|
| 134 |
+
logger.debug(f"Grant {grant_id} already exists, skipping")
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
logger.info(f"Fetching grant {grant_id}...")
|
| 138 |
+
html, sections = await fetch_sections_from_overview(url)
|
| 139 |
+
|
| 140 |
+
# Parse dates and funding from the fetched content
|
| 141 |
+
from .snapshot import (
|
| 142 |
+
parse_dates_singleline,
|
| 143 |
+
extract_funding,
|
| 144 |
+
_find_duration_months,
|
| 145 |
+
pick_open_close_from_milestones,
|
| 146 |
+
derive_aux_dates,
|
| 147 |
+
clean_title
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# Parse dates
|
| 151 |
+
dates_text = sections.get("dates_raw", "") or ""
|
| 152 |
+
milestones = parse_dates_singleline(dates_text)
|
| 153 |
+
|
| 154 |
+
# Derive open/close from milestones
|
| 155 |
+
open_date, close_date = pick_open_close_from_milestones(milestones)
|
| 156 |
+
|
| 157 |
+
# If either missing, try scanning all text
|
| 158 |
+
if not open_date or not close_date:
|
| 159 |
+
all_text = " ".join(v for v in sections.values() if isinstance(v, str) and v)
|
| 160 |
+
extra = parse_dates_singleline(all_text)
|
| 161 |
+
seen = {(m["label_raw"], m["date_iso"], m.get("date_iso_end")) for m in milestones}
|
| 162 |
+
for m2 in extra:
|
| 163 |
+
key = (m2["label_raw"], m2["date_iso"], m2.get("date_iso_end"))
|
| 164 |
+
if key not in seen:
|
| 165 |
+
milestones.append(m2)
|
| 166 |
+
seen.add(key)
|
| 167 |
+
od2, cd2 = pick_open_close_from_milestones(milestones)
|
| 168 |
+
open_date = open_date or od2
|
| 169 |
+
close_date = close_date or cd2
|
| 170 |
+
|
| 171 |
+
notify_date, project_start_from = derive_aux_dates(milestones)
|
| 172 |
+
|
| 173 |
+
# Parse funding
|
| 174 |
+
funding = extract_funding(sections)
|
| 175 |
+
|
| 176 |
+
# Parse duration
|
| 177 |
+
dur_min, dur_max = _find_duration_months(sections.get("eligibility_raw", "") or "")
|
| 178 |
+
if dur_min is None or dur_max is None:
|
| 179 |
+
all_text_for_duration = " ".join(v for v in sections.values() if isinstance(v, str) and v)
|
| 180 |
+
dur_min, dur_max = _find_duration_months(all_text_for_duration)
|
| 181 |
+
duration_months = {"min": dur_min, "max": dur_max}
|
| 182 |
+
|
| 183 |
+
# Extract title
|
| 184 |
+
raw_title = sections.get("summary_raw", "").split("\n")[0] if sections.get("summary_raw") else ""
|
| 185 |
+
title = clean_title(raw_title)
|
| 186 |
+
|
| 187 |
+
# Create snapshot
|
| 188 |
+
snapshot = {
|
| 189 |
+
"id": f"competition-{grant_id}",
|
| 190 |
+
"competition_id": grant_id,
|
| 191 |
+
"url": url,
|
| 192 |
+
"title": title,
|
| 193 |
+
"programme": "",
|
| 194 |
+
"round": "",
|
| 195 |
+
"open_date": open_date,
|
| 196 |
+
"close_date": close_date,
|
| 197 |
+
"notify_date": notify_date,
|
| 198 |
+
"project_start_from": project_start_from,
|
| 199 |
+
"funding": funding,
|
| 200 |
+
"duration_months": duration_months,
|
| 201 |
+
"sections": sections,
|
| 202 |
+
"pdfs": [],
|
| 203 |
+
"summaries": {},
|
| 204 |
+
"extracted": {"milestones": milestones},
|
| 205 |
+
"wonky": {"score": 0.0, "reasons": []},
|
| 206 |
+
"prev_round_refs": [],
|
| 207 |
+
"diff_summary": "",
|
| 208 |
+
"history_stats": {},
|
| 209 |
+
"created_at": datetime.now(UTC).isoformat(),
|
| 210 |
+
"updated_at": datetime.now(UTC).isoformat(),
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
# Save snapshot
|
| 214 |
+
output_path.write_text(
|
| 215 |
+
json.dumps(snapshot, indent=2),
|
| 216 |
+
encoding="utf-8"
|
| 217 |
+
)
|
| 218 |
+
logger.info(f"Saved grant {grant_id} snapshot")
|
| 219 |
+
|
| 220 |
+
return output_path.name
|
| 221 |
+
|
| 222 |
+
except Exception as e:
|
| 223 |
+
logger.error(f"Failed to fetch grant from {url}: {e}")
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
async def discover_and_fetch_grants(
|
| 228 |
+
output_dir: Path,
|
| 229 |
+
skip_existing: bool = True
|
| 230 |
+
) -> Tuple[int, int, List[str]]:
|
| 231 |
+
"""
|
| 232 |
+
Discover all grants and fetch new ones.
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
output_dir: Directory to save snapshots
|
| 236 |
+
skip_existing: Skip grants that already exist
|
| 237 |
+
|
| 238 |
+
Returns:
|
| 239 |
+
Tuple of (total_discovered, newly_fetched, new_filenames)
|
| 240 |
+
"""
|
| 241 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 242 |
+
|
| 243 |
+
# Step 1: Discover all grant URLs
|
| 244 |
+
urls = await discover_grant_urls()
|
| 245 |
+
if not urls:
|
| 246 |
+
logger.warning("No grants discovered")
|
| 247 |
+
return 0, 0, []
|
| 248 |
+
|
| 249 |
+
# Step 2: Get existing grant IDs if skipping
|
| 250 |
+
existing_ids: Set[str] = set()
|
| 251 |
+
if skip_existing:
|
| 252 |
+
for json_file in output_dir.glob("competition-*.json"):
|
| 253 |
+
match = re.search(r'competition-(\d+)', json_file.name)
|
| 254 |
+
if match:
|
| 255 |
+
existing_ids.add(match.group(1))
|
| 256 |
+
|
| 257 |
+
# Step 3: Fetch new grants concurrently
|
| 258 |
+
new_files = []
|
| 259 |
+
tasks = []
|
| 260 |
+
|
| 261 |
+
for url in urls:
|
| 262 |
+
match = re.search(r'/competition/(\d+)/', url)
|
| 263 |
+
if match and match.group(1) in existing_ids:
|
| 264 |
+
logger.debug(f"Grant {match.group(1)} already exists")
|
| 265 |
+
continue
|
| 266 |
+
|
| 267 |
+
tasks.append(fetch_grant_snapshot(url, output_dir))
|
| 268 |
+
|
| 269 |
+
if tasks:
|
| 270 |
+
logger.info(f"Fetching {len(tasks)} new grants concurrently...")
|
| 271 |
+
results = await asyncio.gather(*tasks, return_exceptions=False)
|
| 272 |
+
new_files = [f for f in results if f is not None]
|
| 273 |
+
|
| 274 |
+
logger.info(
|
| 275 |
+
f"Discovery complete: {len(urls)} total, "
|
| 276 |
+
f"{len(new_files)} newly fetched"
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
return len(urls), len(new_files), new_files
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def main_sync(
|
| 283 |
+
output_dir: str = "data/snapshots",
|
| 284 |
+
skip_existing: bool = True
|
| 285 |
+
) -> Tuple[int, int, List[str]]:
|
| 286 |
+
"""
|
| 287 |
+
Synchronous wrapper for discovering and fetching grants.
|
| 288 |
+
"""
|
| 289 |
+
output_path = Path(output_dir)
|
| 290 |
+
return asyncio.run(
|
| 291 |
+
discover_and_fetch_grants(output_path, skip_existing)
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
if __name__ == "__main__":
|
| 296 |
+
logging.basicConfig(
|
| 297 |
+
level=logging.INFO,
|
| 298 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
total, new, files = main_sync()
|
| 302 |
+
print(f"\n✓ Discovery complete:")
|
| 303 |
+
print(f" Total discovered: {total}")
|
| 304 |
+
print(f" Newly fetched: {new}")
|
| 305 |
+
if files:
|
| 306 |
+
print(f" Files: {', '.join(files)}")
|
|
@@ -0,0 +1,626 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
import pathlib
|
| 4 |
+
import asyncio
|
| 5 |
+
from urllib.parse import urlparse
|
| 6 |
+
from datetime import datetime, UTC
|
| 7 |
+
from typing import List, Optional, Tuple, Dict
|
| 8 |
+
from playwright.async_api import async_playwright
|
| 9 |
+
from bs4 import BeautifulSoup, Tag, NavigableString
|
| 10 |
+
import typer
|
| 11 |
+
|
| 12 |
+
# ---------------------------------------------------------------------------
|
| 13 |
+
# HELPERS
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
|
| 16 |
+
EXPECTED_TITLES = [
|
| 17 |
+
"Summary",
|
| 18 |
+
"Eligibility",
|
| 19 |
+
"Scope",
|
| 20 |
+
"Dates",
|
| 21 |
+
"How to apply",
|
| 22 |
+
"Supporting information",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
# Accept close-enough labels and alias them to canonical 6
|
| 26 |
+
SECTION_ALIASES = {
|
| 27 |
+
"who can apply": "Eligibility",
|
| 28 |
+
"who’s eligible": "Eligibility",
|
| 29 |
+
"who is eligible": "Eligibility",
|
| 30 |
+
"applicant eligibility": "Eligibility",
|
| 31 |
+
"what we ask you": "How to apply",
|
| 32 |
+
"apply": "How to apply",
|
| 33 |
+
"application process": "How to apply",
|
| 34 |
+
"supporting info": "Supporting information",
|
| 35 |
+
"key dates": "Dates",
|
| 36 |
+
"timeline": "Dates",
|
| 37 |
+
"competition dates": "Dates",
|
| 38 |
+
"overview": "Summary",
|
| 39 |
+
"summary": "Summary",
|
| 40 |
+
"scope": "Scope",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
def canonical_label(label: str) -> Optional[str]:
|
| 44 |
+
l = label.strip().lower()
|
| 45 |
+
for t in EXPECTED_TITLES:
|
| 46 |
+
if l == t.lower():
|
| 47 |
+
return t
|
| 48 |
+
return SECTION_ALIASES.get(l, None)
|
| 49 |
+
|
| 50 |
+
def norm_key(label: str) -> str:
|
| 51 |
+
return re.sub(r"[^\w\s-]", "", label).strip().lower().replace(" ", "_") + "_raw"
|
| 52 |
+
|
| 53 |
+
def parse_deeplink(url: str):
|
| 54 |
+
u = urlparse(url)
|
| 55 |
+
m = re.search(r"/competition/(\d+)/overview/([0-9a-f-]{8,})", u.path, re.I)
|
| 56 |
+
if not m:
|
| 57 |
+
raise ValueError("Expected: .../competition/{id}/overview/{uuid}")
|
| 58 |
+
return f"{u.scheme}://{u.netloc}", m.group(1), m.group(2)
|
| 59 |
+
|
| 60 |
+
def get_competition_nav_anchors(soup: BeautifulSoup) -> List[tuple[str, str]]:
|
| 61 |
+
anchors: List[tuple[str, str]] = []
|
| 62 |
+
|
| 63 |
+
headings = soup.find_all(["h2", "h3", "h4"], string=lambda s: isinstance(s, str) and "competition sections" in s.lower())
|
| 64 |
+
nav_root: Optional[Tag] = None
|
| 65 |
+
for h in headings:
|
| 66 |
+
for sib in h.next_siblings:
|
| 67 |
+
if isinstance(sib, Tag) and sib.name in ("nav", "ul", "ol", "div"):
|
| 68 |
+
nav_root = sib
|
| 69 |
+
break
|
| 70 |
+
if nav_root:
|
| 71 |
+
break
|
| 72 |
+
|
| 73 |
+
if not nav_root:
|
| 74 |
+
for candidate in soup.find_all("nav"):
|
| 75 |
+
if candidate.find("a", href=True):
|
| 76 |
+
nav_root = candidate
|
| 77 |
+
break
|
| 78 |
+
|
| 79 |
+
if nav_root:
|
| 80 |
+
seen = set()
|
| 81 |
+
for a in nav_root.find_all("a", href=True):
|
| 82 |
+
href = a.get("href", "")
|
| 83 |
+
if not href.startswith("#"):
|
| 84 |
+
continue
|
| 85 |
+
frag = href[1:].strip()
|
| 86 |
+
raw = (a.get_text(" ", strip=True) or "").strip()
|
| 87 |
+
if not frag or not raw:
|
| 88 |
+
continue
|
| 89 |
+
canon = canonical_label(raw) or raw
|
| 90 |
+
if canon in EXPECTED_TITLES and frag not in seen:
|
| 91 |
+
anchors.append((canon, frag))
|
| 92 |
+
seen.add(frag)
|
| 93 |
+
|
| 94 |
+
if not anchors:
|
| 95 |
+
anchors = [
|
| 96 |
+
("Summary", "summary"),
|
| 97 |
+
("Eligibility", "eligibility"),
|
| 98 |
+
("Scope", "scope"),
|
| 99 |
+
("Dates", "dates"),
|
| 100 |
+
("How to apply", "how-to-apply"),
|
| 101 |
+
("Supporting information", "supporting-information"),
|
| 102 |
+
]
|
| 103 |
+
return anchors
|
| 104 |
+
|
| 105 |
+
# -----------------------------
|
| 106 |
+
# Footer / cookie / consent trimmer
|
| 107 |
+
# -----------------------------
|
| 108 |
+
|
| 109 |
+
_FOOTER_STOPS = [
|
| 110 |
+
"Need help with this service?",
|
| 111 |
+
"Support links",
|
| 112 |
+
"GOV.UK uses cookies",
|
| 113 |
+
"Create one update function for each consent parameter",
|
| 114 |
+
"© Crown copyright",
|
| 115 |
+
"All content is available under the Open Government Licence",
|
| 116 |
+
]
|
| 117 |
+
|
| 118 |
+
def trim_footer(text: str) -> str:
|
| 119 |
+
if not text:
|
| 120 |
+
return text
|
| 121 |
+
for marker in _FOOTER_STOPS:
|
| 122 |
+
i = text.find(marker)
|
| 123 |
+
if i != -1:
|
| 124 |
+
return text[:i].rstrip()
|
| 125 |
+
return text
|
| 126 |
+
|
| 127 |
+
def _strip_boilerplate(soup: BeautifulSoup):
|
| 128 |
+
selectors = [
|
| 129 |
+
"#global-cookie-message", ".cookie-banner", "#ccc-notify", "#onetrust-banner-sdk",
|
| 130 |
+
"footer", ".govuk-footer",
|
| 131 |
+
".govuk-prototype-kit-warning",
|
| 132 |
+
]
|
| 133 |
+
for sel in selectors:
|
| 134 |
+
for el in soup.select(sel):
|
| 135 |
+
el.decompose()
|
| 136 |
+
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
# TITLE
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
def clean_title(raw: str) -> str:
|
| 142 |
+
if not raw:
|
| 143 |
+
return raw
|
| 144 |
+
raw = raw.strip()
|
| 145 |
+
return re.sub(r"^\s*Funding competition\s+", "", raw, flags=re.I).strip()
|
| 146 |
+
|
| 147 |
+
def extract_between_ids(soup: BeautifulSoup, start_id: str, end_id: Optional[str]) -> str:
|
| 148 |
+
start = soup.find(id=start_id)
|
| 149 |
+
if not start:
|
| 150 |
+
return ""
|
| 151 |
+
out_chunks: List[str] = []
|
| 152 |
+
for el in start.next_elements:
|
| 153 |
+
if isinstance(el, Tag):
|
| 154 |
+
if end_id and el.get("id") == end_id:
|
| 155 |
+
break
|
| 156 |
+
if el.name in ("script", "style", "noscript"):
|
| 157 |
+
continue
|
| 158 |
+
if isinstance(el, NavigableString):
|
| 159 |
+
txt = el.strip()
|
| 160 |
+
if txt:
|
| 161 |
+
out_chunks.append(txt)
|
| 162 |
+
text = " ".join(out_chunks)
|
| 163 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 164 |
+
text = trim_footer(text)
|
| 165 |
+
return text
|
| 166 |
+
|
| 167 |
+
# ---------------------------------------------------------------------------
|
| 168 |
+
# PARSING
|
| 169 |
+
# ---------------------------------------------------------------------------
|
| 170 |
+
|
| 171 |
+
def slice_by_anchors(html: str) -> dict:
|
| 172 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 173 |
+
_strip_boilerplate(soup)
|
| 174 |
+
anchors = get_competition_nav_anchors(soup)
|
| 175 |
+
ids_in_order = [aid for _, aid in anchors]
|
| 176 |
+
id_to_next = {ids_in_order[i]: (ids_in_order[i + 1] if i + 1 < len(ids_in_order) else None)
|
| 177 |
+
for i in range(len(ids_in_order))}
|
| 178 |
+
out = {}
|
| 179 |
+
for label, start_id in anchors:
|
| 180 |
+
next_id = id_to_next.get(start_id)
|
| 181 |
+
key = norm_key(label)
|
| 182 |
+
out[key] = extract_between_ids(soup, start_id, next_id)
|
| 183 |
+
return out
|
| 184 |
+
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
# MAIN SCRAPER
|
| 187 |
+
# ---------------------------------------------------------------------------
|
| 188 |
+
|
| 189 |
+
async def fetch_sections_from_overview(url: str) -> tuple[str, dict]:
|
| 190 |
+
scheme_host, comp_id, uuid = parse_deeplink(url)
|
| 191 |
+
overview_url = f"{scheme_host}/competition/{comp_id}/overview/{uuid}"
|
| 192 |
+
|
| 193 |
+
async with async_playwright() as pw:
|
| 194 |
+
browser = await pw.chromium.launch(headless=True, args=["--disable-dev-shm-usage"])
|
| 195 |
+
context = await browser.new_context(
|
| 196 |
+
user_agent=("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
| 197 |
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
|
| 198 |
+
locale="en-GB",
|
| 199 |
+
timezone_id="Europe/London",
|
| 200 |
+
)
|
| 201 |
+
page = await context.new_page()
|
| 202 |
+
page.set_default_timeout(30000)
|
| 203 |
+
|
| 204 |
+
for attempt in range(2):
|
| 205 |
+
try:
|
| 206 |
+
await page.goto(overview_url, wait_until="domcontentloaded")
|
| 207 |
+
await page.wait_for_load_state("networkidle")
|
| 208 |
+
break
|
| 209 |
+
except Exception:
|
| 210 |
+
if attempt == 1:
|
| 211 |
+
raise
|
| 212 |
+
html = await page.content()
|
| 213 |
+
await context.close()
|
| 214 |
+
await browser.close()
|
| 215 |
+
|
| 216 |
+
return html, slice_by_anchors(html)
|
| 217 |
+
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
# DATE PARSING — SINGLE-LINE CHUNKS
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
+
|
| 222 |
+
# tokens
|
| 223 |
+
_DATE_WORD = r"(?:\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}|[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4})"
|
| 224 |
+
_TIME_WORD = r"(?:\d{1,2}:\d{2}\s*[ap]m|\d{1,2}\s*[ap]m)"
|
| 225 |
+
|
| 226 |
+
_DATE_ONLY_RX = re.compile(_DATE_WORD, re.I)
|
| 227 |
+
_TIME_RX = re.compile(_TIME_WORD, re.I)
|
| 228 |
+
|
| 229 |
+
# e.g. "9 to 20 March 2026", "9–20 March 2026", "9 - 20 March 2026"
|
| 230 |
+
_DATE_RANGE_RX = re.compile(r"(\d{1,2})\s*(?:to|-|–)\s*(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})", re.I)
|
| 231 |
+
|
| 232 |
+
_MONTHS = {m.lower(): i for i, m in enumerate(
|
| 233 |
+
["January","February","March","April","May","June","July","August","September","October","November","December"], 1
|
| 234 |
+
)}
|
| 235 |
+
|
| 236 |
+
_EXCLUDE_SENTENCE_CUES = ["briefing event", "briefing", "webinar", "register to attend", "register", "info session"]
|
| 237 |
+
|
| 238 |
+
_LABEL_RULES = [
|
| 239 |
+
("opens", ["competition opens", "opens"]),
|
| 240 |
+
("closes", ["competition closes", "closes", "deadline"]),
|
| 241 |
+
("notify", ["applicants notified", "applicants will be notified", "notification"]),
|
| 242 |
+
("project_start", ["project start from", "project starts from", "project start date", "project start"]),
|
| 243 |
+
("assessment", ["interview", "assessment", "panel"]),
|
| 244 |
+
("results", ["results published", "winners announced"]),
|
| 245 |
+
("eligibility_cutoff", ["eligibility closes", "registration closes"]),
|
| 246 |
+
("info_session", ["briefing", "webinar", "register"]),
|
| 247 |
+
]
|
| 248 |
+
|
| 249 |
+
def _classify_label(sent_lower: str) -> str:
|
| 250 |
+
for norm, cues in _LABEL_RULES:
|
| 251 |
+
if any(c in sent_lower for c in cues):
|
| 252 |
+
return norm
|
| 253 |
+
return "other"
|
| 254 |
+
|
| 255 |
+
def _parse_single_date(token: str, time_hint: Optional[str]) -> Optional[str]:
|
| 256 |
+
token = token.strip()
|
| 257 |
+
m_comma = re.match(r"([A-Za-z]{3,9})\s+(\d{1,2}),?\s+(\d{4})", token)
|
| 258 |
+
if m_comma:
|
| 259 |
+
month_name, day, year = m_comma.groups()
|
| 260 |
+
else:
|
| 261 |
+
m = re.match(r"(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})", token)
|
| 262 |
+
if not m:
|
| 263 |
+
return None
|
| 264 |
+
day, month_name, year = m.groups()
|
| 265 |
+
month = _MONTHS.get(month_name.lower())
|
| 266 |
+
if not month:
|
| 267 |
+
return None
|
| 268 |
+
if time_hint:
|
| 269 |
+
t = time_hint.lower().replace(" ", "")
|
| 270 |
+
mm = re.match(r"(\d{1,2})(?::(\d{2}))?([ap]m)", t)
|
| 271 |
+
if mm:
|
| 272 |
+
hh = int(mm.group(1))
|
| 273 |
+
mins = int(mm.group(2) or 0)
|
| 274 |
+
ampm = mm.group(3)
|
| 275 |
+
if ampm == "pm" and hh != 12: hh += 12
|
| 276 |
+
if ampm == "am" and hh == 12: hh = 0
|
| 277 |
+
return f"{int(year):04d}-{month:02d}-{int(day):02d}T{hh:02d}:{mins:02d}:00"
|
| 278 |
+
return f"{int(year):04d}-{month:02d}-{int(day):02d}"
|
| 279 |
+
|
| 280 |
+
def parse_dates_singleline(text: str) -> List[dict]:
|
| 281 |
+
"""
|
| 282 |
+
Split the Dates section into *one milestone per line/chunk*:
|
| 283 |
+
chunk := from each DATE token up to the next DATE token (or end).
|
| 284 |
+
Keeps the entire chunk in label_raw.
|
| 285 |
+
"""
|
| 286 |
+
milestones: List[dict] = []
|
| 287 |
+
if not text:
|
| 288 |
+
return milestones
|
| 289 |
+
|
| 290 |
+
# find all date token positions
|
| 291 |
+
matches = list(_DATE_ONLY_RX.finditer(text))
|
| 292 |
+
if not matches:
|
| 293 |
+
return milestones
|
| 294 |
+
|
| 295 |
+
spans = []
|
| 296 |
+
for i, m in enumerate(matches):
|
| 297 |
+
start = m.start()
|
| 298 |
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
| 299 |
+
spans.append((start, end))
|
| 300 |
+
|
| 301 |
+
for (start, end) in spans:
|
| 302 |
+
chunk = text[start:end].strip()
|
| 303 |
+
if not chunk:
|
| 304 |
+
continue
|
| 305 |
+
low = chunk.lower()
|
| 306 |
+
excluded = any(k in low for k in _EXCLUDE_SENTENCE_CUES)
|
| 307 |
+
|
| 308 |
+
# primary date + optional time in this chunk
|
| 309 |
+
first_date = _DATE_ONLY_RX.search(chunk)
|
| 310 |
+
time_hint_match = _TIME_RX.search(chunk)
|
| 311 |
+
iso = _parse_single_date(first_date.group(0), time_hint_match.group(0) if time_hint_match else None) if first_date else None
|
| 312 |
+
|
| 313 |
+
# optional same-month day range inside the chunk
|
| 314 |
+
r = _DATE_RANGE_RX.search(chunk)
|
| 315 |
+
date_end_iso = None
|
| 316 |
+
if r:
|
| 317 |
+
d1, d2, mon_name, year = r.groups()
|
| 318 |
+
month = _MONTHS.get(mon_name.lower())
|
| 319 |
+
if month:
|
| 320 |
+
date_end_iso = f"{int(year):04d}-{month:02d}-{int(d2):02d}"
|
| 321 |
+
# if the start of the range equals first_date, keep iso as start;
|
| 322 |
+
# otherwise we still keep iso from first_date (which begins the chunk)
|
| 323 |
+
|
| 324 |
+
if iso:
|
| 325 |
+
milestones.append({
|
| 326 |
+
"label_raw": chunk,
|
| 327 |
+
"label_norm": _classify_label(low),
|
| 328 |
+
"date_iso": iso,
|
| 329 |
+
"date_iso_end": date_end_iso,
|
| 330 |
+
"has_time": bool(time_hint_match),
|
| 331 |
+
"excluded_from_open_close": excluded,
|
| 332 |
+
})
|
| 333 |
+
|
| 334 |
+
return milestones
|
| 335 |
+
|
| 336 |
+
def pick_open_close_from_milestones(milestones: List[dict]) -> Tuple[Optional[str], Optional[str]]:
|
| 337 |
+
open_iso = close_iso = None
|
| 338 |
+
for m in milestones:
|
| 339 |
+
if m["excluded_from_open_close"]:
|
| 340 |
+
continue
|
| 341 |
+
if m["label_norm"] == "opens" and open_iso is None:
|
| 342 |
+
open_iso = m["date_iso"].split("T")[0]
|
| 343 |
+
if m["label_norm"] == "closes" and close_iso is None:
|
| 344 |
+
close_iso = m["date_iso"]
|
| 345 |
+
if open_iso is None:
|
| 346 |
+
for m in milestones:
|
| 347 |
+
if m["label_norm"] == "opens":
|
| 348 |
+
open_iso = m["date_iso"].split("T")[0]
|
| 349 |
+
break
|
| 350 |
+
if close_iso is None:
|
| 351 |
+
for m in milestones:
|
| 352 |
+
if m["label_norm"] == "closes":
|
| 353 |
+
close_iso = m["date_iso"]
|
| 354 |
+
break
|
| 355 |
+
return open_iso, close_iso
|
| 356 |
+
|
| 357 |
+
def derive_aux_dates(milestones: List[dict]) -> Tuple[Optional[str], Optional[str]]:
|
| 358 |
+
notify = project_start_from = None
|
| 359 |
+
for m in milestones:
|
| 360 |
+
if notify is None and m["label_norm"] == "notify":
|
| 361 |
+
notify = m["date_iso"].split("T")[0]
|
| 362 |
+
if project_start_from is None and m["label_norm"] == "project_start":
|
| 363 |
+
project_start_from = m["date_iso"].split("T")[0]
|
| 364 |
+
if notify and project_start_from:
|
| 365 |
+
break
|
| 366 |
+
return notify, project_start_from
|
| 367 |
+
|
| 368 |
+
# ---------------------------------------------------------------------------
|
| 369 |
+
# FUNDING / COMPENSATION PARSING
|
| 370 |
+
# ---------------------------------------------------------------------------
|
| 371 |
+
|
| 372 |
+
_MONEY_TOKEN = re.compile(r"(£|\bGBP\s*)([\d,]+(?:\.\d+)?)(?:\s*(million|m|billion|bn|k))?", re.I)
|
| 373 |
+
|
| 374 |
+
def _money_to_int(sign: str, num_str: str, mag: Optional[str]) -> int:
|
| 375 |
+
val = float(num_str.replace(",", ""))
|
| 376 |
+
if mag:
|
| 377 |
+
m = mag.lower()
|
| 378 |
+
if m in ("million", "m"):
|
| 379 |
+
val *= 1_000_000
|
| 380 |
+
elif m in ("billion", "bn"):
|
| 381 |
+
val *= 1_000_000_000
|
| 382 |
+
elif m in ("k",):
|
| 383 |
+
val *= 1_000
|
| 384 |
+
return int(round(val))
|
| 385 |
+
|
| 386 |
+
_TOTAL_CUES = [
|
| 387 |
+
"total prize fund", "total prize pot", "total funding available", "available in total",
|
| 388 |
+
"total pot", "prize fund", "funding pot", "overall budget", "total budget",
|
| 389 |
+
"total allocation", "in total across", "total amount available",
|
| 390 |
+
]
|
| 391 |
+
_AWARD_CUES = [
|
| 392 |
+
"per project", "each project", "you can apply for", "can apply for", "apply for up to",
|
| 393 |
+
"grant of up to", "awards of up to", "awards between", "awards of between",
|
| 394 |
+
"fund between", "we will fund", "we can fund", "project costs between",
|
| 395 |
+
"total eligible project costs between", "your project must have total costs between",
|
| 396 |
+
"maximum grant", "minimum grant", "maximum funding", "minimum funding",
|
| 397 |
+
"up to", "no more than", "at least",
|
| 398 |
+
"grant funding request", "eligible grant funding", "eligible grant", "funding request must be between",
|
| 399 |
+
]
|
| 400 |
+
_EXCLUDE_CUES = [
|
| 401 |
+
"market", "industry", "global", "worldwide", "valuation", "addressable", "gdp",
|
| 402 |
+
"economy", "sector value", "turnover", "revenue", "jobs", "headcount",
|
| 403 |
+
]
|
| 404 |
+
|
| 405 |
+
_RANGE_PATTERNS = [
|
| 406 |
+
re.compile(rf"(?:between|from)\s+{_MONEY_TOKEN.pattern}\s+(?:and|to)\s+{_MONEY_TOKEN.pattern}", re.I),
|
| 407 |
+
re.compile(rf"{_MONEY_TOKEN.pattern}\s*(?:to|-)\s*{_MONEY_TOKEN.pattern}", re.I),
|
| 408 |
+
]
|
| 409 |
+
_MAX_PATTERNS = [
|
| 410 |
+
re.compile(rf"(?:up to|no more than|max(?:imum)?(?:\s+grant|\s+funding)?)\s+{_MONEY_TOKEN.pattern}", re.I),
|
| 411 |
+
]
|
| 412 |
+
_MIN_PATTERNS = [
|
| 413 |
+
re.compile(rf"(?:at least|minimum(?:\s+grant|\s+funding)?)\s+{_MONEY_TOKEN.pattern}", re.I),
|
| 414 |
+
]
|
| 415 |
+
|
| 416 |
+
def _contains_any(text: str, cues: List[str]) -> bool:
|
| 417 |
+
low = text.lower()
|
| 418 |
+
return any(c in low for c in cues)
|
| 419 |
+
|
| 420 |
+
def _is_excluded_sentence(sent: str) -> bool:
|
| 421 |
+
return _contains_any(sent, _EXCLUDE_CUES)
|
| 422 |
+
|
| 423 |
+
def _split_sentences_generic(text: str) -> List[str]:
|
| 424 |
+
parts = re.split(r"(?:\n+|(?<=[\.\!\?])\s+)", text)
|
| 425 |
+
return [p.strip() for p in parts if p and p.strip()]
|
| 426 |
+
|
| 427 |
+
def _find_total_pot(text: str) -> Optional[int]:
|
| 428 |
+
if not text:
|
| 429 |
+
return None
|
| 430 |
+
best = None
|
| 431 |
+
for sent in _split_sentences_generic(text):
|
| 432 |
+
if _is_excluded_sentence(sent):
|
| 433 |
+
continue
|
| 434 |
+
if _contains_any(sent, _TOTAL_CUES):
|
| 435 |
+
vals = []
|
| 436 |
+
for m in _MONEY_TOKEN.finditer(sent):
|
| 437 |
+
_, num_str, mag = m.groups()
|
| 438 |
+
vals.append(_money_to_int("£", num_str, mag))
|
| 439 |
+
if vals:
|
| 440 |
+
v = max(vals)
|
| 441 |
+
best = v if best is None or v > best else best
|
| 442 |
+
return best
|
| 443 |
+
|
| 444 |
+
def _find_award_range(text: str) -> Tuple[Optional[int], Optional[int]]:
|
| 445 |
+
if not text:
|
| 446 |
+
return None, None
|
| 447 |
+
|
| 448 |
+
# Strong: explicit ranges in a sentence that has award cues
|
| 449 |
+
for sent in _split_sentences_generic(text):
|
| 450 |
+
if _is_excluded_sentence(sent):
|
| 451 |
+
continue
|
| 452 |
+
if not _contains_any(sent, _AWARD_CUES):
|
| 453 |
+
continue
|
| 454 |
+
for rx in _RANGE_PATTERNS:
|
| 455 |
+
m = rx.search(sent)
|
| 456 |
+
if not m:
|
| 457 |
+
continue
|
| 458 |
+
monies = list(_MONEY_TOKEN.finditer(m.group(0)))
|
| 459 |
+
if len(monies) >= 2:
|
| 460 |
+
v1 = _money_to_int(*("£", monies[-2].group(2), monies[-2].group(3)))
|
| 461 |
+
v2 = _money_to_int(*("£", monies[-1].group(2), monies[-1].group(3)))
|
| 462 |
+
lo, hi = sorted([v1, v2])
|
| 463 |
+
return lo, hi
|
| 464 |
+
|
| 465 |
+
# Next: max-only / min-only with cues
|
| 466 |
+
chosen_min = None
|
| 467 |
+
chosen_max = None
|
| 468 |
+
for sent in _split_sentences_generic(text):
|
| 469 |
+
if _is_excluded_sentence(sent):
|
| 470 |
+
continue
|
| 471 |
+
if not _contains_any(sent, _AWARD_CUES):
|
| 472 |
+
continue
|
| 473 |
+
|
| 474 |
+
if chosen_max is None:
|
| 475 |
+
for rx in _MAX_PATTERNS:
|
| 476 |
+
m = rx.search(sent)
|
| 477 |
+
if m:
|
| 478 |
+
money = _MONEY_TOKEN.search(m.group(0))
|
| 479 |
+
if money:
|
| 480 |
+
chosen_max = _money_to_int(*("£", money.group(2), money.group(3)))
|
| 481 |
+
break
|
| 482 |
+
|
| 483 |
+
if chosen_min is None:
|
| 484 |
+
for rx in _MIN_PATTERNS:
|
| 485 |
+
m = rx.search(sent)
|
| 486 |
+
if m:
|
| 487 |
+
money = _MONEY_TOKEN.search(m.group(0))
|
| 488 |
+
if money:
|
| 489 |
+
chosen_min = _money_to_int(*("£", money.group(2), money.group(3)))
|
| 490 |
+
break
|
| 491 |
+
|
| 492 |
+
if chosen_min is not None and chosen_max is not None:
|
| 493 |
+
break
|
| 494 |
+
|
| 495 |
+
return chosen_min, chosen_max
|
| 496 |
+
|
| 497 |
+
# NEW: funding rates & duration
|
| 498 |
+
_RATE_LINE = re.compile(
|
| 499 |
+
r"up to\s*(\d{1,3})%\s*if you are a\s*(?:micro|small).*?up to\s*(\d{1,3})%\s*if you are a\s*medium.*?up to\s*(\d{1,3})%\s*if you are a\s*large",
|
| 500 |
+
re.I | re.S,
|
| 501 |
+
)
|
| 502 |
+
def _find_funding_rates(text: str) -> Optional[dict]:
|
| 503 |
+
if not text:
|
| 504 |
+
return None
|
| 505 |
+
m = _RATE_LINE.search(text)
|
| 506 |
+
if not m:
|
| 507 |
+
return None
|
| 508 |
+
small, medium, large = map(int, m.groups())
|
| 509 |
+
return {"micro_small": small, "medium": medium, "large": large}
|
| 510 |
+
|
| 511 |
+
_DURATION_RX = re.compile(r"last\s+between\s+(\d{1,3})\s*(?:and|to|–|-)\s*(\d{1,3})\s+months", re.I)
|
| 512 |
+
def _find_duration_months(text: str) -> Tuple[Optional[int], Optional[int]]:
|
| 513 |
+
if not text:
|
| 514 |
+
return None, None
|
| 515 |
+
m = _DURATION_RX.search(text)
|
| 516 |
+
if not m:
|
| 517 |
+
return None, None
|
| 518 |
+
lo, hi = map(int, m.groups())
|
| 519 |
+
return (lo if lo <= hi else hi), (hi if hi >= lo else lo)
|
| 520 |
+
|
| 521 |
+
def extract_funding(sections: dict) -> dict:
|
| 522 |
+
summary = sections.get("summary_raw", "") or ""
|
| 523 |
+
support = sections.get("supporting_information_raw", "") or ""
|
| 524 |
+
scope = sections.get("scope_raw", "") or ""
|
| 525 |
+
eligibility = sections.get("eligibility_raw", "") or ""
|
| 526 |
+
all_text = " ".join(v for v in sections.values() if isinstance(v, str) and v)
|
| 527 |
+
|
| 528 |
+
total_pot = _find_total_pot(summary) or _find_total_pot(support) or _find_total_pot(all_text)
|
| 529 |
+
|
| 530 |
+
min_award = max_award = None
|
| 531 |
+
for candidate in (summary, eligibility, support, scope, all_text):
|
| 532 |
+
lo, hi = _find_award_range(candidate)
|
| 533 |
+
if lo is not None or hi is not None:
|
| 534 |
+
if lo is not None: min_award = lo
|
| 535 |
+
if hi is not None: max_award = hi
|
| 536 |
+
break
|
| 537 |
+
|
| 538 |
+
rates = None
|
| 539 |
+
for candidate in (eligibility, support, all_text):
|
| 540 |
+
rates = _find_funding_rates(candidate or "")
|
| 541 |
+
if rates:
|
| 542 |
+
break
|
| 543 |
+
|
| 544 |
+
return {"min": min_award, "max": max_award, "total_pot": total_pot, "rates": rates}
|
| 545 |
+
|
| 546 |
+
# ---------------------------------------------------------------------------
|
| 547 |
+
# MAIN
|
| 548 |
+
# ---------------------------------------------------------------------------
|
| 549 |
+
|
| 550 |
+
async def _main_async(url: str):
|
| 551 |
+
m = re.search(r"/competition/(\d+)", url)
|
| 552 |
+
slug = f"competition-{m.group(1)}" if m else re.sub(r"[^a-z0-9]+", "-", url.lower()).strip("-")[-60:]
|
| 553 |
+
out_path = pathlib.Path("data/snapshots") / f"{slug}.json"
|
| 554 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 555 |
+
|
| 556 |
+
html, sections = await fetch_sections_from_overview(url)
|
| 557 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 558 |
+
h1 = soup.find("h1")
|
| 559 |
+
raw_title = h1.get_text(" ", strip=True) if h1 else ""
|
| 560 |
+
title = clean_title(raw_title)
|
| 561 |
+
|
| 562 |
+
# Dates -> single-line chunks
|
| 563 |
+
dates_text = sections.get("dates_raw", "") or ""
|
| 564 |
+
milestones = parse_dates_singleline(dates_text)
|
| 565 |
+
|
| 566 |
+
# Derive open/close from milestones (with exclusions for briefing lines)
|
| 567 |
+
open_date, close_date = pick_open_close_from_milestones(milestones)
|
| 568 |
+
|
| 569 |
+
# If either missing, try scanning all text but still as single-line chunks
|
| 570 |
+
if not open_date or not close_date:
|
| 571 |
+
all_text = " ".join(v for v in sections.values() if isinstance(v, str) and v)
|
| 572 |
+
extra = parse_dates_singleline(all_text)
|
| 573 |
+
# merge de-duped by (label_raw, date_iso, date_iso_end)
|
| 574 |
+
seen = {(m["label_raw"], m["date_iso"], m.get("date_iso_end")) for m in milestones}
|
| 575 |
+
for m2 in extra:
|
| 576 |
+
key = (m2["label_raw"], m2["date_iso"], m2.get("date_iso_end"))
|
| 577 |
+
if key not in seen:
|
| 578 |
+
milestones.append(m2)
|
| 579 |
+
seen.add(key)
|
| 580 |
+
od2, cd2 = pick_open_close_from_milestones(milestones)
|
| 581 |
+
open_date = open_date or od2
|
| 582 |
+
close_date = close_date or cd2
|
| 583 |
+
|
| 584 |
+
notify_date, project_start_from = derive_aux_dates(milestones)
|
| 585 |
+
|
| 586 |
+
# Funding
|
| 587 |
+
funding = extract_funding(sections)
|
| 588 |
+
|
| 589 |
+
# Duration months
|
| 590 |
+
dur_min, dur_max = _find_duration_months(sections.get("eligibility_raw", "") or "")
|
| 591 |
+
if dur_min is None or dur_max is None:
|
| 592 |
+
all_text_for_duration = " ".join(v for v in sections.values() if isinstance(v, str) and v)
|
| 593 |
+
dur_min, dur_max = _find_duration_months(all_text_for_duration)
|
| 594 |
+
duration_months = {"min": dur_min, "max": dur_max}
|
| 595 |
+
|
| 596 |
+
snapshot = {
|
| 597 |
+
"url": url,
|
| 598 |
+
"title": title,
|
| 599 |
+
"programme": "",
|
| 600 |
+
"round": "",
|
| 601 |
+
"open_date": open_date,
|
| 602 |
+
"close_date": close_date,
|
| 603 |
+
"notify_date": notify_date,
|
| 604 |
+
"project_start_from": project_start_from,
|
| 605 |
+
"funding": funding,
|
| 606 |
+
"duration_months": duration_months,
|
| 607 |
+
"sections": sections,
|
| 608 |
+
"pdfs": [],
|
| 609 |
+
"summaries": {},
|
| 610 |
+
"extracted": {"milestones": milestones},
|
| 611 |
+
"wonky": {"score": 0.0, "reasons": []},
|
| 612 |
+
"prev_round_refs": [],
|
| 613 |
+
"diff_summary": "",
|
| 614 |
+
"history_stats": {},
|
| 615 |
+
"created_at": datetime.now(UTC).isoformat(),
|
| 616 |
+
"updated_at": datetime.now(UTC).isoformat(),
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
out_path.write_text(json.dumps(snapshot, indent=2), encoding="utf-8")
|
| 620 |
+
print(f"Snapshot saved to {out_path}")
|
| 621 |
+
|
| 622 |
+
def main(url: str = typer.Argument(..., help="IFS overview URL e.g. .../competition/{id}/overview/{uuid}")):
|
| 623 |
+
asyncio.run(_main_async(url))
|
| 624 |
+
|
| 625 |
+
if __name__ == "__main__":
|
| 626 |
+
typer.run(main)
|
|
@@ -3,10 +3,14 @@ from __future__ import annotations
|
|
| 3 |
import logging
|
| 4 |
import os
|
| 5 |
import time
|
| 6 |
-
from typing import Any, Dict, List, Optional, Callable
|
| 7 |
|
| 8 |
from .utils.errors import LLMError, ConfigError
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
try:
|
| 11 |
from openai import OpenAI
|
| 12 |
import httpx
|
|
@@ -28,9 +32,14 @@ class LLMClient:
|
|
| 28 |
def __init__(self, cfg: Any):
|
| 29 |
# Extract config (supports both dict and object)
|
| 30 |
self.provider = self._get_cfg(cfg, "provider", "openai")
|
| 31 |
-
self.model = self._get_cfg(cfg, "model", "gpt-
|
| 32 |
self.disable_llm = self._get_cfg(cfg, "disable_llm", False)
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
api_key = self._get_cfg(cfg, "api_key") or os.getenv("OPENAI_API_KEY")
|
| 35 |
base_url = self._get_cfg(cfg, "base_url") or os.getenv(
|
| 36 |
"OPENAI_API_BASE",
|
|
@@ -85,6 +94,37 @@ class LLMClient:
|
|
| 85 |
"""Check if LLM client is ready to use."""
|
| 86 |
return self.client is not None and not self.disable_llm
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
def _retry_with_backoff(
|
| 89 |
self,
|
| 90 |
fn: Callable[[], Any],
|
|
@@ -143,9 +183,12 @@ class LLMClient:
|
|
| 143 |
temperature: float = 0.2,
|
| 144 |
top_p: float = 1.0,
|
| 145 |
stream: bool = False,
|
|
|
|
|
|
|
|
|
|
| 146 |
) -> str:
|
| 147 |
"""
|
| 148 |
-
Single chat completion call with optional streaming.
|
| 149 |
|
| 150 |
Args:
|
| 151 |
messages: List of message dicts with 'role' and 'content'
|
|
@@ -153,6 +196,15 @@ class LLMClient:
|
|
| 153 |
temperature: Sampling temperature (0-2)
|
| 154 |
top_p: Nucleus sampling parameter
|
| 155 |
stream: If True, returns generator yielding tokens (else full response)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
Returns:
|
| 158 |
Generated text response (or generator if stream=True)
|
|
@@ -169,15 +221,27 @@ class LLMClient:
|
|
| 169 |
)
|
| 170 |
|
| 171 |
def _make_call():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
try:
|
| 173 |
-
resp = self.client.chat.completions.create(
|
| 174 |
-
model=self.model,
|
| 175 |
-
messages=messages,
|
| 176 |
-
temperature=temperature,
|
| 177 |
-
top_p=top_p,
|
| 178 |
-
max_tokens=max_tokens,
|
| 179 |
-
stream=stream,
|
| 180 |
-
)
|
| 181 |
except Exception as e:
|
| 182 |
# Handle httpx exceptions if available
|
| 183 |
if httpx and isinstance(e, httpx.TimeoutException):
|
|
|
|
| 3 |
import logging
|
| 4 |
import os
|
| 5 |
import time
|
| 6 |
+
from typing import Any, Dict, List, Optional, Callable, Literal
|
| 7 |
|
| 8 |
from .utils.errors import LLMError, ConfigError
|
| 9 |
|
| 10 |
+
ModelType = Literal["router", "translator", "analyzer"]
|
| 11 |
+
VerbosityLevel = Literal["low", "medium", "high"]
|
| 12 |
+
ReasoningEffort = Literal["minimal", "medium", "high"]
|
| 13 |
+
|
| 14 |
try:
|
| 15 |
from openai import OpenAI
|
| 16 |
import httpx
|
|
|
|
| 32 |
def __init__(self, cfg: Any):
|
| 33 |
# Extract config (supports both dict and object)
|
| 34 |
self.provider = self._get_cfg(cfg, "provider", "openai")
|
| 35 |
+
self.model = self._get_cfg(cfg, "model", "gpt-5-mini")
|
| 36 |
self.disable_llm = self._get_cfg(cfg, "disable_llm", False)
|
| 37 |
|
| 38 |
+
# Store model variants for different use cases (GPT-5 family)
|
| 39 |
+
self.model_router = self._get_cfg(cfg, "model_router", "gpt-5-nano")
|
| 40 |
+
self.model_translator = self._get_cfg(cfg, "model_translator", "gpt-5-mini")
|
| 41 |
+
self.model_analyzer = self._get_cfg(cfg, "model_analyzer", "gpt-5")
|
| 42 |
+
|
| 43 |
api_key = self._get_cfg(cfg, "api_key") or os.getenv("OPENAI_API_KEY")
|
| 44 |
base_url = self._get_cfg(cfg, "base_url") or os.getenv(
|
| 45 |
"OPENAI_API_BASE",
|
|
|
|
| 94 |
"""Check if LLM client is ready to use."""
|
| 95 |
return self.client is not None and not self.disable_llm
|
| 96 |
|
| 97 |
+
def _get_model_for_type(self, model_type: Optional[ModelType] = None) -> str:
|
| 98 |
+
"""Get the appropriate model for the given type."""
|
| 99 |
+
if model_type == "router":
|
| 100 |
+
return self.model_router
|
| 101 |
+
elif model_type == "translator":
|
| 102 |
+
return self.model_translator
|
| 103 |
+
elif model_type == "analyzer":
|
| 104 |
+
return self.model_analyzer
|
| 105 |
+
else:
|
| 106 |
+
return self.model
|
| 107 |
+
|
| 108 |
+
@staticmethod
|
| 109 |
+
def get_recommended_params(task_type: str) -> Dict[str, Any]:
|
| 110 |
+
"""
|
| 111 |
+
Get recommended verbosity and reasoning_effort for common tasks.
|
| 112 |
+
|
| 113 |
+
Args:
|
| 114 |
+
task_type: One of "translation", "analysis", "routing", "summary", "comparison"
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
Dict with verbosity and reasoning_effort settings
|
| 118 |
+
"""
|
| 119 |
+
presets = {
|
| 120 |
+
"translation": {"verbosity": "medium", "reasoning_effort": "minimal"},
|
| 121 |
+
"analysis": {"verbosity": "high", "reasoning_effort": "high"},
|
| 122 |
+
"routing": {"verbosity": "low", "reasoning_effort": "minimal"},
|
| 123 |
+
"summary": {"verbosity": "medium", "reasoning_effort": "medium"},
|
| 124 |
+
"comparison": {"verbosity": "high", "reasoning_effort": "high"}
|
| 125 |
+
}
|
| 126 |
+
return presets.get(task_type, {"verbosity": "medium", "reasoning_effort": "medium"})
|
| 127 |
+
|
| 128 |
def _retry_with_backoff(
|
| 129 |
self,
|
| 130 |
fn: Callable[[], Any],
|
|
|
|
| 183 |
temperature: float = 0.2,
|
| 184 |
top_p: float = 1.0,
|
| 185 |
stream: bool = False,
|
| 186 |
+
model_type: Optional[ModelType] = None,
|
| 187 |
+
verbosity: Optional[VerbosityLevel] = None,
|
| 188 |
+
reasoning_effort: Optional[ReasoningEffort] = None,
|
| 189 |
) -> str:
|
| 190 |
"""
|
| 191 |
+
Single chat completion call with optional streaming and GPT-5 features.
|
| 192 |
|
| 193 |
Args:
|
| 194 |
messages: List of message dicts with 'role' and 'content'
|
|
|
|
| 196 |
temperature: Sampling temperature (0-2)
|
| 197 |
top_p: Nucleus sampling parameter
|
| 198 |
stream: If True, returns generator yielding tokens (else full response)
|
| 199 |
+
model_type: Type of model to use ('router', 'translator', or 'analyzer')
|
| 200 |
+
verbosity: Response length control (GPT-5 feature)
|
| 201 |
+
- 'low': Brief, concise responses
|
| 202 |
+
- 'medium': Standard length responses
|
| 203 |
+
- 'high': Detailed, comprehensive responses
|
| 204 |
+
reasoning_effort: Thinking time control (GPT-5 feature)
|
| 205 |
+
- 'minimal': Quick, straightforward responses
|
| 206 |
+
- 'medium': Moderate analysis and reasoning
|
| 207 |
+
- 'high': Deep analysis and careful reasoning
|
| 208 |
|
| 209 |
Returns:
|
| 210 |
Generated text response (or generator if stream=True)
|
|
|
|
| 221 |
)
|
| 222 |
|
| 223 |
def _make_call():
|
| 224 |
+
# Select model based on model_type
|
| 225 |
+
model = self._get_model_for_type(model_type)
|
| 226 |
+
|
| 227 |
+
# Build API parameters
|
| 228 |
+
api_params = {
|
| 229 |
+
"model": model,
|
| 230 |
+
"messages": messages,
|
| 231 |
+
"temperature": temperature,
|
| 232 |
+
"top_p": top_p,
|
| 233 |
+
"max_tokens": max_tokens,
|
| 234 |
+
"stream": stream,
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
# Add GPT-5 specific parameters if provided
|
| 238 |
+
if verbosity is not None:
|
| 239 |
+
api_params["verbosity"] = verbosity
|
| 240 |
+
if reasoning_effort is not None:
|
| 241 |
+
api_params["reasoning_effort"] = reasoning_effort
|
| 242 |
+
|
| 243 |
try:
|
| 244 |
+
resp = self.client.chat.completions.create(**api_params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
except Exception as e:
|
| 246 |
# Handle httpx exceptions if available
|
| 247 |
if httpx and isinstance(e, httpx.TimeoutException):
|
|
Binary files a/src/__pycache__/__init__.cpython-312.pyc and b/src/__pycache__/__init__.cpython-312.pyc differ
|
|
|
|
Binary file (155 Bytes)
|
|
|
|
Binary file (5.49 kB)
|
|
|
|
Binary file (15.7 kB)
|
|
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import asyncio
|
| 4 |
import logging
|
| 5 |
import re
|
|
|
|
| 6 |
from dataclasses import dataclass
|
| 7 |
from datetime import datetime
|
| 8 |
from pathlib import Path
|
|
@@ -55,6 +56,51 @@ def _norm(s: Any) -> str:
|
|
| 55 |
return clean(str(s)) # Use utils.text.clean() for final normalization
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
# ---------------------------------------------------------------------
|
| 59 |
# Main ChatTools class
|
| 60 |
# ---------------------------------------------------------------------
|
|
@@ -86,17 +132,6 @@ class ChatTools:
|
|
| 86 |
# Initialize cache for summaries (NEW: Optimized caching)
|
| 87 |
self.summary_cache = SummaryCache(ttl_seconds=3600)
|
| 88 |
|
| 89 |
-
# -----------------------------------------------------------------
|
| 90 |
-
# Cache stats logging helper
|
| 91 |
-
# -----------------------------------------------------------------
|
| 92 |
-
def _log_cache_stats(self) -> None:
|
| 93 |
-
"""Log cache statistics for monitoring."""
|
| 94 |
-
stats = self.summary_cache.stats()
|
| 95 |
-
logging.info(
|
| 96 |
-
f"📊 Cache stats: {stats['valid']}/{stats['cached']} valid entries "
|
| 97 |
-
f"({stats['valid']/max(stats['cached'], 1)*100:.1f}% hit rate)"
|
| 98 |
-
)
|
| 99 |
-
|
| 100 |
# -----------------------------------------------------------------
|
| 101 |
# Status calculation (NEW)
|
| 102 |
# -----------------------------------------------------------------
|
|
@@ -154,7 +189,8 @@ class ChatTools:
|
|
| 154 |
results = []
|
| 155 |
for r in self.current:
|
| 156 |
txt = _norm(r)
|
| 157 |
-
|
|
|
|
| 158 |
continue
|
| 159 |
if max_award is not None:
|
| 160 |
ma = to_number(r.get("max_award") or r.get("funding_max"))
|
|
@@ -183,6 +219,76 @@ class ChatTools:
|
|
| 183 |
return results
|
| 184 |
return results[:limit]
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
# -----------------------------------------------------------------
|
| 187 |
# Retrieve a single grant
|
| 188 |
# -----------------------------------------------------------------
|
|
@@ -254,13 +360,8 @@ class ChatTools:
|
|
| 254 |
# Yield each result as it's ready
|
| 255 |
for result in results:
|
| 256 |
yield result
|
| 257 |
-
|
| 258 |
-
# Log cache stats after batch completion
|
| 259 |
-
self._log_cache_stats()
|
| 260 |
except Exception as e:
|
| 261 |
logging.error(f"Batch summarization failed: {e}")
|
| 262 |
-
# Log cache stats even on error
|
| 263 |
-
self._log_cache_stats()
|
| 264 |
raise
|
| 265 |
|
| 266 |
async def get_all_grant_summaries(self, batch_size: int = 5):
|
|
@@ -292,20 +393,16 @@ class ChatTools:
|
|
| 292 |
async for result in self.summarize_grants_batch(all_grant_ids, batch_size=batch_size):
|
| 293 |
yield result
|
| 294 |
|
| 295 |
-
# Log final cache stats
|
| 296 |
-
self._log_cache_stats()
|
| 297 |
-
|
| 298 |
# -----------------------------------------------------------------
|
| 299 |
# Summarize a grant
|
| 300 |
# -----------------------------------------------------------------
|
| 301 |
-
def summarize_grant(self, gid: str, include_supporting: bool = True
|
| 302 |
"""
|
| 303 |
-
Summarize a grant using LLM with
|
| 304 |
|
| 305 |
Args:
|
| 306 |
gid: Grant ID
|
| 307 |
include_supporting: If True, include supporting PDFs and materials in context
|
| 308 |
-
summary_type: Type of summary to retrieve ("layman", "technical", "exec")
|
| 309 |
|
| 310 |
Returns:
|
| 311 |
Dict with summary_md, title, id
|
|
@@ -314,27 +411,10 @@ class ChatTools:
|
|
| 314 |
title = row.get("title", "(untitled)")
|
| 315 |
grant_id = row.get("id") or gid
|
| 316 |
|
| 317 |
-
# NEW: Check
|
| 318 |
-
try:
|
| 319 |
-
from ...database import SummaryStore
|
| 320 |
-
summary_store = SummaryStore()
|
| 321 |
-
mongodb_summary = summary_store.get_summary(grant_id, summary_type)
|
| 322 |
-
|
| 323 |
-
if mongodb_summary:
|
| 324 |
-
logging.info(f"📦 MongoDB HIT for grant {grant_id} ({summary_type})")
|
| 325 |
-
return {
|
| 326 |
-
"summary_md": mongodb_summary,
|
| 327 |
-
"title": title,
|
| 328 |
-
"id": grant_id,
|
| 329 |
-
}
|
| 330 |
-
except Exception as e:
|
| 331 |
-
logging.warning(f"MongoDB lookup failed for {grant_id}: {e}")
|
| 332 |
-
# Continue to memory cache/LLM fallback
|
| 333 |
-
|
| 334 |
-
# Check memory cache
|
| 335 |
cached_summary = self.summary_cache.get(row)
|
| 336 |
if cached_summary:
|
| 337 |
-
logging.info("📦
|
| 338 |
return {
|
| 339 |
"summary_md": cached_summary,
|
| 340 |
"title": title,
|
|
@@ -372,9 +452,6 @@ class ChatTools:
|
|
| 372 |
# NEW: Cache the summary
|
| 373 |
self.summary_cache.set(row, text)
|
| 374 |
|
| 375 |
-
# Log cache stats
|
| 376 |
-
self._log_cache_stats()
|
| 377 |
-
|
| 378 |
return {"summary_md": text, "title": title, "id": grant_id}
|
| 379 |
|
| 380 |
# -----------------------------------------------------------------
|
|
@@ -405,150 +482,6 @@ class ChatTools:
|
|
| 405 |
parts.append(f"{k.upper()}:\n{_norm(v)}")
|
| 406 |
return "\n".join(parts)
|
| 407 |
|
| 408 |
-
# -----------------------------------------------------------------
|
| 409 |
-
# Batch process multiple grants
|
| 410 |
-
# -----------------------------------------------------------------
|
| 411 |
-
def batch_process_grants(
|
| 412 |
-
self,
|
| 413 |
-
grant_ids: List[str],
|
| 414 |
-
operation_type: str = "summarize",
|
| 415 |
-
batch_size: int = 5
|
| 416 |
-
) -> Dict[str, str]:
|
| 417 |
-
"""
|
| 418 |
-
Batch process multiple grants with a single LLM call per batch.
|
| 419 |
-
|
| 420 |
-
This method groups grants into batches and sends them as a single prompt
|
| 421 |
-
with numbered sections, then parses the response to extract individual results.
|
| 422 |
-
|
| 423 |
-
Args:
|
| 424 |
-
grant_ids: List of grant IDs to process
|
| 425 |
-
operation_type: Type of operation - "summarize", "translate", "simplify", etc.
|
| 426 |
-
batch_size: Number of grants per batch (default 5)
|
| 427 |
-
|
| 428 |
-
Returns:
|
| 429 |
-
Dictionary mapping grant_id to result text
|
| 430 |
-
|
| 431 |
-
Example:
|
| 432 |
-
results = tools.batch_process_grants(
|
| 433 |
-
["competition-2315", "competition-2316"],
|
| 434 |
-
operation_type="summarize"
|
| 435 |
-
)
|
| 436 |
-
# Returns: {"competition-2315": "summary text...", "competition-2316": "..."}
|
| 437 |
-
"""
|
| 438 |
-
import hashlib
|
| 439 |
-
|
| 440 |
-
if not grant_ids:
|
| 441 |
-
return {}
|
| 442 |
-
|
| 443 |
-
if not self.client or not self.client.is_ready():
|
| 444 |
-
logging.warning("LLM client not available for batch processing")
|
| 445 |
-
return {gid: "LLM unavailable" for gid in grant_ids}
|
| 446 |
-
|
| 447 |
-
# Build operation-specific instruction
|
| 448 |
-
operation_instructions = {
|
| 449 |
-
"summarize": "Provide a concise summary highlighting key information, deadlines, and funding details.",
|
| 450 |
-
"translate": "Translate the grant information into simple, everyday language that anyone can understand.",
|
| 451 |
-
"simplify": "Explain this grant in layman's terms, avoiding technical jargon.",
|
| 452 |
-
"analyze": "Analyze this grant's strengths, requirements, and suitability for different applicants.",
|
| 453 |
-
}
|
| 454 |
-
instruction = operation_instructions.get(operation_type, "Process this grant information.")
|
| 455 |
-
|
| 456 |
-
results = {}
|
| 457 |
-
|
| 458 |
-
# Process grants in batches
|
| 459 |
-
for batch_start in range(0, len(grant_ids), batch_size):
|
| 460 |
-
batch_ids = grant_ids[batch_start:batch_start + batch_size]
|
| 461 |
-
|
| 462 |
-
# Build batch prompt with numbered sections
|
| 463 |
-
prompt_parts = [
|
| 464 |
-
f"Process the following {len(batch_ids)} grants. {instruction}",
|
| 465 |
-
"\nFor each grant, start your response with '### Grant N:' where N is the grant number.",
|
| 466 |
-
"\n---\n"
|
| 467 |
-
]
|
| 468 |
-
|
| 469 |
-
# Add each grant with its context
|
| 470 |
-
grant_contexts = []
|
| 471 |
-
for idx, grant_id in enumerate(batch_ids, 1):
|
| 472 |
-
try:
|
| 473 |
-
grant = self.get_grant(grant_id)
|
| 474 |
-
# Use extract_minimal_context for efficiency
|
| 475 |
-
from ..summarizer_optimized import extract_minimal_context
|
| 476 |
-
context = extract_minimal_context(grant)
|
| 477 |
-
|
| 478 |
-
prompt_parts.append(f"### Grant {idx}:")
|
| 479 |
-
prompt_parts.append(f"ID: {grant_id}")
|
| 480 |
-
prompt_parts.append(context)
|
| 481 |
-
prompt_parts.append("\n---\n")
|
| 482 |
-
|
| 483 |
-
grant_contexts.append((idx, grant_id))
|
| 484 |
-
|
| 485 |
-
except Exception as e:
|
| 486 |
-
logging.error(f"Failed to load grant {grant_id}: {e}")
|
| 487 |
-
results[grant_id] = f"Error loading grant: {e}"
|
| 488 |
-
|
| 489 |
-
if not grant_contexts:
|
| 490 |
-
continue
|
| 491 |
-
|
| 492 |
-
# Build final prompt
|
| 493 |
-
full_prompt = "\n".join(prompt_parts)
|
| 494 |
-
|
| 495 |
-
# Check cache for batch
|
| 496 |
-
cache_key = hashlib.md5(full_prompt.encode()).hexdigest()[:12]
|
| 497 |
-
cache_dict = {"id": f"batch_{operation_type}_{cache_key}"}
|
| 498 |
-
cached_response = self.summary_cache.get(cache_dict)
|
| 499 |
-
|
| 500 |
-
if cached_response:
|
| 501 |
-
logging.info(f"📦 Cache HIT for batch {cache_key}")
|
| 502 |
-
response_text = cached_response
|
| 503 |
-
else:
|
| 504 |
-
# Call LLM with batch prompt
|
| 505 |
-
try:
|
| 506 |
-
messages = [
|
| 507 |
-
{"role": "system", "content": "You are a grant analyst. Process each grant separately and clearly mark each response with the grant number."},
|
| 508 |
-
{"role": "user", "content": full_prompt}
|
| 509 |
-
]
|
| 510 |
-
response_text = self.client.chat(
|
| 511 |
-
messages,
|
| 512 |
-
max_tokens=batch_size * 400, # ~400 tokens per grant
|
| 513 |
-
temperature=0.3
|
| 514 |
-
)
|
| 515 |
-
|
| 516 |
-
# Cache the response
|
| 517 |
-
self.summary_cache.set(cache_dict, response_text)
|
| 518 |
-
logging.info(f"✅ Batch processed {len(batch_ids)} grants")
|
| 519 |
-
|
| 520 |
-
except Exception as e:
|
| 521 |
-
logging.error(f"Batch processing failed: {e}")
|
| 522 |
-
for _, grant_id in grant_contexts:
|
| 523 |
-
results[grant_id] = f"Batch processing error: {e}"
|
| 524 |
-
continue
|
| 525 |
-
|
| 526 |
-
# Parse response to extract individual results
|
| 527 |
-
# Use regex to split by "### Grant N:" markers
|
| 528 |
-
import re
|
| 529 |
-
pattern = r'### Grant (\d+):(.*?)(?=### Grant \d+:|$)'
|
| 530 |
-
matches = re.findall(pattern, response_text, re.DOTALL)
|
| 531 |
-
|
| 532 |
-
# Map results back to grant IDs
|
| 533 |
-
for grant_num, result_text in matches:
|
| 534 |
-
grant_idx = int(grant_num)
|
| 535 |
-
# Find corresponding grant_id
|
| 536 |
-
for idx, grant_id in grant_contexts:
|
| 537 |
-
if idx == grant_idx:
|
| 538 |
-
results[grant_id] = result_text.strip()
|
| 539 |
-
break
|
| 540 |
-
|
| 541 |
-
# Handle any grants that didn't get matched
|
| 542 |
-
for idx, grant_id in grant_contexts:
|
| 543 |
-
if grant_id not in results:
|
| 544 |
-
logging.warning(f"No result found for grant {grant_id} (index {idx})")
|
| 545 |
-
results[grant_id] = "No response generated"
|
| 546 |
-
|
| 547 |
-
# Log cache stats
|
| 548 |
-
self._log_cache_stats()
|
| 549 |
-
|
| 550 |
-
return results
|
| 551 |
-
|
| 552 |
# -----------------------------------------------------------------
|
| 553 |
# Compare two grants (deterministic)
|
| 554 |
# -----------------------------------------------------------------
|
|
@@ -617,30 +550,18 @@ class ChatTools:
|
|
| 617 |
|
| 618 |
insight = ""
|
| 619 |
if self.client and self.client.is_ready() and context_text.strip():
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
"highlighting *meaningful differences* that matter to SMEs (funding size, "
|
| 633 |
-
"duration, eligibility, etc.). Do not restate identical facts.\n\n"
|
| 634 |
-
+ "\n".join(table)
|
| 635 |
-
+ "\n\n"
|
| 636 |
-
+ context_text
|
| 637 |
-
)
|
| 638 |
-
insight = self.client.summarize(prompt)
|
| 639 |
-
# Cache the insight
|
| 640 |
-
self.summary_cache.set(cache_key, insight)
|
| 641 |
-
logging.info("✅ Generated and cached comparison insight")
|
| 642 |
-
except Exception as e:
|
| 643 |
-
logging.warning("compare_grants insight failed: %s", e)
|
| 644 |
|
| 645 |
md = [
|
| 646 |
"### Comparison",
|
|
@@ -650,9 +571,6 @@ class ChatTools:
|
|
| 650 |
if insight:
|
| 651 |
md += ["\n### Key differences", insight]
|
| 652 |
|
| 653 |
-
# Log cache stats
|
| 654 |
-
self._log_cache_stats()
|
| 655 |
-
|
| 656 |
return {"comparison_md": "\n".join(md)}
|
| 657 |
|
| 658 |
# -----------------------------------------------------------------
|
|
@@ -818,22 +736,8 @@ RECOMMENDED GRANTS:
|
|
| 818 |
- Why: [1-2 sentence explanation of fit]
|
| 819 |
"""
|
| 820 |
|
| 821 |
-
#
|
| 822 |
-
|
| 823 |
-
url_hash = hashlib.md5(company_url.encode()).hexdigest()[:12]
|
| 824 |
-
cache_key = {"id": f"company_{url_hash}"}
|
| 825 |
-
|
| 826 |
-
# Check cache first
|
| 827 |
-
cached_analysis = self.summary_cache.get(cache_key)
|
| 828 |
-
if cached_analysis:
|
| 829 |
-
logging.info("📦 Cache HIT for company analysis: %s", company_url)
|
| 830 |
-
analysis_text = cached_analysis
|
| 831 |
-
else:
|
| 832 |
-
# Get LLM analysis
|
| 833 |
-
analysis_text = self.client.summarize(prompt)
|
| 834 |
-
# Cache the analysis
|
| 835 |
-
self.summary_cache.set(cache_key, analysis_text)
|
| 836 |
-
logging.info("✅ Generated and cached company analysis")
|
| 837 |
|
| 838 |
# Extract recommended grant IDs from the response
|
| 839 |
recommended_ids = []
|
|
@@ -861,9 +765,6 @@ RECOMMENDED GRANTS:
|
|
| 861 |
except KeyError:
|
| 862 |
continue
|
| 863 |
|
| 864 |
-
# Log cache stats
|
| 865 |
-
self._log_cache_stats()
|
| 866 |
-
|
| 867 |
return {
|
| 868 |
"company_url": company_url,
|
| 869 |
"analysis": analysis_text,
|
|
@@ -872,8 +773,6 @@ RECOMMENDED GRANTS:
|
|
| 872 |
|
| 873 |
except Exception as e:
|
| 874 |
logging.error(f"Failed to analyze company: {e}")
|
| 875 |
-
# Log cache stats even on error
|
| 876 |
-
self._log_cache_stats()
|
| 877 |
return {
|
| 878 |
"error": f"Analysis failed: {str(e)}",
|
| 879 |
"company_url": company_url,
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import logging
|
| 5 |
import re
|
| 6 |
+
import difflib
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from datetime import datetime
|
| 9 |
from pathlib import Path
|
|
|
|
| 56 |
return clean(str(s)) # Use utils.text.clean() for final normalization
|
| 57 |
|
| 58 |
|
| 59 |
+
# Fuzzy matching helper - supports partial/typo matches
|
| 60 |
+
def _fuzzy_match(keyword: str, text: str, threshold: float = 0.6) -> bool:
|
| 61 |
+
"""
|
| 62 |
+
Check if keyword matches text using fuzzy matching.
|
| 63 |
+
|
| 64 |
+
Returns True if:
|
| 65 |
+
- Exact substring match (e.g., "ai" in "agentic ai")
|
| 66 |
+
- Fuzzy word match with high similarity (e.g., "agent" vs "agentic" @ 86%)
|
| 67 |
+
- Any word in text starts with the keyword
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
keyword: The search keyword
|
| 71 |
+
text: The text to search in
|
| 72 |
+
threshold: Minimum similarity score (0-1) for fuzzy match
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
True if match found, False otherwise
|
| 76 |
+
"""
|
| 77 |
+
keyword_lower = keyword.lower()
|
| 78 |
+
text_lower = text.lower()
|
| 79 |
+
|
| 80 |
+
# Exact substring match (fastest, most common case)
|
| 81 |
+
if keyword_lower in text_lower:
|
| 82 |
+
return True
|
| 83 |
+
|
| 84 |
+
# Split into words and try fuzzy matching on individual words
|
| 85 |
+
text_words = text_lower.split()
|
| 86 |
+
keyword_words = keyword_lower.split()
|
| 87 |
+
|
| 88 |
+
for kw_word in keyword_words:
|
| 89 |
+
# Check if any text word starts with the keyword word
|
| 90 |
+
for text_word in text_words:
|
| 91 |
+
# Allow leading punctuation in text_word
|
| 92 |
+
clean_text_word = re.sub(r'^[^a-z0-9]+', '', text_word)
|
| 93 |
+
if clean_text_word.startswith(kw_word):
|
| 94 |
+
return True
|
| 95 |
+
|
| 96 |
+
# Fuzzy match single words (e.g., "agent" vs "agentic")
|
| 97 |
+
ratio = difflib.SequenceMatcher(None, kw_word, clean_text_word).ratio()
|
| 98 |
+
if ratio >= threshold:
|
| 99 |
+
return True
|
| 100 |
+
|
| 101 |
+
return False
|
| 102 |
+
|
| 103 |
+
|
| 104 |
# ---------------------------------------------------------------------
|
| 105 |
# Main ChatTools class
|
| 106 |
# ---------------------------------------------------------------------
|
|
|
|
| 132 |
# Initialize cache for summaries (NEW: Optimized caching)
|
| 133 |
self.summary_cache = SummaryCache(ttl_seconds=3600)
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
# -----------------------------------------------------------------
|
| 136 |
# Status calculation (NEW)
|
| 137 |
# -----------------------------------------------------------------
|
|
|
|
| 189 |
results = []
|
| 190 |
for r in self.current:
|
| 191 |
txt = _norm(r)
|
| 192 |
+
# Use fuzzy matching instead of exact substring match
|
| 193 |
+
if kw and not _fuzzy_match(kw, txt):
|
| 194 |
continue
|
| 195 |
if max_award is not None:
|
| 196 |
ma = to_number(r.get("max_award") or r.get("funding_max"))
|
|
|
|
| 219 |
return results
|
| 220 |
return results[:limit]
|
| 221 |
|
| 222 |
+
# -----------------------------------------------------------------
|
| 223 |
+
# Search past winners
|
| 224 |
+
# -----------------------------------------------------------------
|
| 225 |
+
def search_past_winners(
|
| 226 |
+
self,
|
| 227 |
+
keyword: Optional[str] = None,
|
| 228 |
+
competition: Optional[str] = None,
|
| 229 |
+
limit: Optional[int] = None,
|
| 230 |
+
) -> List[Dict[str, Any]]:
|
| 231 |
+
"""
|
| 232 |
+
Search past winners by keyword or competition name.
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
keyword: Filter by keyword in project title, organization, or description
|
| 236 |
+
competition: Filter by competition/programme name
|
| 237 |
+
limit: Max results to return. If None, returns ALL matching winners.
|
| 238 |
+
|
| 239 |
+
Returns:
|
| 240 |
+
List of past winner records sorted by year (newest first)
|
| 241 |
+
"""
|
| 242 |
+
kw = (keyword or "").lower()
|
| 243 |
+
comp = (competition or "").lower()
|
| 244 |
+
results = []
|
| 245 |
+
|
| 246 |
+
for r in self.past:
|
| 247 |
+
# Filter by keyword (searches project title, org name, and description)
|
| 248 |
+
if kw:
|
| 249 |
+
searchable_text = _norm({
|
| 250 |
+
"project_title": r.get("project_title"),
|
| 251 |
+
"lead_org": r.get("lead_org"),
|
| 252 |
+
"participant_name": r.get("participant_name"),
|
| 253 |
+
"public_description": r.get("public_description"),
|
| 254 |
+
"abstract": r.get("abstract"),
|
| 255 |
+
})
|
| 256 |
+
# Use fuzzy matching instead of exact substring match
|
| 257 |
+
if not _fuzzy_match(kw, searchable_text):
|
| 258 |
+
continue
|
| 259 |
+
|
| 260 |
+
# Filter by competition name
|
| 261 |
+
if comp:
|
| 262 |
+
comp_text = _norm({
|
| 263 |
+
"competition": r.get("competition"),
|
| 264 |
+
"competition_title": r.get("competition_title"),
|
| 265 |
+
"programme_title": r.get("programme_title"),
|
| 266 |
+
})
|
| 267 |
+
# Use fuzzy matching instead of exact substring match
|
| 268 |
+
if not _fuzzy_match(comp, comp_text):
|
| 269 |
+
continue
|
| 270 |
+
|
| 271 |
+
# Extract key fields for display
|
| 272 |
+
results.append({
|
| 273 |
+
"project_title": r.get("project_title") or "(untitled)",
|
| 274 |
+
"lead_org": r.get("lead_org") or r.get("participant_name") or "(unknown)",
|
| 275 |
+
"competition": r.get("competition_title") or r.get("competition") or "(unknown)",
|
| 276 |
+
"award_amount": r.get("award_amount"),
|
| 277 |
+
"year": r.get("year") or r.get("project_start_date"),
|
| 278 |
+
"abstract": r.get("public_description") or r.get("abstract"),
|
| 279 |
+
})
|
| 280 |
+
|
| 281 |
+
# Sort by year (newest first)
|
| 282 |
+
results.sort(
|
| 283 |
+
key=lambda x: _parse_date(x.get("year")) or datetime.min,
|
| 284 |
+
reverse=True
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
# Return all results if limit is None
|
| 288 |
+
if limit is None:
|
| 289 |
+
return results
|
| 290 |
+
return results[:limit]
|
| 291 |
+
|
| 292 |
# -----------------------------------------------------------------
|
| 293 |
# Retrieve a single grant
|
| 294 |
# -----------------------------------------------------------------
|
|
|
|
| 360 |
# Yield each result as it's ready
|
| 361 |
for result in results:
|
| 362 |
yield result
|
|
|
|
|
|
|
|
|
|
| 363 |
except Exception as e:
|
| 364 |
logging.error(f"Batch summarization failed: {e}")
|
|
|
|
|
|
|
| 365 |
raise
|
| 366 |
|
| 367 |
async def get_all_grant_summaries(self, batch_size: int = 5):
|
|
|
|
| 393 |
async for result in self.summarize_grants_batch(all_grant_ids, batch_size=batch_size):
|
| 394 |
yield result
|
| 395 |
|
|
|
|
|
|
|
|
|
|
| 396 |
# -----------------------------------------------------------------
|
| 397 |
# Summarize a grant
|
| 398 |
# -----------------------------------------------------------------
|
| 399 |
+
def summarize_grant(self, gid: str, include_supporting: bool = True) -> Dict[str, Any]:
|
| 400 |
"""
|
| 401 |
+
Summarize a grant using LLM with caching.
|
| 402 |
|
| 403 |
Args:
|
| 404 |
gid: Grant ID
|
| 405 |
include_supporting: If True, include supporting PDFs and materials in context
|
|
|
|
| 406 |
|
| 407 |
Returns:
|
| 408 |
Dict with summary_md, title, id
|
|
|
|
| 411 |
title = row.get("title", "(untitled)")
|
| 412 |
grant_id = row.get("id") or gid
|
| 413 |
|
| 414 |
+
# NEW: Check cache first
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
cached_summary = self.summary_cache.get(row)
|
| 416 |
if cached_summary:
|
| 417 |
+
logging.info("📦 Cache HIT for grant %s", grant_id)
|
| 418 |
return {
|
| 419 |
"summary_md": cached_summary,
|
| 420 |
"title": title,
|
|
|
|
| 452 |
# NEW: Cache the summary
|
| 453 |
self.summary_cache.set(row, text)
|
| 454 |
|
|
|
|
|
|
|
|
|
|
| 455 |
return {"summary_md": text, "title": title, "id": grant_id}
|
| 456 |
|
| 457 |
# -----------------------------------------------------------------
|
|
|
|
| 482 |
parts.append(f"{k.upper()}:\n{_norm(v)}")
|
| 483 |
return "\n".join(parts)
|
| 484 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
# -----------------------------------------------------------------
|
| 486 |
# Compare two grants (deterministic)
|
| 487 |
# -----------------------------------------------------------------
|
|
|
|
| 550 |
|
| 551 |
insight = ""
|
| 552 |
if self.client and self.client.is_ready() and context_text.strip():
|
| 553 |
+
try:
|
| 554 |
+
prompt = (
|
| 555 |
+
"Given the factual table and context below, write 3-5 bullet points "
|
| 556 |
+
"highlighting *meaningful differences* that matter to SMEs (funding size, "
|
| 557 |
+
"duration, eligibility, etc.). Do not restate identical facts.\n\n"
|
| 558 |
+
+ "\n".join(table)
|
| 559 |
+
+ "\n\n"
|
| 560 |
+
+ context_text
|
| 561 |
+
)
|
| 562 |
+
insight = self.client.summarize(prompt)
|
| 563 |
+
except Exception as e:
|
| 564 |
+
logging.warning("compare_grants insight failed: %s", e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
|
| 566 |
md = [
|
| 567 |
"### Comparison",
|
|
|
|
| 571 |
if insight:
|
| 572 |
md += ["\n### Key differences", insight]
|
| 573 |
|
|
|
|
|
|
|
|
|
|
| 574 |
return {"comparison_md": "\n".join(md)}
|
| 575 |
|
| 576 |
# -----------------------------------------------------------------
|
|
|
|
| 736 |
- Why: [1-2 sentence explanation of fit]
|
| 737 |
"""
|
| 738 |
|
| 739 |
+
# Get LLM analysis
|
| 740 |
+
analysis_text = self.client.summarize(prompt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
|
| 742 |
# Extract recommended grant IDs from the response
|
| 743 |
recommended_ids = []
|
|
|
|
| 765 |
except KeyError:
|
| 766 |
continue
|
| 767 |
|
|
|
|
|
|
|
|
|
|
| 768 |
return {
|
| 769 |
"company_url": company_url,
|
| 770 |
"analysis": analysis_text,
|
|
|
|
| 773 |
|
| 774 |
except Exception as e:
|
| 775 |
logging.error(f"Failed to analyze company: {e}")
|
|
|
|
|
|
|
| 776 |
return {
|
| 777 |
"error": f"Analysis failed: {str(e)}",
|
| 778 |
"company_url": company_url,
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -23,13 +23,6 @@ except ImportError:
|
|
| 23 |
print("ERROR: Gradio not installed. Install with: pip install gradio")
|
| 24 |
sys.exit(1)
|
| 25 |
|
| 26 |
-
try:
|
| 27 |
-
import httpx
|
| 28 |
-
except ImportError:
|
| 29 |
-
print("ERROR: httpx not installed. Install with: pip install httpx")
|
| 30 |
-
httpx = None
|
| 31 |
-
|
| 32 |
-
|
| 33 |
from ..config import load_config
|
| 34 |
from ..data_loader import load_current_grants, load_past_winners
|
| 35 |
from ..llm_client import LLMClient
|
|
@@ -47,7 +40,9 @@ PRESET_QUESTIONS = {
|
|
| 47 |
"Show upcoming deadlines": "What are the upcoming grant deadlines?",
|
| 48 |
"Compare two grants": "Compare competition-2313 and competition-2314",
|
| 49 |
"Grant details": "Tell me about competition-2317 in detail",
|
| 50 |
-
"SME funding options": "What grants are available for SMEs with funding over £100k?"
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
|
| 53 |
|
|
@@ -92,7 +87,7 @@ class GrantAnalystDemo:
|
|
| 92 |
# Initialize LLM
|
| 93 |
self.llm_client = LLMClient(self.cfg)
|
| 94 |
if not self.llm_client.is_ready():
|
| 95 |
-
return False, "
|
| 96 |
|
| 97 |
# Load index
|
| 98 |
try:
|
|
@@ -122,38 +117,50 @@ class GrantAnalystDemo:
|
|
| 122 |
{
|
| 123 |
"role": "system",
|
| 124 |
"content": (
|
| 125 |
-
"You are an expert UK grant analyst assistant. Your PRIMARY DIRECTIVE is to
|
| 126 |
-
"
|
| 127 |
-
"-
|
| 128 |
-
"-
|
| 129 |
-
"-
|
| 130 |
-
"-
|
| 131 |
-
"-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
"CRITICAL RULES:\n"
|
| 133 |
-
"-
|
| 134 |
-
"-
|
| 135 |
-
"-
|
| 136 |
-
"-
|
| 137 |
-
"-
|
| 138 |
-
"-
|
| 139 |
-
"
|
| 140 |
-
"
|
| 141 |
-
"-
|
| 142 |
-
"-
|
| 143 |
-
"- summarize_grant:
|
| 144 |
-
"-
|
| 145 |
-
"-
|
| 146 |
-
"-
|
| 147 |
-
"-
|
|
|
|
| 148 |
"RESPONSE FORMAT:\n"
|
| 149 |
-
"-
|
| 150 |
-
"-
|
| 151 |
-
"-
|
| 152 |
-
"-
|
| 153 |
-
"
|
| 154 |
-
"-
|
| 155 |
-
"-
|
| 156 |
-
"
|
|
|
|
|
|
|
|
|
|
| 157 |
)
|
| 158 |
}
|
| 159 |
]
|
|
@@ -189,9 +196,9 @@ class GrantAnalystDemo:
|
|
| 189 |
batch_size = tool_args.get("batch_size", 5)
|
| 190 |
|
| 191 |
if not grant_ids:
|
| 192 |
-
return "
|
| 193 |
|
| 194 |
-
logging.info(f"Starting batch summarization of {len(grant_ids)} grants")
|
| 195 |
|
| 196 |
# Collect results from async generator
|
| 197 |
results = []
|
|
@@ -221,7 +228,7 @@ class GrantAnalystDemo:
|
|
| 221 |
|
| 222 |
# Format results for display
|
| 223 |
if not results:
|
| 224 |
-
return "
|
| 225 |
|
| 226 |
formatted = f"Batch summarization complete for {len(results)} grants:\n\n"
|
| 227 |
for i, result in enumerate(results, 1):
|
|
@@ -238,7 +245,7 @@ class GrantAnalystDemo:
|
|
| 238 |
# Get summaries of ALL grants in one batch
|
| 239 |
batch_size = tool_args.get("batch_size", 5)
|
| 240 |
|
| 241 |
-
logging.info(f"Starting to get summaries for ALL grants (batch_size={batch_size})")
|
| 242 |
|
| 243 |
# Collect results from async generator
|
| 244 |
results = []
|
|
@@ -263,7 +270,7 @@ class GrantAnalystDemo:
|
|
| 263 |
|
| 264 |
# Format results for display
|
| 265 |
if not results:
|
| 266 |
-
return "
|
| 267 |
|
| 268 |
formatted = f"Summaries for ALL {len(results)} grants:\n\n"
|
| 269 |
for i, result in enumerate(results, 1):
|
|
@@ -309,6 +316,37 @@ class GrantAnalystDemo:
|
|
| 309 |
])
|
| 310 |
return formatted
|
| 311 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
elif tool_name == "fetch_link":
|
| 313 |
# NEW: Handle external link fetching
|
| 314 |
try:
|
|
@@ -340,168 +378,9 @@ class GrantAnalystDemo:
|
|
| 340 |
logging.error(f"Tool {tool_name} failed: {e}", exc_info=True)
|
| 341 |
return {"error": str(e)}
|
| 342 |
|
| 343 |
-
def chat_stream(self, user_message: str, history: List, use_sse: bool = False, use_websocket: bool = False):
|
| 344 |
-
"""
|
| 345 |
-
Process a chat message with optional SSE or WebSocket streaming.
|
| 346 |
-
|
| 347 |
-
Args:
|
| 348 |
-
user_message: User's input
|
| 349 |
-
history: Gradio chat history
|
| 350 |
-
use_sse: If True, use SSE streaming from API endpoint
|
| 351 |
-
use_websocket: If True, use WebSocket streaming (takes precedence over SSE)
|
| 352 |
-
|
| 353 |
-
Yields:
|
| 354 |
-
Updated history for streaming response
|
| 355 |
-
"""
|
| 356 |
-
import time
|
| 357 |
-
import json
|
| 358 |
-
|
| 359 |
-
if not self.initialized:
|
| 360 |
-
yield history + [[user_message, "WARNING: System not initialized. Please restart the app."]]
|
| 361 |
-
return
|
| 362 |
-
|
| 363 |
-
# WebSocket streaming takes precedence
|
| 364 |
-
if use_websocket and HAS_WEBSOCKET:
|
| 365 |
-
try:
|
| 366 |
-
accumulated_response = ""
|
| 367 |
-
intent = None
|
| 368 |
-
citations = []
|
| 369 |
-
|
| 370 |
-
# Connect to WebSocket endpoint
|
| 371 |
-
ws = websocket.create_connection("ws://localhost:8000/ws/query", timeout=60)
|
| 372 |
-
|
| 373 |
-
# Send query
|
| 374 |
-
message = {
|
| 375 |
-
"query": user_message,
|
| 376 |
-
"session_id": "gradio_session"
|
| 377 |
-
}
|
| 378 |
-
ws.send(json.dumps(message))
|
| 379 |
-
|
| 380 |
-
# Receive and process stream
|
| 381 |
-
while True:
|
| 382 |
-
try:
|
| 383 |
-
msg = ws.recv()
|
| 384 |
-
data = json.loads(msg)
|
| 385 |
-
msg_type = data.get("type")
|
| 386 |
-
|
| 387 |
-
if msg_type == "metadata":
|
| 388 |
-
# Initial metadata received
|
| 389 |
-
logging.info(f"WebSocket session: {data.get('session_id')}")
|
| 390 |
-
|
| 391 |
-
elif msg_type == "intent":
|
| 392 |
-
intent = data.get("intent")
|
| 393 |
-
# Show intent in response
|
| 394 |
-
status_msg = f"*Detected intent: {intent}*\n\n"
|
| 395 |
-
yield history + [[user_message, status_msg]]
|
| 396 |
-
|
| 397 |
-
elif msg_type == "token":
|
| 398 |
-
# Stream token to UI
|
| 399 |
-
token = data.get("token", "")
|
| 400 |
-
accumulated_response += token
|
| 401 |
-
# Yield updated history with partial response
|
| 402 |
-
status_prefix = f"*Intent: {intent}*\n\n" if intent else ""
|
| 403 |
-
yield history + [[user_message, status_prefix + accumulated_response]]
|
| 404 |
-
|
| 405 |
-
elif msg_type == "citations":
|
| 406 |
-
citations = data.get("citations", [])
|
| 407 |
-
|
| 408 |
-
elif msg_type == "done":
|
| 409 |
-
latency_ms = data.get("latency_ms")
|
| 410 |
-
logging.info(f"WebSocket stream completed in {latency_ms}ms")
|
| 411 |
-
break
|
| 412 |
-
|
| 413 |
-
elif msg_type == "error":
|
| 414 |
-
error_msg = data.get("error", "Unknown error")
|
| 415 |
-
yield history + [[user_message, f"ERROR: Error: {error_msg}"]]
|
| 416 |
-
ws.close()
|
| 417 |
-
return
|
| 418 |
-
|
| 419 |
-
except websocket.WebSocketTimeoutException:
|
| 420 |
-
logging.warning("WebSocket timeout")
|
| 421 |
-
break
|
| 422 |
-
except json.JSONDecodeError as e:
|
| 423 |
-
logging.error(f"Failed to parse WebSocket message: {e}")
|
| 424 |
-
continue
|
| 425 |
-
|
| 426 |
-
ws.close()
|
| 427 |
-
|
| 428 |
-
# Add citations to final response
|
| 429 |
-
if citations:
|
| 430 |
-
accumulated_response += "\n\n**Citations:**\n"
|
| 431 |
-
for cite in citations:
|
| 432 |
-
title = cite.get("title", "Unknown")
|
| 433 |
-
grant_id = cite.get("grant_id", "N/A")
|
| 434 |
-
accumulated_response += f"- **{title}** (ID: {grant_id})\n"
|
| 435 |
-
|
| 436 |
-
# Add intent badge if available
|
| 437 |
-
if intent:
|
| 438 |
-
final_response = f"*Intent: {intent}*\n\n{accumulated_response}"
|
| 439 |
-
else:
|
| 440 |
-
final_response = accumulated_response
|
| 441 |
-
|
| 442 |
-
yield history + [[user_message, final_response]]
|
| 443 |
-
|
| 444 |
-
except Exception as e:
|
| 445 |
-
logging.error(f"WebSocket streaming error: {e}")
|
| 446 |
-
yield history + [[user_message, f"ERROR: WebSocket error: {e}"]]
|
| 447 |
-
return
|
| 448 |
-
|
| 449 |
-
if use_sse and httpx:
|
| 450 |
-
# Use SSE streaming from API endpoint
|
| 451 |
-
try:
|
| 452 |
-
accumulated_response = ""
|
| 453 |
-
citations = []
|
| 454 |
-
|
| 455 |
-
with httpx.Client(timeout=60.0) as client:
|
| 456 |
-
with client.stream(
|
| 457 |
-
"POST",
|
| 458 |
-
"http://localhost:8000/qa/stream", # Adjust URL as needed
|
| 459 |
-
json={"query": user_message, "use_llm_routing": True}
|
| 460 |
-
) as response:
|
| 461 |
-
for line in response.iter_lines():
|
| 462 |
-
if line.startswith("data: "):
|
| 463 |
-
data_str = line[6:] # Remove "data: " prefix
|
| 464 |
-
try:
|
| 465 |
-
data = json.loads(data_str)
|
| 466 |
-
event_type = data.get("type")
|
| 467 |
-
|
| 468 |
-
if event_type == "token":
|
| 469 |
-
token = data.get("token", "")
|
| 470 |
-
accumulated_response += token
|
| 471 |
-
# Yield updated history with partial response
|
| 472 |
-
yield history + [[user_message, accumulated_response]]
|
| 473 |
-
|
| 474 |
-
elif event_type == "citations":
|
| 475 |
-
citations = data.get("citations", [])
|
| 476 |
-
|
| 477 |
-
elif event_type == "error":
|
| 478 |
-
error = data.get("error", "Unknown error")
|
| 479 |
-
yield history + [[user_message, f"ERROR: Error: {error}"]]
|
| 480 |
-
return
|
| 481 |
-
|
| 482 |
-
except json.JSONDecodeError:
|
| 483 |
-
continue
|
| 484 |
-
|
| 485 |
-
# Add citations to final response
|
| 486 |
-
if citations:
|
| 487 |
-
accumulated_response += "\n\n**Citations:**\n"
|
| 488 |
-
for cite in citations:
|
| 489 |
-
accumulated_response += f"- {cite.get('title')} (ID: {cite.get('grant_id')})\n"
|
| 490 |
-
|
| 491 |
-
yield history + [[user_message, accumulated_response]]
|
| 492 |
-
|
| 493 |
-
except Exception as e:
|
| 494 |
-
logging.error(f"SSE streaming error: {e}")
|
| 495 |
-
yield history + [[user_message, f"ERROR: Streaming error: {e}"]]
|
| 496 |
-
return
|
| 497 |
-
|
| 498 |
-
# Fallback to original non-streaming chat
|
| 499 |
-
response, updated_history = self.chat(user_message, history)
|
| 500 |
-
yield updated_history
|
| 501 |
-
|
| 502 |
def chat(self, user_message: str, history: List) -> Tuple[str, List]:
|
| 503 |
"""
|
| 504 |
-
Process a chat message
|
| 505 |
|
| 506 |
Args:
|
| 507 |
user_message: User's input
|
|
@@ -513,7 +392,7 @@ class GrantAnalystDemo:
|
|
| 513 |
import time
|
| 514 |
|
| 515 |
if not self.initialized:
|
| 516 |
-
return "
|
| 517 |
|
| 518 |
start_time = time.time()
|
| 519 |
tools_called = []
|
|
@@ -530,7 +409,7 @@ class GrantAnalystDemo:
|
|
| 530 |
messages=self.messages,
|
| 531 |
tools=self.available_tools,
|
| 532 |
tool_choice="auto",
|
| 533 |
-
temperature=0.
|
| 534 |
max_tokens=4096, # INCREASED to allow detailed summaries without truncation
|
| 535 |
)
|
| 536 |
timing_info["llm_call"] = time.time() - llm_start
|
|
@@ -557,7 +436,7 @@ class GrantAnalystDemo:
|
|
| 557 |
tool_start = time.time()
|
| 558 |
tool_result = self._dispatch_tool(function_name, function_args)
|
| 559 |
tool_time = time.time() - tool_start
|
| 560 |
-
logging.info(f"{function_name} took {tool_time:.2f}s")
|
| 561 |
timing_info[f"tool_{function_name}"] = tool_time
|
| 562 |
|
| 563 |
# Add tool result
|
|
@@ -573,7 +452,7 @@ class GrantAnalystDemo:
|
|
| 573 |
final_response = self.llm_client.client.chat.completions.create(
|
| 574 |
model=self.llm_client.model,
|
| 575 |
messages=self.messages,
|
| 576 |
-
temperature=0.
|
| 577 |
max_tokens=4096, # INCREASED to allow complete answers without truncation
|
| 578 |
)
|
| 579 |
timing_info["final_llm_call"] = time.time() - final_start
|
|
@@ -588,7 +467,7 @@ class GrantAnalystDemo:
|
|
| 588 |
# Log timing info
|
| 589 |
response_time_ms = int((time.time() - start_time) * 1000)
|
| 590 |
timing_str = " | ".join([f"{k}:{v:.2f}s" for k, v in timing_info.items()])
|
| 591 |
-
logging.info(f"Total: {response_time_ms}ms | {timing_str}")
|
| 592 |
|
| 593 |
# Direct logging to CSV (simpler, more reliable)
|
| 594 |
try:
|
|
@@ -630,12 +509,12 @@ class GrantAnalystDemo:
|
|
| 630 |
logging.info(f"Query logged to {csv_path}")
|
| 631 |
|
| 632 |
except Exception as log_error:
|
| 633 |
-
logging.error(f"
|
| 634 |
|
| 635 |
return assistant_message, history + [[user_message, assistant_message]]
|
| 636 |
|
| 637 |
except Exception as e:
|
| 638 |
-
error_msg = f"
|
| 639 |
logging.error(f"Chat error: {e}", exc_info=True)
|
| 640 |
|
| 641 |
# Log failed interactions too
|
|
@@ -681,7 +560,7 @@ class GrantAnalystDemo:
|
|
| 681 |
logging.info(f"Failed query logged to {csv_path}")
|
| 682 |
|
| 683 |
except Exception as log_error:
|
| 684 |
-
logging.error(f"
|
| 685 |
|
| 686 |
return error_msg, history + [[user_message, error_msg]]
|
| 687 |
|
|
@@ -693,8 +572,17 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
|
|
| 693 |
title="Grant Analyst Demo",
|
| 694 |
theme=gr.themes.Soft(),
|
| 695 |
css="""
|
| 696 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
#status-box { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
|
|
|
|
|
|
|
| 698 |
"""
|
| 699 |
) as app:
|
| 700 |
|
|
@@ -720,7 +608,7 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
|
|
| 720 |
if success:
|
| 721 |
return f"**System Ready** — {msg}"
|
| 722 |
else:
|
| 723 |
-
return f"
|
| 724 |
|
| 725 |
# Chatbot interface
|
| 726 |
with gr.Row():
|
|
@@ -739,8 +627,7 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
|
|
| 739 |
)
|
| 740 |
send_btn = gr.Button("Send", scale=1, variant="primary")
|
| 741 |
|
| 742 |
-
|
| 743 |
-
clear_btn = gr.Button("Clear Conversation")
|
| 744 |
|
| 745 |
with gr.Column(scale=1):
|
| 746 |
gr.Markdown("### Preset Questions")
|
|
@@ -778,27 +665,15 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
|
|
| 778 |
|
| 779 |
# Event handlers
|
| 780 |
def respond(message, chat_history):
|
| 781 |
-
"""Handle user message."""
|
| 782 |
-
# Use non-streaming response
|
| 783 |
bot_response, updated_history = demo.chat(message, chat_history)
|
| 784 |
-
|
| 785 |
|
| 786 |
def use_preset(question, chat_history):
|
| 787 |
-
|
| 788 |
-
for result in respond(question, chat_history):
|
| 789 |
-
yield result
|
| 790 |
|
| 791 |
# Wire up events
|
| 792 |
-
msg_input.submit(
|
| 793 |
-
|
| 794 |
-
[msg_input, chatbot],
|
| 795 |
-
[msg_input, chatbot]
|
| 796 |
-
)
|
| 797 |
-
send_btn.click(
|
| 798 |
-
respond,
|
| 799 |
-
[msg_input, chatbot],
|
| 800 |
-
[msg_input, chatbot]
|
| 801 |
-
)
|
| 802 |
clear_btn.click(lambda: [], None, chatbot)
|
| 803 |
|
| 804 |
for btn, question in preset_btns:
|
|
@@ -816,12 +691,16 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
|
|
| 816 |
|
| 817 |
def main():
|
| 818 |
"""Launch the demo application."""
|
|
|
|
| 819 |
|
| 820 |
parser = argparse.ArgumentParser(description="Grant Analyst Demo App")
|
| 821 |
parser.add_argument("--share", action="store_true", help="Create public shareable link")
|
| 822 |
-
parser.add_argument("--port", type=int, default=
|
| 823 |
args = parser.parse_args()
|
| 824 |
|
|
|
|
|
|
|
|
|
|
| 825 |
# Setup logging (create log directory if needed)
|
| 826 |
log_handlers = [logging.StreamHandler(sys.stderr)]
|
| 827 |
try:
|
|
@@ -845,12 +724,12 @@ def main():
|
|
| 845 |
app = create_demo_ui(demo)
|
| 846 |
|
| 847 |
print("\n" + "="*60)
|
| 848 |
-
print("Launching Grant Analyst Demo...")
|
| 849 |
print("="*60)
|
| 850 |
|
| 851 |
app.launch(
|
| 852 |
server_name="0.0.0.0",
|
| 853 |
-
server_port=
|
| 854 |
share=args.share,
|
| 855 |
show_error=True,
|
| 856 |
)
|
|
|
|
| 23 |
print("ERROR: Gradio not installed. Install with: pip install gradio")
|
| 24 |
sys.exit(1)
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
from ..config import load_config
|
| 27 |
from ..data_loader import load_current_grants, load_past_winners
|
| 28 |
from ..llm_client import LLMClient
|
|
|
|
| 40 |
"Show upcoming deadlines": "What are the upcoming grant deadlines?",
|
| 41 |
"Compare two grants": "Compare competition-2313 and competition-2314",
|
| 42 |
"Grant details": "Tell me about competition-2317 in detail",
|
| 43 |
+
"SME funding options": "What grants are available for SMEs with funding over £100k?",
|
| 44 |
+
"Past winners of open grants": "Of the grants that are currently open, are there any past winners listed as reference?",
|
| 45 |
+
"Past AI winners": "Show me past winners related to AI projects"
|
| 46 |
}
|
| 47 |
|
| 48 |
|
|
|
|
| 87 |
# Initialize LLM
|
| 88 |
self.llm_client = LLMClient(self.cfg)
|
| 89 |
if not self.llm_client.is_ready():
|
| 90 |
+
return False, "❌ LLM client not ready. Check API key configuration."
|
| 91 |
|
| 92 |
# Load index
|
| 93 |
try:
|
|
|
|
| 117 |
{
|
| 118 |
"role": "system",
|
| 119 |
"content": (
|
| 120 |
+
"You are an expert UK grant analyst assistant. Your PRIMARY DIRECTIVE is to PROVIDE COMPLETE, THOROUGH RESPONSES.\n\n"
|
| 121 |
+
"CORE BEHAVIOR:\n"
|
| 122 |
+
"- When user asks about a grant, ALWAYS call tools to get the information\n"
|
| 123 |
+
"- When tools return data, ALWAYS present ALL the data to the user\n"
|
| 124 |
+
"- NEVER say 'I don't have information' without first calling tools\n"
|
| 125 |
+
"- NEVER give partial answers - if more information exists, include it\n"
|
| 126 |
+
"- If user challenges your answer, you likely gave incomplete information - call tools again\n\n"
|
| 127 |
+
"TOOL USAGE - WHEN USER ASKS FOR:\n"
|
| 128 |
+
"- 'tell me about [grant name]' → call search_grants with the grant name, then summarize_grant with the ID\n"
|
| 129 |
+
"- 'biomedical catalyst grant' → call search_grants with query='biomedical catalyst'\n"
|
| 130 |
+
"- 'description/summaries of all/every grant' → call get_all_grant_summaries\n"
|
| 131 |
+
"- 'list all grants' (NO descriptions) → call list_grants with limit=None\n"
|
| 132 |
+
"- 'find grants about [topic]' → call search_grants with query=[topic]\n"
|
| 133 |
+
"- 'who won', 'past winners' → call search_past_winners\n"
|
| 134 |
+
"- 'competition-XXXX details' → call get_grant then summarize_grant\n\n"
|
| 135 |
"CRITICAL RULES:\n"
|
| 136 |
+
"- ALWAYS call tools first, respond second\n"
|
| 137 |
+
"- If tool returns data, present ALL of it - don't truncate or paraphrase\n"
|
| 138 |
+
"- If user asks 'tell me more' or 'is that all', you missed information - call tools again\n"
|
| 139 |
+
"- search_grants finds grants by fuzzy matching - use it liberally\n"
|
| 140 |
+
"- If search finds 0 results, try a simpler/shorter query\n"
|
| 141 |
+
"- NEVER assume a grant doesn't exist - always search first\n\n"
|
| 142 |
+
"AVAILABLE TOOLS:\n"
|
| 143 |
+
"- search_grants: Find grants by keyword/topic (fuzzy matching, searches titles + descriptions)\n"
|
| 144 |
+
"- list_grants: List all grants with optional filters (keyword, status, max_award)\n"
|
| 145 |
+
"- get_grant: Get full structured data for one grant ID\n"
|
| 146 |
+
"- summarize_grant: Get AI-generated summary of one grant\n"
|
| 147 |
+
"- summarize_grants_batch: Get summaries for multiple grants\n"
|
| 148 |
+
"- get_all_grant_summaries: Get summaries for ALL grants at once\n"
|
| 149 |
+
"- compare_grants: Side-by-side comparison of two grants\n"
|
| 150 |
+
"- search_past_winners: Search historical winners (10+ years of data)\n"
|
| 151 |
+
"- deadlines_overview: Show upcoming deadlines\n\n"
|
| 152 |
"RESPONSE FORMAT:\n"
|
| 153 |
+
"- Use markdown: headers (##), numbered lists (1. 2. 3.), bold (**text**)\n"
|
| 154 |
+
"- Include ALL details from tool results: funding amounts, dates, eligibility, scope\n"
|
| 155 |
+
"- Be comprehensive and thorough - no length limits\n"
|
| 156 |
+
"- Present complete information on first response, not piecemeal\n\n"
|
| 157 |
+
"WRITING STYLE:\n"
|
| 158 |
+
"- Write in a tight, conversational voice — confident, warm, and clear\n"
|
| 159 |
+
"- Avoid fluff, filler, or over-formality\n"
|
| 160 |
+
"- Skip headings, bullet points, or 'as an AI' disclaimers\n"
|
| 161 |
+
"- Prefer short, active sentences\n"
|
| 162 |
+
"- Be bold, human, and efficient\n\n"
|
| 163 |
+
"Current date: 2025-11-05"
|
| 164 |
)
|
| 165 |
}
|
| 166 |
]
|
|
|
|
| 196 |
batch_size = tool_args.get("batch_size", 5)
|
| 197 |
|
| 198 |
if not grant_ids:
|
| 199 |
+
return "❌ No grant IDs provided for batch summarization"
|
| 200 |
|
| 201 |
+
logging.info(f"📦 Starting batch summarization of {len(grant_ids)} grants")
|
| 202 |
|
| 203 |
# Collect results from async generator
|
| 204 |
results = []
|
|
|
|
| 228 |
|
| 229 |
# Format results for display
|
| 230 |
if not results:
|
| 231 |
+
return "No grants could be summarized"
|
| 232 |
|
| 233 |
formatted = f"Batch summarization complete for {len(results)} grants:\n\n"
|
| 234 |
for i, result in enumerate(results, 1):
|
|
|
|
| 245 |
# Get summaries of ALL grants in one batch
|
| 246 |
batch_size = tool_args.get("batch_size", 5)
|
| 247 |
|
| 248 |
+
logging.info(f"📦 Starting to get summaries for ALL grants (batch_size={batch_size})")
|
| 249 |
|
| 250 |
# Collect results from async generator
|
| 251 |
results = []
|
|
|
|
| 270 |
|
| 271 |
# Format results for display
|
| 272 |
if not results:
|
| 273 |
+
return "No grants could be summarized"
|
| 274 |
|
| 275 |
formatted = f"Summaries for ALL {len(results)} grants:\n\n"
|
| 276 |
for i, result in enumerate(results, 1):
|
|
|
|
| 316 |
])
|
| 317 |
return formatted
|
| 318 |
|
| 319 |
+
elif tool_name == "search_past_winners":
|
| 320 |
+
results = self.tools.search_past_winners(
|
| 321 |
+
keyword=tool_args.get("keyword"),
|
| 322 |
+
competition=tool_args.get("competition"),
|
| 323 |
+
limit=tool_args.get("limit") # If None, returns ALL
|
| 324 |
+
)
|
| 325 |
+
# Format results for better conversation flow
|
| 326 |
+
if not results:
|
| 327 |
+
return "No past winners found matching those criteria."
|
| 328 |
+
|
| 329 |
+
if len(results) > 50:
|
| 330 |
+
# If too many, return summary + first 20
|
| 331 |
+
summary = f"Found {len(results)} past winners matching the criteria. Showing first 20:\n\n"
|
| 332 |
+
display = results[:20]
|
| 333 |
+
else:
|
| 334 |
+
summary = f"Found {len(results)} past winner(s):\n\n"
|
| 335 |
+
display = results
|
| 336 |
+
|
| 337 |
+
formatted_items = []
|
| 338 |
+
for i, r in enumerate(display, 1):
|
| 339 |
+
item = f"{i}. **{r['project_title']}**\n"
|
| 340 |
+
item += f" Organization: {r['lead_org']}\n"
|
| 341 |
+
item += f" Competition: {r['competition']}\n"
|
| 342 |
+
if r.get('award_amount'):
|
| 343 |
+
item += f" Award: £{r['award_amount']}\n"
|
| 344 |
+
if r.get('year'):
|
| 345 |
+
item += f" Year: {r['year']}\n"
|
| 346 |
+
formatted_items.append(item)
|
| 347 |
+
|
| 348 |
+
return summary + "\n".join(formatted_items)
|
| 349 |
+
|
| 350 |
elif tool_name == "fetch_link":
|
| 351 |
# NEW: Handle external link fetching
|
| 352 |
try:
|
|
|
|
| 378 |
logging.error(f"Tool {tool_name} failed: {e}", exc_info=True)
|
| 379 |
return {"error": str(e)}
|
| 380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
def chat(self, user_message: str, history: List) -> Tuple[str, List]:
|
| 382 |
"""
|
| 383 |
+
Process a chat message.
|
| 384 |
|
| 385 |
Args:
|
| 386 |
user_message: User's input
|
|
|
|
| 392 |
import time
|
| 393 |
|
| 394 |
if not self.initialized:
|
| 395 |
+
return "⚠️ System not initialized. Please restart the app.", history
|
| 396 |
|
| 397 |
start_time = time.time()
|
| 398 |
tools_called = []
|
|
|
|
| 409 |
messages=self.messages,
|
| 410 |
tools=self.available_tools,
|
| 411 |
tool_choice="auto",
|
| 412 |
+
temperature=0.2, # Lower temperature for more consistent, thorough responses
|
| 413 |
max_tokens=4096, # INCREASED to allow detailed summaries without truncation
|
| 414 |
)
|
| 415 |
timing_info["llm_call"] = time.time() - llm_start
|
|
|
|
| 436 |
tool_start = time.time()
|
| 437 |
tool_result = self._dispatch_tool(function_name, function_args)
|
| 438 |
tool_time = time.time() - tool_start
|
| 439 |
+
logging.info(f"⏱️ {function_name} took {tool_time:.2f}s")
|
| 440 |
timing_info[f"tool_{function_name}"] = tool_time
|
| 441 |
|
| 442 |
# Add tool result
|
|
|
|
| 452 |
final_response = self.llm_client.client.chat.completions.create(
|
| 453 |
model=self.llm_client.model,
|
| 454 |
messages=self.messages,
|
| 455 |
+
temperature=0.2, # Lower temperature for more complete, consistent responses
|
| 456 |
max_tokens=4096, # INCREASED to allow complete answers without truncation
|
| 457 |
)
|
| 458 |
timing_info["final_llm_call"] = time.time() - final_start
|
|
|
|
| 467 |
# Log timing info
|
| 468 |
response_time_ms = int((time.time() - start_time) * 1000)
|
| 469 |
timing_str = " | ".join([f"{k}:{v:.2f}s" for k, v in timing_info.items()])
|
| 470 |
+
logging.info(f"⏱️ Total: {response_time_ms}ms | {timing_str}")
|
| 471 |
|
| 472 |
# Direct logging to CSV (simpler, more reliable)
|
| 473 |
try:
|
|
|
|
| 509 |
logging.info(f"Query logged to {csv_path}")
|
| 510 |
|
| 511 |
except Exception as log_error:
|
| 512 |
+
logging.error(f"Logging failed: {log_error}", exc_info=True)
|
| 513 |
|
| 514 |
return assistant_message, history + [[user_message, assistant_message]]
|
| 515 |
|
| 516 |
except Exception as e:
|
| 517 |
+
error_msg = f"❌ Error: {e}"
|
| 518 |
logging.error(f"Chat error: {e}", exc_info=True)
|
| 519 |
|
| 520 |
# Log failed interactions too
|
|
|
|
| 560 |
logging.info(f"Failed query logged to {csv_path}")
|
| 561 |
|
| 562 |
except Exception as log_error:
|
| 563 |
+
logging.error(f"Logging failed: {log_error}", exc_info=True)
|
| 564 |
|
| 565 |
return error_msg, history + [[user_message, error_msg]]
|
| 566 |
|
|
|
|
| 572 |
title="Grant Analyst Demo",
|
| 573 |
theme=gr.themes.Soft(),
|
| 574 |
css="""
|
| 575 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 576 |
+
|
| 577 |
+
* {
|
| 578 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif !important;
|
| 579 |
+
}
|
| 580 |
+
.gradio-container { max-width: 100% !important; padding-left: 0 !important; padding-right: 0 !important; }
|
| 581 |
+
.main { max-width: 100% !important; }
|
| 582 |
+
.contain { max-width: 100% !important; }
|
| 583 |
#status-box { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
|
| 584 |
+
.chatbot { min-height: 500px; }
|
| 585 |
+
code, pre { font-family: 'Fira Code', 'Consolas', monospace !important; }
|
| 586 |
"""
|
| 587 |
) as app:
|
| 588 |
|
|
|
|
| 608 |
if success:
|
| 609 |
return f"**System Ready** — {msg}"
|
| 610 |
else:
|
| 611 |
+
return f"**Initialization Failed** — {msg}"
|
| 612 |
|
| 613 |
# Chatbot interface
|
| 614 |
with gr.Row():
|
|
|
|
| 627 |
)
|
| 628 |
send_btn = gr.Button("Send", scale=1, variant="primary")
|
| 629 |
|
| 630 |
+
clear_btn = gr.Button("Clear Conversation")
|
|
|
|
| 631 |
|
| 632 |
with gr.Column(scale=1):
|
| 633 |
gr.Markdown("### Preset Questions")
|
|
|
|
| 665 |
|
| 666 |
# Event handlers
|
| 667 |
def respond(message, chat_history):
|
|
|
|
|
|
|
| 668 |
bot_response, updated_history = demo.chat(message, chat_history)
|
| 669 |
+
return "", updated_history
|
| 670 |
|
| 671 |
def use_preset(question, chat_history):
|
| 672 |
+
return respond(question, chat_history)
|
|
|
|
|
|
|
| 673 |
|
| 674 |
# Wire up events
|
| 675 |
+
msg_input.submit(respond, [msg_input, chatbot], [msg_input, chatbot])
|
| 676 |
+
send_btn.click(respond, [msg_input, chatbot], [msg_input, chatbot])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
clear_btn.click(lambda: [], None, chatbot)
|
| 678 |
|
| 679 |
for btn, question in preset_btns:
|
|
|
|
| 691 |
|
| 692 |
def main():
|
| 693 |
"""Launch the demo application."""
|
| 694 |
+
import os
|
| 695 |
|
| 696 |
parser = argparse.ArgumentParser(description="Grant Analyst Demo App")
|
| 697 |
parser.add_argument("--share", action="store_true", help="Create public shareable link")
|
| 698 |
+
parser.add_argument("--port", type=int, default=None, help="Port to run on")
|
| 699 |
args = parser.parse_args()
|
| 700 |
|
| 701 |
+
# Use PORT environment variable if set (for AWS/cloud deployments), otherwise use CLI arg or default
|
| 702 |
+
port = int(os.environ.get("PORT", args.port or 7860))
|
| 703 |
+
|
| 704 |
# Setup logging (create log directory if needed)
|
| 705 |
log_handlers = [logging.StreamHandler(sys.stderr)]
|
| 706 |
try:
|
|
|
|
| 724 |
app = create_demo_ui(demo)
|
| 725 |
|
| 726 |
print("\n" + "="*60)
|
| 727 |
+
print("🚀 Launching Grant Analyst Demo...")
|
| 728 |
print("="*60)
|
| 729 |
|
| 730 |
app.launch(
|
| 731 |
server_name="0.0.0.0",
|
| 732 |
+
server_port=port,
|
| 733 |
share=args.share,
|
| 734 |
show_error=True,
|
| 735 |
)
|
|
@@ -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 = [
|
|
@@ -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 |
|
|
@@ -92,9 +92,10 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
|
|
| 92 |
"function": {
|
| 93 |
"name": "list_grants",
|
| 94 |
"description": (
|
| 95 |
-
"List all
|
| 96 |
-
"
|
| 97 |
-
"
|
|
|
|
| 98 |
),
|
| 99 |
"parameters": {
|
| 100 |
"type": "object",
|
|
@@ -125,6 +126,36 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
|
|
| 125 |
},
|
| 126 |
},
|
| 127 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
{
|
| 129 |
"type": "function",
|
| 130 |
"function": {
|
|
@@ -203,9 +234,10 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
|
|
| 203 |
"function": {
|
| 204 |
"name": "get_all_grant_summaries",
|
| 205 |
"description": (
|
| 206 |
-
"Get detailed summaries of ALL
|
| 207 |
-
"
|
| 208 |
-
"
|
|
|
|
| 209 |
),
|
| 210 |
"parameters": {
|
| 211 |
"type": "object",
|
|
@@ -394,7 +426,7 @@ def get_tools(provider: str = "openai", *, extended: bool = False) -> List[Dict[
|
|
| 394 |
Example:
|
| 395 |
tools = get_tools("openai", extended=True)
|
| 396 |
response = client.chat.completions.create(
|
| 397 |
-
model="gpt-
|
| 398 |
messages=[...],
|
| 399 |
tools=tools
|
| 400 |
)
|
|
|
|
| 92 |
"function": {
|
| 93 |
"name": "list_grants",
|
| 94 |
"description": (
|
| 95 |
+
"List all grant IDs and titles (no descriptions) with optional filters. "
|
| 96 |
+
"Use this when user asks 'list all grants' or 'show all grants'. "
|
| 97 |
+
"Returns simple list with ID, title, deadline, status ONLY (fast, no summaries). "
|
| 98 |
+
"For full grant descriptions/summaries, use get_all_grant_summaries instead."
|
| 99 |
),
|
| 100 |
"parameters": {
|
| 101 |
"type": "object",
|
|
|
|
| 126 |
},
|
| 127 |
},
|
| 128 |
},
|
| 129 |
+
{
|
| 130 |
+
"type": "function",
|
| 131 |
+
"function": {
|
| 132 |
+
"name": "search_past_winners",
|
| 133 |
+
"description": (
|
| 134 |
+
"Search past winners from 10+ years of UK innovation funding history. "
|
| 135 |
+
"Use this to find previous winners of a grant or search by organization/project name. "
|
| 136 |
+
"Returns past projects with their funding amounts and winning organizations. "
|
| 137 |
+
"If limit is not specified, returns ALL matching winners."
|
| 138 |
+
),
|
| 139 |
+
"parameters": {
|
| 140 |
+
"type": "object",
|
| 141 |
+
"properties": {
|
| 142 |
+
"keyword": {
|
| 143 |
+
"type": "string",
|
| 144 |
+
"description": "Search by project title, organization name, or description."
|
| 145 |
+
},
|
| 146 |
+
"competition": {
|
| 147 |
+
"type": "string",
|
| 148 |
+
"description": "Filter by grant/competition name to find past winners of a specific grant."
|
| 149 |
+
},
|
| 150 |
+
"limit": {
|
| 151 |
+
"type": "integer",
|
| 152 |
+
"description": "Maximum results to return. If omitted, returns all matching winners."
|
| 153 |
+
},
|
| 154 |
+
},
|
| 155 |
+
"required": [],
|
| 156 |
+
},
|
| 157 |
+
},
|
| 158 |
+
},
|
| 159 |
{
|
| 160 |
"type": "function",
|
| 161 |
"function": {
|
|
|
|
| 234 |
"function": {
|
| 235 |
"name": "get_all_grant_summaries",
|
| 236 |
"description": (
|
| 237 |
+
"Get detailed summaries and descriptions of ALL grants. "
|
| 238 |
+
"Use ONLY when user asks 'describe all grants', 'summarize all grants', or wants 'details/descriptions'. "
|
| 239 |
+
"Do NOT use for simple 'list all grants' requests (use list_grants instead). "
|
| 240 |
+
"Returns comprehensive summaries with descriptions and analysis for each grant."
|
| 241 |
),
|
| 242 |
"parameters": {
|
| 243 |
"type": "object",
|
|
|
|
| 426 |
Example:
|
| 427 |
tools = get_tools("openai", extended=True)
|
| 428 |
response = client.chat.completions.create(
|
| 429 |
+
model="gpt-4",
|
| 430 |
messages=[...],
|
| 431 |
tools=tools
|
| 432 |
)
|
|
@@ -32,7 +32,7 @@ from typing import Literal, Optional
|
|
| 32 |
Provider = Literal["openai", "anthropic"]
|
| 33 |
|
| 34 |
DEFAULT_MODELS = {
|
| 35 |
-
"openai": "gpt-5-mini",
|
| 36 |
"anthropic": "claude-3-5-haiku-20241022", # fast, low-cost Claude
|
| 37 |
}
|
| 38 |
|
|
@@ -45,10 +45,10 @@ class Config:
|
|
| 45 |
openai_api_key: Optional[str] = None
|
| 46 |
anthropic_api_key: Optional[str] = None
|
| 47 |
|
| 48 |
-
# Model-specific configurations for different use cases
|
| 49 |
-
model_router: str = "gpt-5-nano"
|
| 50 |
-
model_translator: str = "gpt-5-mini"
|
| 51 |
-
model_analyzer: str = "gpt-5"
|
| 52 |
|
| 53 |
temperature: float = 0.2
|
| 54 |
max_output_tokens: int = 800
|
|
|
|
| 32 |
Provider = Literal["openai", "anthropic"]
|
| 33 |
|
| 34 |
DEFAULT_MODELS = {
|
| 35 |
+
"openai": "gpt-5-mini", # GPT-5 family: nano/mini/main for different use cases
|
| 36 |
"anthropic": "claude-3-5-haiku-20241022", # fast, low-cost Claude
|
| 37 |
}
|
| 38 |
|
|
|
|
| 45 |
openai_api_key: Optional[str] = None
|
| 46 |
anthropic_api_key: Optional[str] = None
|
| 47 |
|
| 48 |
+
# Model-specific configurations for different use cases (GPT-5 family)
|
| 49 |
+
model_router: str = "gpt-5-nano" # Fast routing and classification
|
| 50 |
+
model_translator: str = "gpt-5-mini" # Translations and summaries
|
| 51 |
+
model_analyzer: str = "gpt-5" # Complex analysis and comparisons
|
| 52 |
|
| 53 |
temperature: float = 0.2
|
| 54 |
max_output_tokens: int = 800
|
|
@@ -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,
|
|
@@ -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():
|
|
@@ -35,7 +35,7 @@ class LLMClient:
|
|
| 35 |
self.model = self._get_cfg(cfg, "model", "gpt-5-mini")
|
| 36 |
self.disable_llm = self._get_cfg(cfg, "disable_llm", False)
|
| 37 |
|
| 38 |
-
# Store model variants for different use cases
|
| 39 |
self.model_router = self._get_cfg(cfg, "model_router", "gpt-5-nano")
|
| 40 |
self.model_translator = self._get_cfg(cfg, "model_translator", "gpt-5-mini")
|
| 41 |
self.model_analyzer = self._get_cfg(cfg, "model_analyzer", "gpt-5")
|
|
@@ -106,39 +106,22 @@ class LLMClient:
|
|
| 106 |
return self.model
|
| 107 |
|
| 108 |
@staticmethod
|
| 109 |
-
def get_recommended_params(
|
| 110 |
-
task_type: str
|
| 111 |
-
) -> Dict[str, Any]:
|
| 112 |
"""
|
| 113 |
Get recommended verbosity and reasoning_effort for common tasks.
|
| 114 |
|
| 115 |
Args:
|
| 116 |
-
task_type: One of "translation", "analysis", "routing", "summary"
|
| 117 |
|
| 118 |
Returns:
|
| 119 |
Dict with verbosity and reasoning_effort settings
|
| 120 |
"""
|
| 121 |
presets = {
|
| 122 |
-
"translation": {
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
},
|
| 126 |
-
"
|
| 127 |
-
"verbosity": "high",
|
| 128 |
-
"reasoning_effort": "high"
|
| 129 |
-
},
|
| 130 |
-
"routing": {
|
| 131 |
-
"verbosity": "low",
|
| 132 |
-
"reasoning_effort": "minimal"
|
| 133 |
-
},
|
| 134 |
-
"summary": {
|
| 135 |
-
"verbosity": "medium",
|
| 136 |
-
"reasoning_effort": "medium"
|
| 137 |
-
},
|
| 138 |
-
"comparison": {
|
| 139 |
-
"verbosity": "high",
|
| 140 |
-
"reasoning_effort": "high"
|
| 141 |
-
}
|
| 142 |
}
|
| 143 |
return presets.get(task_type, {"verbosity": "medium", "reasoning_effort": "medium"})
|
| 144 |
|
|
@@ -205,7 +188,7 @@ class LLMClient:
|
|
| 205 |
reasoning_effort: Optional[ReasoningEffort] = None,
|
| 206 |
) -> str:
|
| 207 |
"""
|
| 208 |
-
Single chat completion call with optional streaming.
|
| 209 |
|
| 210 |
Args:
|
| 211 |
messages: List of message dicts with 'role' and 'content'
|
|
@@ -223,11 +206,6 @@ class LLMClient:
|
|
| 223 |
- 'medium': Moderate analysis and reasoning
|
| 224 |
- 'high': Deep analysis and careful reasoning
|
| 225 |
|
| 226 |
-
Recommended combinations:
|
| 227 |
-
- Translations: verbosity='medium', reasoning_effort='minimal'
|
| 228 |
-
- Complex analysis: verbosity='high', reasoning_effort='high'
|
| 229 |
-
- Routing decisions: verbosity='low', reasoning_effort='minimal'
|
| 230 |
-
|
| 231 |
Returns:
|
| 232 |
Generated text response (or generator if stream=True)
|
| 233 |
|
|
@@ -242,28 +220,28 @@ class LLMClient:
|
|
| 242 |
"LLM client not initialized. Check API key and configuration."
|
| 243 |
)
|
| 244 |
|
| 245 |
-
# Select model based on type
|
| 246 |
-
selected_model = self._get_model_for_type(model_type)
|
| 247 |
-
|
| 248 |
def _make_call():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
try:
|
| 250 |
-
|
| 251 |
-
params = {
|
| 252 |
-
"model": selected_model,
|
| 253 |
-
"messages": messages,
|
| 254 |
-
"temperature": temperature,
|
| 255 |
-
"top_p": top_p,
|
| 256 |
-
"max_tokens": max_tokens,
|
| 257 |
-
"stream": stream,
|
| 258 |
-
}
|
| 259 |
-
|
| 260 |
-
# Add GPT-5 specific parameters if provided
|
| 261 |
-
if verbosity is not None:
|
| 262 |
-
params["verbosity"] = verbosity
|
| 263 |
-
if reasoning_effort is not None:
|
| 264 |
-
params["reasoning_effort"] = reasoning_effort
|
| 265 |
-
|
| 266 |
-
resp = self.client.chat.completions.create(**params)
|
| 267 |
except Exception as e:
|
| 268 |
# Handle httpx exceptions if available
|
| 269 |
if httpx and isinstance(e, httpx.TimeoutException):
|
|
@@ -288,19 +266,6 @@ class LLMClient:
|
|
| 288 |
if not content:
|
| 289 |
raise LLMError("LLM returned empty response")
|
| 290 |
|
| 291 |
-
# Record metrics (token usage and model distribution)
|
| 292 |
-
try:
|
| 293 |
-
from src.monitoring import record_tokens, record_model_use
|
| 294 |
-
if hasattr(resp, 'usage') and resp.usage:
|
| 295 |
-
record_tokens(
|
| 296 |
-
prompt_tokens=resp.usage.prompt_tokens,
|
| 297 |
-
completion_tokens=resp.usage.completion_tokens
|
| 298 |
-
)
|
| 299 |
-
record_model_use(selected_model)
|
| 300 |
-
except Exception as e:
|
| 301 |
-
# Don't fail the request if metrics recording fails
|
| 302 |
-
logging.warning(f"Failed to record metrics: {e}")
|
| 303 |
-
|
| 304 |
return content.strip()
|
| 305 |
|
| 306 |
return self._retry_with_backoff(_make_call, max_attempts=self.max_retries)
|
|
@@ -327,8 +292,6 @@ class LLMClient:
|
|
| 327 |
system_text: str = "You are an expert UK grant analyst. Be precise, grounded, concise.",
|
| 328 |
max_tokens: int = 900,
|
| 329 |
temperature: float = 0.2,
|
| 330 |
-
verbosity: Optional[VerbosityLevel] = None,
|
| 331 |
-
reasoning_effort: Optional[ReasoningEffort] = None,
|
| 332 |
) -> str:
|
| 333 |
"""
|
| 334 |
Convenience: single-turn chat.
|
|
@@ -338,8 +301,6 @@ class LLMClient:
|
|
| 338 |
system_text: System prompt
|
| 339 |
max_tokens: Maximum tokens
|
| 340 |
temperature: Sampling temperature
|
| 341 |
-
verbosity: Response length control (GPT-5)
|
| 342 |
-
reasoning_effort: Thinking time control (GPT-5)
|
| 343 |
|
| 344 |
Returns:
|
| 345 |
Generated summary
|
|
@@ -351,13 +312,7 @@ class LLMClient:
|
|
| 351 |
{"role": "system", "content": system_text},
|
| 352 |
{"role": "user", "content": user_text},
|
| 353 |
]
|
| 354 |
-
return self.chat(
|
| 355 |
-
messages,
|
| 356 |
-
max_tokens=max_tokens,
|
| 357 |
-
temperature=temperature,
|
| 358 |
-
verbosity=verbosity,
|
| 359 |
-
reasoning_effort=reasoning_effort
|
| 360 |
-
)
|
| 361 |
|
| 362 |
def summarize_long(
|
| 363 |
self,
|
|
|
|
| 35 |
self.model = self._get_cfg(cfg, "model", "gpt-5-mini")
|
| 36 |
self.disable_llm = self._get_cfg(cfg, "disable_llm", False)
|
| 37 |
|
| 38 |
+
# Store model variants for different use cases (GPT-5 family)
|
| 39 |
self.model_router = self._get_cfg(cfg, "model_router", "gpt-5-nano")
|
| 40 |
self.model_translator = self._get_cfg(cfg, "model_translator", "gpt-5-mini")
|
| 41 |
self.model_analyzer = self._get_cfg(cfg, "model_analyzer", "gpt-5")
|
|
|
|
| 106 |
return self.model
|
| 107 |
|
| 108 |
@staticmethod
|
| 109 |
+
def get_recommended_params(task_type: str) -> Dict[str, Any]:
|
|
|
|
|
|
|
| 110 |
"""
|
| 111 |
Get recommended verbosity and reasoning_effort for common tasks.
|
| 112 |
|
| 113 |
Args:
|
| 114 |
+
task_type: One of "translation", "analysis", "routing", "summary", "comparison"
|
| 115 |
|
| 116 |
Returns:
|
| 117 |
Dict with verbosity and reasoning_effort settings
|
| 118 |
"""
|
| 119 |
presets = {
|
| 120 |
+
"translation": {"verbosity": "medium", "reasoning_effort": "minimal"},
|
| 121 |
+
"analysis": {"verbosity": "high", "reasoning_effort": "high"},
|
| 122 |
+
"routing": {"verbosity": "low", "reasoning_effort": "minimal"},
|
| 123 |
+
"summary": {"verbosity": "medium", "reasoning_effort": "medium"},
|
| 124 |
+
"comparison": {"verbosity": "high", "reasoning_effort": "high"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
return presets.get(task_type, {"verbosity": "medium", "reasoning_effort": "medium"})
|
| 127 |
|
|
|
|
| 188 |
reasoning_effort: Optional[ReasoningEffort] = None,
|
| 189 |
) -> str:
|
| 190 |
"""
|
| 191 |
+
Single chat completion call with optional streaming and GPT-5 features.
|
| 192 |
|
| 193 |
Args:
|
| 194 |
messages: List of message dicts with 'role' and 'content'
|
|
|
|
| 206 |
- 'medium': Moderate analysis and reasoning
|
| 207 |
- 'high': Deep analysis and careful reasoning
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
Returns:
|
| 210 |
Generated text response (or generator if stream=True)
|
| 211 |
|
|
|
|
| 220 |
"LLM client not initialized. Check API key and configuration."
|
| 221 |
)
|
| 222 |
|
|
|
|
|
|
|
|
|
|
| 223 |
def _make_call():
|
| 224 |
+
# Select model based on model_type
|
| 225 |
+
model = self._get_model_for_type(model_type)
|
| 226 |
+
|
| 227 |
+
# Build API parameters
|
| 228 |
+
api_params = {
|
| 229 |
+
"model": model,
|
| 230 |
+
"messages": messages,
|
| 231 |
+
"temperature": temperature,
|
| 232 |
+
"top_p": top_p,
|
| 233 |
+
"max_tokens": max_tokens,
|
| 234 |
+
"stream": stream,
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
# Add GPT-5 specific parameters if provided
|
| 238 |
+
if verbosity is not None:
|
| 239 |
+
api_params["verbosity"] = verbosity
|
| 240 |
+
if reasoning_effort is not None:
|
| 241 |
+
api_params["reasoning_effort"] = reasoning_effort
|
| 242 |
+
|
| 243 |
try:
|
| 244 |
+
resp = self.client.chat.completions.create(**api_params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
except Exception as e:
|
| 246 |
# Handle httpx exceptions if available
|
| 247 |
if httpx and isinstance(e, httpx.TimeoutException):
|
|
|
|
| 266 |
if not content:
|
| 267 |
raise LLMError("LLM returned empty response")
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
return content.strip()
|
| 270 |
|
| 271 |
return self._retry_with_backoff(_make_call, max_attempts=self.max_retries)
|
|
|
|
| 292 |
system_text: str = "You are an expert UK grant analyst. Be precise, grounded, concise.",
|
| 293 |
max_tokens: int = 900,
|
| 294 |
temperature: float = 0.2,
|
|
|
|
|
|
|
| 295 |
) -> str:
|
| 296 |
"""
|
| 297 |
Convenience: single-turn chat.
|
|
|
|
| 301 |
system_text: System prompt
|
| 302 |
max_tokens: Maximum tokens
|
| 303 |
temperature: Sampling temperature
|
|
|
|
|
|
|
| 304 |
|
| 305 |
Returns:
|
| 306 |
Generated summary
|
|
|
|
| 312 |
{"role": "system", "content": system_text},
|
| 313 |
{"role": "user", "content": user_text},
|
| 314 |
]
|
| 315 |
+
return self.chat(messages, max_tokens=max_tokens, temperature=temperature)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
def summarize_long(
|
| 318 |
self,
|
|
@@ -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 |
)
|
|
@@ -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 =================================
|
|
@@ -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
|
|
Binary file (161 Bytes)
|
|
|
|
Binary file (2.68 kB)
|
|
|
|
@@ -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
|
|
@@ -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
|