Spaces:
Sleeping
fix: Restore exact working structure from commit c72a240
Browse filesMajor changes to match the working HF Spaces deployment:
1. **analyzer/ directory** - Copied src/analyzer/ to analyzer/
- Replaced symlink with actual directory (HF doesn't support symlinks)
- This matches the structure from c72a240 that was working
2. **requirements.txt** - Restored minimal HF-optimized version
- Removed all unnecessary dependencies (FastAPI, MongoDB, PDF, testing, etc.)
- Gradio provided by HF Spaces, not in requirements
- Much faster build times on HF
3. **README.md** - Updated sdk_version to 5.49.1 (from 5.49.0)
This exactly replicates the working deployment from c72a240 before
all the merge/force-push issues started.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- README.md +1 -1
- analyzer +0 -1
- analyzer/__init__.py +0 -0
- analyzer/cache/__init__.py +0 -0
- analyzer/cache/memo.py +36 -0
- analyzer/chat/__init__.py +0 -0
- analyzer/chat/chat_tools.py +881 -0
- analyzer/chat/company_analyzer.py +384 -0
- analyzer/chat/demo_app.py +860 -0
- analyzer/chat/insight_tools.py +94 -0
- analyzer/chat/memory.py +95 -0
- analyzer/chat/query_router.py +345 -0
- analyzer/chat/run_chat.py +408 -0
- analyzer/chat/run_chat_llm.py +297 -0
- analyzer/chat/run_chat_plus.py +39 -0
- analyzer/chat/tool_schemas.py +487 -0
- analyzer/chat/tool_schemas_plus.py +30 -0
- analyzer/config.py +120 -0
- analyzer/context_builder.py +274 -0
- analyzer/crawler/discover_grants.py +306 -0
- analyzer/crawler/scheduler.py +514 -0
- analyzer/crawler/snapshot.py +626 -0
- analyzer/data_loader.py +167 -0
- analyzer/data_loader_supporting.py +163 -0
- analyzer/exporters.py +77 -0
- analyzer/llm_client.py +396 -0
- analyzer/logging_setup.py +35 -0
- analyzer/net/fetcher.py +75 -0
- analyzer/prompt_templates.py +81 -0
- analyzer/run_generate.py +104 -0
- analyzer/search/__init__.py +0 -0
- analyzer/search/build_index.py +80 -0
- analyzer/search/hybrid_index.py +476 -0
- analyzer/search/past_winners_integration.py +181 -0
- analyzer/search/query.py +32 -0
- analyzer/streaming_summarizer.py +267 -0
- analyzer/summarizer.py +107 -0
- analyzer/summarizer_optimized.py +590 -0
- analyzer/telemetry/__init__.py +2 -0
- analyzer/telemetry/logger.py +54 -0
- analyzer/utils/__init__.py +0 -0
- analyzer/utils/citations.py +182 -0
- analyzer/utils/dates.py +88 -0
- analyzer/utils/errors.py +96 -0
- analyzer/utils/query_logger.py +317 -0
- analyzer/utils/text.py +57 -0
- analyzer/utils/validation.py +204 -0
- requirements.txt +22 -54
|
@@ -4,7 +4,7 @@ emoji: 🎯
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 5.49.
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
|
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
|
@@ -1 +0,0 @@
|
|
| 1 |
-
src/analyzer
|
|
|
|
|
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cache/memo.py — tiny in-memory and file-backed cache
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Optional
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
_CACHE: dict[str, Any] = {}
|
| 12 |
+
_CACHE_DIR = Path(".cache")
|
| 13 |
+
_CACHE_DIR.mkdir(exist_ok=True)
|
| 14 |
+
|
| 15 |
+
def cache_get(key: str) -> Optional[Any]:
|
| 16 |
+
if key in _CACHE:
|
| 17 |
+
return _CACHE[key]
|
| 18 |
+
f = _CACHE_DIR / (key.replace(":", "_") + ".json")
|
| 19 |
+
if f.exists():
|
| 20 |
+
try:
|
| 21 |
+
with open(f, "r", encoding="utf-8") as fh:
|
| 22 |
+
val = json.load(fh)
|
| 23 |
+
_CACHE[key] = val
|
| 24 |
+
return val
|
| 25 |
+
except Exception:
|
| 26 |
+
return None
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
def cache_put(key: str, value: Any) -> None:
|
| 30 |
+
_CACHE[key] = value
|
| 31 |
+
f = _CACHE_DIR / (key.replace(":", "_") + ".json")
|
| 32 |
+
try:
|
| 33 |
+
with open(f, "w", encoding="utf-8") as fh:
|
| 34 |
+
json.dump(value, fh, ensure_ascii=False, indent=2)
|
| 35 |
+
except Exception:
|
| 36 |
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,881 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/chat/chat_tools.py
|
| 2 |
+
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
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
from ..config import load_config
|
| 12 |
+
from ..llm_client import LLMClient
|
| 13 |
+
from ..prompt_templates import build_prompt
|
| 14 |
+
from ..context_builder import build_context_with_supporting
|
| 15 |
+
from ..utils.errors import DataLoadError, ValidationError, LLMError
|
| 16 |
+
from ..utils.text import clean, to_number
|
| 17 |
+
from ..utils.dates import parse_date, format_date
|
| 18 |
+
from ..summarizer_optimized import ( # NEW: Optimized caching + batch processing
|
| 19 |
+
SummaryCache,
|
| 20 |
+
summarize_grants_async,
|
| 21 |
+
extract_minimal_context,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Optional: try to load hybrid search index (requires scikit-learn)
|
| 25 |
+
try:
|
| 26 |
+
from ..search.hybrid_index import load_index, search_by_grant_id
|
| 27 |
+
HAS_SEARCH_INDEX = True
|
| 28 |
+
except ImportError:
|
| 29 |
+
HAS_SEARCH_INDEX = False
|
| 30 |
+
load_index = None
|
| 31 |
+
search_by_grant_id = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------------
|
| 35 |
+
# Utility helpers (moved to utils modules)
|
| 36 |
+
# ---------------------------------------------------------------------
|
| 37 |
+
# Date parsing: use utils.dates.parse_date() and format_date()
|
| 38 |
+
# Text normalization: use utils.text.clean()
|
| 39 |
+
# Number parsing: use utils.text.to_number()
|
| 40 |
+
|
| 41 |
+
# Keep backward compatibility wrappers
|
| 42 |
+
_parse_date = parse_date
|
| 43 |
+
_fmt_date = format_date
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# Helper to normalize complex objects to searchable text
|
| 47 |
+
def _norm(s: Any) -> str:
|
| 48 |
+
"""Normalize any object to searchable text string."""
|
| 49 |
+
if s is None:
|
| 50 |
+
return ""
|
| 51 |
+
if isinstance(s, (list, tuple, set)):
|
| 52 |
+
return " ".join(_norm(x) for x in s if x)
|
| 53 |
+
if isinstance(s, dict):
|
| 54 |
+
return " ".join(_norm(v) for v in s.values() if v)
|
| 55 |
+
return clean(str(s)) # Use utils.text.clean() for final normalization
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------
|
| 59 |
+
# Main ChatTools class
|
| 60 |
+
# ---------------------------------------------------------------------
|
| 61 |
+
@dataclass
|
| 62 |
+
class ChatTools:
|
| 63 |
+
current: List[Dict[str, Any]]
|
| 64 |
+
past: List[Dict[str, Any]]
|
| 65 |
+
|
| 66 |
+
def __init__(self, current: List[Dict[str, Any]], past: List[Dict[str, Any]]) -> None:
|
| 67 |
+
self.current = current or []
|
| 68 |
+
self.past = past or []
|
| 69 |
+
self.cfg = load_config()
|
| 70 |
+
try:
|
| 71 |
+
self.client = LLMClient(self.cfg)
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logging.warning("LLMClient init failed: %s", e)
|
| 74 |
+
self.client = None
|
| 75 |
+
|
| 76 |
+
# Try to load hybrid index for enrichment (optional - requires scikit-learn)
|
| 77 |
+
self.support_idx = None
|
| 78 |
+
if HAS_SEARCH_INDEX:
|
| 79 |
+
idx_path = Path("data/index/hybrid_index.pkl")
|
| 80 |
+
try:
|
| 81 |
+
self.support_idx = load_index() if idx_path.exists() else None
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logging.warning("Could not load search index: %s", e)
|
| 84 |
+
self.support_idx = None
|
| 85 |
+
|
| 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 |
+
# -----------------------------------------------------------------
|
| 103 |
+
def _calculate_grant_status(self, grant: Dict[str, Any]) -> str:
|
| 104 |
+
"""
|
| 105 |
+
Calculate grant status based on open_date and close_date.
|
| 106 |
+
|
| 107 |
+
Returns: "upcoming", "open", or "closed"
|
| 108 |
+
"""
|
| 109 |
+
today = datetime.now()
|
| 110 |
+
|
| 111 |
+
close_date = _parse_date(grant.get("close_date") or grant.get("deadline"))
|
| 112 |
+
open_date = _parse_date(grant.get("open_date"))
|
| 113 |
+
|
| 114 |
+
# If we can't parse dates, assume open
|
| 115 |
+
if not close_date:
|
| 116 |
+
return "unknown"
|
| 117 |
+
|
| 118 |
+
# If deadline has passed, it's closed
|
| 119 |
+
if close_date < today:
|
| 120 |
+
return "closed"
|
| 121 |
+
|
| 122 |
+
# If hasn't opened yet, it's upcoming
|
| 123 |
+
if open_date and open_date > today:
|
| 124 |
+
return "upcoming"
|
| 125 |
+
|
| 126 |
+
# Otherwise it's open
|
| 127 |
+
return "open"
|
| 128 |
+
|
| 129 |
+
# -----------------------------------------------------------------
|
| 130 |
+
# Listing grants
|
| 131 |
+
# -----------------------------------------------------------------
|
| 132 |
+
def list_grants(
|
| 133 |
+
self,
|
| 134 |
+
keyword: Optional[str] = None,
|
| 135 |
+
max_award: Optional[float] = None,
|
| 136 |
+
audience: Optional[str] = None,
|
| 137 |
+
status: Optional[str] = None,
|
| 138 |
+
limit: Optional[int] = None,
|
| 139 |
+
) -> List[Dict[str, Any]]:
|
| 140 |
+
"""
|
| 141 |
+
List all grants (or filtered subset) sorted by deadline.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
keyword: Filter by keyword in title/description
|
| 145 |
+
max_award: Filter by maximum funding ceiling
|
| 146 |
+
audience: Filter by audience type (not yet implemented)
|
| 147 |
+
status: Filter by status - "open", "closed", "upcoming", or None for all
|
| 148 |
+
limit: Max results to return. If None, returns ALL matching grants.
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
List of grant dicts sorted by deadline, with status field included
|
| 152 |
+
"""
|
| 153 |
+
kw = (keyword or "").lower()
|
| 154 |
+
results = []
|
| 155 |
+
for r in self.current:
|
| 156 |
+
txt = _norm(r)
|
| 157 |
+
if kw and kw not in txt.lower():
|
| 158 |
+
continue
|
| 159 |
+
if max_award is not None:
|
| 160 |
+
ma = to_number(r.get("max_award") or r.get("funding_max"))
|
| 161 |
+
if ma and ma > max_award:
|
| 162 |
+
continue
|
| 163 |
+
|
| 164 |
+
# Calculate status based on dates
|
| 165 |
+
grant_status = self._calculate_grant_status(r)
|
| 166 |
+
|
| 167 |
+
# Filter by status if specified
|
| 168 |
+
if status is not None and grant_status != status:
|
| 169 |
+
continue
|
| 170 |
+
|
| 171 |
+
results.append(
|
| 172 |
+
{
|
| 173 |
+
"id": r.get("id") or r.get("competition_id"),
|
| 174 |
+
"title": r.get("title") or "(untitled)",
|
| 175 |
+
"deadline": r.get("deadline") or r.get("close_date") or "n/a",
|
| 176 |
+
"status": grant_status, # NEW: Include status field
|
| 177 |
+
}
|
| 178 |
+
)
|
| 179 |
+
results.sort(key=lambda x: _parse_date(x.get("deadline")) or datetime.max)
|
| 180 |
+
|
| 181 |
+
# KEY FIX: Return ALL results if limit is None, not hardcoded 5
|
| 182 |
+
if limit is None:
|
| 183 |
+
return results
|
| 184 |
+
return results[:limit]
|
| 185 |
+
|
| 186 |
+
# -----------------------------------------------------------------
|
| 187 |
+
# Retrieve a single grant
|
| 188 |
+
# -----------------------------------------------------------------
|
| 189 |
+
def get_grant(self, gid: str) -> Dict[str, Any]:
|
| 190 |
+
gid = str(gid).replace("competition-", "").replace("grant-", "").strip().lower()
|
| 191 |
+
for coll in (self.current, self.past):
|
| 192 |
+
for r in coll:
|
| 193 |
+
rid = str(r.get("id") or r.get("competition_id") or "").lower()
|
| 194 |
+
if rid.replace("competition-", "") == gid:
|
| 195 |
+
return r
|
| 196 |
+
raise KeyError(f"Grant not found: {gid}")
|
| 197 |
+
|
| 198 |
+
# -----------------------------------------------------------------
|
| 199 |
+
# Batch Summarize Multiple Grants (NEW - Parallelized)
|
| 200 |
+
# -----------------------------------------------------------------
|
| 201 |
+
async def summarize_grants_batch(
|
| 202 |
+
self,
|
| 203 |
+
grant_ids: List[str],
|
| 204 |
+
include_supporting: bool = False,
|
| 205 |
+
batch_size: int = 5,
|
| 206 |
+
):
|
| 207 |
+
"""
|
| 208 |
+
Batch summarize multiple grants efficiently using parallel processing.
|
| 209 |
+
|
| 210 |
+
This method:
|
| 211 |
+
1. Resolves grant IDs to grant objects
|
| 212 |
+
2. Processes them in parallel batches (5 per batch by default)
|
| 213 |
+
3. Caches results for future use
|
| 214 |
+
4. Yields results as they complete (parallelized)
|
| 215 |
+
|
| 216 |
+
Args:
|
| 217 |
+
grant_ids: List of grant IDs to summarize
|
| 218 |
+
include_supporting: If True, include supporting materials (slower)
|
| 219 |
+
batch_size: Number of grants per batch (default 5)
|
| 220 |
+
|
| 221 |
+
Yields:
|
| 222 |
+
Dict with grant_id, title, summary_md as each completes
|
| 223 |
+
"""
|
| 224 |
+
import asyncio
|
| 225 |
+
|
| 226 |
+
# Resolve all grant IDs to actual grant objects
|
| 227 |
+
grants_to_summarize = []
|
| 228 |
+
for gid in grant_ids:
|
| 229 |
+
try:
|
| 230 |
+
grant = self.get_grant(gid)
|
| 231 |
+
grants_to_summarize.append(grant)
|
| 232 |
+
except KeyError:
|
| 233 |
+
logging.warning(f"Grant not found: {gid}")
|
| 234 |
+
continue
|
| 235 |
+
|
| 236 |
+
if not grants_to_summarize:
|
| 237 |
+
logging.warning("No valid grants found to summarize")
|
| 238 |
+
return
|
| 239 |
+
|
| 240 |
+
logging.info(
|
| 241 |
+
f"📦 Starting batch summarization of {len(grants_to_summarize)} grants "
|
| 242 |
+
f"(batch_size={batch_size})"
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# Use the optimized async batch processing function
|
| 246 |
+
try:
|
| 247 |
+
results = await summarize_grants_async(
|
| 248 |
+
grants_to_summarize,
|
| 249 |
+
past_winners=self.past,
|
| 250 |
+
client=self.client,
|
| 251 |
+
cache=self.summary_cache,
|
| 252 |
+
batch_size=batch_size,
|
| 253 |
+
)
|
| 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):
|
| 267 |
+
"""
|
| 268 |
+
Get summaries of ALL grants in a single efficient batch operation.
|
| 269 |
+
|
| 270 |
+
This method:
|
| 271 |
+
1. Extracts all grant IDs from current database
|
| 272 |
+
2. Summarizes them all in parallel batches
|
| 273 |
+
3. Returns all results formatted for display
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
batch_size: Number of grants per batch (default 5)
|
| 277 |
+
|
| 278 |
+
Yields:
|
| 279 |
+
Dict with grant_id, title, summary_md as each completes
|
| 280 |
+
"""
|
| 281 |
+
# Get all grant IDs
|
| 282 |
+
all_grants = self.list_grants(limit=None) # Get ALL grants
|
| 283 |
+
all_grant_ids = [g["id"] for g in all_grants]
|
| 284 |
+
|
| 285 |
+
if not all_grant_ids:
|
| 286 |
+
logging.warning("No grants found in database")
|
| 287 |
+
return
|
| 288 |
+
|
| 289 |
+
logging.info(f"📦 Getting summaries for ALL {len(all_grant_ids)} grants in batch")
|
| 290 |
+
|
| 291 |
+
# Use batch summarization with all IDs
|
| 292 |
+
async for result in self.summarize_grants_batch(all_grant_ids, batch_size=batch_size):
|
| 293 |
+
yield result
|
| 294 |
+
|
| 295 |
+
# Log final cache stats
|
| 296 |
+
self._log_cache_stats()
|
| 297 |
+
|
| 298 |
+
# -----------------------------------------------------------------
|
| 299 |
+
# Summarize a grant
|
| 300 |
+
# -----------------------------------------------------------------
|
| 301 |
+
def summarize_grant(self, gid: str, include_supporting: bool = True, summary_type: str = "layman") -> Dict[str, Any]:
|
| 302 |
+
"""
|
| 303 |
+
Summarize a grant using LLM with MongoDB and memory caching.
|
| 304 |
+
|
| 305 |
+
Args:
|
| 306 |
+
gid: Grant ID
|
| 307 |
+
include_supporting: If True, include supporting PDFs and materials in context
|
| 308 |
+
summary_type: Type of summary to retrieve ("layman", "technical", "exec")
|
| 309 |
+
|
| 310 |
+
Returns:
|
| 311 |
+
Dict with summary_md, title, id
|
| 312 |
+
"""
|
| 313 |
+
row = self.get_grant(gid)
|
| 314 |
+
title = row.get("title", "(untitled)")
|
| 315 |
+
grant_id = row.get("id") or gid
|
| 316 |
+
|
| 317 |
+
# NEW: Check MongoDB first for pre-computed summaries
|
| 318 |
+
try:
|
| 319 |
+
from ...database import SummaryStore
|
| 320 |
+
summary_store = SummaryStore()
|
| 321 |
+
mongodb_summary = summary_store.get_summary(grant_id, summary_type)
|
| 322 |
+
|
| 323 |
+
if mongodb_summary:
|
| 324 |
+
logging.info(f"📦 MongoDB HIT for grant {grant_id} ({summary_type})")
|
| 325 |
+
return {
|
| 326 |
+
"summary_md": mongodb_summary,
|
| 327 |
+
"title": title,
|
| 328 |
+
"id": grant_id,
|
| 329 |
+
}
|
| 330 |
+
except Exception as e:
|
| 331 |
+
logging.warning(f"MongoDB lookup failed for {grant_id}: {e}")
|
| 332 |
+
# Continue to memory cache/LLM fallback
|
| 333 |
+
|
| 334 |
+
# Check memory cache
|
| 335 |
+
cached_summary = self.summary_cache.get(row)
|
| 336 |
+
if cached_summary:
|
| 337 |
+
logging.info("📦 Memory cache HIT for grant %s", grant_id)
|
| 338 |
+
return {
|
| 339 |
+
"summary_md": cached_summary,
|
| 340 |
+
"title": title,
|
| 341 |
+
"id": grant_id,
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
# Use enhanced context builder that includes supporting materials
|
| 345 |
+
if include_supporting:
|
| 346 |
+
try:
|
| 347 |
+
context = build_context_with_supporting(row, k=5)
|
| 348 |
+
except Exception as e:
|
| 349 |
+
logging.warning("Failed to build context with supporting materials: %s", e)
|
| 350 |
+
# Fallback to basic context
|
| 351 |
+
context = self._build_basic_context(row)
|
| 352 |
+
else:
|
| 353 |
+
context = self._build_basic_context(row)
|
| 354 |
+
|
| 355 |
+
if not self.client or not self.client.is_ready():
|
| 356 |
+
result = {
|
| 357 |
+
"summary_md": f"LLM unavailable — context excerpt:\n\n{context[:1000]}",
|
| 358 |
+
"title": title,
|
| 359 |
+
"id": grant_id,
|
| 360 |
+
}
|
| 361 |
+
self.summary_cache.set(row, result["summary_md"])
|
| 362 |
+
return result
|
| 363 |
+
|
| 364 |
+
payload = build_prompt("openai", context)
|
| 365 |
+
try:
|
| 366 |
+
text = self.client.chat(payload["messages"], max_tokens=1200, temperature=0.25)
|
| 367 |
+
logging.info("✅ Generated summary for grant %s", grant_id)
|
| 368 |
+
except Exception as e:
|
| 369 |
+
text = f"LLM error: {e}\n\n{context[:800]}"
|
| 370 |
+
logging.error("❌ Failed to summarize %s: %s", grant_id, e)
|
| 371 |
+
|
| 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 |
+
# -----------------------------------------------------------------
|
| 381 |
+
# Helper method for basic context (without supporting materials)
|
| 382 |
+
# -----------------------------------------------------------------
|
| 383 |
+
def _build_basic_context(self, row: Dict[str, Any]) -> str:
|
| 384 |
+
"""Build basic grant context without supporting materials."""
|
| 385 |
+
title = row.get("title", "(untitled)")
|
| 386 |
+
url = row.get("url") or row.get("source_url") or ""
|
| 387 |
+
parts = [
|
| 388 |
+
f"TITLE: {title}",
|
| 389 |
+
f"ID: {row.get('id') or row.get('competition_id')}",
|
| 390 |
+
f"URL: {url}",
|
| 391 |
+
f"DEADLINE: {_fmt_date(row.get('deadline') or row.get('close_date'))}",
|
| 392 |
+
]
|
| 393 |
+
for k in (
|
| 394 |
+
"summary",
|
| 395 |
+
"overview",
|
| 396 |
+
"scope",
|
| 397 |
+
"eligibility",
|
| 398 |
+
"funding",
|
| 399 |
+
"dates",
|
| 400 |
+
"how_to_apply",
|
| 401 |
+
"supporting_information",
|
| 402 |
+
):
|
| 403 |
+
v = row.get(k)
|
| 404 |
+
if v:
|
| 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 |
+
# -----------------------------------------------------------------
|
| 555 |
+
def compare_grants(self, grant_id_a: str, grant_id_b: str) -> dict:
|
| 556 |
+
"""
|
| 557 |
+
Deterministic comparison:
|
| 558 |
+
- Loads both grant records and any supporting index data
|
| 559 |
+
- Builds a factual side-by-side table from structured fields
|
| 560 |
+
- Optionally adds a short insight section from the LLM
|
| 561 |
+
"""
|
| 562 |
+
# ---------------- enrich ----------------
|
| 563 |
+
def enrich(gid: str) -> dict:
|
| 564 |
+
base = self.get_grant(gid)
|
| 565 |
+
row = dict(base)
|
| 566 |
+
if self.support_idx:
|
| 567 |
+
hits = search_by_grant_id(
|
| 568 |
+
self.support_idx,
|
| 569 |
+
gid.replace("competition-", "").replace("grant-", ""),
|
| 570 |
+
k=5
|
| 571 |
+
)
|
| 572 |
+
# Adapt to new format: [(doc_dict, score), ...]
|
| 573 |
+
if hits:
|
| 574 |
+
doc, score = hits[0]
|
| 575 |
+
meta = doc.get("meta", {})
|
| 576 |
+
for k, v in meta.items():
|
| 577 |
+
if v and k not in row:
|
| 578 |
+
row[k] = v
|
| 579 |
+
row["_support_text"] = doc.get("text", "")
|
| 580 |
+
return row
|
| 581 |
+
|
| 582 |
+
A = enrich(grant_id_a)
|
| 583 |
+
B = enrich(grant_id_b)
|
| 584 |
+
|
| 585 |
+
def getf(d: dict, key: str) -> str:
|
| 586 |
+
v = d.get(key) or d.get(key.replace("_", " ")) or ""
|
| 587 |
+
return str(v).strip() if v not in (None, "", "n/a") else "—"
|
| 588 |
+
|
| 589 |
+
# ---------------- table fields ----------------
|
| 590 |
+
fields = [
|
| 591 |
+
("Title", getf(A, "title"), getf(B, "title")),
|
| 592 |
+
("Open date", getf(A, "open_date"), getf(B, "open_date")),
|
| 593 |
+
("Close date", getf(A, "close_date"), getf(B, "close_date")),
|
| 594 |
+
(
|
| 595 |
+
"Funding per project",
|
| 596 |
+
f"£{getf(A, 'funding_min')}–£{getf(A, 'funding_max')}",
|
| 597 |
+
f"£{getf(B, 'funding_min')}–£{getf(B, 'funding_max')}",
|
| 598 |
+
),
|
| 599 |
+
("Total pot", f"£{getf(A, 'total_pot')}", f"£{getf(B, 'total_pot')}"),
|
| 600 |
+
(
|
| 601 |
+
"Duration (months)",
|
| 602 |
+
f"{getf(A, 'duration_min')}–{getf(A, 'duration_max')}",
|
| 603 |
+
f"{getf(B, 'duration_min')}–{getf(B, 'duration_max')}",
|
| 604 |
+
),
|
| 605 |
+
]
|
| 606 |
+
|
| 607 |
+
table = ["### Side-by-side", "| Field | A | B |", "|---|---|---|"]
|
| 608 |
+
for name, va, vb in fields:
|
| 609 |
+
table.append(f"| {name} | {va} | {vb} |")
|
| 610 |
+
|
| 611 |
+
# ---------------- optional insight ----------------
|
| 612 |
+
context_text = ""
|
| 613 |
+
if "_support_text" in A:
|
| 614 |
+
context_text += "\n\n[Grant A Supporting Text]\n" + A["_support_text"][:1500]
|
| 615 |
+
if "_support_text" in B:
|
| 616 |
+
context_text += "\n\n[Grant B Supporting Text]\n" + B["_support_text"][:1500]
|
| 617 |
+
|
| 618 |
+
insight = ""
|
| 619 |
+
if self.client and self.client.is_ready() and context_text.strip():
|
| 620 |
+
# Create a cache key for comparison (use sorted grant IDs to ensure consistency)
|
| 621 |
+
cache_key = {"id": f"compare_{min(grant_id_a, grant_id_b)}_{max(grant_id_a, grant_id_b)}"}
|
| 622 |
+
|
| 623 |
+
# Check cache first
|
| 624 |
+
cached_insight = self.summary_cache.get(cache_key)
|
| 625 |
+
if cached_insight:
|
| 626 |
+
logging.info("📦 Cache HIT for comparison %s vs %s", grant_id_a, grant_id_b)
|
| 627 |
+
insight = cached_insight
|
| 628 |
+
else:
|
| 629 |
+
try:
|
| 630 |
+
prompt = (
|
| 631 |
+
"Given the factual table and context below, write 3-5 bullet points "
|
| 632 |
+
"highlighting *meaningful differences* that matter to SMEs (funding size, "
|
| 633 |
+
"duration, eligibility, etc.). Do not restate identical facts.\n\n"
|
| 634 |
+
+ "\n".join(table)
|
| 635 |
+
+ "\n\n"
|
| 636 |
+
+ context_text
|
| 637 |
+
)
|
| 638 |
+
insight = self.client.summarize(prompt)
|
| 639 |
+
# Cache the insight
|
| 640 |
+
self.summary_cache.set(cache_key, insight)
|
| 641 |
+
logging.info("✅ Generated and cached comparison insight")
|
| 642 |
+
except Exception as e:
|
| 643 |
+
logging.warning("compare_grants insight failed: %s", e)
|
| 644 |
+
|
| 645 |
+
md = [
|
| 646 |
+
"### Comparison",
|
| 647 |
+
f"**{getf(A, 'title')}** _(A)_ vs **{getf(B, 'title')}** _(B)_",
|
| 648 |
+
"\n".join(table),
|
| 649 |
+
]
|
| 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 |
+
# -----------------------------------------------------------------
|
| 659 |
+
# Deadlines overview
|
| 660 |
+
# -----------------------------------------------------------------
|
| 661 |
+
def deadlines_overview(self, n: Optional[int] = None) -> List[Dict[str, Any]]:
|
| 662 |
+
"""
|
| 663 |
+
Get upcoming grant deadlines sorted by date.
|
| 664 |
+
|
| 665 |
+
If n is None, returns all deadlines. Otherwise returns top n.
|
| 666 |
+
"""
|
| 667 |
+
rows = []
|
| 668 |
+
for r in self.current:
|
| 669 |
+
d = _parse_date(r.get("deadline") or r.get("close_date"))
|
| 670 |
+
if not d:
|
| 671 |
+
continue
|
| 672 |
+
status = self._calculate_grant_status(r)
|
| 673 |
+
rows.append(
|
| 674 |
+
{
|
| 675 |
+
"id": r.get("id") or r.get("competition_id"),
|
| 676 |
+
"title": r.get("title") or "(untitled)",
|
| 677 |
+
"deadline": d.strftime("%Y-%m-%d %H:%M:%S"),
|
| 678 |
+
"status": status, # NEW: Include status
|
| 679 |
+
}
|
| 680 |
+
)
|
| 681 |
+
rows.sort(key=lambda x: _parse_date(x["deadline"]) or datetime.max)
|
| 682 |
+
|
| 683 |
+
# Default to 5 if not specified (for backward compat with UI)
|
| 684 |
+
if n is None:
|
| 685 |
+
n = 5
|
| 686 |
+
return rows[:n]
|
| 687 |
+
|
| 688 |
+
# -----------------------------------------------------------------
|
| 689 |
+
# Analyze company for grant matching
|
| 690 |
+
# -----------------------------------------------------------------
|
| 691 |
+
def analyze_company_for_grants(self, company_url: str, limit: int = 3) -> Dict[str, Any]:
|
| 692 |
+
"""
|
| 693 |
+
Fetch a company website and analyze which grants would be most suitable.
|
| 694 |
+
|
| 695 |
+
Args:
|
| 696 |
+
company_url: URL of the company website to analyze
|
| 697 |
+
limit: Number of grant recommendations to return
|
| 698 |
+
|
| 699 |
+
Returns:
|
| 700 |
+
Dict with company analysis and recommended grants
|
| 701 |
+
"""
|
| 702 |
+
import urllib.request
|
| 703 |
+
from html.parser import HTMLParser
|
| 704 |
+
|
| 705 |
+
# Improved HTML to text parser that handles scripts/styles
|
| 706 |
+
class HTMLTextExtractor(HTMLParser):
|
| 707 |
+
def __init__(self):
|
| 708 |
+
super().__init__()
|
| 709 |
+
self.text = []
|
| 710 |
+
self.skip_tags = set()
|
| 711 |
+
|
| 712 |
+
def handle_starttag(self, tag, attrs):
|
| 713 |
+
# Skip script, style, noscript tags
|
| 714 |
+
if tag in ('script', 'style', 'noscript', 'svg'):
|
| 715 |
+
self.skip_tags.add(tag)
|
| 716 |
+
|
| 717 |
+
def handle_endtag(self, tag):
|
| 718 |
+
self.skip_tags.discard(tag)
|
| 719 |
+
|
| 720 |
+
def handle_data(self, data):
|
| 721 |
+
# Only add text if not in skip tags
|
| 722 |
+
if not self.skip_tags:
|
| 723 |
+
stripped = data.strip()
|
| 724 |
+
if stripped and len(stripped) > 3: # Filter out single chars
|
| 725 |
+
self.text.append(stripped)
|
| 726 |
+
|
| 727 |
+
def get_text(self):
|
| 728 |
+
return ' '.join(self.text)
|
| 729 |
+
|
| 730 |
+
try:
|
| 731 |
+
# Fetch the website
|
| 732 |
+
logging.info(f"Fetching company website: {company_url}")
|
| 733 |
+
|
| 734 |
+
# Add scheme if missing
|
| 735 |
+
if not company_url.startswith(('http://', 'https://')):
|
| 736 |
+
company_url = 'https://' + company_url
|
| 737 |
+
|
| 738 |
+
# Set a realistic user agent to avoid blocks
|
| 739 |
+
headers = {
|
| 740 |
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
| 741 |
+
}
|
| 742 |
+
req = urllib.request.Request(company_url, headers=headers)
|
| 743 |
+
|
| 744 |
+
# Fetch with timeout
|
| 745 |
+
with urllib.request.urlopen(req, timeout=15) as response:
|
| 746 |
+
html = response.read().decode('utf-8', errors='ignore')
|
| 747 |
+
|
| 748 |
+
# Extract text from HTML
|
| 749 |
+
parser = HTMLTextExtractor()
|
| 750 |
+
parser.feed(html)
|
| 751 |
+
company_text = parser.get_text()
|
| 752 |
+
|
| 753 |
+
# Clean and truncate (keep first 4000 chars for analysis)
|
| 754 |
+
company_text = ' '.join(company_text.split())[:4000]
|
| 755 |
+
|
| 756 |
+
logging.info(f"Extracted {len(company_text)} chars from {company_url}")
|
| 757 |
+
|
| 758 |
+
# Debug: log first 200 chars
|
| 759 |
+
logging.debug(f"First 200 chars: {company_text[:200]}")
|
| 760 |
+
|
| 761 |
+
except Exception as e:
|
| 762 |
+
logging.error(f"Failed to fetch company website: {e}")
|
| 763 |
+
return {
|
| 764 |
+
"error": f"Could not fetch website: {str(e)}",
|
| 765 |
+
"company_url": company_url,
|
| 766 |
+
"recommendations": []
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
# Use LLM to analyze company and match with grants
|
| 770 |
+
if not self.client or not self.client.is_ready():
|
| 771 |
+
return {
|
| 772 |
+
"error": "LLM not available for analysis",
|
| 773 |
+
"company_url": company_url,
|
| 774 |
+
"recommendations": []
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
try:
|
| 778 |
+
# Get list of available grants
|
| 779 |
+
available_grants = []
|
| 780 |
+
for r in self.current[:20]: # Limit to 20 grants for context
|
| 781 |
+
available_grants.append({
|
| 782 |
+
"id": r.get("id") or r.get("competition_id"),
|
| 783 |
+
"title": r.get("title", "(untitled)"),
|
| 784 |
+
"summary": r.get("summary", "")[:200],
|
| 785 |
+
"scope": r.get("scope", "")[:200],
|
| 786 |
+
"funding_max": r.get("funding_max") or r.get("max_award"),
|
| 787 |
+
"deadline": r.get("deadline") or r.get("close_date")
|
| 788 |
+
})
|
| 789 |
+
|
| 790 |
+
# Build analysis prompt
|
| 791 |
+
grants_context = "\n".join([
|
| 792 |
+
f"- {g['id']}: {g['title']} (max funding: £{g['funding_max']}, deadline: {g['deadline']})"
|
| 793 |
+
for g in available_grants
|
| 794 |
+
])
|
| 795 |
+
|
| 796 |
+
prompt = f"""Analyze this company website and recommend the most suitable grants.
|
| 797 |
+
|
| 798 |
+
COMPANY WEBSITE TEXT:
|
| 799 |
+
{company_text}
|
| 800 |
+
|
| 801 |
+
AVAILABLE GRANTS:
|
| 802 |
+
{grants_context}
|
| 803 |
+
|
| 804 |
+
Based on the company's activities, industry, and apparent needs, which grants would be most suitable?
|
| 805 |
+
Provide your answer in this format:
|
| 806 |
+
|
| 807 |
+
COMPANY ANALYSIS:
|
| 808 |
+
[Brief 2-3 sentence analysis of what the company does]
|
| 809 |
+
|
| 810 |
+
RECOMMENDED GRANTS:
|
| 811 |
+
1. [Grant ID]: [Grant Title]
|
| 812 |
+
- Why: [1-2 sentence explanation of fit]
|
| 813 |
+
|
| 814 |
+
2. [Grant ID]: [Grant Title]
|
| 815 |
+
- Why: [1-2 sentence explanation of fit]
|
| 816 |
+
|
| 817 |
+
3. [Grant ID]: [Grant Title]
|
| 818 |
+
- Why: [1-2 sentence explanation of fit]
|
| 819 |
+
"""
|
| 820 |
+
|
| 821 |
+
# Create cache key for company analysis (hash the URL)
|
| 822 |
+
import hashlib
|
| 823 |
+
url_hash = hashlib.md5(company_url.encode()).hexdigest()[:12]
|
| 824 |
+
cache_key = {"id": f"company_{url_hash}"}
|
| 825 |
+
|
| 826 |
+
# Check cache first
|
| 827 |
+
cached_analysis = self.summary_cache.get(cache_key)
|
| 828 |
+
if cached_analysis:
|
| 829 |
+
logging.info("📦 Cache HIT for company analysis: %s", company_url)
|
| 830 |
+
analysis_text = cached_analysis
|
| 831 |
+
else:
|
| 832 |
+
# Get LLM analysis
|
| 833 |
+
analysis_text = self.client.summarize(prompt)
|
| 834 |
+
# Cache the analysis
|
| 835 |
+
self.summary_cache.set(cache_key, analysis_text)
|
| 836 |
+
logging.info("✅ Generated and cached company analysis")
|
| 837 |
+
|
| 838 |
+
# Extract recommended grant IDs from the response
|
| 839 |
+
recommended_ids = []
|
| 840 |
+
for line in analysis_text.split('\n'):
|
| 841 |
+
# Look for patterns like "1. competition-2313:" or "- 2313:"
|
| 842 |
+
match = re.search(r'(?:competition-)?(\d{4})', line)
|
| 843 |
+
if match:
|
| 844 |
+
gid = match.group(1)
|
| 845 |
+
if gid not in recommended_ids:
|
| 846 |
+
recommended_ids.append(gid)
|
| 847 |
+
if len(recommended_ids) >= limit:
|
| 848 |
+
break
|
| 849 |
+
|
| 850 |
+
# Get full details for recommended grants
|
| 851 |
+
recommendations = []
|
| 852 |
+
for gid in recommended_ids:
|
| 853 |
+
try:
|
| 854 |
+
grant = self.get_grant(gid)
|
| 855 |
+
recommendations.append({
|
| 856 |
+
"id": grant.get("id") or grant.get("competition_id"),
|
| 857 |
+
"title": grant.get("title"),
|
| 858 |
+
"deadline": grant.get("deadline") or grant.get("close_date"),
|
| 859 |
+
"funding_max": grant.get("funding_max") or grant.get("max_award"),
|
| 860 |
+
})
|
| 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,
|
| 870 |
+
"recommendations": recommendations
|
| 871 |
+
}
|
| 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,
|
| 880 |
+
"recommendations": []
|
| 881 |
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|
|
@@ -0,0 +1,860 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
demo_app.py — Client-ready demo interface with preset questions
|
| 4 |
+
|
| 5 |
+
A Gradio web app for testing the grant analyst with clients.
|
| 6 |
+
Includes 5 preset questions that are guaranteed to work.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python -m src.analyzer.chat.demo_app
|
| 10 |
+
python -m src.analyzer.chat.demo_app --share # Create public URL
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import logging
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Dict, List, Optional, Tuple
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
import gradio as gr
|
| 22 |
+
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
|
| 36 |
+
from ..search.hybrid_index import load_index
|
| 37 |
+
from .chat_tools import ChatTools
|
| 38 |
+
from .tool_schemas import openai_tools, detect_extended_features
|
| 39 |
+
from ..utils.query_logger import get_query_logger
|
| 40 |
+
from ..summarizer_optimized import SummaryCache # NEW: Optimized caching
|
| 41 |
+
import asyncio # NEW: For batch processing
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# Preset questions that are guaranteed to work
|
| 45 |
+
PRESET_QUESTIONS = {
|
| 46 |
+
"List battery grants": "List all grants related to batteries",
|
| 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 |
+
|
| 54 |
+
class GrantAnalystDemo:
|
| 55 |
+
"""Demo application state manager."""
|
| 56 |
+
|
| 57 |
+
def __init__(self):
|
| 58 |
+
"""Initialize the demo application."""
|
| 59 |
+
self.cfg = None
|
| 60 |
+
self.llm_client = None
|
| 61 |
+
self.tools = None
|
| 62 |
+
self.available_tools = []
|
| 63 |
+
self.messages = []
|
| 64 |
+
self.initialized = False
|
| 65 |
+
self.summary_cache = SummaryCache(ttl_seconds=3600) # NEW: Cache summaries for 1 hour
|
| 66 |
+
|
| 67 |
+
def initialize(self) -> Tuple[bool, str]:
|
| 68 |
+
"""
|
| 69 |
+
Initialize all components.
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
(success, message) tuple
|
| 73 |
+
"""
|
| 74 |
+
try:
|
| 75 |
+
# Load config
|
| 76 |
+
self.cfg = load_config()
|
| 77 |
+
|
| 78 |
+
# Load data
|
| 79 |
+
current = load_current_grants(Path("data/snapshots"))
|
| 80 |
+
|
| 81 |
+
# Try to load past winners (optional - graceful fallback)
|
| 82 |
+
past = []
|
| 83 |
+
try:
|
| 84 |
+
xlsx_path = Path("data/IUK-141025-InnovateUKFundedProjects-FY2015-16topresent.xlsx")
|
| 85 |
+
if xlsx_path.exists():
|
| 86 |
+
past = load_past_winners(history_xlsx=xlsx_path)
|
| 87 |
+
logging.info(f"Loaded {len(past)} past winners")
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logging.warning(f"Could not load past winners: {e}")
|
| 90 |
+
past = []
|
| 91 |
+
|
| 92 |
+
# Initialize LLM
|
| 93 |
+
self.llm_client = LLMClient(self.cfg)
|
| 94 |
+
if not self.llm_client.is_ready():
|
| 95 |
+
return False, "ERROR: LLM client not ready. Check API key configuration."
|
| 96 |
+
|
| 97 |
+
# Load index
|
| 98 |
+
try:
|
| 99 |
+
idx_path = Path("data/index/hybrid_index.pkl")
|
| 100 |
+
if idx_path.exists():
|
| 101 |
+
_ = load_index()
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logging.warning(f"Could not load search index: {e}")
|
| 104 |
+
|
| 105 |
+
# Initialize tools
|
| 106 |
+
self.tools = ChatTools(current, past)
|
| 107 |
+
|
| 108 |
+
# Register tools
|
| 109 |
+
extended_mode = detect_extended_features()
|
| 110 |
+
self.available_tools = openai_tools(extended=extended_mode)
|
| 111 |
+
|
| 112 |
+
# Log which tools are available
|
| 113 |
+
tool_names = [t["function"]["name"] for t in self.available_tools]
|
| 114 |
+
logging.info(f"Loaded {len(tool_names)} tools: {', '.join(tool_names)}")
|
| 115 |
+
if extended_mode:
|
| 116 |
+
logging.info("Extended tools ENABLED (fetch_link, insight_search)")
|
| 117 |
+
else:
|
| 118 |
+
logging.info("Extended tools DISABLED (run with ENABLE_EXTENDED_TOOLS=1 to enable)")
|
| 119 |
+
|
| 120 |
+
# Initialize conversation
|
| 121 |
+
self.messages = [
|
| 122 |
+
{
|
| 123 |
+
"role": "system",
|
| 124 |
+
"content": (
|
| 125 |
+
"You are an expert UK grant analyst assistant. Your PRIMARY DIRECTIVE is to EXECUTE user requests.\n\n"
|
| 126 |
+
"WHEN USER ASKS FOR:\n"
|
| 127 |
+
"- 'description/summaries of all/every grant' → IMMEDIATELY call get_all_grant_summaries (ONE SINGLE TOOL CALL)\n"
|
| 128 |
+
"- 'description/summaries of grants' → IMMEDIATELY call summarize_grants_batch\n"
|
| 129 |
+
"- 'list all grants' (NO descriptions) → IMMEDIATELY call list_grants with limit=None\n"
|
| 130 |
+
"- 'find grants about [topic]' → IMMEDIATELY call search_grants\n"
|
| 131 |
+
"- ANY REQUEST FOR INFORMATION → DO NOT DESCRIBE WHAT YOU WILL DO, JUST DO IT\n\n"
|
| 132 |
+
"CRITICAL RULES:\n"
|
| 133 |
+
"- DO NOT make multiple tool calls. Make ONE tool call and wait for results.\n"
|
| 134 |
+
"- DO NOT return raw JSON lists when user asks for descriptions/summaries\n"
|
| 135 |
+
"- When user asks for 'all grants', use get_all_grant_summaries (NOT list_grants + summarize)\n"
|
| 136 |
+
"- DO NOT say 'I will do X' and then stop. ACTUALLY CALL THE TOOL.\n"
|
| 137 |
+
"- DO NOT provide preliminary responses. CALL TOOLS FIRST, THEN RESPOND.\n"
|
| 138 |
+
"- If user asks for information, ALWAYS use tools - NEVER make up answers.\n"
|
| 139 |
+
"- Never promise to do something later. Do it immediately.\n\n"
|
| 140 |
+
"SPECIFIC TOOL USAGE:\n"
|
| 141 |
+
"- get_all_grant_summaries: For 'all grants', 'every grant', 'all grant opportunities' (ONE SINGLE CALL - most efficient)\n"
|
| 142 |
+
"- summarize_grants_batch: For summaries/descriptions of specific grant groups\n"
|
| 143 |
+
"- summarize_grant: Only for single grant details\n"
|
| 144 |
+
"- list_grants: To get IDs/titles only (NOT for descriptions)\n"
|
| 145 |
+
"- search_grants: For finding grants by topic/keyword\n"
|
| 146 |
+
"- get_grant: For full structured data on one grant\n"
|
| 147 |
+
"- compare_grants: For side-by-side comparisons\n\n"
|
| 148 |
+
"RESPONSE FORMAT:\n"
|
| 149 |
+
"- ALWAYS include complete tool results in your response\n"
|
| 150 |
+
"- Do NOT paraphrase or summarize tool results - display them exactly as provided\n"
|
| 151 |
+
"- Use markdown formatting (headers, numbered lists, tables)\n"
|
| 152 |
+
"- When displaying lists of grants, ALWAYS use numbered format (1., 2., 3., etc.) NOT bullet points\n"
|
| 153 |
+
"- Include all details: funding, eligibility, deadlines, scope\n"
|
| 154 |
+
"- No length limits - be comprehensive\n"
|
| 155 |
+
"- NEVER omit tool results from your response\n\n"
|
| 156 |
+
"Current date: 2025-10-27"
|
| 157 |
+
)
|
| 158 |
+
}
|
| 159 |
+
]
|
| 160 |
+
|
| 161 |
+
self.initialized = True
|
| 162 |
+
return True, f"Loaded {len(current)} grants with {len(self.available_tools)} tools"
|
| 163 |
+
|
| 164 |
+
except Exception as e:
|
| 165 |
+
logging.error(f"Initialization failed: {e}", exc_info=True)
|
| 166 |
+
return False, f"ERROR: Initialization failed: {e}"
|
| 167 |
+
|
| 168 |
+
def _dispatch_tool(self, tool_name: str, tool_args: Dict) -> any:
|
| 169 |
+
"""Execute a tool call."""
|
| 170 |
+
try:
|
| 171 |
+
if tool_name == "list_grants":
|
| 172 |
+
return self.tools.list_grants(
|
| 173 |
+
keyword=tool_args.get("keyword"),
|
| 174 |
+
max_award=tool_args.get("max_award"),
|
| 175 |
+
audience=tool_args.get("audience"),
|
| 176 |
+
status=tool_args.get("status"), # NEW: Add status filter
|
| 177 |
+
limit=tool_args.get("limit") # FIXED: Don't default to 5, pass None for all
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
elif tool_name == "get_grant":
|
| 181 |
+
return self.tools.get_grant(tool_args["grant_id"])
|
| 182 |
+
|
| 183 |
+
elif tool_name == "summarize_grant":
|
| 184 |
+
return self.tools.summarize_grant(tool_args["grant_id"])
|
| 185 |
+
|
| 186 |
+
elif tool_name == "summarize_grants_batch":
|
| 187 |
+
# NEW: Batch summarization with parallel processing
|
| 188 |
+
grant_ids = tool_args.get("grant_ids", [])
|
| 189 |
+
batch_size = tool_args.get("batch_size", 5)
|
| 190 |
+
|
| 191 |
+
if not grant_ids:
|
| 192 |
+
return "ERROR: No grant IDs provided for batch summarization"
|
| 193 |
+
|
| 194 |
+
logging.info(f"Starting batch summarization of {len(grant_ids)} grants")
|
| 195 |
+
|
| 196 |
+
# Collect results from async generator
|
| 197 |
+
results = []
|
| 198 |
+
try:
|
| 199 |
+
loop = asyncio.get_event_loop()
|
| 200 |
+
except RuntimeError:
|
| 201 |
+
loop = asyncio.new_event_loop()
|
| 202 |
+
asyncio.set_event_loop(loop)
|
| 203 |
+
|
| 204 |
+
async def collect_batch_results():
|
| 205 |
+
"""Collect all batch results."""
|
| 206 |
+
async for result in self.tools.summarize_grants_batch(
|
| 207 |
+
grant_ids,
|
| 208 |
+
batch_size=batch_size
|
| 209 |
+
):
|
| 210 |
+
results.append(result)
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
loop.run_until_complete(collect_batch_results())
|
| 214 |
+
except RuntimeError as e:
|
| 215 |
+
if "already running" in str(e):
|
| 216 |
+
# If loop is already running (shouldn't happen in Gradio), use current loop
|
| 217 |
+
logging.warning(f"Event loop already running, using current loop")
|
| 218 |
+
# In this case, we need to return a message instead
|
| 219 |
+
return "WARNING: Batch summarization not available in this context. Please try individual summaries."
|
| 220 |
+
raise
|
| 221 |
+
|
| 222 |
+
# Format results for display
|
| 223 |
+
if not results:
|
| 224 |
+
return "ERROR: No grants could be summarized"
|
| 225 |
+
|
| 226 |
+
formatted = f"Batch summarization complete for {len(results)} grants:\n\n"
|
| 227 |
+
for i, result in enumerate(results, 1):
|
| 228 |
+
title = result.get("title", "(untitled)")
|
| 229 |
+
summary = result.get("summary_md", "No summary")
|
| 230 |
+
# Truncate long summaries for display
|
| 231 |
+
if len(summary) > 500:
|
| 232 |
+
summary = summary[:500] + "\n\n[... truncated ...]"
|
| 233 |
+
formatted += f"**{i}. {title}**\n{summary}\n\n---\n\n"
|
| 234 |
+
|
| 235 |
+
return formatted
|
| 236 |
+
|
| 237 |
+
elif tool_name == "get_all_grant_summaries":
|
| 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 = []
|
| 245 |
+
try:
|
| 246 |
+
loop = asyncio.get_event_loop()
|
| 247 |
+
except RuntimeError:
|
| 248 |
+
loop = asyncio.new_event_loop()
|
| 249 |
+
asyncio.set_event_loop(loop)
|
| 250 |
+
|
| 251 |
+
async def collect_all_summaries():
|
| 252 |
+
"""Collect all grant summaries."""
|
| 253 |
+
async for result in self.tools.get_all_grant_summaries(batch_size=batch_size):
|
| 254 |
+
results.append(result)
|
| 255 |
+
|
| 256 |
+
try:
|
| 257 |
+
loop.run_until_complete(collect_all_summaries())
|
| 258 |
+
except RuntimeError as e:
|
| 259 |
+
if "already running" in str(e):
|
| 260 |
+
logging.warning(f"Event loop already running, using current loop")
|
| 261 |
+
return "WARNING: Cannot get all summaries in this context. Please try specific summaries."
|
| 262 |
+
raise
|
| 263 |
+
|
| 264 |
+
# Format results for display
|
| 265 |
+
if not results:
|
| 266 |
+
return "ERROR: No grants could be summarized"
|
| 267 |
+
|
| 268 |
+
formatted = f"Summaries for ALL {len(results)} grants:\n\n"
|
| 269 |
+
for i, result in enumerate(results, 1):
|
| 270 |
+
title = result.get("title", "(untitled)")
|
| 271 |
+
summary = result.get("summary_md", "No summary")
|
| 272 |
+
formatted += f"**{i}. {title}**\n{summary}\n\n---\n\n"
|
| 273 |
+
|
| 274 |
+
return formatted
|
| 275 |
+
|
| 276 |
+
elif tool_name == "compare_grants":
|
| 277 |
+
return self.tools.compare_grants(
|
| 278 |
+
tool_args["grant_id_a"],
|
| 279 |
+
tool_args["grant_id_b"]
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
elif tool_name == "deadlines_overview":
|
| 283 |
+
return self.tools.deadlines_overview(tool_args.get("n", 5))
|
| 284 |
+
|
| 285 |
+
elif tool_name == "analyze_company_for_grants":
|
| 286 |
+
return self.tools.analyze_company_for_grants(
|
| 287 |
+
tool_args["company_url"],
|
| 288 |
+
limit=tool_args.get("limit", 3)
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
elif tool_name == "search_grants":
|
| 292 |
+
results = self.tools.list_grants(
|
| 293 |
+
keyword=tool_args.get("query"),
|
| 294 |
+
status=tool_args.get("status"),
|
| 295 |
+
limit=tool_args.get("limit") # If None, returns ALL
|
| 296 |
+
)
|
| 297 |
+
# Format results for better conversation flow (avoid bloating history)
|
| 298 |
+
if len(results) > 50:
|
| 299 |
+
# If too many, return summary + first 20
|
| 300 |
+
summary = f"Found {len(results)} grants matching the criteria. Showing first 20:\n"
|
| 301 |
+
display = results[:20]
|
| 302 |
+
else:
|
| 303 |
+
summary = f"Found {len(results)} grants:\n"
|
| 304 |
+
display = results
|
| 305 |
+
|
| 306 |
+
formatted = summary + "\n".join([
|
| 307 |
+
f" • {r['title'][:60]} (ID: {r['id']}, Deadline: {r['deadline']}, Status: {r['status']})"
|
| 308 |
+
for r in display
|
| 309 |
+
])
|
| 310 |
+
return formatted
|
| 311 |
+
|
| 312 |
+
elif tool_name == "fetch_link":
|
| 313 |
+
# NEW: Handle external link fetching
|
| 314 |
+
try:
|
| 315 |
+
from ..net.fetcher import fetch_link
|
| 316 |
+
url = tool_args.get("url")
|
| 317 |
+
if not url:
|
| 318 |
+
return {"error": "No URL provided"}
|
| 319 |
+
|
| 320 |
+
logging.info(f"Fetching external link: {url}")
|
| 321 |
+
content = fetch_link(url)
|
| 322 |
+
if not content:
|
| 323 |
+
return {"error": f"Could not fetch content from {url}"}
|
| 324 |
+
|
| 325 |
+
# Truncate very long content to avoid bloating conversation
|
| 326 |
+
if len(content) > 10000:
|
| 327 |
+
content = content[:10000] + "\n\n[... content truncated ...]"
|
| 328 |
+
|
| 329 |
+
return content
|
| 330 |
+
except ImportError:
|
| 331 |
+
return {"error": "Link fetching not available"}
|
| 332 |
+
except Exception as e:
|
| 333 |
+
logging.error(f"Failed to fetch link {tool_args.get('url')}: {e}")
|
| 334 |
+
return {"error": f"Failed to fetch link: {str(e)[:200]}"}
|
| 335 |
+
|
| 336 |
+
else:
|
| 337 |
+
return {"error": f"Unknown tool: {tool_name}"}
|
| 338 |
+
|
| 339 |
+
except Exception as e:
|
| 340 |
+
logging.error(f"Tool {tool_name} failed: {e}", exc_info=True)
|
| 341 |
+
return {"error": str(e)}
|
| 342 |
+
|
| 343 |
+
def chat_stream(self, user_message: str, history: List, use_sse: bool = False, use_websocket: bool = False):
|
| 344 |
+
"""
|
| 345 |
+
Process a chat message with optional SSE or WebSocket streaming.
|
| 346 |
+
|
| 347 |
+
Args:
|
| 348 |
+
user_message: User's input
|
| 349 |
+
history: Gradio chat history
|
| 350 |
+
use_sse: If True, use SSE streaming from API endpoint
|
| 351 |
+
use_websocket: If True, use WebSocket streaming (takes precedence over SSE)
|
| 352 |
+
|
| 353 |
+
Yields:
|
| 354 |
+
Updated history for streaming response
|
| 355 |
+
"""
|
| 356 |
+
import time
|
| 357 |
+
import json
|
| 358 |
+
|
| 359 |
+
if not self.initialized:
|
| 360 |
+
yield history + [[user_message, "WARNING: System not initialized. Please restart the app."]]
|
| 361 |
+
return
|
| 362 |
+
|
| 363 |
+
# WebSocket streaming takes precedence
|
| 364 |
+
if use_websocket and HAS_WEBSOCKET:
|
| 365 |
+
try:
|
| 366 |
+
accumulated_response = ""
|
| 367 |
+
intent = None
|
| 368 |
+
citations = []
|
| 369 |
+
|
| 370 |
+
# Connect to WebSocket endpoint
|
| 371 |
+
ws = websocket.create_connection("ws://localhost:8000/ws/query", timeout=60)
|
| 372 |
+
|
| 373 |
+
# Send query
|
| 374 |
+
message = {
|
| 375 |
+
"query": user_message,
|
| 376 |
+
"session_id": "gradio_session"
|
| 377 |
+
}
|
| 378 |
+
ws.send(json.dumps(message))
|
| 379 |
+
|
| 380 |
+
# Receive and process stream
|
| 381 |
+
while True:
|
| 382 |
+
try:
|
| 383 |
+
msg = ws.recv()
|
| 384 |
+
data = json.loads(msg)
|
| 385 |
+
msg_type = data.get("type")
|
| 386 |
+
|
| 387 |
+
if msg_type == "metadata":
|
| 388 |
+
# Initial metadata received
|
| 389 |
+
logging.info(f"WebSocket session: {data.get('session_id')}")
|
| 390 |
+
|
| 391 |
+
elif msg_type == "intent":
|
| 392 |
+
intent = data.get("intent")
|
| 393 |
+
# Show intent in response
|
| 394 |
+
status_msg = f"*Detected intent: {intent}*\n\n"
|
| 395 |
+
yield history + [[user_message, status_msg]]
|
| 396 |
+
|
| 397 |
+
elif msg_type == "token":
|
| 398 |
+
# Stream token to UI
|
| 399 |
+
token = data.get("token", "")
|
| 400 |
+
accumulated_response += token
|
| 401 |
+
# Yield updated history with partial response
|
| 402 |
+
status_prefix = f"*Intent: {intent}*\n\n" if intent else ""
|
| 403 |
+
yield history + [[user_message, status_prefix + accumulated_response]]
|
| 404 |
+
|
| 405 |
+
elif msg_type == "citations":
|
| 406 |
+
citations = data.get("citations", [])
|
| 407 |
+
|
| 408 |
+
elif msg_type == "done":
|
| 409 |
+
latency_ms = data.get("latency_ms")
|
| 410 |
+
logging.info(f"WebSocket stream completed in {latency_ms}ms")
|
| 411 |
+
break
|
| 412 |
+
|
| 413 |
+
elif msg_type == "error":
|
| 414 |
+
error_msg = data.get("error", "Unknown error")
|
| 415 |
+
yield history + [[user_message, f"ERROR: Error: {error_msg}"]]
|
| 416 |
+
ws.close()
|
| 417 |
+
return
|
| 418 |
+
|
| 419 |
+
except websocket.WebSocketTimeoutException:
|
| 420 |
+
logging.warning("WebSocket timeout")
|
| 421 |
+
break
|
| 422 |
+
except json.JSONDecodeError as e:
|
| 423 |
+
logging.error(f"Failed to parse WebSocket message: {e}")
|
| 424 |
+
continue
|
| 425 |
+
|
| 426 |
+
ws.close()
|
| 427 |
+
|
| 428 |
+
# Add citations to final response
|
| 429 |
+
if citations:
|
| 430 |
+
accumulated_response += "\n\n**Citations:**\n"
|
| 431 |
+
for cite in citations:
|
| 432 |
+
title = cite.get("title", "Unknown")
|
| 433 |
+
grant_id = cite.get("grant_id", "N/A")
|
| 434 |
+
accumulated_response += f"- **{title}** (ID: {grant_id})\n"
|
| 435 |
+
|
| 436 |
+
# Add intent badge if available
|
| 437 |
+
if intent:
|
| 438 |
+
final_response = f"*Intent: {intent}*\n\n{accumulated_response}"
|
| 439 |
+
else:
|
| 440 |
+
final_response = accumulated_response
|
| 441 |
+
|
| 442 |
+
yield history + [[user_message, final_response]]
|
| 443 |
+
|
| 444 |
+
except Exception as e:
|
| 445 |
+
logging.error(f"WebSocket streaming error: {e}")
|
| 446 |
+
yield history + [[user_message, f"ERROR: WebSocket error: {e}"]]
|
| 447 |
+
return
|
| 448 |
+
|
| 449 |
+
if use_sse and httpx:
|
| 450 |
+
# Use SSE streaming from API endpoint
|
| 451 |
+
try:
|
| 452 |
+
accumulated_response = ""
|
| 453 |
+
citations = []
|
| 454 |
+
|
| 455 |
+
with httpx.Client(timeout=60.0) as client:
|
| 456 |
+
with client.stream(
|
| 457 |
+
"POST",
|
| 458 |
+
"http://localhost:8000/qa/stream", # Adjust URL as needed
|
| 459 |
+
json={"query": user_message, "use_llm_routing": True}
|
| 460 |
+
) as response:
|
| 461 |
+
for line in response.iter_lines():
|
| 462 |
+
if line.startswith("data: "):
|
| 463 |
+
data_str = line[6:] # Remove "data: " prefix
|
| 464 |
+
try:
|
| 465 |
+
data = json.loads(data_str)
|
| 466 |
+
event_type = data.get("type")
|
| 467 |
+
|
| 468 |
+
if event_type == "token":
|
| 469 |
+
token = data.get("token", "")
|
| 470 |
+
accumulated_response += token
|
| 471 |
+
# Yield updated history with partial response
|
| 472 |
+
yield history + [[user_message, accumulated_response]]
|
| 473 |
+
|
| 474 |
+
elif event_type == "citations":
|
| 475 |
+
citations = data.get("citations", [])
|
| 476 |
+
|
| 477 |
+
elif event_type == "error":
|
| 478 |
+
error = data.get("error", "Unknown error")
|
| 479 |
+
yield history + [[user_message, f"ERROR: Error: {error}"]]
|
| 480 |
+
return
|
| 481 |
+
|
| 482 |
+
except json.JSONDecodeError:
|
| 483 |
+
continue
|
| 484 |
+
|
| 485 |
+
# Add citations to final response
|
| 486 |
+
if citations:
|
| 487 |
+
accumulated_response += "\n\n**Citations:**\n"
|
| 488 |
+
for cite in citations:
|
| 489 |
+
accumulated_response += f"- {cite.get('title')} (ID: {cite.get('grant_id')})\n"
|
| 490 |
+
|
| 491 |
+
yield history + [[user_message, accumulated_response]]
|
| 492 |
+
|
| 493 |
+
except Exception as e:
|
| 494 |
+
logging.error(f"SSE streaming error: {e}")
|
| 495 |
+
yield history + [[user_message, f"ERROR: Streaming error: {e}"]]
|
| 496 |
+
return
|
| 497 |
+
|
| 498 |
+
# Fallback to original non-streaming chat
|
| 499 |
+
response, updated_history = self.chat(user_message, history)
|
| 500 |
+
yield updated_history
|
| 501 |
+
|
| 502 |
+
def chat(self, user_message: str, history: List) -> Tuple[str, List]:
|
| 503 |
+
"""
|
| 504 |
+
Process a chat message (non-streaming).
|
| 505 |
+
|
| 506 |
+
Args:
|
| 507 |
+
user_message: User's input
|
| 508 |
+
history: Gradio chat history
|
| 509 |
+
|
| 510 |
+
Returns:
|
| 511 |
+
(response, updated_history) tuple
|
| 512 |
+
"""
|
| 513 |
+
import time
|
| 514 |
+
|
| 515 |
+
if not self.initialized:
|
| 516 |
+
return "WARNING: System not initialized. Please restart the app.", history
|
| 517 |
+
|
| 518 |
+
start_time = time.time()
|
| 519 |
+
tools_called = []
|
| 520 |
+
timing_info = {}
|
| 521 |
+
|
| 522 |
+
try:
|
| 523 |
+
# Add user message
|
| 524 |
+
self.messages.append({"role": "user", "content": user_message})
|
| 525 |
+
|
| 526 |
+
# Call LLM with function calling
|
| 527 |
+
llm_start = time.time()
|
| 528 |
+
response = self.llm_client.client.chat.completions.create(
|
| 529 |
+
model=self.llm_client.model,
|
| 530 |
+
messages=self.messages,
|
| 531 |
+
tools=self.available_tools,
|
| 532 |
+
tool_choice="auto",
|
| 533 |
+
temperature=0.5, # INCREASED from 0.1 to allow more thorough, creative responses
|
| 534 |
+
max_tokens=4096, # INCREASED to allow detailed summaries without truncation
|
| 535 |
+
)
|
| 536 |
+
timing_info["llm_call"] = time.time() - llm_start
|
| 537 |
+
|
| 538 |
+
response_message = response.choices[0].message
|
| 539 |
+
tool_calls = response_message.tool_calls
|
| 540 |
+
|
| 541 |
+
# If LLM wants to call tools
|
| 542 |
+
if tool_calls:
|
| 543 |
+
# Add assistant's response with tool calls
|
| 544 |
+
self.messages.append(response_message)
|
| 545 |
+
|
| 546 |
+
# Execute each tool call
|
| 547 |
+
tools_start = time.time()
|
| 548 |
+
for tool_call in tool_calls:
|
| 549 |
+
function_name = tool_call.function.name
|
| 550 |
+
import json
|
| 551 |
+
function_args = json.loads(tool_call.function.arguments)
|
| 552 |
+
|
| 553 |
+
logging.info(f"Calling: {function_name}({function_args})")
|
| 554 |
+
tools_called.append(function_name) # Track for logging
|
| 555 |
+
|
| 556 |
+
# Execute tool
|
| 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
|
| 564 |
+
self.messages.append({
|
| 565 |
+
"role": "tool",
|
| 566 |
+
"tool_call_id": tool_call.id,
|
| 567 |
+
"name": function_name,
|
| 568 |
+
"content": str(tool_result)
|
| 569 |
+
})
|
| 570 |
+
|
| 571 |
+
# Get final response
|
| 572 |
+
final_start = time.time()
|
| 573 |
+
final_response = self.llm_client.client.chat.completions.create(
|
| 574 |
+
model=self.llm_client.model,
|
| 575 |
+
messages=self.messages,
|
| 576 |
+
temperature=0.5, # INCREASED from 0.1 for thorough final responses
|
| 577 |
+
max_tokens=4096, # INCREASED to allow complete answers without truncation
|
| 578 |
+
)
|
| 579 |
+
timing_info["final_llm_call"] = time.time() - final_start
|
| 580 |
+
|
| 581 |
+
assistant_message = final_response.choices[0].message.content
|
| 582 |
+
self.messages.append({"role": "assistant", "content": assistant_message})
|
| 583 |
+
else:
|
| 584 |
+
# No tool calls
|
| 585 |
+
assistant_message = response_message.content
|
| 586 |
+
self.messages.append({"role": "assistant", "content": assistant_message})
|
| 587 |
+
|
| 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:
|
| 595 |
+
import csv
|
| 596 |
+
from datetime import datetime
|
| 597 |
+
|
| 598 |
+
log_dir = Path(".") / "logs"
|
| 599 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 600 |
+
|
| 601 |
+
today = datetime.now().strftime("%Y%m%d")
|
| 602 |
+
csv_path = log_dir / f"queries_{today}.csv"
|
| 603 |
+
|
| 604 |
+
# Check if file exists to write header
|
| 605 |
+
file_exists = csv_path.exists()
|
| 606 |
+
|
| 607 |
+
with open(csv_path, 'a', newline='', encoding='utf-8') as f:
|
| 608 |
+
writer = csv.writer(f)
|
| 609 |
+
|
| 610 |
+
# Write header if new file
|
| 611 |
+
if not file_exists:
|
| 612 |
+
writer.writerow([
|
| 613 |
+
'timestamp', 'user_query', 'ai_response', 'tools_called',
|
| 614 |
+
'response_time_ms', 'success', 'rating', 'feedback', 'model'
|
| 615 |
+
])
|
| 616 |
+
|
| 617 |
+
# Write the query
|
| 618 |
+
writer.writerow([
|
| 619 |
+
datetime.now().isoformat(),
|
| 620 |
+
user_message,
|
| 621 |
+
assistant_message[:5000], # Truncate very long responses
|
| 622 |
+
','.join(tools_called),
|
| 623 |
+
response_time_ms,
|
| 624 |
+
'True',
|
| 625 |
+
'', # rating
|
| 626 |
+
'', # feedback
|
| 627 |
+
self.llm_client.model
|
| 628 |
+
])
|
| 629 |
+
|
| 630 |
+
logging.info(f"Query logged to {csv_path}")
|
| 631 |
+
|
| 632 |
+
except Exception as log_error:
|
| 633 |
+
logging.error(f"ERROR: Logging failed: {log_error}", exc_info=True)
|
| 634 |
+
|
| 635 |
+
return assistant_message, history + [[user_message, assistant_message]]
|
| 636 |
+
|
| 637 |
+
except Exception as e:
|
| 638 |
+
error_msg = f"ERROR: Error: {e}"
|
| 639 |
+
logging.error(f"Chat error: {e}", exc_info=True)
|
| 640 |
+
|
| 641 |
+
# Log failed interactions too
|
| 642 |
+
response_time_ms = int((time.time() - start_time) * 1000)
|
| 643 |
+
|
| 644 |
+
# Direct logging to CSV (simpler, more reliable)
|
| 645 |
+
try:
|
| 646 |
+
import csv
|
| 647 |
+
from datetime import datetime
|
| 648 |
+
|
| 649 |
+
log_dir = Path(".") / "logs"
|
| 650 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 651 |
+
|
| 652 |
+
today = datetime.now().strftime("%Y%m%d")
|
| 653 |
+
csv_path = log_dir / f"queries_{today}.csv"
|
| 654 |
+
|
| 655 |
+
# Check if file exists to write header
|
| 656 |
+
file_exists = csv_path.exists()
|
| 657 |
+
|
| 658 |
+
with open(csv_path, 'a', newline='', encoding='utf-8') as f:
|
| 659 |
+
writer = csv.writer(f)
|
| 660 |
+
|
| 661 |
+
# Write header if new file
|
| 662 |
+
if not file_exists:
|
| 663 |
+
writer.writerow([
|
| 664 |
+
'timestamp', 'user_query', 'ai_response', 'tools_called',
|
| 665 |
+
'response_time_ms', 'success', 'rating', 'feedback', 'model'
|
| 666 |
+
])
|
| 667 |
+
|
| 668 |
+
# Write the failed query
|
| 669 |
+
writer.writerow([
|
| 670 |
+
datetime.now().isoformat(),
|
| 671 |
+
user_message,
|
| 672 |
+
error_msg,
|
| 673 |
+
','.join(tools_called),
|
| 674 |
+
response_time_ms,
|
| 675 |
+
'False',
|
| 676 |
+
'', # rating
|
| 677 |
+
'', # feedback
|
| 678 |
+
self.llm_client.model
|
| 679 |
+
])
|
| 680 |
+
|
| 681 |
+
logging.info(f"Failed query logged to {csv_path}")
|
| 682 |
+
|
| 683 |
+
except Exception as log_error:
|
| 684 |
+
logging.error(f"ERROR: Logging failed: {log_error}", exc_info=True)
|
| 685 |
+
|
| 686 |
+
return error_msg, history + [[user_message, error_msg]]
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
|
| 690 |
+
"""Create the Gradio UI."""
|
| 691 |
+
|
| 692 |
+
with gr.Blocks(
|
| 693 |
+
title="Grant Analyst Demo",
|
| 694 |
+
theme=gr.themes.Soft(),
|
| 695 |
+
css="""
|
| 696 |
+
.contain { max-width: 1200px; margin: auto; }
|
| 697 |
+
#status-box { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
|
| 698 |
+
"""
|
| 699 |
+
) as app:
|
| 700 |
+
|
| 701 |
+
gr.Markdown(
|
| 702 |
+
"""
|
| 703 |
+
# Grant Analyst — Client Demo
|
| 704 |
+
|
| 705 |
+
**Intelligent grant search and analysis powered by AI**
|
| 706 |
+
|
| 707 |
+
Ask questions about Innovate UK grants in natural language, or try one of the preset questions below.
|
| 708 |
+
"""
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
# Status display
|
| 712 |
+
status = gr.Markdown(
|
| 713 |
+
"Initializing system...",
|
| 714 |
+
elem_id="status-box"
|
| 715 |
+
)
|
| 716 |
+
|
| 717 |
+
# Initialize on load
|
| 718 |
+
def init_system():
|
| 719 |
+
success, msg = demo.initialize()
|
| 720 |
+
if success:
|
| 721 |
+
return f"**System Ready** — {msg}"
|
| 722 |
+
else:
|
| 723 |
+
return f"ERROR: **Initialization Failed** — {msg}"
|
| 724 |
+
|
| 725 |
+
# Chatbot interface
|
| 726 |
+
with gr.Row():
|
| 727 |
+
with gr.Column(scale=3):
|
| 728 |
+
chatbot = gr.Chatbot(
|
| 729 |
+
label="Conversation",
|
| 730 |
+
height=500,
|
| 731 |
+
bubble_full_width=False
|
| 732 |
+
)
|
| 733 |
+
|
| 734 |
+
with gr.Row():
|
| 735 |
+
msg_input = gr.Textbox(
|
| 736 |
+
label="Your Question",
|
| 737 |
+
placeholder="Ask me anything about grants...",
|
| 738 |
+
scale=4
|
| 739 |
+
)
|
| 740 |
+
send_btn = gr.Button("Send", scale=1, variant="primary")
|
| 741 |
+
|
| 742 |
+
with gr.Row():
|
| 743 |
+
clear_btn = gr.Button("Clear Conversation")
|
| 744 |
+
|
| 745 |
+
with gr.Column(scale=1):
|
| 746 |
+
gr.Markdown("### Preset Questions")
|
| 747 |
+
gr.Markdown("*Click to try these guaranteed examples:*")
|
| 748 |
+
|
| 749 |
+
preset_btns = []
|
| 750 |
+
for label, question in PRESET_QUESTIONS.items():
|
| 751 |
+
btn = gr.Button(label, size="sm")
|
| 752 |
+
preset_btns.append((btn, question))
|
| 753 |
+
|
| 754 |
+
# Examples section
|
| 755 |
+
gr.Markdown(
|
| 756 |
+
"""
|
| 757 |
+
---
|
| 758 |
+
### What You Can Ask
|
| 759 |
+
|
| 760 |
+
**Listing & Search:**
|
| 761 |
+
- "Show me all battery-related grants"
|
| 762 |
+
- "What grants are available for SMEs?"
|
| 763 |
+
- "List grants with funding over £500k"
|
| 764 |
+
|
| 765 |
+
**Deadlines:**
|
| 766 |
+
- "What are the upcoming deadlines?"
|
| 767 |
+
- "When does competition-2313 close?"
|
| 768 |
+
|
| 769 |
+
**Comparisons:**
|
| 770 |
+
- "Compare competition-2313 and competition-2314"
|
| 771 |
+
- "What's the difference between these two grants?"
|
| 772 |
+
|
| 773 |
+
**Details:**
|
| 774 |
+
- "Tell me about competition-2317"
|
| 775 |
+
- "Summarize the battery feasibility grant"
|
| 776 |
+
"""
|
| 777 |
+
)
|
| 778 |
+
|
| 779 |
+
# Event handlers
|
| 780 |
+
def respond(message, chat_history):
|
| 781 |
+
"""Handle user message."""
|
| 782 |
+
# Use non-streaming response
|
| 783 |
+
bot_response, updated_history = demo.chat(message, chat_history)
|
| 784 |
+
yield "", updated_history
|
| 785 |
+
|
| 786 |
+
def use_preset(question, chat_history):
|
| 787 |
+
"""Handle preset question click."""
|
| 788 |
+
for result in respond(question, chat_history):
|
| 789 |
+
yield result
|
| 790 |
+
|
| 791 |
+
# Wire up events
|
| 792 |
+
msg_input.submit(
|
| 793 |
+
respond,
|
| 794 |
+
[msg_input, chatbot],
|
| 795 |
+
[msg_input, chatbot]
|
| 796 |
+
)
|
| 797 |
+
send_btn.click(
|
| 798 |
+
respond,
|
| 799 |
+
[msg_input, chatbot],
|
| 800 |
+
[msg_input, chatbot]
|
| 801 |
+
)
|
| 802 |
+
clear_btn.click(lambda: [], None, chatbot)
|
| 803 |
+
|
| 804 |
+
for btn, question in preset_btns:
|
| 805 |
+
btn.click(
|
| 806 |
+
use_preset,
|
| 807 |
+
inputs=[gr.State(question), chatbot],
|
| 808 |
+
outputs=[msg_input, chatbot]
|
| 809 |
+
)
|
| 810 |
+
|
| 811 |
+
# Initialize on load
|
| 812 |
+
app.load(init_system, None, status)
|
| 813 |
+
|
| 814 |
+
return app
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def main():
|
| 818 |
+
"""Launch the demo application."""
|
| 819 |
+
|
| 820 |
+
parser = argparse.ArgumentParser(description="Grant Analyst Demo App")
|
| 821 |
+
parser.add_argument("--share", action="store_true", help="Create public shareable link")
|
| 822 |
+
parser.add_argument("--port", type=int, default=7860, help="Port to run on")
|
| 823 |
+
args = parser.parse_args()
|
| 824 |
+
|
| 825 |
+
# Setup logging (create log directory if needed)
|
| 826 |
+
log_handlers = [logging.StreamHandler(sys.stderr)]
|
| 827 |
+
try:
|
| 828 |
+
from pathlib import Path
|
| 829 |
+
log_dir = Path("_out/logs")
|
| 830 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 831 |
+
log_handlers.append(logging.FileHandler(log_dir / "demo_app.log"))
|
| 832 |
+
except Exception as e:
|
| 833 |
+
logging.warning(f"Could not create log file: {e}")
|
| 834 |
+
|
| 835 |
+
logging.basicConfig(
|
| 836 |
+
level=logging.INFO,
|
| 837 |
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
| 838 |
+
handlers=log_handlers
|
| 839 |
+
)
|
| 840 |
+
|
| 841 |
+
# Create demo instance
|
| 842 |
+
demo = GrantAnalystDemo()
|
| 843 |
+
|
| 844 |
+
# Create and launch UI
|
| 845 |
+
app = create_demo_ui(demo)
|
| 846 |
+
|
| 847 |
+
print("\n" + "="*60)
|
| 848 |
+
print("Launching Grant Analyst Demo...")
|
| 849 |
+
print("="*60)
|
| 850 |
+
|
| 851 |
+
app.launch(
|
| 852 |
+
server_name="0.0.0.0",
|
| 853 |
+
server_port=args.port,
|
| 854 |
+
share=args.share,
|
| 855 |
+
show_error=True,
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
if __name__ == "__main__":
|
| 860 |
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/chat/insight_tools.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from typing import Dict, Any, List, Optional
|
| 4 |
+
|
| 5 |
+
from ..config import load_config
|
| 6 |
+
try:
|
| 7 |
+
from ..llm_client import LLMClient
|
| 8 |
+
except Exception:
|
| 9 |
+
LLMClient = None # type: ignore
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
from ..search.hybrid_index import load_index, search, top_supporting_for_grant
|
| 13 |
+
except Exception:
|
| 14 |
+
load_index = None # type: ignore
|
| 15 |
+
search = None # type: ignore
|
| 16 |
+
top_supporting_for_grant = None # type: ignore
|
| 17 |
+
|
| 18 |
+
from ..prompt_templates import build_open_prompt
|
| 19 |
+
|
| 20 |
+
class InsightTools:
|
| 21 |
+
"""
|
| 22 |
+
Retrieval + LLM glue for open, lightly-guarded answers.
|
| 23 |
+
- Accepts an existing LLMClient (preferred) to ensure identical config across entrypoints
|
| 24 |
+
- Falls back gracefully if LLM or index is unavailable
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, llm_client: Optional["LLMClient"] = None):
|
| 28 |
+
self._cfg = load_config()
|
| 29 |
+
self._client = llm_client
|
| 30 |
+
if self._client is None and LLMClient is not None:
|
| 31 |
+
try:
|
| 32 |
+
self._client = LLMClient(self._cfg)
|
| 33 |
+
except Exception:
|
| 34 |
+
self._client = None
|
| 35 |
+
|
| 36 |
+
self._idx = None
|
| 37 |
+
if callable(load_index):
|
| 38 |
+
try:
|
| 39 |
+
self._idx = load_index()
|
| 40 |
+
except Exception:
|
| 41 |
+
self._idx = None
|
| 42 |
+
|
| 43 |
+
# Optional helper some callers use to append snippets under a summary
|
| 44 |
+
def supporting_snippets_md(self, grant_id: str, k: int = 5) -> str:
|
| 45 |
+
if not self._idx or not callable(top_supporting_for_grant):
|
| 46 |
+
return ""
|
| 47 |
+
try:
|
| 48 |
+
hits = top_supporting_for_grant(self._idx, grant_id, k=k)
|
| 49 |
+
except Exception:
|
| 50 |
+
return ""
|
| 51 |
+
items: List[str] = []
|
| 52 |
+
for doc, score in hits:
|
| 53 |
+
if doc.get("_source") != "supporting":
|
| 54 |
+
continue
|
| 55 |
+
sec = doc.get("section") or "(Supporting)"
|
| 56 |
+
url = doc.get("url","")
|
| 57 |
+
txt = (doc.get("text","") or "").replace("\n"," ")
|
| 58 |
+
snippet = (txt[:400] + "…") if len(txt) > 400 else txt
|
| 59 |
+
items.append(f"- **{sec}** — {url}\n > {snippet}")
|
| 60 |
+
if not items:
|
| 61 |
+
return ""
|
| 62 |
+
return "\n\n---\n**Supporting info (top snippets)**\n" + "\n".join(items)
|
| 63 |
+
|
| 64 |
+
def insight_search(self, question: str, *, k: int = 8, use_llm: bool = True) -> Dict[str, Any]:
|
| 65 |
+
"""Open-style grounded QA: supply question + top snippets; let the LLM pick format and length."""
|
| 66 |
+
if not self._idx or not callable(search):
|
| 67 |
+
return {"answer_md": "Search index not available."}
|
| 68 |
+
|
| 69 |
+
hits = search(self._idx, question, k=k, filters=None)
|
| 70 |
+
ev_lines: List[str] = []
|
| 71 |
+
raw_context: List[str] = []
|
| 72 |
+
for doc, score in hits[:k]:
|
| 73 |
+
sec = doc.get("section") or doc.get("title","")
|
| 74 |
+
url = doc.get("url","")
|
| 75 |
+
txt = (doc.get("text","") or "")
|
| 76 |
+
ev_lines.append(f"- **{sec}** — {url}")
|
| 77 |
+
raw_context.append(f"[{sec}] {url}\n{txt}")
|
| 78 |
+
|
| 79 |
+
evidence_md = "**Sources**\n" + "\n".join(ev_lines) if ev_lines else ""
|
| 80 |
+
|
| 81 |
+
if use_llm and self._client:
|
| 82 |
+
payload = build_open_prompt(
|
| 83 |
+
provider=getattr(self._cfg, "provider", "openai") if self._cfg else "openai",
|
| 84 |
+
question=question,
|
| 85 |
+
context="\n\n---\n".join(raw_context[:6]),
|
| 86 |
+
)
|
| 87 |
+
try:
|
| 88 |
+
# Slightly higher budget; model decides format naturally
|
| 89 |
+
answer = self._client.chat(payload["messages"], max_tokens=1200, temperature=0.3)
|
| 90 |
+
return {"answer_md": f"{answer}\n\n---\n{evidence_md}"}
|
| 91 |
+
except Exception:
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
return {"answer_md": "No LLM available. See sources below.\n\n---\n" + evidence_md}
|
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# memory.py — lightweight conversation memory
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from dataclasses import dataclass, asdict
|
| 4 |
+
from typing import List, Dict, Optional
|
| 5 |
+
import json, time, os
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class Turn:
|
| 9 |
+
role: str # "user" | "assistant" | "tool"
|
| 10 |
+
content: str
|
| 11 |
+
meta: Dict = None
|
| 12 |
+
ts: float = 0.0
|
| 13 |
+
|
| 14 |
+
def to_dict(self):
|
| 15 |
+
d = asdict(self)
|
| 16 |
+
d["ts"] = self.ts or time.time()
|
| 17 |
+
d["meta"] = self.meta or {}
|
| 18 |
+
return d
|
| 19 |
+
|
| 20 |
+
class ConversationMemory:
|
| 21 |
+
"""
|
| 22 |
+
Simple memory with:
|
| 23 |
+
- buffer: last N turns (short-term)
|
| 24 |
+
- summary: rolling abstractive summary (long-term)
|
| 25 |
+
- entities: key entities/ids spotted so far
|
| 26 |
+
"""
|
| 27 |
+
def __init__(self, path: str, buffer_size: int = 12):
|
| 28 |
+
self.path = path
|
| 29 |
+
self.buffer_size = buffer_size
|
| 30 |
+
self.buffer: List[Turn] = []
|
| 31 |
+
self.summary: str = ""
|
| 32 |
+
self.entities: Dict[str, List[str]] = {} # e.g., {"competition_id": ["2313", ...]}
|
| 33 |
+
self._load()
|
| 34 |
+
|
| 35 |
+
# ------------ persistence ------------
|
| 36 |
+
def _load(self):
|
| 37 |
+
if not os.path.exists(self.path): return
|
| 38 |
+
with open(self.path, "r") as f:
|
| 39 |
+
data = json.load(f)
|
| 40 |
+
self.buffer = [Turn(**t) for t in data.get("buffer", [])]
|
| 41 |
+
self.summary = data.get("summary", "")
|
| 42 |
+
self.entities = data.get("entities", {})
|
| 43 |
+
|
| 44 |
+
def _save(self):
|
| 45 |
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
| 46 |
+
with open(self.path, "w") as f:
|
| 47 |
+
json.dump({
|
| 48 |
+
"buffer": [t.to_dict() for t in self.buffer],
|
| 49 |
+
"summary": self.summary,
|
| 50 |
+
"entities": self.entities,
|
| 51 |
+
}, f, ensure_ascii=False, indent=2)
|
| 52 |
+
|
| 53 |
+
# ------------ public API ------------
|
| 54 |
+
def add_turn(self, role: str, content: str, meta: Optional[Dict]=None):
|
| 55 |
+
self.buffer.append(Turn(role=role, content=content, meta=meta or {}, ts=time.time()))
|
| 56 |
+
if len(self.buffer) > self.buffer_size:
|
| 57 |
+
self.buffer = self.buffer[-self.buffer_size:]
|
| 58 |
+
self._save()
|
| 59 |
+
|
| 60 |
+
def get_context(self) -> Dict:
|
| 61 |
+
"""What to feed into prompts/tools."""
|
| 62 |
+
return {
|
| 63 |
+
"summary": self.summary,
|
| 64 |
+
"recent": [{"role": t.role, "content": t.content} for t in self.buffer[-self.buffer_size:]],
|
| 65 |
+
"entities": self.entities,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
def update_summary(self, llm_summarize_fn):
|
| 69 |
+
"""
|
| 70 |
+
Call with a function that maps (summary, recent) -> new_summary.
|
| 71 |
+
Only do this occasionally (e.g., every 6–10 user turns).
|
| 72 |
+
"""
|
| 73 |
+
if not self.buffer:
|
| 74 |
+
return
|
| 75 |
+
recent_text = "\n".join(
|
| 76 |
+
f"{t.role.upper()}: {t.content}" for t in self.buffer[-self.buffer_size:]
|
| 77 |
+
)
|
| 78 |
+
prompt = (
|
| 79 |
+
"You are a diligent note-taker. Update the long-term summary of this conversation.\n"
|
| 80 |
+
"Keep it under 150 words. Capture tasks, preferences, important grant IDs/themes, and open questions.\n\n"
|
| 81 |
+
f"EXISTING SUMMARY:\n{self.summary or '(none)'}\n\n"
|
| 82 |
+
f"RECENT TURNS:\n{recent_text}\n\n"
|
| 83 |
+
"Return ONLY the updated summary text."
|
| 84 |
+
)
|
| 85 |
+
new_sum = llm_summarize_fn(prompt).strip()
|
| 86 |
+
if new_sum:
|
| 87 |
+
self.summary = new_sum
|
| 88 |
+
self._save()
|
| 89 |
+
|
| 90 |
+
def add_entity(self, kind: str, value: str):
|
| 91 |
+
if not value: return
|
| 92 |
+
arr = self.entities.setdefault(kind, [])
|
| 93 |
+
if value not in arr:
|
| 94 |
+
arr.append(value)
|
| 95 |
+
self._save()
|
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/chat/query_router.py
|
| 2 |
+
"""
|
| 3 |
+
Query routing: detect user intent from natural language questions.
|
| 4 |
+
|
| 5 |
+
Supports synonyms and variations:
|
| 6 |
+
- "What grants are available?" = "List all grants"
|
| 7 |
+
- "Show me funding opportunities" = "List all grants"
|
| 8 |
+
- "List all open grants" = "Filter by status=open"
|
| 9 |
+
- "What can I apply for?" = "List available opportunities"
|
| 10 |
+
|
| 11 |
+
This helps reduce redundant information requests and improves UX.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import Dict, Optional, Tuple, List
|
| 16 |
+
import json, re, hashlib, time, logging
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
_INTENTS = {"list", "summarize", "compare", "deadlines", "search", "general", "get_grant", "list_grants"}
|
| 21 |
+
|
| 22 |
+
# Routing cache with 24-hour TTL
|
| 23 |
+
_routing_cache: Dict[str, Tuple[Dict, float]] = {}
|
| 24 |
+
_CACHE_TTL = 86400 # 24 hours in seconds
|
| 25 |
+
|
| 26 |
+
# Accept "competition-2315", "2315", "comp-2315"
|
| 27 |
+
_ID_RE = re.compile(r"(?:comp(?:etition)?-)?([0-9]{3,7})", re.IGNORECASE)
|
| 28 |
+
|
| 29 |
+
# Synonym groups for better intent detection
|
| 30 |
+
GRANT_SYNONYMS = {
|
| 31 |
+
"grants", "grant", "calls", "call", "opportunities", "opportunity",
|
| 32 |
+
"funding", "competitions", "competition", "schemes", "scheme"
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
LIST_INTENT_SYNONYMS = {
|
| 36 |
+
"list", "show", "display", "what", "which", "all", "available",
|
| 37 |
+
"open", "upcoming", "closed", "find", "get"
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
STOPWORDS = {
|
| 41 |
+
"what","which","are","is","the","a","an","for","about","of","to","in","on","and","with",
|
| 42 |
+
"available","there","any","please","show","me","find","search","grants","grant","calls",
|
| 43 |
+
"opportunities","funding","compare","vs","versus","between","two","both",
|
| 44 |
+
"can", "i", "me", "my", "your", "apply", "get"
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class Routed:
|
| 49 |
+
intent: str
|
| 50 |
+
args: Dict
|
| 51 |
+
confidence: float
|
| 52 |
+
def to_dict(self) -> Dict:
|
| 53 |
+
return {"intent": self.intent, "args": self.args, "confidence": self.confidence}
|
| 54 |
+
|
| 55 |
+
def _extract_ids(text: str) -> Tuple[List[str], str]:
|
| 56 |
+
ids = [m.group(1) for m in _ID_RE.finditer(text)]
|
| 57 |
+
residual = _ID_RE.sub("", text).strip()
|
| 58 |
+
return ids, residual
|
| 59 |
+
|
| 60 |
+
def _keywords_from_question(t: str) -> str:
|
| 61 |
+
# pull phrase after 'for ' or 'in ' if present, else keep content tokens
|
| 62 |
+
m = re.search(r"(?:for|in)\s+([A-Za-z0-9\- ][A-Za-z0-9\-\s]+)\??$", t, flags=re.IGNORECASE)
|
| 63 |
+
phrase = (m.group(1) if m else t).strip()
|
| 64 |
+
tokens = [w.lower() for w in re.findall(r"[A-Za-z0-9\-]+", phrase) if w.lower() not in STOPWORDS]
|
| 65 |
+
return " ".join(tokens[-4:]) if tokens else phrase.lower()
|
| 66 |
+
|
| 67 |
+
def _detect_list_intent(text: str) -> bool:
|
| 68 |
+
"""
|
| 69 |
+
Detect if user wants to list/view all grants.
|
| 70 |
+
|
| 71 |
+
Recognizes patterns like:
|
| 72 |
+
- "What grants are available?"
|
| 73 |
+
- "Show me funding opportunities"
|
| 74 |
+
- "List all open grants"
|
| 75 |
+
- "What can I apply for?"
|
| 76 |
+
"""
|
| 77 |
+
low = text.lower()
|
| 78 |
+
|
| 79 |
+
# Explicit list/show commands
|
| 80 |
+
if low.startswith(("list", "show", "display", "get me")):
|
| 81 |
+
return True
|
| 82 |
+
|
| 83 |
+
# "What/which ... grants/opportunities/funding" patterns
|
| 84 |
+
list_patterns = [
|
| 85 |
+
"what grants", "what funding", "what opportunities", "what calls",
|
| 86 |
+
"which grants", "which funding", "which opportunities", "which calls",
|
| 87 |
+
"what can i apply for", "what's available", "what's open",
|
| 88 |
+
"show me grants", "show me funding", "show me opportunities", "show me calls",
|
| 89 |
+
"what opportunities", "what calls"
|
| 90 |
+
]
|
| 91 |
+
if any(p in low for p in list_patterns):
|
| 92 |
+
return True
|
| 93 |
+
|
| 94 |
+
# "All grants" or "all open/closed grants"
|
| 95 |
+
if "all " in low and any(w in low for w in GRANT_SYNONYMS):
|
| 96 |
+
return True
|
| 97 |
+
|
| 98 |
+
return False
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _detect_status_filter(text: str) -> Optional[str]:
|
| 102 |
+
"""Extract status filter from question if present."""
|
| 103 |
+
low = text.lower()
|
| 104 |
+
|
| 105 |
+
if "open" in low and ("grants" in low or "opportunities" in low or "calls" in low):
|
| 106 |
+
return "open"
|
| 107 |
+
if "closed" in low and any(w in low for w in GRANT_SYNONYMS):
|
| 108 |
+
return "closed"
|
| 109 |
+
if "upcoming" in low and any(w in low for w in GRANT_SYNONYMS):
|
| 110 |
+
return "upcoming"
|
| 111 |
+
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def classify_complexity(query: str) -> str:
|
| 116 |
+
"""
|
| 117 |
+
Classify query complexity to select appropriate model.
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
'simple': Use gpt-5-mini for simple translation/explanation tasks
|
| 121 |
+
'complex': Use gpt-5 for detailed analysis/comparisons
|
| 122 |
+
'medium': Default, use gpt-5-mini
|
| 123 |
+
"""
|
| 124 |
+
low = query.lower()
|
| 125 |
+
|
| 126 |
+
# Simple queries: translation, explanation, layman's terms
|
| 127 |
+
simple_keywords = {"translate", "explain", "simple", "layman", "what is", "define"}
|
| 128 |
+
if any(kw in low for kw in simple_keywords):
|
| 129 |
+
return 'simple'
|
| 130 |
+
|
| 131 |
+
# Complex queries: detailed analysis, comparisons
|
| 132 |
+
complex_keywords = {"compare", "analyze", "detailed", "comprehensive", "in-depth"}
|
| 133 |
+
if any(kw in low for kw in complex_keywords):
|
| 134 |
+
return 'complex'
|
| 135 |
+
|
| 136 |
+
# Default to medium complexity
|
| 137 |
+
return 'medium'
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _route_with_llm(text: str) -> Optional[str]:
|
| 141 |
+
"""
|
| 142 |
+
Route query using GPT-5-nano for intelligent intent classification.
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
Intent string or None if LLM fails
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
from ..llm_client import LLMClient
|
| 149 |
+
from ..config import load_config
|
| 150 |
+
|
| 151 |
+
# Initialize LLM client with router model
|
| 152 |
+
config = load_config()
|
| 153 |
+
client = LLMClient(config)
|
| 154 |
+
|
| 155 |
+
if not client.is_ready():
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
# Build routing prompt
|
| 159 |
+
prompt = f"""Classify this query into ONE of these intents: search, summarize, compare, get_grant, list_grants, deadlines, general.
|
| 160 |
+
|
| 161 |
+
Query: {text}
|
| 162 |
+
|
| 163 |
+
Respond with ONLY the intent word, nothing else."""
|
| 164 |
+
|
| 165 |
+
# Get LLM classification with routing parameters
|
| 166 |
+
messages = [
|
| 167 |
+
{"role": "system", "content": "You are a query intent classifier. Respond with only one word."},
|
| 168 |
+
{"role": "user", "content": prompt}
|
| 169 |
+
]
|
| 170 |
+
|
| 171 |
+
response = client.chat(
|
| 172 |
+
messages,
|
| 173 |
+
model_type="router", # Use gpt-5-nano
|
| 174 |
+
verbosity="low",
|
| 175 |
+
reasoning_effort="minimal",
|
| 176 |
+
max_tokens=10,
|
| 177 |
+
temperature=0.1
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# Parse single word response
|
| 181 |
+
intent = response.strip().lower()
|
| 182 |
+
|
| 183 |
+
# Validate intent
|
| 184 |
+
if intent in _INTENTS:
|
| 185 |
+
logger.info(f"🤖 LLM routing: '{text[:50]}...' -> {intent}")
|
| 186 |
+
return intent
|
| 187 |
+
else:
|
| 188 |
+
logger.warning(f"LLM returned invalid intent: {intent}")
|
| 189 |
+
return None
|
| 190 |
+
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.warning(f"LLM routing failed: {e}")
|
| 193 |
+
return None
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _route_with_regex(text: str) -> Dict:
|
| 197 |
+
"""
|
| 198 |
+
Fallback regex-based routing (original implementation).
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
Routed dict with intent, args, and confidence
|
| 202 |
+
"""
|
| 203 |
+
t = text.strip()
|
| 204 |
+
low = t.lower()
|
| 205 |
+
|
| 206 |
+
# Check for status-specific listing first (higher priority)
|
| 207 |
+
status_filter = _detect_status_filter(t)
|
| 208 |
+
|
| 209 |
+
# Explicit list/show commands (highest confidence)
|
| 210 |
+
if low.startswith("list") or low.startswith("show") or _detect_list_intent(t):
|
| 211 |
+
# Extract keyword, but be smart about filler words
|
| 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",
|
| 221 |
+
"is", "are", "the", "a", "an", "and", "or", "for", "to", "in", "on", "with"
|
| 222 |
+
}
|
| 223 |
+
kw_tokens = [w for w in kw.lower().split() if w not in filler]
|
| 224 |
+
kw = " ".join(kw_tokens) if kw_tokens else ""
|
| 225 |
+
|
| 226 |
+
args = {}
|
| 227 |
+
if kw:
|
| 228 |
+
args["keyword"] = kw
|
| 229 |
+
if status_filter:
|
| 230 |
+
args["status"] = status_filter
|
| 231 |
+
|
| 232 |
+
args["limit"] = None
|
| 233 |
+
return Routed("list", args, 0.75).to_dict()
|
| 234 |
+
|
| 235 |
+
if low.startswith("summarize") or low.startswith("summarise"):
|
| 236 |
+
ids, _ = _extract_ids(t)
|
| 237 |
+
if len(ids) >= 2:
|
| 238 |
+
return Routed("compare", {"grant_id_a": f"competition-{ids[0]}", "grant_id_b": f"competition-{ids[1]}"}, 0.85).to_dict()
|
| 239 |
+
if len(ids) == 1:
|
| 240 |
+
return Routed("summarize", {"grant_id": f"competition-{ids[0]}"}, 0.85).to_dict()
|
| 241 |
+
kw = t.split(" ", 1)[1].strip() if " " in t else ""
|
| 242 |
+
return Routed("search", {"keyword": kw, "limit": None}, 0.6).to_dict()
|
| 243 |
+
|
| 244 |
+
# Deadline queries
|
| 245 |
+
if any(p in low for p in ["deadline", "close date", "when is it due", "when do i need to apply", "application deadline"]):
|
| 246 |
+
return Routed("deadlines", {"n": None}, 0.75).to_dict()
|
| 247 |
+
|
| 248 |
+
# compare / vs / versus / between → compare two grants
|
| 249 |
+
if "compare" in low or " vs " in low or "versus" in low or "between" in low:
|
| 250 |
+
ids, residual = _extract_ids(t)
|
| 251 |
+
if len(ids) >= 2:
|
| 252 |
+
return Routed(
|
| 253 |
+
"compare",
|
| 254 |
+
{"grant_id_a": f"competition-{ids[0]}", "grant_id_b": f"competition-{ids[1]}"},
|
| 255 |
+
0.82
|
| 256 |
+
).to_dict()
|
| 257 |
+
if len(ids) == 1:
|
| 258 |
+
return Routed("summarize", {"grant_id": f"competition-{ids[0]}"}, 0.7).to_dict()
|
| 259 |
+
|
| 260 |
+
# Natural search
|
| 261 |
+
if any(w in low for w in ["grant", "funding", "competition", "call", "apply", "what", "which", "find", "search"]):
|
| 262 |
+
kw = _keywords_from_question(t)
|
| 263 |
+
return Routed("search", {"keyword": kw, "limit": None}, 0.65).to_dict()
|
| 264 |
+
|
| 265 |
+
return Routed("general", {"question": t}, 0.5).to_dict()
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def route(text: str, *, use_llm: bool = True) -> Dict:
|
| 269 |
+
"""
|
| 270 |
+
Route a user query to the appropriate intent handler.
|
| 271 |
+
|
| 272 |
+
Uses GPT-5-nano for intelligent routing with regex fallback.
|
| 273 |
+
Results are cached for 24 hours for performance.
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
text: User query text
|
| 277 |
+
use_llm: If True, use GPT-5-nano for routing (default: True)
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
Dict with intent, args, and confidence
|
| 281 |
+
"""
|
| 282 |
+
t = text.strip()
|
| 283 |
+
|
| 284 |
+
# Check cache first
|
| 285 |
+
cache_key = hashlib.md5(t.lower().encode()).hexdigest()
|
| 286 |
+
if cache_key in _routing_cache:
|
| 287 |
+
cached_result, cached_time = _routing_cache[cache_key]
|
| 288 |
+
if time.time() - cached_time < _CACHE_TTL:
|
| 289 |
+
logger.debug(f"📦 Cache HIT for routing: '{t[:50]}...'")
|
| 290 |
+
return cached_result
|
| 291 |
+
|
| 292 |
+
# Try LLM routing first (if enabled)
|
| 293 |
+
result = None
|
| 294 |
+
if use_llm:
|
| 295 |
+
llm_intent = _route_with_llm(t)
|
| 296 |
+
if llm_intent:
|
| 297 |
+
# Extract args based on intent
|
| 298 |
+
args = {}
|
| 299 |
+
ids, _ = _extract_ids(t)
|
| 300 |
+
|
| 301 |
+
if llm_intent == "summarize" and len(ids) == 1:
|
| 302 |
+
args["grant_id"] = f"competition-{ids[0]}"
|
| 303 |
+
elif llm_intent == "compare" and len(ids) >= 2:
|
| 304 |
+
args["grant_id_a"] = f"competition-{ids[0]}"
|
| 305 |
+
args["grant_id_b"] = f"competition-{ids[1]}"
|
| 306 |
+
elif llm_intent in ("search", "list_grants"):
|
| 307 |
+
kw = _keywords_from_question(t)
|
| 308 |
+
if kw:
|
| 309 |
+
args["keyword"] = kw
|
| 310 |
+
status = _detect_status_filter(t)
|
| 311 |
+
if status:
|
| 312 |
+
args["status"] = status
|
| 313 |
+
args["limit"] = None
|
| 314 |
+
elif llm_intent == "get_grant" and len(ids) == 1:
|
| 315 |
+
args["grant_id"] = f"competition-{ids[0]}"
|
| 316 |
+
elif llm_intent == "deadlines":
|
| 317 |
+
args["n"] = None
|
| 318 |
+
else:
|
| 319 |
+
args["question"] = t
|
| 320 |
+
|
| 321 |
+
result = Routed(llm_intent, args, 0.95).to_dict()
|
| 322 |
+
|
| 323 |
+
# Fall back to regex if LLM failed or disabled
|
| 324 |
+
if result is None:
|
| 325 |
+
logger.debug(f"🔧 Using regex fallback for: '{t[:50]}...'")
|
| 326 |
+
result = _route_with_regex(t)
|
| 327 |
+
|
| 328 |
+
# Cache the result
|
| 329 |
+
_routing_cache[cache_key] = (result, time.time())
|
| 330 |
+
|
| 331 |
+
return result
|
| 332 |
+
|
| 333 |
+
# Self-test
|
| 334 |
+
if __name__ == "__main__":
|
| 335 |
+
tests = [
|
| 336 |
+
"list AI calls",
|
| 337 |
+
"summarize competition-2316",
|
| 338 |
+
"summarize 2315 2318",
|
| 339 |
+
"compare 2315 vs 2318 for total funding per project and start-by",
|
| 340 |
+
"find grants for battery feasibility studies",
|
| 341 |
+
"what funding is available for hydrogen?",
|
| 342 |
+
"when is the deadline?",
|
| 343 |
+
]
|
| 344 |
+
for s in tests:
|
| 345 |
+
print(s, "->", route(s))
|
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
run_chat.py — unified interactive terminal chatbot for grant data
|
| 3 |
+
|
| 4 |
+
Features (controlled by flags):
|
| 5 |
+
- Startup diagnostics (--verbose)
|
| 6 |
+
- LLM-assisted routing (--use-llm-routing, default ON)
|
| 7 |
+
- Memory persistence (--with-memory)
|
| 8 |
+
- Extended tools (auto-detected or --extended-tools)
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python -m src.analyzer.chat.run_chat
|
| 12 |
+
python -m src.analyzer.chat.run_chat --verbose --limit 10
|
| 13 |
+
python -m src.analyzer.chat.run_chat --no-llm-routing
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import logging
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
import time
|
| 22 |
+
from collections import Counter
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import List, Dict, Optional
|
| 25 |
+
import re
|
| 26 |
+
|
| 27 |
+
from dotenv import load_dotenv; load_dotenv()
|
| 28 |
+
|
| 29 |
+
from ..config import load_config
|
| 30 |
+
from ..data_loader import load_current_grants, load_past_winners
|
| 31 |
+
from ..llm_client import LLMClient
|
| 32 |
+
from .chat_tools import ChatTools
|
| 33 |
+
from .query_router import route
|
| 34 |
+
from ..logging_setup import setup_logging
|
| 35 |
+
from ..telemetry.logger import QALogger
|
| 36 |
+
from .tool_schemas import openai_tools, detect_extended_features
|
| 37 |
+
from ..utils.errors import (
|
| 38 |
+
GrantAnalyzerError,
|
| 39 |
+
ValidationError,
|
| 40 |
+
DataLoadError,
|
| 41 |
+
SearchError,
|
| 42 |
+
LLMError,
|
| 43 |
+
ConfigError
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Optional: memory (graceful if not available)
|
| 47 |
+
try:
|
| 48 |
+
from .memory import ConversationMemory
|
| 49 |
+
MEMORY_AVAILABLE = True
|
| 50 |
+
except ImportError:
|
| 51 |
+
MEMORY_AVAILABLE = False
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ---------------- Domain-term extraction (for dynamic themes) ---------------- #
|
| 55 |
+
|
| 56 |
+
_STOP = {
|
| 57 |
+
"the","a","an","and","or","of","for","to","in","on","with","by","about","into","from","at","as",
|
| 58 |
+
"call","grant","competition","innovate","uk","round","study","studies","feasibility","phase",
|
| 59 |
+
"funding","programme","program","projects","project","research","development","pilot"
|
| 60 |
+
}
|
| 61 |
+
_TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9\-]+", re.IGNORECASE)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _extract_domain_terms(rows: List[Dict], top_n: int = 150) -> set[str]:
|
| 65 |
+
"""
|
| 66 |
+
Build a compact vocabulary from your grant corpus (titles/summaries/themes).
|
| 67 |
+
This feeds the router's dynamic theme detection (no hardcoding).
|
| 68 |
+
"""
|
| 69 |
+
texts = []
|
| 70 |
+
for r in rows:
|
| 71 |
+
parts = [
|
| 72 |
+
str(r.get("title","")),
|
| 73 |
+
str(r.get("summary","")),
|
| 74 |
+
str(r.get("overview","")),
|
| 75 |
+
str(r.get("scope","")),
|
| 76 |
+
str(r.get("theme","")),
|
| 77 |
+
]
|
| 78 |
+
texts.append(" ".join(p for p in parts if p))
|
| 79 |
+
|
| 80 |
+
unigrams = Counter()
|
| 81 |
+
bigrams = Counter()
|
| 82 |
+
|
| 83 |
+
for txt in texts:
|
| 84 |
+
toks = [t.lower() for t in _TOKEN_RE.findall(txt) if t.lower() not in _STOP and len(t) >= 3]
|
| 85 |
+
unigrams.update(toks)
|
| 86 |
+
for i in range(len(toks)-1):
|
| 87 |
+
w1, w2 = toks[i], toks[i+1]
|
| 88 |
+
if w1 in _STOP or w2 in _STOP:
|
| 89 |
+
continue
|
| 90 |
+
bigrams.update([f"{w1} {w2}"])
|
| 91 |
+
|
| 92 |
+
vocab = set([w for w, _ in unigrams.most_common(top_n)])
|
| 93 |
+
vocab |= set([w for w, _ in bigrams.most_common(max(1, top_n // 2))])
|
| 94 |
+
return vocab
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _startup_diagnostics(cfg, *, llm_ok: bool, idx_ok: bool, mem_ok: bool) -> str:
|
| 98 |
+
"""Generate startup diagnostics string."""
|
| 99 |
+
provider = getattr(cfg, "provider", "?") if hasattr(cfg, "provider") else cfg.get("provider", "?")
|
| 100 |
+
model = getattr(cfg, "model", "?") if hasattr(cfg, "model") else cfg.get("model", "?")
|
| 101 |
+
openai_key_present = bool(os.getenv("OPENAI_API_KEY"))
|
| 102 |
+
|
| 103 |
+
lines = [
|
| 104 |
+
"=== Grant Analyst Chat — Startup Diagnostics ===",
|
| 105 |
+
f"Provider: {provider}",
|
| 106 |
+
f"Model: {model}",
|
| 107 |
+
f"LLM Ready: {llm_ok}",
|
| 108 |
+
f"Index OK: {idx_ok}",
|
| 109 |
+
f"Memory OK: {mem_ok}",
|
| 110 |
+
f"API Key: {'✓' if openai_key_present else '✗'}",
|
| 111 |
+
"",
|
| 112 |
+
]
|
| 113 |
+
return "\n".join(lines)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _index_ok_verbose() -> tuple[bool, str]:
|
| 117 |
+
"""Check if hybrid index is present and valid."""
|
| 118 |
+
from pathlib import Path
|
| 119 |
+
import pickle
|
| 120 |
+
|
| 121 |
+
p = Path("data/index/hybrid_index.pkl")
|
| 122 |
+
if not p.exists():
|
| 123 |
+
return False, f"missing file: {p}"
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
with p.open("rb") as f:
|
| 127 |
+
payload = pickle.load(f)
|
| 128 |
+
except Exception as e:
|
| 129 |
+
return False, f"could not read {p.name}: {e}"
|
| 130 |
+
|
| 131 |
+
if not isinstance(payload, dict):
|
| 132 |
+
return False, f"{p.name} is not a dict payload"
|
| 133 |
+
|
| 134 |
+
docs = payload.get("docs")
|
| 135 |
+
if not isinstance(docs, list):
|
| 136 |
+
return False, f"{p.name} has no 'docs' list"
|
| 137 |
+
|
| 138 |
+
if len(docs) == 0:
|
| 139 |
+
return False, f"{p.name} contains 0 docs"
|
| 140 |
+
|
| 141 |
+
return True, f"{p.name} with {len(docs)} docs"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def main(argv: List[str] | None = None) -> None:
|
| 145 |
+
setup_logging()
|
| 146 |
+
cfg = load_config()
|
| 147 |
+
|
| 148 |
+
ap = argparse.ArgumentParser(description="Interactive chatbot for grant insights")
|
| 149 |
+
ap.add_argument("--snapshots-dir", type=Path, default=Path("data/snapshots"))
|
| 150 |
+
ap.add_argument("--history-xlsx", type=Path,
|
| 151 |
+
default=Path("data/IUK-141025-InnovateUKFundedProjects-FY2015-16topresent.xlsx"))
|
| 152 |
+
ap.add_argument("--limit", type=int, default=0,
|
| 153 |
+
help="Load at most N grants (0 = all)")
|
| 154 |
+
ap.add_argument("--log-jsonl", type=Path, default=Path("_out/chat.jsonl"))
|
| 155 |
+
|
| 156 |
+
# Feature flags
|
| 157 |
+
ap.add_argument("--use-llm-routing", dest="use_llm_routing", action="store_true", default=True,
|
| 158 |
+
help="Enable LLM-assisted routing (default: ON)")
|
| 159 |
+
ap.add_argument("--no-llm-routing", dest="use_llm_routing", action="store_false",
|
| 160 |
+
help="Disable LLM routing (heuristics only)")
|
| 161 |
+
ap.add_argument("--extended-tools", action="store_true", default=False,
|
| 162 |
+
help="Force extended tools (insight_search, fetch_link)")
|
| 163 |
+
ap.add_argument("--with-memory", action="store_true", default=False,
|
| 164 |
+
help="Enable conversation memory (requires memory.py)")
|
| 165 |
+
ap.add_argument("--verbose", action="store_true", default=False,
|
| 166 |
+
help="Show startup diagnostics")
|
| 167 |
+
|
| 168 |
+
args = ap.parse_args(argv)
|
| 169 |
+
|
| 170 |
+
# Load data
|
| 171 |
+
logging.info("Loading data ...")
|
| 172 |
+
current = load_current_grants(args.snapshots_dir, limit=args.limit or None)
|
| 173 |
+
past = load_past_winners(args.history_xlsx)
|
| 174 |
+
logging.info("Loaded %d current grants; %d past winners", len(current), len(past))
|
| 175 |
+
|
| 176 |
+
# Initialize components
|
| 177 |
+
tools = ChatTools(current, past)
|
| 178 |
+
|
| 179 |
+
llm_client = None
|
| 180 |
+
try:
|
| 181 |
+
llm_client = LLMClient(cfg)
|
| 182 |
+
except ConfigError as e:
|
| 183 |
+
logging.warning("LLM config error: %s", e)
|
| 184 |
+
except Exception as e:
|
| 185 |
+
logging.warning("LLMClient init failed: %s", e)
|
| 186 |
+
|
| 187 |
+
# JSONL logger
|
| 188 |
+
try:
|
| 189 |
+
args.log_jsonl.parent.mkdir(parents=True, exist_ok=True)
|
| 190 |
+
except Exception:
|
| 191 |
+
pass
|
| 192 |
+
qalog = QALogger(args.log_jsonl)
|
| 193 |
+
|
| 194 |
+
# Index check
|
| 195 |
+
idx_ok, idx_msg = _index_ok_verbose()
|
| 196 |
+
if not idx_ok:
|
| 197 |
+
logging.info("Index check: %s", idx_msg)
|
| 198 |
+
|
| 199 |
+
# Memory (optional)
|
| 200 |
+
memory = None
|
| 201 |
+
mem_ok = False
|
| 202 |
+
if args.with_memory and MEMORY_AVAILABLE:
|
| 203 |
+
try:
|
| 204 |
+
memory = ConversationMemory("_out/memory/session.json")
|
| 205 |
+
mem_ok = True
|
| 206 |
+
logging.info("Conversation memory enabled")
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logging.warning("Could not init memory: %s", e)
|
| 209 |
+
|
| 210 |
+
# Tool registration
|
| 211 |
+
extended_mode = args.extended_tools or detect_extended_features()
|
| 212 |
+
available_tools = openai_tools(extended=extended_mode)
|
| 213 |
+
logging.info("Registered %d tools (extended: %s)", len(available_tools), extended_mode)
|
| 214 |
+
|
| 215 |
+
# Domain terms for routing (if router supports it)
|
| 216 |
+
domain_terms = _extract_domain_terms(current)
|
| 217 |
+
if domain_terms:
|
| 218 |
+
# Try to inject into router (optional feature)
|
| 219 |
+
try:
|
| 220 |
+
from . import query_router
|
| 221 |
+
if hasattr(query_router, 'set_domain_terms'):
|
| 222 |
+
query_router.set_domain_terms(domain_terms)
|
| 223 |
+
query_router.set_fuzzy_threshold(0.84)
|
| 224 |
+
sample = ", ".join(list(sorted(domain_terms, key=len, reverse=True))[:5])
|
| 225 |
+
logging.info("Loaded %d domain terms (e.g., %s ...)", len(domain_terms), sample)
|
| 226 |
+
else:
|
| 227 |
+
logging.debug("Router does not support dynamic domain terms")
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logging.debug("Could not inject domain terms into router: %s", e)
|
| 230 |
+
|
| 231 |
+
# Startup diagnostics
|
| 232 |
+
if args.verbose:
|
| 233 |
+
diag = _startup_diagnostics(
|
| 234 |
+
cfg,
|
| 235 |
+
llm_ok=bool(llm_client and llm_client.is_ready()),
|
| 236 |
+
idx_ok=idx_ok,
|
| 237 |
+
mem_ok=mem_ok
|
| 238 |
+
)
|
| 239 |
+
print(diag)
|
| 240 |
+
|
| 241 |
+
print("\n💬 Grant Analyst Chat ready! Type 'exit' to quit.\n")
|
| 242 |
+
|
| 243 |
+
# REPL loop
|
| 244 |
+
turn_count = 0
|
| 245 |
+
while True:
|
| 246 |
+
try:
|
| 247 |
+
user_input = input("You: ").strip()
|
| 248 |
+
except (EOFError, KeyboardInterrupt):
|
| 249 |
+
print("\n👋 Goodbye.")
|
| 250 |
+
break
|
| 251 |
+
|
| 252 |
+
if not user_input:
|
| 253 |
+
continue
|
| 254 |
+
|
| 255 |
+
if user_input.lower() in {"exit", "quit"}:
|
| 256 |
+
print("👋 Goodbye.")
|
| 257 |
+
break
|
| 258 |
+
|
| 259 |
+
turn_count += 1
|
| 260 |
+
t0 = time.time()
|
| 261 |
+
|
| 262 |
+
# Route intent
|
| 263 |
+
try:
|
| 264 |
+
routed = route(user_input, use_llm=args.use_llm_routing)
|
| 265 |
+
except Exception as e:
|
| 266 |
+
logging.warning("Routing failed (%s). Falling back to heuristic.", e)
|
| 267 |
+
routed = route(user_input, use_llm=False)
|
| 268 |
+
|
| 269 |
+
intent = str(routed.get("intent") or "general")
|
| 270 |
+
rargs = routed.get("args") or {}
|
| 271 |
+
answer_md = ""
|
| 272 |
+
ok = True
|
| 273 |
+
|
| 274 |
+
try:
|
| 275 |
+
# Handle intents with proper error handling
|
| 276 |
+
if intent in {"search", "list"}:
|
| 277 |
+
filt = (rargs.get("filters") or {}) if isinstance(rargs, dict) else {}
|
| 278 |
+
candidates = rargs.get("keyword_candidates") or []
|
| 279 |
+
primary_kw = rargs.get("keyword") or rargs.get("keyword_hint") or rargs.get("query") or ""
|
| 280 |
+
if primary_kw:
|
| 281 |
+
candidates = [primary_kw] + [c for c in candidates if c != primary_kw]
|
| 282 |
+
if not candidates:
|
| 283 |
+
candidates = [""]
|
| 284 |
+
|
| 285 |
+
res = []
|
| 286 |
+
used_kw = None
|
| 287 |
+
for kw in candidates:
|
| 288 |
+
list_kwargs = {
|
| 289 |
+
"keyword": (kw.strip() if isinstance(kw, str) else ""),
|
| 290 |
+
"max_award": rargs.get("max_award") or filt.get("max_award"),
|
| 291 |
+
"audience": rargs.get("audience") or filt.get("audience"),
|
| 292 |
+
"status": rargs.get("status") or filt.get("status"), # NEW: Add status filter
|
| 293 |
+
"limit": rargs.get("limit"), # FIXED: Don't default to 5, pass None for all
|
| 294 |
+
}
|
| 295 |
+
clean_kwargs = {k: v for k, v in list_kwargs.items() if v not in (None, "")}
|
| 296 |
+
res = tools.list_grants(**clean_kwargs)
|
| 297 |
+
if res:
|
| 298 |
+
used_kw = kw
|
| 299 |
+
break
|
| 300 |
+
|
| 301 |
+
if not res:
|
| 302 |
+
answer_md = "No matching grants found."
|
| 303 |
+
else:
|
| 304 |
+
hdr = f"### Results (matched on '{used_kw}')" if used_kw else "### Results"
|
| 305 |
+
# Include status in display
|
| 306 |
+
bullets = [
|
| 307 |
+
f"- **{r['id']}** — {r['title']}\n"
|
| 308 |
+
f" Status: {r.get('status', 'unknown')} | Deadline: {r.get('deadline','n/a')}"
|
| 309 |
+
for r in res
|
| 310 |
+
]
|
| 311 |
+
# Add count summary
|
| 312 |
+
status_counts = {}
|
| 313 |
+
for r in res:
|
| 314 |
+
s = r.get('status', 'unknown')
|
| 315 |
+
status_counts[s] = status_counts.get(s, 0) + 1
|
| 316 |
+
|
| 317 |
+
count_summary = f"\n**Found {len(res)} grant(s)**: " + \
|
| 318 |
+
", ".join(f"{count} {status}" for status, count in sorted(status_counts.items()))
|
| 319 |
+
|
| 320 |
+
answer_md = hdr + count_summary + "\n\n" + "\n".join(bullets)
|
| 321 |
+
|
| 322 |
+
elif intent == "summarize":
|
| 323 |
+
row = tools.summarize_grant(rargs["grant_id"])
|
| 324 |
+
answer_md = row.get("summary_md", str(row))
|
| 325 |
+
|
| 326 |
+
elif intent == "compare":
|
| 327 |
+
diff = tools.compare_grants(rargs["grant_id_a"], rargs["grant_id_b"])
|
| 328 |
+
answer_md = diff.get("comparison_md", str(diff))
|
| 329 |
+
|
| 330 |
+
elif intent == "deadlines":
|
| 331 |
+
dl = tools.deadlines_overview(rargs.get("n", 5))
|
| 332 |
+
if not dl:
|
| 333 |
+
answer_md = "No deadlines available."
|
| 334 |
+
else:
|
| 335 |
+
answer_md = "### Upcoming deadlines\n" + "\n".join(
|
| 336 |
+
f"- **{d['title']}** → {d['deadline']}" for d in dl
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
else:
|
| 340 |
+
# General Q&A
|
| 341 |
+
if llm_client and llm_client.is_ready():
|
| 342 |
+
answer_md = llm_client.summarize(user_input)
|
| 343 |
+
else:
|
| 344 |
+
answer_md = "LLM not available. Try a structured command like `list battery` or `summarize competition-2316`."
|
| 345 |
+
|
| 346 |
+
except ValidationError as e:
|
| 347 |
+
ok = False
|
| 348 |
+
answer_md = f"⚠️ Invalid input: {e}"
|
| 349 |
+
logging.debug("Validation error: %s", e)
|
| 350 |
+
|
| 351 |
+
except DataLoadError as e:
|
| 352 |
+
ok = False
|
| 353 |
+
answer_md = f"⚠️ Data error: {e}"
|
| 354 |
+
logging.error("Data load error: %s", e)
|
| 355 |
+
|
| 356 |
+
except SearchError as e:
|
| 357 |
+
ok = False
|
| 358 |
+
answer_md = f"⚠️ Search error: {e}"
|
| 359 |
+
logging.error("Search error: %s", e)
|
| 360 |
+
|
| 361 |
+
except LLMError as e:
|
| 362 |
+
ok = False
|
| 363 |
+
answer_md = f"⚠️ LLM error: {e}\n💡 Tip: Check your API key and internet connection"
|
| 364 |
+
logging.error("LLM error: %s", e)
|
| 365 |
+
|
| 366 |
+
except GrantAnalyzerError as e:
|
| 367 |
+
ok = False
|
| 368 |
+
answer_md = f"⚠️ Error: {e}"
|
| 369 |
+
logging.error("Grant analyzer error: %s", e)
|
| 370 |
+
|
| 371 |
+
except Exception as e:
|
| 372 |
+
ok = False
|
| 373 |
+
answer_md = f"❌ Unexpected error: {e}"
|
| 374 |
+
logging.error("Unexpected error in chat turn", exc_info=True)
|
| 375 |
+
|
| 376 |
+
latency_ms = int((time.time() - t0) * 1000)
|
| 377 |
+
print(answer_md)
|
| 378 |
+
|
| 379 |
+
# Log turn
|
| 380 |
+
try:
|
| 381 |
+
qalog.write(
|
| 382 |
+
user=user_input, intent=intent, args=rargs,
|
| 383 |
+
answer_md=answer_md, ok=ok, latency_ms=latency_ms,
|
| 384 |
+
meta={
|
| 385 |
+
"model": getattr(llm_client, "model", None),
|
| 386 |
+
"provider": getattr(llm_client, "provider", None),
|
| 387 |
+
"use_llm_routing": args.use_llm_routing,
|
| 388 |
+
"extended_tools": extended_mode
|
| 389 |
+
}
|
| 390 |
+
)
|
| 391 |
+
except Exception:
|
| 392 |
+
pass
|
| 393 |
+
|
| 394 |
+
# Update memory (if enabled)
|
| 395 |
+
if memory:
|
| 396 |
+
try:
|
| 397 |
+
memory.add_turn("user", user_input)
|
| 398 |
+
memory.add_turn("assistant", answer_md)
|
| 399 |
+
|
| 400 |
+
# Periodic summarization (every 6 turns)
|
| 401 |
+
if turn_count % 6 == 0 and llm_client:
|
| 402 |
+
memory.update_summary(llm_client.summarize)
|
| 403 |
+
except Exception as e:
|
| 404 |
+
logging.warning("Memory update failed: %s", e)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
if __name__ == "__main__":
|
| 408 |
+
main()
|
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
run_chat_llm.py — Natural language chat with LLM-driven function calling
|
| 4 |
+
|
| 5 |
+
This version uses the LLM's native function calling to handle ALL queries,
|
| 6 |
+
making it much better at understanding complex natural language requests.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python -m src.analyzer.chat.run_chat_llm
|
| 10 |
+
python -m src.analyzer.chat.run_chat_llm --verbose
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import logging
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, Dict, List, Optional
|
| 20 |
+
|
| 21 |
+
from ..config import load_config
|
| 22 |
+
from ..data_loader import load_current_grants, load_past_winners
|
| 23 |
+
from ..llm_client import LLMClient
|
| 24 |
+
from ..search.hybrid_index import load_index
|
| 25 |
+
from ..utils.errors import (
|
| 26 |
+
GrantAnalyzerError,
|
| 27 |
+
ValidationError,
|
| 28 |
+
DataLoadError,
|
| 29 |
+
SearchError,
|
| 30 |
+
LLMError,
|
| 31 |
+
ConfigError
|
| 32 |
+
)
|
| 33 |
+
from .chat_tools import ChatTools
|
| 34 |
+
from .tool_schemas import openai_tools, detect_extended_features
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _startup_diagnostics(cfg: Dict, llm_ok: bool, idx_ok: bool) -> str:
|
| 38 |
+
"""Generate startup diagnostics display."""
|
| 39 |
+
provider = cfg.get("llm_provider", "unknown")
|
| 40 |
+
model = cfg.get("llm_model", "unknown")
|
| 41 |
+
api_key = cfg.get("openai_api_key") or cfg.get("anthropic_api_key")
|
| 42 |
+
|
| 43 |
+
diag = [
|
| 44 |
+
"=== Grant Analyst Chat — LLM Function Calling Mode ===",
|
| 45 |
+
f"Provider: {provider}",
|
| 46 |
+
f"Model: {model}",
|
| 47 |
+
f"LLM Ready: {llm_ok}",
|
| 48 |
+
f"Index OK: {idx_ok}",
|
| 49 |
+
f"API Key: {'✓' if api_key else '✗'}",
|
| 50 |
+
""
|
| 51 |
+
]
|
| 52 |
+
return "\n".join(diag)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _dispatch_tool_call(tools: ChatTools, tool_name: str, tool_args: Dict[str, Any]) -> Any:
|
| 56 |
+
"""
|
| 57 |
+
Dispatch a tool call to the appropriate ChatTools method.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
tools: ChatTools instance
|
| 61 |
+
tool_name: Name of the tool to call
|
| 62 |
+
tool_args: Arguments for the tool
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
Tool result (dict or string)
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
if tool_name == "list_grants":
|
| 69 |
+
return tools.list_grants(
|
| 70 |
+
keyword=tool_args.get("keyword"),
|
| 71 |
+
max_award=tool_args.get("max_award"),
|
| 72 |
+
audience=tool_args.get("audience"),
|
| 73 |
+
status=tool_args.get("status"), # NEW: Add status filter
|
| 74 |
+
limit=tool_args.get("limit") # FIXED: Don't default to 5, pass None for all
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
elif tool_name == "get_grant":
|
| 78 |
+
return tools.get_grant(tool_args["grant_id"])
|
| 79 |
+
|
| 80 |
+
elif tool_name == "summarize_grant":
|
| 81 |
+
return tools.summarize_grant(
|
| 82 |
+
tool_args["grant_id"],
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
elif tool_name == "compare_grants":
|
| 86 |
+
return tools.compare_grants(
|
| 87 |
+
tool_args["grant_id_a"],
|
| 88 |
+
tool_args["grant_id_b"]
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
elif tool_name == "deadlines_overview":
|
| 92 |
+
return tools.deadlines_overview(tool_args.get("n", 5))
|
| 93 |
+
|
| 94 |
+
elif tool_name == "analyze_company_for_grants":
|
| 95 |
+
return tools.analyze_company_for_grants(
|
| 96 |
+
tool_args["company_url"],
|
| 97 |
+
limit=tool_args.get("limit", 3)
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
elif tool_name == "search_grants":
|
| 101 |
+
# This would need to be implemented in ChatTools or InsightTools
|
| 102 |
+
return {
|
| 103 |
+
"results": tools.list_grants(
|
| 104 |
+
keyword=tool_args.get("query"),
|
| 105 |
+
status=tool_args.get("status"), # NEW: Add status filter
|
| 106 |
+
limit=tool_args.get("limit") # FIXED: Don't default to 10, pass None for all
|
| 107 |
+
),
|
| 108 |
+
"query": tool_args.get("query")
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
else:
|
| 112 |
+
return {"error": f"Unknown tool: {tool_name}"}
|
| 113 |
+
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logging.error(f"Tool {tool_name} failed: {e}", exc_info=True)
|
| 116 |
+
return {"error": str(e)}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def main(argv: Optional[List[str]] = None):
|
| 120 |
+
"""Main chat loop with LLM function calling."""
|
| 121 |
+
|
| 122 |
+
# Parse arguments
|
| 123 |
+
ap = argparse.ArgumentParser(description="Grant Analyst Chat with LLM function calling")
|
| 124 |
+
ap.add_argument("--verbose", action="store_true", help="Show startup diagnostics")
|
| 125 |
+
ap.add_argument("--limit", type=int, help="Limit number of grants to load")
|
| 126 |
+
ap.add_argument("--extended-tools", action="store_true", help="Enable extended tools")
|
| 127 |
+
args = ap.parse_args(argv)
|
| 128 |
+
|
| 129 |
+
# Setup logging
|
| 130 |
+
logging.basicConfig(
|
| 131 |
+
level=logging.INFO,
|
| 132 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 133 |
+
handlers=[
|
| 134 |
+
logging.FileHandler("_out/logs/chat_llm.log"),
|
| 135 |
+
logging.StreamHandler(sys.stderr)
|
| 136 |
+
]
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# Load configuration
|
| 140 |
+
try:
|
| 141 |
+
cfg = load_config()
|
| 142 |
+
except ConfigError as e:
|
| 143 |
+
print(f"⚠️ Configuration error: {e}")
|
| 144 |
+
sys.exit(1)
|
| 145 |
+
|
| 146 |
+
# Load data
|
| 147 |
+
logging.info("Loading data...")
|
| 148 |
+
try:
|
| 149 |
+
current = load_current_grants(Path("data/snapshots"), limit=args.limit)
|
| 150 |
+
past = load_past_winners(
|
| 151 |
+
history_xlsx=Path("data/IUK-141025-InnovateUKFundedProjects-FY2015-16topresent.xlsx")
|
| 152 |
+
)
|
| 153 |
+
logging.info(f"Loaded {len(current)} current grants; {len(past)} past winners")
|
| 154 |
+
except DataLoadError as e:
|
| 155 |
+
print(f"⚠️ Data loading error: {e}")
|
| 156 |
+
sys.exit(1)
|
| 157 |
+
|
| 158 |
+
# Initialize LLM client
|
| 159 |
+
try:
|
| 160 |
+
llm_client = LLMClient(cfg)
|
| 161 |
+
llm_ok = llm_client.is_ready()
|
| 162 |
+
if not llm_ok:
|
| 163 |
+
print("⚠️ LLM client not ready. Check your API key configuration.")
|
| 164 |
+
sys.exit(1)
|
| 165 |
+
except Exception as e:
|
| 166 |
+
print(f"⚠️ LLM initialization failed: {e}")
|
| 167 |
+
sys.exit(1)
|
| 168 |
+
|
| 169 |
+
# Load search index
|
| 170 |
+
idx_ok = False
|
| 171 |
+
try:
|
| 172 |
+
idx_path = Path("data/index/hybrid_index.pkl")
|
| 173 |
+
if idx_path.exists():
|
| 174 |
+
_ = load_index()
|
| 175 |
+
idx_ok = True
|
| 176 |
+
except Exception as e:
|
| 177 |
+
logging.warning(f"Could not load search index: {e}")
|
| 178 |
+
|
| 179 |
+
# Initialize ChatTools
|
| 180 |
+
tools = ChatTools(current, past)
|
| 181 |
+
|
| 182 |
+
# Tool registration
|
| 183 |
+
extended_mode = args.extended_tools or detect_extended_features()
|
| 184 |
+
available_tools = openai_tools(extended=extended_mode)
|
| 185 |
+
logging.info(f"Registered {len(available_tools)} tools (extended: {extended_mode})")
|
| 186 |
+
|
| 187 |
+
# Startup diagnostics
|
| 188 |
+
if args.verbose:
|
| 189 |
+
print(_startup_diagnostics(cfg, llm_ok, idx_ok))
|
| 190 |
+
|
| 191 |
+
print("\n💬 Grant Analyst Chat (LLM Function Calling Mode)")
|
| 192 |
+
print(" Ask me anything in natural language! Type 'exit' to quit.\n")
|
| 193 |
+
|
| 194 |
+
# Conversation history
|
| 195 |
+
messages = [
|
| 196 |
+
{
|
| 197 |
+
"role": "system",
|
| 198 |
+
"content": (
|
| 199 |
+
"You are a helpful grant analyst assistant for Innovate UK grants. "
|
| 200 |
+
"Use the available tools to answer user questions about grants, funding, "
|
| 201 |
+
"deadlines, eligibility, and comparisons. Always use tools when you need "
|
| 202 |
+
"to look up grant data - don't make up information. When listing grants, "
|
| 203 |
+
"format them clearly with bullets. When comparing, use tables. Be concise "
|
| 204 |
+
"but informative. Current date: 2025-10-22."
|
| 205 |
+
)
|
| 206 |
+
}
|
| 207 |
+
]
|
| 208 |
+
|
| 209 |
+
# REPL loop
|
| 210 |
+
turn_count = 0
|
| 211 |
+
while True:
|
| 212 |
+
try:
|
| 213 |
+
user_input = input("You: ").strip()
|
| 214 |
+
except (EOFError, KeyboardInterrupt):
|
| 215 |
+
print("\n👋 Goodbye.")
|
| 216 |
+
break
|
| 217 |
+
|
| 218 |
+
if not user_input:
|
| 219 |
+
continue
|
| 220 |
+
|
| 221 |
+
if user_input.lower() in {"exit", "quit"}:
|
| 222 |
+
print("👋 Goodbye.")
|
| 223 |
+
break
|
| 224 |
+
|
| 225 |
+
turn_count += 1
|
| 226 |
+
t0 = time.time()
|
| 227 |
+
|
| 228 |
+
# Add user message
|
| 229 |
+
messages.append({"role": "user", "content": user_input})
|
| 230 |
+
|
| 231 |
+
try:
|
| 232 |
+
# Call LLM with function calling
|
| 233 |
+
response = llm_client.client.chat.completions.create(
|
| 234 |
+
model=llm_client.model,
|
| 235 |
+
messages=messages,
|
| 236 |
+
tools=available_tools,
|
| 237 |
+
tool_choice="auto",
|
| 238 |
+
temperature=0.1,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
response_message = response.choices[0].message
|
| 242 |
+
tool_calls = response_message.tool_calls
|
| 243 |
+
|
| 244 |
+
# If LLM wants to call tools
|
| 245 |
+
if tool_calls:
|
| 246 |
+
# Add assistant's response with tool calls
|
| 247 |
+
messages.append(response_message)
|
| 248 |
+
|
| 249 |
+
# Execute each tool call
|
| 250 |
+
for tool_call in tool_calls:
|
| 251 |
+
function_name = tool_call.function.name
|
| 252 |
+
function_args = eval(tool_call.function.arguments)
|
| 253 |
+
|
| 254 |
+
logging.info(f"Calling tool: {function_name} with args: {function_args}")
|
| 255 |
+
|
| 256 |
+
# Execute the tool
|
| 257 |
+
tool_result = _dispatch_tool_call(tools, function_name, function_args)
|
| 258 |
+
|
| 259 |
+
# Add tool result to conversation
|
| 260 |
+
messages.append({
|
| 261 |
+
"role": "tool",
|
| 262 |
+
"tool_call_id": tool_call.id,
|
| 263 |
+
"name": function_name,
|
| 264 |
+
"content": str(tool_result)
|
| 265 |
+
})
|
| 266 |
+
|
| 267 |
+
# Get final response from LLM
|
| 268 |
+
final_response = llm_client.client.chat.completions.create(
|
| 269 |
+
model=llm_client.model,
|
| 270 |
+
messages=messages,
|
| 271 |
+
temperature=0.1,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
assistant_message = final_response.choices[0].message.content
|
| 275 |
+
messages.append({"role": "assistant", "content": assistant_message})
|
| 276 |
+
else:
|
| 277 |
+
# No tool calls, just use the response
|
| 278 |
+
assistant_message = response_message.content
|
| 279 |
+
messages.append({"role": "assistant", "content": assistant_message})
|
| 280 |
+
|
| 281 |
+
# Display response
|
| 282 |
+
print(f"\n{assistant_message}\n")
|
| 283 |
+
|
| 284 |
+
latency_ms = int((time.time() - t0) * 1000)
|
| 285 |
+
logging.info(f"Turn {turn_count} completed in {latency_ms}ms")
|
| 286 |
+
|
| 287 |
+
except LLMError as e:
|
| 288 |
+
print(f"\n⚠️ LLM error: {e}\n")
|
| 289 |
+
logging.error(f"LLM error: {e}")
|
| 290 |
+
|
| 291 |
+
except Exception as e:
|
| 292 |
+
print(f"\n❌ Error: {e}\n")
|
| 293 |
+
logging.error(f"Unexpected error: {e}", exc_info=True)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
if __name__ == "__main__":
|
| 297 |
+
main()
|
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/chat/run_chat_plus.py
|
| 2 |
+
"""
|
| 3 |
+
DEPRECATED: This file has been merged into run_chat.py
|
| 4 |
+
|
| 5 |
+
Migration:
|
| 6 |
+
python -m src.analyzer.chat.run_chat_plus --verbose
|
| 7 |
+
↓
|
| 8 |
+
python -m src.analyzer.chat.run_chat --verbose
|
| 9 |
+
|
| 10 |
+
All features are now available via flags in run_chat.py:
|
| 11 |
+
--verbose Show startup diagnostics
|
| 12 |
+
--with-memory Enable conversation memory
|
| 13 |
+
--extended-tools Force extended tools
|
| 14 |
+
--no-llm-routing Disable LLM routing
|
| 15 |
+
--limit N Load at most N grants
|
| 16 |
+
"""
|
| 17 |
+
import warnings
|
| 18 |
+
import sys
|
| 19 |
+
|
| 20 |
+
warnings.warn(
|
| 21 |
+
"run_chat_plus.py is deprecated. Use run_chat.py with --verbose flag instead.",
|
| 22 |
+
DeprecationWarning,
|
| 23 |
+
stacklevel=2
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Redirect to new unified version
|
| 27 |
+
from .run_chat import main
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
print("⚠️ WARNING: run_chat_plus.py is deprecated")
|
| 31 |
+
print(" Use: python -m src.analyzer.chat.run_chat --verbose")
|
| 32 |
+
print(" Redirecting to new version...\n")
|
| 33 |
+
|
| 34 |
+
# Auto-enable verbose mode for backward compatibility
|
| 35 |
+
argv = sys.argv[1:]
|
| 36 |
+
if "--verbose" not in argv and "-v" not in argv:
|
| 37 |
+
argv = ["--verbose"] + argv
|
| 38 |
+
|
| 39 |
+
main(argv)
|
|
@@ -0,0 +1,487 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/chat/tool_schemas.py
|
| 2 |
+
"""
|
| 3 |
+
Unified tool definitions for OpenAI + Anthropic with feature flags.
|
| 4 |
+
|
| 5 |
+
Provides JSON schemas for all available tools. Use 'extended' flag to enable
|
| 6 |
+
advanced features (insight_search, fetch_link) instead of maintaining separate files.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
from typing import Any, Dict, List
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _filters_schema() -> Dict[str, Any]:
|
| 14 |
+
"""Shared filter schema for search operations."""
|
| 15 |
+
return {
|
| 16 |
+
"type": "object",
|
| 17 |
+
"properties": {
|
| 18 |
+
"status": {
|
| 19 |
+
"type": "string",
|
| 20 |
+
"description": "Grant status filter, e.g. 'open', 'closed', 'upcoming'.",
|
| 21 |
+
"enum": ["open", "closed", "upcoming"],
|
| 22 |
+
},
|
| 23 |
+
"audience": {
|
| 24 |
+
"type": "string",
|
| 25 |
+
"description": "Applicant type such as 'SME', 'university', 'research_organisation', etc."
|
| 26 |
+
},
|
| 27 |
+
"theme": {
|
| 28 |
+
"type": "string",
|
| 29 |
+
"description": "High-level theme or domain, e.g. 'battery', 'net zero', 'quantum', 'AI'."
|
| 30 |
+
},
|
| 31 |
+
"min_award": {"type": "number", "description": "Lower bound for funding (£)."},
|
| 32 |
+
"max_award": {"type": "number", "description": "Upper bound for funding (£)."},
|
| 33 |
+
"deadline_before": {
|
| 34 |
+
"type": "string",
|
| 35 |
+
"description": "Only include grants with a deadline before this ISO date (YYYY-MM-DD).",
|
| 36 |
+
"pattern": r"^\d{4}-\d{2}-\d{2}$"
|
| 37 |
+
},
|
| 38 |
+
"deadline_after": {
|
| 39 |
+
"type": "string",
|
| 40 |
+
"description": "Only include grants with a deadline on/after this ISO date (YYYY-MM-DD).",
|
| 41 |
+
"pattern": r"^\d{4}-\d{2}-\d{2}$"
|
| 42 |
+
},
|
| 43 |
+
"timeframe": {
|
| 44 |
+
"type": "string",
|
| 45 |
+
"description": "Loose time window like 'this_month', 'next_quarter'."
|
| 46 |
+
},
|
| 47 |
+
},
|
| 48 |
+
"additionalProperties": True,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
|
| 53 |
+
"""
|
| 54 |
+
Get tool schemas for OpenAI function calling.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
extended: If True, include advanced tools (insight_search, fetch_link)
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
List of OpenAI function call schemas
|
| 61 |
+
"""
|
| 62 |
+
# Core tools (always included)
|
| 63 |
+
core_tools = [
|
| 64 |
+
{
|
| 65 |
+
"type": "function",
|
| 66 |
+
"function": {
|
| 67 |
+
"name": "search_grants",
|
| 68 |
+
"description": (
|
| 69 |
+
"Natural-language search over grants with optional structured filters. "
|
| 70 |
+
"Use this for messy user prompts or when you need fuzzy matching. "
|
| 71 |
+
"If limit is not specified, returns ALL matching grants."
|
| 72 |
+
),
|
| 73 |
+
"parameters": {
|
| 74 |
+
"type": "object",
|
| 75 |
+
"properties": {
|
| 76 |
+
"query": {
|
| 77 |
+
"type": "string",
|
| 78 |
+
"description": "User's search query in natural language."
|
| 79 |
+
},
|
| 80 |
+
"filters": _filters_schema(),
|
| 81 |
+
"limit": {
|
| 82 |
+
"type": "integer",
|
| 83 |
+
"description": "Maximum results to return. If omitted, returns ALL matching grants."
|
| 84 |
+
},
|
| 85 |
+
},
|
| 86 |
+
"required": ["query"],
|
| 87 |
+
},
|
| 88 |
+
},
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"type": "function",
|
| 92 |
+
"function": {
|
| 93 |
+
"name": "list_grants",
|
| 94 |
+
"description": (
|
| 95 |
+
"List all grants with optional filters by keyword, funding, status, or audience. "
|
| 96 |
+
"Returns ALL matching grants if limit is not specified. "
|
| 97 |
+
"Prefer 'search_grants' for natural language; use this for precise filtering."
|
| 98 |
+
),
|
| 99 |
+
"parameters": {
|
| 100 |
+
"type": "object",
|
| 101 |
+
"properties": {
|
| 102 |
+
"keyword": {
|
| 103 |
+
"type": "string",
|
| 104 |
+
"description": "Keyword to match in title or description."
|
| 105 |
+
},
|
| 106 |
+
"status": {
|
| 107 |
+
"type": "string",
|
| 108 |
+
"description": "Filter by grant status: 'open', 'closed', or 'upcoming'.",
|
| 109 |
+
"enum": ["open", "closed", "upcoming"]
|
| 110 |
+
},
|
| 111 |
+
"max_award": {
|
| 112 |
+
"type": "number",
|
| 113 |
+
"description": "Upper funding bound (£)."
|
| 114 |
+
},
|
| 115 |
+
"audience": {
|
| 116 |
+
"type": "string",
|
| 117 |
+
"description": "Applicant type such as SME, university, etc."
|
| 118 |
+
},
|
| 119 |
+
"limit": {
|
| 120 |
+
"type": "integer",
|
| 121 |
+
"description": "Maximum results to return. If omitted, returns all matching grants.",
|
| 122 |
+
},
|
| 123 |
+
},
|
| 124 |
+
"required": [],
|
| 125 |
+
},
|
| 126 |
+
},
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"type": "function",
|
| 130 |
+
"function": {
|
| 131 |
+
"name": "get_grant",
|
| 132 |
+
"description": "Retrieve full structured JSON for a grant by its ID.",
|
| 133 |
+
"parameters": {
|
| 134 |
+
"type": "object",
|
| 135 |
+
"properties": {
|
| 136 |
+
"grant_id": {
|
| 137 |
+
"type": "string",
|
| 138 |
+
"description": "Grant ID (e.g., 'competition-2315' or '2315')"
|
| 139 |
+
}
|
| 140 |
+
},
|
| 141 |
+
"required": ["grant_id"],
|
| 142 |
+
},
|
| 143 |
+
},
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"type": "function",
|
| 147 |
+
"function": {
|
| 148 |
+
"name": "summarize_grant",
|
| 149 |
+
"description": "Generate an insightful markdown summary for a specific grant ID.",
|
| 150 |
+
"parameters": {
|
| 151 |
+
"type": "object",
|
| 152 |
+
"properties": {
|
| 153 |
+
"grant_id": {
|
| 154 |
+
"type": "string",
|
| 155 |
+
"description": "Grant ID to summarize"
|
| 156 |
+
},
|
| 157 |
+
"audience": {
|
| 158 |
+
"type": "string",
|
| 159 |
+
"description": "Target audience style, e.g. 'exec', 'technical', 'sme', 'founder'.",
|
| 160 |
+
"enum": ["exec", "technical", "sme", "founder"],
|
| 161 |
+
"default": "exec"
|
| 162 |
+
},
|
| 163 |
+
"word_limit": {
|
| 164 |
+
"type": "integer",
|
| 165 |
+
"description": "Approximate word limit for summary",
|
| 166 |
+
"default": 250
|
| 167 |
+
}
|
| 168 |
+
},
|
| 169 |
+
"required": ["grant_id"],
|
| 170 |
+
},
|
| 171 |
+
},
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
"type": "function",
|
| 175 |
+
"function": {
|
| 176 |
+
"name": "summarize_grants_batch",
|
| 177 |
+
"description": (
|
| 178 |
+
"Batch summarize multiple grants efficiently in parallel. "
|
| 179 |
+
"Much faster than summarizing grants individually when you need summaries for multiple grants. "
|
| 180 |
+
"Results stream back as they complete. Use this when user asks for 'summaries for all', "
|
| 181 |
+
"'summarize X grants', or when processing multiple search results."
|
| 182 |
+
),
|
| 183 |
+
"parameters": {
|
| 184 |
+
"type": "object",
|
| 185 |
+
"properties": {
|
| 186 |
+
"grant_ids": {
|
| 187 |
+
"type": "array",
|
| 188 |
+
"items": {"type": "string"},
|
| 189 |
+
"description": "List of grant IDs to summarize (e.g., ['2313', '2314', '2315'])"
|
| 190 |
+
},
|
| 191 |
+
"batch_size": {
|
| 192 |
+
"type": "integer",
|
| 193 |
+
"description": "Number of grants to process per batch (default: 5)",
|
| 194 |
+
"default": 5
|
| 195 |
+
}
|
| 196 |
+
},
|
| 197 |
+
"required": ["grant_ids"],
|
| 198 |
+
},
|
| 199 |
+
},
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
"type": "function",
|
| 203 |
+
"function": {
|
| 204 |
+
"name": "get_all_grant_summaries",
|
| 205 |
+
"description": (
|
| 206 |
+
"Get detailed summaries of ALL available grants in a single efficient batch operation. "
|
| 207 |
+
"Perfect when user asks 'describe all grants', 'summaries of every grant', 'all grant opportunities', etc. "
|
| 208 |
+
"Processes all grants in parallel batches for speed."
|
| 209 |
+
),
|
| 210 |
+
"parameters": {
|
| 211 |
+
"type": "object",
|
| 212 |
+
"properties": {
|
| 213 |
+
"batch_size": {
|
| 214 |
+
"type": "integer",
|
| 215 |
+
"description": "Number of grants to process per batch (default: 5)",
|
| 216 |
+
"default": 5
|
| 217 |
+
}
|
| 218 |
+
},
|
| 219 |
+
"required": [],
|
| 220 |
+
},
|
| 221 |
+
},
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
"type": "function",
|
| 225 |
+
"function": {
|
| 226 |
+
"name": "compare_grants",
|
| 227 |
+
"description": (
|
| 228 |
+
"Compare two grants side-by-side and highlight key differences in funding, "
|
| 229 |
+
"deadlines, eligibility, and scope. Use this when user asks to 'compare' grants "
|
| 230 |
+
"or wants to see differences between two grant IDs. Accepts IDs like '2313', "
|
| 231 |
+
"'competition-2313', or 'grant-2313'."
|
| 232 |
+
),
|
| 233 |
+
"parameters": {
|
| 234 |
+
"type": "object",
|
| 235 |
+
"properties": {
|
| 236 |
+
"grant_id_a": {
|
| 237 |
+
"type": "string",
|
| 238 |
+
"description": "First grant ID (e.g., '2313', 'competition-2313')"
|
| 239 |
+
},
|
| 240 |
+
"grant_id_b": {
|
| 241 |
+
"type": "string",
|
| 242 |
+
"description": "Second grant ID (e.g., '2314', 'competition-2314')"
|
| 243 |
+
},
|
| 244 |
+
},
|
| 245 |
+
"required": ["grant_id_a", "grant_id_b"],
|
| 246 |
+
},
|
| 247 |
+
},
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"type": "function",
|
| 251 |
+
"function": {
|
| 252 |
+
"name": "deadlines_overview",
|
| 253 |
+
"description": "List upcoming deadlines for current grants, sorted by date.",
|
| 254 |
+
"parameters": {
|
| 255 |
+
"type": "object",
|
| 256 |
+
"properties": {
|
| 257 |
+
"n": {
|
| 258 |
+
"type": "integer",
|
| 259 |
+
"description": "Number of deadlines to show",
|
| 260 |
+
"default": 5
|
| 261 |
+
}
|
| 262 |
+
},
|
| 263 |
+
"required": [],
|
| 264 |
+
},
|
| 265 |
+
},
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"type": "function",
|
| 269 |
+
"function": {
|
| 270 |
+
"name": "analyze_company_for_grants",
|
| 271 |
+
"description": (
|
| 272 |
+
"Fetch and analyze a company website to recommend suitable grants. "
|
| 273 |
+
"Takes a company URL, extracts information about what they do, and "
|
| 274 |
+
"matches them with the most relevant available grants. Use this when "
|
| 275 |
+
"the user provides a company website and asks 'what grants are suitable' "
|
| 276 |
+
"or 'which grants should this company apply for'."
|
| 277 |
+
),
|
| 278 |
+
"parameters": {
|
| 279 |
+
"type": "object",
|
| 280 |
+
"properties": {
|
| 281 |
+
"company_url": {
|
| 282 |
+
"type": "string",
|
| 283 |
+
"description": "URL of the company website to analyze (e.g., 'https://example.com' or 'example.com')"
|
| 284 |
+
},
|
| 285 |
+
"limit": {
|
| 286 |
+
"type": "integer",
|
| 287 |
+
"description": "Number of grant recommendations to return",
|
| 288 |
+
"default": 3
|
| 289 |
+
}
|
| 290 |
+
},
|
| 291 |
+
"required": ["company_url"],
|
| 292 |
+
},
|
| 293 |
+
},
|
| 294 |
+
},
|
| 295 |
+
]
|
| 296 |
+
|
| 297 |
+
# Extended tools (only if requested)
|
| 298 |
+
extended_tools = [
|
| 299 |
+
{
|
| 300 |
+
"type": "function",
|
| 301 |
+
"function": {
|
| 302 |
+
"name": "insight_search",
|
| 303 |
+
"description": (
|
| 304 |
+
"Answer a policy/eligibility question by searching the local index "
|
| 305 |
+
"(grants + supporting docs). Returns grounded markdown with citations. "
|
| 306 |
+
"Use this for 'how do I', 'what are the rules', 'am I eligible' questions."
|
| 307 |
+
),
|
| 308 |
+
"parameters": {
|
| 309 |
+
"type": "object",
|
| 310 |
+
"properties": {
|
| 311 |
+
"question": {
|
| 312 |
+
"type": "string",
|
| 313 |
+
"description": "User's question about grant policies, rules, or procedures"
|
| 314 |
+
},
|
| 315 |
+
"k": {
|
| 316 |
+
"type": "integer",
|
| 317 |
+
"description": "Number of supporting documents to retrieve",
|
| 318 |
+
"default": 8
|
| 319 |
+
},
|
| 320 |
+
},
|
| 321 |
+
"required": ["question"],
|
| 322 |
+
},
|
| 323 |
+
},
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"type": "function",
|
| 327 |
+
"function": {
|
| 328 |
+
"name": "fetch_link",
|
| 329 |
+
"description": (
|
| 330 |
+
"Fetch and parse a URL (HTML or PDF) related to a grant. "
|
| 331 |
+
"Returns plain text. Use this when the answer likely lives in "
|
| 332 |
+
"a linked guidance/terms page not in the index."
|
| 333 |
+
),
|
| 334 |
+
"parameters": {
|
| 335 |
+
"type": "object",
|
| 336 |
+
"properties": {
|
| 337 |
+
"url": {
|
| 338 |
+
"type": "string",
|
| 339 |
+
"description": "Full URL to fetch (must be https://)"
|
| 340 |
+
},
|
| 341 |
+
"force": {
|
| 342 |
+
"type": "boolean",
|
| 343 |
+
"description": "Force re-fetch even if cached",
|
| 344 |
+
"default": False
|
| 345 |
+
}
|
| 346 |
+
},
|
| 347 |
+
"required": ["url"],
|
| 348 |
+
},
|
| 349 |
+
},
|
| 350 |
+
},
|
| 351 |
+
]
|
| 352 |
+
|
| 353 |
+
return core_tools + (extended_tools if extended else [])
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def anthropic_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
|
| 357 |
+
"""
|
| 358 |
+
Get tool schemas for Anthropic function calling.
|
| 359 |
+
|
| 360 |
+
Anthropic uses slightly different format (name at top level, input_schema
|
| 361 |
+
instead of parameters).
|
| 362 |
+
|
| 363 |
+
Args:
|
| 364 |
+
extended: If True, include advanced tools
|
| 365 |
+
|
| 366 |
+
Returns:
|
| 367 |
+
List of Anthropic function call schemas
|
| 368 |
+
"""
|
| 369 |
+
openai_schemas = openai_tools(extended=extended)
|
| 370 |
+
|
| 371 |
+
anthropic_schemas = []
|
| 372 |
+
for tool in openai_schemas:
|
| 373 |
+
fn = tool["function"]
|
| 374 |
+
anthropic_schemas.append({
|
| 375 |
+
"name": fn["name"],
|
| 376 |
+
"description": fn["description"],
|
| 377 |
+
"input_schema": fn["parameters"],
|
| 378 |
+
})
|
| 379 |
+
|
| 380 |
+
return anthropic_schemas
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def get_tools(provider: str = "openai", *, extended: bool = False) -> List[Dict[str, Any]]:
|
| 384 |
+
"""
|
| 385 |
+
Convenience function to get tools for any provider.
|
| 386 |
+
|
| 387 |
+
Args:
|
| 388 |
+
provider: "openai" or "anthropic"
|
| 389 |
+
extended: Whether to include extended tools
|
| 390 |
+
|
| 391 |
+
Returns:
|
| 392 |
+
Tool schemas appropriate for the provider
|
| 393 |
+
|
| 394 |
+
Example:
|
| 395 |
+
tools = get_tools("openai", extended=True)
|
| 396 |
+
response = client.chat.completions.create(
|
| 397 |
+
model="gpt-5",
|
| 398 |
+
messages=[...],
|
| 399 |
+
tools=tools
|
| 400 |
+
)
|
| 401 |
+
"""
|
| 402 |
+
provider = provider.lower()
|
| 403 |
+
if provider == "anthropic":
|
| 404 |
+
return anthropic_tools(extended=extended)
|
| 405 |
+
return openai_tools(extended=extended)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
# ---------------------------------------------------------------------------
|
| 409 |
+
# Feature Detection
|
| 410 |
+
# ---------------------------------------------------------------------------
|
| 411 |
+
|
| 412 |
+
def detect_extended_features() -> bool:
|
| 413 |
+
"""
|
| 414 |
+
Detect if extended features should be enabled based on environment.
|
| 415 |
+
|
| 416 |
+
Checks for:
|
| 417 |
+
- ENABLE_EXTENDED_TOOLS=1
|
| 418 |
+
- Presence of hybrid index file
|
| 419 |
+
- Availability of fetcher module
|
| 420 |
+
|
| 421 |
+
Returns:
|
| 422 |
+
True if extended tools should be enabled
|
| 423 |
+
"""
|
| 424 |
+
import os
|
| 425 |
+
from pathlib import Path
|
| 426 |
+
|
| 427 |
+
# Explicit override
|
| 428 |
+
if os.getenv("ENABLE_EXTENDED_TOOLS", "").lower() in ("1", "true", "yes"):
|
| 429 |
+
return True
|
| 430 |
+
|
| 431 |
+
# Check if index exists (needed for insight_search)
|
| 432 |
+
index_path = Path("data/index/hybrid_index.pkl")
|
| 433 |
+
if not index_path.exists():
|
| 434 |
+
return False
|
| 435 |
+
|
| 436 |
+
# Check if fetcher is available (needed for fetch_link)
|
| 437 |
+
try:
|
| 438 |
+
from ..net.fetcher import fetch_link
|
| 439 |
+
return True
|
| 440 |
+
except ImportError:
|
| 441 |
+
return False
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
# ---------------------------------------------------------------------------
|
| 445 |
+
# Migration Helpers (Backward Compatibility)
|
| 446 |
+
# ---------------------------------------------------------------------------
|
| 447 |
+
|
| 448 |
+
def openai_tools_legacy() -> List[Dict[str, Any]]:
|
| 449 |
+
"""
|
| 450 |
+
DEPRECATED: For backward compatibility with old code.
|
| 451 |
+
Use openai_tools(extended=False) instead.
|
| 452 |
+
"""
|
| 453 |
+
import warnings
|
| 454 |
+
warnings.warn(
|
| 455 |
+
"openai_tools_legacy() is deprecated. Use openai_tools(extended=False).",
|
| 456 |
+
DeprecationWarning,
|
| 457 |
+
stacklevel=2
|
| 458 |
+
)
|
| 459 |
+
return openai_tools(extended=False)
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
# ---------------------------------------------------------------------------
|
| 463 |
+
# Self-Test
|
| 464 |
+
# ---------------------------------------------------------------------------
|
| 465 |
+
|
| 466 |
+
if __name__ == "__main__":
|
| 467 |
+
import json
|
| 468 |
+
|
| 469 |
+
print("=== Core Tools (OpenAI) ===")
|
| 470 |
+
core = openai_tools(extended=False)
|
| 471 |
+
print(f"Count: {len(core)}")
|
| 472 |
+
for tool in core:
|
| 473 |
+
print(f" - {tool['function']['name']}")
|
| 474 |
+
|
| 475 |
+
print("\n=== Extended Tools (OpenAI) ===")
|
| 476 |
+
extended = openai_tools(extended=True)
|
| 477 |
+
print(f"Count: {len(extended)}")
|
| 478 |
+
for tool in extended:
|
| 479 |
+
print(f" - {tool['function']['name']}")
|
| 480 |
+
|
| 481 |
+
print("\n=== Anthropic Format ===")
|
| 482 |
+
anthropic = anthropic_tools(extended=True)
|
| 483 |
+
print(f"Count: {len(anthropic)}")
|
| 484 |
+
print(json.dumps(anthropic[0], indent=2)[:300])
|
| 485 |
+
|
| 486 |
+
print(f"\n=== Feature Detection ===")
|
| 487 |
+
print(f"Extended features available: {detect_extended_features()}")
|
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DEPRECATED: This module has been merged into tool_schemas.py
|
| 3 |
+
|
| 4 |
+
Migration:
|
| 5 |
+
from .tool_schemas_plus import openai_tools
|
| 6 |
+
↓
|
| 7 |
+
from .tool_schemas import openai_tools
|
| 8 |
+
|
| 9 |
+
To enable extended features:
|
| 10 |
+
openai_tools(extended=True)
|
| 11 |
+
# or set ENABLE_EXTENDED_TOOLS=1 in environment
|
| 12 |
+
"""
|
| 13 |
+
import warnings
|
| 14 |
+
warnings.warn(
|
| 15 |
+
"tool_schemas_plus is deprecated. Use tool_schemas with extended=True flag.",
|
| 16 |
+
DeprecationWarning,
|
| 17 |
+
stacklevel=2
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
# Thin wrapper for backward compatibility
|
| 21 |
+
from .tool_schemas import openai_tools as _openai_tools
|
| 22 |
+
from .tool_schemas import anthropic_tools as _anthropic_tools
|
| 23 |
+
|
| 24 |
+
def openai_tools():
|
| 25 |
+
"""DEPRECATED: Use tool_schemas.openai_tools(extended=True) instead."""
|
| 26 |
+
return _openai_tools(extended=True)
|
| 27 |
+
|
| 28 |
+
def anthropic_tools():
|
| 29 |
+
"""DEPRECATED: Use tool_schemas.anthropic_tools(extended=True) instead."""
|
| 30 |
+
return _anthropic_tools(extended=True)
|
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
config.py — central configuration for the summarizer MVP
|
| 3 |
+
|
| 4 |
+
Minimal, dependency-free (std‑lib) config loader. Pulls settings from env vars
|
| 5 |
+
with sensible defaults. Supports OpenAI *or* Anthropic via LLM_PROVIDER.
|
| 6 |
+
|
| 7 |
+
ENV VARS (all optional unless noted)
|
| 8 |
+
------------------------------------
|
| 9 |
+
# Provider + model
|
| 10 |
+
LLM_PROVIDER=openai|anthropic # default: openai
|
| 11 |
+
LLM_MODEL= # default per provider (see DEFAULT_MODELS)
|
| 12 |
+
|
| 13 |
+
# Auth
|
| 14 |
+
OPENAI_API_KEY= # required if LLM_PROVIDER=openai
|
| 15 |
+
ANTHROPIC_API_KEY= # required if LLM_PROVIDER=anthropic
|
| 16 |
+
|
| 17 |
+
# Behavior
|
| 18 |
+
LLM_TEMPERATURE=0.2 # 0..2
|
| 19 |
+
LLM_MAX_OUTPUT_TOKENS=800 # model-dependent cap
|
| 20 |
+
LLM_TIMEOUT_S=30 # HTTP timeout (seconds)
|
| 21 |
+
LLM_DISABLE=0 # 1 disables external calls (draft-only)
|
| 22 |
+
|
| 23 |
+
# Dev / tracing (optional)
|
| 24 |
+
LOG_LEVEL=INFO # DEBUG|INFO|WARNING|ERROR
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import os
|
| 29 |
+
from dataclasses import dataclass
|
| 30 |
+
from typing import Literal, Optional
|
| 31 |
+
|
| 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 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class Config:
|
| 42 |
+
provider: Provider = "openai"
|
| 43 |
+
model: str = DEFAULT_MODELS["openai"]
|
| 44 |
+
|
| 45 |
+
openai_api_key: Optional[str] = None
|
| 46 |
+
anthropic_api_key: Optional[str] = None
|
| 47 |
+
|
| 48 |
+
# Model-specific configurations for different use cases
|
| 49 |
+
model_router: str = "gpt-5-nano"
|
| 50 |
+
model_translator: str = "gpt-5-mini"
|
| 51 |
+
model_analyzer: str = "gpt-5"
|
| 52 |
+
|
| 53 |
+
temperature: float = 0.2
|
| 54 |
+
max_output_tokens: int = 800
|
| 55 |
+
timeout_s: float = 30.0
|
| 56 |
+
disable_llm: bool = False
|
| 57 |
+
|
| 58 |
+
log_level: str = "INFO"
|
| 59 |
+
|
| 60 |
+
def validate(self) -> None:
|
| 61 |
+
p = self.provider
|
| 62 |
+
if p not in ("openai", "anthropic"):
|
| 63 |
+
raise ValueError(f"LLM_PROVIDER must be 'openai' or 'anthropic', got: {p}")
|
| 64 |
+
if not self.model:
|
| 65 |
+
raise ValueError("LLM_MODEL must be non-empty")
|
| 66 |
+
if self.disable_llm:
|
| 67 |
+
return # no auth needed if disabled
|
| 68 |
+
if p == "openai" and not self.openai_api_key:
|
| 69 |
+
raise ValueError("OPENAI_API_KEY is required when LLM_PROVIDER=openai")
|
| 70 |
+
if p == "anthropic" and not self.anthropic_api_key:
|
| 71 |
+
raise ValueError("ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic")
|
| 72 |
+
if not (0.0 <= float(self.temperature) <= 2.0):
|
| 73 |
+
raise ValueError("LLM_TEMPERATURE must be between 0 and 2")
|
| 74 |
+
if int(self.max_output_tokens) <= 0:
|
| 75 |
+
raise ValueError("LLM_MAX_OUTPUT_TOKENS must be > 0")
|
| 76 |
+
if float(self.timeout_s) <= 0:
|
| 77 |
+
raise ValueError("LLM_TIMEOUT_S must be > 0")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _env(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 81 |
+
val = os.getenv(name)
|
| 82 |
+
return val if val is not None and val != "" else default
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def load_config() -> Config:
|
| 86 |
+
provider: Provider = cast_provider(_env("LLM_PROVIDER", "openai"))
|
| 87 |
+
model = _env("LLM_MODEL", DEFAULT_MODELS[provider])
|
| 88 |
+
|
| 89 |
+
cfg = Config(
|
| 90 |
+
provider=provider,
|
| 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")),
|
| 100 |
+
disable_llm=_env("LLM_DISABLE", "0") == "1",
|
| 101 |
+
log_level=_env("LOG_LEVEL", "INFO"),
|
| 102 |
+
)
|
| 103 |
+
cfg.validate()
|
| 104 |
+
return cfg
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def cast_provider(s: Optional[str]) -> Provider:
|
| 108 |
+
s = (s or "openai").strip().lower()
|
| 109 |
+
if s in ("openai", "anthropic"):
|
| 110 |
+
return s # type: ignore[return-value]
|
| 111 |
+
raise ValueError(f"Unsupported LLM_PROVIDER: {s}")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# Quick self-test when run directly
|
| 115 |
+
if __name__ == "__main__":
|
| 116 |
+
try:
|
| 117 |
+
c = load_config()
|
| 118 |
+
print("Config loaded:\n", c)
|
| 119 |
+
except Exception as e:
|
| 120 |
+
print("Config error:", e)
|
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
context_builder.py — prepares text context for the LLM summarizer
|
| 3 |
+
|
| 4 |
+
Takes structured JSON grant records + optional past winners and builds a single
|
| 5 |
+
text block for each grant, suitable as LLM input.
|
| 6 |
+
|
| 7 |
+
Responsibilities:
|
| 8 |
+
- Extract key text fields from each grant JSON (title, description, sections)
|
| 9 |
+
- Summarize/flatten them into a readable context string
|
| 10 |
+
- Optionally include supporting documents (HTML sections + PDF text extracts)
|
| 11 |
+
- Optionally include a few relevant past-winner snippets (if any exist)
|
| 12 |
+
|
| 13 |
+
Public API
|
| 14 |
+
----------
|
| 15 |
+
build_context(grant: dict, past_winners: list[dict] | None = None, include_supporting: bool = False) -> str
|
| 16 |
+
build_context_with_supporting(grant: dict, k: int = 5, past_winners: list[dict] | None = None) -> str
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 21 |
+
import re
|
| 22 |
+
import json
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
# ----------------------------- Text utilities ---------------------------------
|
| 26 |
+
|
| 27 |
+
def _clean(s: Any) -> str:
|
| 28 |
+
return re.sub(r"\s+", " ", str(s or "")).strip()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _maybe(k: str, v: Any) -> str:
|
| 32 |
+
if not v:
|
| 33 |
+
return ""
|
| 34 |
+
return f"{k}: {_clean(v)}\n"
|
| 35 |
+
|
| 36 |
+
# ----------------------------- Context builder --------------------------------
|
| 37 |
+
|
| 38 |
+
def build_context(grant: Dict[str, Any], past_winners: Optional[List[Dict[str, Any]]] = None) -> str:
|
| 39 |
+
"""
|
| 40 |
+
Flatten a grant JSON object + optional past winners into a readable context string.
|
| 41 |
+
|
| 42 |
+
CRITICAL: All URLs are provided in FULL form (https://...), never partial paths.
|
| 43 |
+
The model receives complete, actionable URLs directly from the snapshot JSON.
|
| 44 |
+
"""
|
| 45 |
+
lines: List[str] = []
|
| 46 |
+
|
| 47 |
+
# --- Basic info ---
|
| 48 |
+
title = grant.get("title") or grant.get("name") or grant.get("competition_title")
|
| 49 |
+
if title:
|
| 50 |
+
lines.append(f"TITLE: {_clean(title)}")
|
| 51 |
+
|
| 52 |
+
# --- FULL URL: Always provide complete https:// URLs, never relative paths ---
|
| 53 |
+
url = grant.get("url") or grant.get("link")
|
| 54 |
+
if url:
|
| 55 |
+
lines.append(f"URL: {url}") # Example: "https://apply-for-innovation-funding.service.gov.uk/competition/2276/overview/..."
|
| 56 |
+
|
| 57 |
+
# --- Funding information ---
|
| 58 |
+
funding = grant.get("funding") or {}
|
| 59 |
+
if isinstance(funding, dict):
|
| 60 |
+
max_funding = funding.get("max")
|
| 61 |
+
if max_funding:
|
| 62 |
+
lines.append(f"FUNDING: Up to £{max_funding:,}")
|
| 63 |
+
min_funding = funding.get("min")
|
| 64 |
+
if min_funding and min_funding != max_funding:
|
| 65 |
+
lines.append(f"MINIMUM FUNDING: £{min_funding:,}")
|
| 66 |
+
else:
|
| 67 |
+
funding_text = grant.get("funding_amount") or grant.get("amount") or grant.get("funding_rate")
|
| 68 |
+
if funding_text:
|
| 69 |
+
lines.append(f"FUNDING: {_clean(funding_text)}")
|
| 70 |
+
|
| 71 |
+
# --- Dates ---
|
| 72 |
+
deadline = grant.get("close_date") or grant.get("deadline")
|
| 73 |
+
if deadline:
|
| 74 |
+
lines.append(f"DEADLINE: {_clean(deadline)}")
|
| 75 |
+
|
| 76 |
+
open_date = grant.get("open_date")
|
| 77 |
+
if open_date:
|
| 78 |
+
lines.append(f"OPENS: {_clean(open_date)}")
|
| 79 |
+
|
| 80 |
+
# --- Core text sections from snapshot ---
|
| 81 |
+
sections = grant.get("sections") or {}
|
| 82 |
+
if sections:
|
| 83 |
+
# Process snapshot sections in order: summary, eligibility, scope, dates, how_to_apply
|
| 84 |
+
section_order = [
|
| 85 |
+
"summary_raw", "eligibility_raw", "scope_raw",
|
| 86 |
+
"dates_raw", "how_to_apply_raw", "supporting_information_raw"
|
| 87 |
+
]
|
| 88 |
+
for section_key in section_order:
|
| 89 |
+
v = sections.get(section_key)
|
| 90 |
+
if v and _clean(v): # Only include non-empty sections
|
| 91 |
+
section_name = section_key.replace("_raw", "").replace("_", " ").upper()
|
| 92 |
+
lines.append(f"\n{section_name}:\n{_clean(v)}")
|
| 93 |
+
else:
|
| 94 |
+
# Fallback to common text fields
|
| 95 |
+
desc = grant.get("description") or grant.get("summary") or grant.get("scope")
|
| 96 |
+
if desc:
|
| 97 |
+
lines.append(f"\nDESCRIPTION:\n{_clean(desc)}")
|
| 98 |
+
|
| 99 |
+
# --- Optional extras ---
|
| 100 |
+
eligibility = grant.get("eligibility")
|
| 101 |
+
if eligibility:
|
| 102 |
+
lines.append(f"\nELIGIBILITY:\n{_clean(eligibility)}")
|
| 103 |
+
|
| 104 |
+
scope = grant.get("scope")
|
| 105 |
+
if scope:
|
| 106 |
+
lines.append(f"\nSCOPE:\n{_clean(scope)}")
|
| 107 |
+
|
| 108 |
+
# --- Contact Information ---
|
| 109 |
+
lines.append("\n--- CONTACT INFORMATION ---")
|
| 110 |
+
lines.append("Email: support@iuk.ukri.org")
|
| 111 |
+
lines.append("Phone: 0300 321 4357")
|
| 112 |
+
lines.append("Hours: 9am-12pm, 2pm-5pm, Monday-Friday (excluding bank holidays)")
|
| 113 |
+
|
| 114 |
+
# --- Past winners summary ---
|
| 115 |
+
if past_winners:
|
| 116 |
+
lines.append("\n--- RELATED PAST WINNERS ---")
|
| 117 |
+
for w in past_winners[:5]: # limit to top 5 to avoid overloading tokens
|
| 118 |
+
snippet_parts: List[str] = []
|
| 119 |
+
snippet_parts.append(_maybe("Project", w.get("project_title")))
|
| 120 |
+
snippet_parts.append(_maybe("Organisation", w.get("lead_org")))
|
| 121 |
+
snippet_parts.append(_maybe("Award", w.get("award_amount")))
|
| 122 |
+
snippet_parts.append(_maybe("Competition", w.get("competition")))
|
| 123 |
+
abs_ = _clean(w.get("abstract"))
|
| 124 |
+
if abs_:
|
| 125 |
+
snippet_parts.append(f"Abstract: {abs_[:400]}{'…' if len(abs_)>400 else ''}\n")
|
| 126 |
+
lines.append("".join(snippet_parts))
|
| 127 |
+
|
| 128 |
+
# --- Return ---
|
| 129 |
+
context_text = "\n".join(lines).strip()
|
| 130 |
+
return context_text
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ----------------------------- Supporting documents loader --------------------------------
|
| 134 |
+
|
| 135 |
+
def _load_supporting_docs_jsonl(path: Optional[str] = None) -> Dict[str, List[Dict[str, str]]]:
|
| 136 |
+
"""
|
| 137 |
+
Load the supporting documents JSONL file and index by grant_id.
|
| 138 |
+
|
| 139 |
+
Returns: dict[grant_id] -> list of supporting docs
|
| 140 |
+
"""
|
| 141 |
+
if path is None:
|
| 142 |
+
path = "data/supporting_jsonl/docs.jsonl"
|
| 143 |
+
|
| 144 |
+
try:
|
| 145 |
+
p = Path(path)
|
| 146 |
+
if not p.exists():
|
| 147 |
+
return {}
|
| 148 |
+
|
| 149 |
+
indexed = {}
|
| 150 |
+
with open(p, "r", encoding="utf-8") as f:
|
| 151 |
+
for line in f:
|
| 152 |
+
if not line.strip():
|
| 153 |
+
continue
|
| 154 |
+
try:
|
| 155 |
+
doc = json.loads(line)
|
| 156 |
+
gid = doc.get("grant_id", "").replace("competition-", "")
|
| 157 |
+
if gid:
|
| 158 |
+
if gid not in indexed:
|
| 159 |
+
indexed[gid] = []
|
| 160 |
+
indexed[gid].append(doc)
|
| 161 |
+
except json.JSONDecodeError:
|
| 162 |
+
continue
|
| 163 |
+
return indexed
|
| 164 |
+
except Exception:
|
| 165 |
+
return {}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def get_supporting_docs_for_grant(grant_id: str, k: int = 5, doc_types: Optional[List[str]] = None) -> List[Dict[str, str]]:
|
| 169 |
+
"""
|
| 170 |
+
Retrieve supporting documents for a grant.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
grant_id: Grant ID (with or without "competition-" prefix)
|
| 174 |
+
k: Number of documents to return
|
| 175 |
+
doc_types: Filter by document type ("supporting_html", "supporting_pdf", etc.)
|
| 176 |
+
|
| 177 |
+
Returns: List of documents with extracted text
|
| 178 |
+
"""
|
| 179 |
+
# Load cache on first use (could be cached module-level)
|
| 180 |
+
cache = _load_supporting_docs_jsonl()
|
| 181 |
+
|
| 182 |
+
# Normalize grant ID
|
| 183 |
+
gid = str(grant_id).replace("competition-", "").strip()
|
| 184 |
+
docs = cache.get(gid, [])
|
| 185 |
+
|
| 186 |
+
# Filter by type if requested
|
| 187 |
+
if doc_types:
|
| 188 |
+
docs = [d for d in docs if d.get("doc_type") in doc_types]
|
| 189 |
+
|
| 190 |
+
# Return top k
|
| 191 |
+
return docs[:k]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def build_context_with_supporting(
|
| 195 |
+
grant: Dict[str, Any],
|
| 196 |
+
k: int = 5,
|
| 197 |
+
pdf_only: bool = False,
|
| 198 |
+
past_winners: Optional[List[Dict[str, Any]]] = None
|
| 199 |
+
) -> str:
|
| 200 |
+
"""
|
| 201 |
+
Build context including supporting documents (PDFs + HTML sections).
|
| 202 |
+
|
| 203 |
+
Args:
|
| 204 |
+
grant: Grant dict with id/competition_id field (or will extract from URL)
|
| 205 |
+
k: Number of supporting docs to include
|
| 206 |
+
pdf_only: If True, only include PDF documents
|
| 207 |
+
past_winners: Optional past winners for comparison
|
| 208 |
+
|
| 209 |
+
Returns: Context string with supporting materials embedded
|
| 210 |
+
"""
|
| 211 |
+
# Start with base context
|
| 212 |
+
lines = [build_context(grant, past_winners)]
|
| 213 |
+
|
| 214 |
+
# Extract grant ID from multiple sources
|
| 215 |
+
gid = grant.get("id") or grant.get("competition_id")
|
| 216 |
+
|
| 217 |
+
# If not found, try extracting from URL
|
| 218 |
+
if not gid:
|
| 219 |
+
url = grant.get("url") or ""
|
| 220 |
+
match = re.search(r"/competition/(\d+)", url)
|
| 221 |
+
if match:
|
| 222 |
+
gid = match.group(1)
|
| 223 |
+
|
| 224 |
+
if not gid:
|
| 225 |
+
return lines[0]
|
| 226 |
+
|
| 227 |
+
# Filter document types
|
| 228 |
+
doc_types = ["supporting_pdf"] if pdf_only else ["supporting_pdf", "supporting_html"]
|
| 229 |
+
|
| 230 |
+
# Load supporting docs
|
| 231 |
+
supporting = get_supporting_docs_for_grant(str(gid), k=k, doc_types=doc_types)
|
| 232 |
+
|
| 233 |
+
if supporting:
|
| 234 |
+
lines.append("\n" + "="*80)
|
| 235 |
+
lines.append("SUPPORTING MATERIALS & PDF CONTENT:")
|
| 236 |
+
lines.append("="*80)
|
| 237 |
+
|
| 238 |
+
for i, doc in enumerate(supporting, 1):
|
| 239 |
+
doc_type = doc.get("doc_type", "unknown")
|
| 240 |
+
section = doc.get("section", "Supporting Info")
|
| 241 |
+
text = doc.get("text", "")
|
| 242 |
+
|
| 243 |
+
# Truncate long text but keep it substantial
|
| 244 |
+
if len(text) > 2000:
|
| 245 |
+
text = text[:2000] + "\n[... truncated ...]"
|
| 246 |
+
|
| 247 |
+
lines.append(f"\n[{i}] {section.upper()} ({doc_type})")
|
| 248 |
+
lines.append("-" * 60)
|
| 249 |
+
lines.append(text)
|
| 250 |
+
|
| 251 |
+
return "\n".join(lines)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# Self-test
|
| 255 |
+
if __name__ == "__main__":
|
| 256 |
+
fake_grant = {
|
| 257 |
+
"title": "AI Battery Research Program",
|
| 258 |
+
"funding_amount": "up to £1M",
|
| 259 |
+
"deadline": "2025-12-17",
|
| 260 |
+
"sections": {
|
| 261 |
+
"summary_raw": "Funding for early-stage AI-driven battery optimization.",
|
| 262 |
+
"scope_raw": "Projects must demonstrate significant improvement in energy density.",
|
| 263 |
+
},
|
| 264 |
+
}
|
| 265 |
+
fake_winners = [
|
| 266 |
+
{
|
| 267 |
+
"project_title": "BatteryX AI",
|
| 268 |
+
"lead_org": "EnergyAI Ltd",
|
| 269 |
+
"award_amount": "£500,000",
|
| 270 |
+
"competition": "Battery Innovation 2023",
|
| 271 |
+
"abstract": "Developed machine learning models for lithium-ion battery efficiency.",
|
| 272 |
+
}
|
| 273 |
+
]
|
| 274 |
+
print(build_context(fake_grant, fake_winners))
|
|
@@ -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,514 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Automated Grant Scraping Scheduler
|
| 3 |
+
|
| 4 |
+
Runs periodic crawls of Innovate UK and other funding sources to:
|
| 5 |
+
- Fetch new grants and update the database
|
| 6 |
+
- Mark grants as closed when deadlines pass
|
| 7 |
+
- Deduplicate entries
|
| 8 |
+
- Refresh the search index
|
| 9 |
+
|
| 10 |
+
Configuration:
|
| 11 |
+
- Run daily at 2 AM (configurable via CRAWLER_HOUR)
|
| 12 |
+
- Supports both APScheduler and system cron integration
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
from datetime import datetime, timedelta
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Optional, Dict, Any
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# Configuration
|
| 25 |
+
DEFAULT_CRAWL_HOUR = int(os.getenv("CRAWLER_HOUR", "2")) # 2 AM
|
| 26 |
+
CRAWL_ENABLED = os.getenv("CRAWL_ENABLED", "false").lower() in ("true", "1", "yes")
|
| 27 |
+
SNAPSHOTS_DIR = Path(os.getenv("SNAPSHOTS_DIR", "data/snapshots"))
|
| 28 |
+
INDEX_PATH = Path(os.getenv("INDEX_PATH", "data/index/hybrid_index.pkl"))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class CrawlResult:
|
| 33 |
+
"""Result of a crawl operation."""
|
| 34 |
+
timestamp: datetime
|
| 35 |
+
new_grants: int
|
| 36 |
+
updated_grants: int
|
| 37 |
+
closed_grants: int
|
| 38 |
+
duplicates_removed: int
|
| 39 |
+
index_rebuilt: bool
|
| 40 |
+
error: Optional[str] = None
|
| 41 |
+
|
| 42 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 43 |
+
return {
|
| 44 |
+
"timestamp": self.timestamp.isoformat(),
|
| 45 |
+
"new_grants": self.new_grants,
|
| 46 |
+
"updated_grants": self.updated_grants,
|
| 47 |
+
"closed_grants": self.closed_grants,
|
| 48 |
+
"duplicates_removed": self.duplicates_removed,
|
| 49 |
+
"index_rebuilt": self.index_rebuilt,
|
| 50 |
+
"error": self.error,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def deduplicate_grants(snapshots_dir: Path) -> int:
|
| 55 |
+
"""
|
| 56 |
+
Check for duplicate grants and keep only the most recent.
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
Number of duplicates removed
|
| 60 |
+
"""
|
| 61 |
+
import json
|
| 62 |
+
|
| 63 |
+
if not snapshots_dir.exists():
|
| 64 |
+
return 0
|
| 65 |
+
|
| 66 |
+
# Group grants by ID (handles competition-XXXX variants)
|
| 67 |
+
grants_by_id: Dict[str, list] = {}
|
| 68 |
+
|
| 69 |
+
for json_file in snapshots_dir.glob("*.json"):
|
| 70 |
+
try:
|
| 71 |
+
with open(json_file, "r", encoding="utf-8") as f:
|
| 72 |
+
grant = json.load(f)
|
| 73 |
+
|
| 74 |
+
grant_id = grant.get("id") or json_file.stem
|
| 75 |
+
if grant_id not in grants_by_id:
|
| 76 |
+
grants_by_id[grant_id] = []
|
| 77 |
+
|
| 78 |
+
grants_by_id[grant_id].append({
|
| 79 |
+
"file": json_file,
|
| 80 |
+
"timestamp": json_file.stat().st_mtime,
|
| 81 |
+
"data": grant
|
| 82 |
+
})
|
| 83 |
+
except Exception as e:
|
| 84 |
+
logger.warning(f"Failed to read {json_file}: {e}")
|
| 85 |
+
continue
|
| 86 |
+
|
| 87 |
+
# Remove older duplicates
|
| 88 |
+
removed = 0
|
| 89 |
+
for grant_id, versions in grants_by_id.items():
|
| 90 |
+
if len(versions) > 1:
|
| 91 |
+
# Sort by timestamp, keep newest
|
| 92 |
+
versions.sort(key=lambda x: x["timestamp"], reverse=True)
|
| 93 |
+
|
| 94 |
+
for old_version in versions[1:]:
|
| 95 |
+
try:
|
| 96 |
+
old_version["file"].unlink()
|
| 97 |
+
removed += 1
|
| 98 |
+
logger.info(f"Removed duplicate: {old_version['file'].name}")
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.warning(f"Failed to remove {old_version['file']}: {e}")
|
| 101 |
+
|
| 102 |
+
return removed
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def mark_closed_grants(snapshots_dir: Path) -> int:
|
| 106 |
+
"""
|
| 107 |
+
Scan grants and mark those with passed deadlines as 'closed'.
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
Number of grants marked as closed
|
| 111 |
+
"""
|
| 112 |
+
import json
|
| 113 |
+
from datetime import datetime
|
| 114 |
+
|
| 115 |
+
if not snapshots_dir.exists():
|
| 116 |
+
return 0
|
| 117 |
+
|
| 118 |
+
now = datetime.now()
|
| 119 |
+
closed_count = 0
|
| 120 |
+
|
| 121 |
+
for json_file in snapshots_dir.glob("*.json"):
|
| 122 |
+
try:
|
| 123 |
+
with open(json_file, "r", encoding="utf-8") as f:
|
| 124 |
+
grant = json.load(f)
|
| 125 |
+
|
| 126 |
+
# Check deadline
|
| 127 |
+
deadline_str = grant.get("close_date") or grant.get("deadline")
|
| 128 |
+
if not deadline_str:
|
| 129 |
+
continue
|
| 130 |
+
|
| 131 |
+
# Parse deadline
|
| 132 |
+
try:
|
| 133 |
+
# Handle ISO format with time
|
| 134 |
+
if "T" in deadline_str:
|
| 135 |
+
deadline = datetime.fromisoformat(deadline_str.replace("Z", "+00:00"))
|
| 136 |
+
else:
|
| 137 |
+
deadline = datetime.fromisoformat(deadline_str)
|
| 138 |
+
except ValueError:
|
| 139 |
+
continue
|
| 140 |
+
|
| 141 |
+
# If deadline passed and not marked closed, update it
|
| 142 |
+
if deadline < now and grant.get("status") != "closed":
|
| 143 |
+
grant["status"] = "closed"
|
| 144 |
+
grant["marked_closed_at"] = now.isoformat()
|
| 145 |
+
|
| 146 |
+
with open(json_file, "w", encoding="utf-8") as f:
|
| 147 |
+
json.dump(grant, f, indent=2)
|
| 148 |
+
|
| 149 |
+
logger.info(f"Marked as closed: {grant.get('title', json_file.stem)}")
|
| 150 |
+
closed_count += 1
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.warning(f"Failed to process {json_file}: {e}")
|
| 154 |
+
continue
|
| 155 |
+
|
| 156 |
+
return closed_count
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def rebuild_search_index(
|
| 160 |
+
snapshots_dir: Path = SNAPSHOTS_DIR,
|
| 161 |
+
output_path: Path = INDEX_PATH
|
| 162 |
+
) -> bool:
|
| 163 |
+
"""
|
| 164 |
+
Rebuild the search index from current data.
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
True if successful
|
| 168 |
+
"""
|
| 169 |
+
try:
|
| 170 |
+
from ..search.hybrid_index import rebuild_index_from_data
|
| 171 |
+
from ..search.past_winners_integration import (
|
| 172 |
+
enrich_index_with_past_winners
|
| 173 |
+
)
|
| 174 |
+
from ..data_loader import load_past_winners
|
| 175 |
+
|
| 176 |
+
logger.info("Rebuilding search index...")
|
| 177 |
+
|
| 178 |
+
# Rebuild main index
|
| 179 |
+
idx = rebuild_index_from_data(
|
| 180 |
+
snapshots_dir=str(snapshots_dir),
|
| 181 |
+
output_path=str(output_path)
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
# Try to enrich with past winners
|
| 185 |
+
try:
|
| 186 |
+
past_winners = load_past_winners()
|
| 187 |
+
if past_winners:
|
| 188 |
+
enrich_index_with_past_winners(idx, past_winners)
|
| 189 |
+
logger.info(f"Enhanced index with {len(past_winners)} past winners")
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.warning(f"Could not integrate past winners: {e}")
|
| 192 |
+
|
| 193 |
+
logger.info("Search index rebuilt successfully")
|
| 194 |
+
return True
|
| 195 |
+
|
| 196 |
+
except Exception as e:
|
| 197 |
+
logger.error(f"Failed to rebuild search index: {e}")
|
| 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.
|
| 345 |
+
|
| 346 |
+
This is the main entry point for scheduled crawls.
|
| 347 |
+
"""
|
| 348 |
+
import asyncio
|
| 349 |
+
|
| 350 |
+
logger.info("Starting grant crawler cycle...")
|
| 351 |
+
start_time = datetime.now()
|
| 352 |
+
|
| 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 |
+
|
| 360 |
+
logger.info("Discovering new grants from Innovate UK...")
|
| 361 |
+
total_discovered, newly_fetched, new_files = asyncio.run(
|
| 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"
|
| 369 |
+
)
|
| 370 |
+
if new_files:
|
| 371 |
+
logger.info(f"New grants: {', '.join(new_files)}")
|
| 372 |
+
except Exception as e:
|
| 373 |
+
logger.error(f"Grant discovery failed: {e}", exc_info=True)
|
| 374 |
+
# Continue with other steps even if discovery fails
|
| 375 |
+
|
| 376 |
+
# Step 2: Generate summaries for new grants (using gpt-5-mini)
|
| 377 |
+
summaries_generated = 0
|
| 378 |
+
if new_grant_ids:
|
| 379 |
+
try:
|
| 380 |
+
logger.info(f"Generating layman summaries for {len(new_grant_ids)} new grants...")
|
| 381 |
+
summaries_generated = generate_summaries_for_grants(
|
| 382 |
+
SNAPSHOTS_DIR,
|
| 383 |
+
grant_ids=new_grant_ids
|
| 384 |
+
)
|
| 385 |
+
logger.info(f"Generated summaries for {summaries_generated} grants")
|
| 386 |
+
except Exception as e:
|
| 387 |
+
logger.error(f"Summary generation failed: {e}", exc_info=True)
|
| 388 |
+
|
| 389 |
+
# Step 3: Check for duplicates
|
| 390 |
+
duplicates = deduplicate_grants(SNAPSHOTS_DIR)
|
| 391 |
+
|
| 392 |
+
# Step 4: Mark closed grants
|
| 393 |
+
closed = mark_closed_grants(SNAPSHOTS_DIR)
|
| 394 |
+
|
| 395 |
+
# Step 5: Rebuild index
|
| 396 |
+
index_rebuilt = rebuild_search_index()
|
| 397 |
+
|
| 398 |
+
result = CrawlResult(
|
| 399 |
+
timestamp=start_time,
|
| 400 |
+
new_grants=new_grants,
|
| 401 |
+
updated_grants=summaries_generated, # Track summaries in updated_grants
|
| 402 |
+
closed_grants=closed,
|
| 403 |
+
duplicates_removed=duplicates,
|
| 404 |
+
index_rebuilt=index_rebuilt,
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
logger.info(f"Crawl cycle complete: {result}")
|
| 408 |
+
return result
|
| 409 |
+
|
| 410 |
+
except Exception as e:
|
| 411 |
+
logger.error(f"Crawl cycle failed: {e}", exc_info=True)
|
| 412 |
+
return CrawlResult(
|
| 413 |
+
timestamp=start_time,
|
| 414 |
+
new_grants=0,
|
| 415 |
+
updated_grants=0,
|
| 416 |
+
closed_grants=0,
|
| 417 |
+
duplicates_removed=0,
|
| 418 |
+
index_rebuilt=False,
|
| 419 |
+
error=str(e)
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def setup_scheduler():
|
| 424 |
+
"""
|
| 425 |
+
Set up APScheduler for daily crawls.
|
| 426 |
+
|
| 427 |
+
Usage:
|
| 428 |
+
from analyzer.crawler.scheduler import setup_scheduler
|
| 429 |
+
scheduler = setup_scheduler()
|
| 430 |
+
scheduler.start()
|
| 431 |
+
"""
|
| 432 |
+
try:
|
| 433 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
| 434 |
+
from apscheduler.triggers.cron import CronTrigger
|
| 435 |
+
|
| 436 |
+
if not CRAWL_ENABLED:
|
| 437 |
+
logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)")
|
| 438 |
+
return None
|
| 439 |
+
|
| 440 |
+
scheduler = BackgroundScheduler()
|
| 441 |
+
|
| 442 |
+
# Schedule daily crawl at DEFAULT_CRAWL_HOUR (default 2 AM)
|
| 443 |
+
trigger = CronTrigger(hour=DEFAULT_CRAWL_HOUR, minute=0)
|
| 444 |
+
scheduler.add_job(
|
| 445 |
+
run_crawl_cycle,
|
| 446 |
+
trigger=trigger,
|
| 447 |
+
id="grant_crawler",
|
| 448 |
+
name="Daily grant crawl and index refresh",
|
| 449 |
+
replace_existing=True
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
logger.info(
|
| 453 |
+
f"Scheduler configured: Daily crawl at {DEFAULT_CRAWL_HOUR}:00 "
|
| 454 |
+
f"(set CRAWL_ENABLED=true to start)"
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
+
return scheduler
|
| 458 |
+
|
| 459 |
+
except ImportError:
|
| 460 |
+
logger.warning(
|
| 461 |
+
"APScheduler not installed. "
|
| 462 |
+
"Install with: pip install apscheduler"
|
| 463 |
+
)
|
| 464 |
+
return None
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def register_cron_job():
|
| 468 |
+
"""
|
| 469 |
+
Register a system cron job for daily crawls.
|
| 470 |
+
|
| 471 |
+
Useful as alternative to APScheduler for production deployments.
|
| 472 |
+
|
| 473 |
+
Example cron line (runs daily at 2 AM):
|
| 474 |
+
0 2 * * * cd /path/to/grant-analyst && python -m analyzer.crawler.scheduler
|
| 475 |
+
"""
|
| 476 |
+
import subprocess
|
| 477 |
+
import platform
|
| 478 |
+
|
| 479 |
+
if platform.system() == "Windows":
|
| 480 |
+
logger.warning("Cron registration only supported on Unix-like systems")
|
| 481 |
+
return False
|
| 482 |
+
|
| 483 |
+
try:
|
| 484 |
+
script_path = Path(__file__).parent.parent.parent / "crawler" / "scheduler.py"
|
| 485 |
+
cron_line = f"0 {DEFAULT_CRAWL_HOUR} * * * python {script_path}"
|
| 486 |
+
|
| 487 |
+
# This is a guide - actual registration depends on system setup
|
| 488 |
+
logger.info(f"Add this to crontab for daily crawls:\n{cron_line}")
|
| 489 |
+
return True
|
| 490 |
+
|
| 491 |
+
except Exception as e:
|
| 492 |
+
logger.error(f"Failed to register cron job: {e}")
|
| 493 |
+
return False
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
# ============================================================================
|
| 497 |
+
# Entry Points
|
| 498 |
+
# ============================================================================
|
| 499 |
+
|
| 500 |
+
if __name__ == "__main__":
|
| 501 |
+
import sys
|
| 502 |
+
|
| 503 |
+
logging.basicConfig(
|
| 504 |
+
level=logging.INFO,
|
| 505 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
# Run single crawl cycle
|
| 509 |
+
result = run_crawl_cycle()
|
| 510 |
+
print(f"\nCrawl Result: {result.to_dict()}")
|
| 511 |
+
|
| 512 |
+
if result.error:
|
| 513 |
+
sys.exit(1)
|
| 514 |
+
sys.exit(0)
|
|
@@ -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)
|
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
data_loader.py — loads current grant snapshots (JSON) and optional past winners
|
| 3 |
+
|
| 4 |
+
This module keeps IO concerns simple and robust:
|
| 5 |
+
- Recursively loads current-grant JSON files under a snapshots directory
|
| 6 |
+
- Optionally loads past winners from either an Excel file or a JSON directory
|
| 7 |
+
- Returns Python lists of dictionaries; no model code here
|
| 8 |
+
|
| 9 |
+
Public API
|
| 10 |
+
---------
|
| 11 |
+
load_current_grants(snapshots_dir: Path | str, limit: int | None = None) -> list[dict]
|
| 12 |
+
load_past_winners(history_xlsx: Path | str | None = None,
|
| 13 |
+
history_json_dir: Path | str | None = None) -> list[dict]
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any, Dict, List, Optional
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
|
| 22 |
+
import pandas as pd
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# ----------------------------- Current grants ---------------------------------
|
| 27 |
+
|
| 28 |
+
def load_current_grants(snapshots_dir: Path | str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
| 29 |
+
"""Load current grant JSON snapshots from a directory tree.
|
| 30 |
+
|
| 31 |
+
Each file is expected to be one JSON object. The function tolerates
|
| 32 |
+
missing keys and will attach an `id` from the filename if not present.
|
| 33 |
+
"""
|
| 34 |
+
snapshots_dir = Path(snapshots_dir)
|
| 35 |
+
if not snapshots_dir.exists():
|
| 36 |
+
logger.warning("snapshots directory not found: %s", snapshots_dir)
|
| 37 |
+
return []
|
| 38 |
+
|
| 39 |
+
records: List[Dict[str, Any]] = []
|
| 40 |
+
for p in sorted(snapshots_dir.rglob("*.json")):
|
| 41 |
+
try:
|
| 42 |
+
with open(p, "r", encoding="utf-8") as f:
|
| 43 |
+
rec = json.load(f)
|
| 44 |
+
if not isinstance(rec, dict):
|
| 45 |
+
logger.debug("Skipping non-object JSON: %s", p)
|
| 46 |
+
continue
|
| 47 |
+
rec.setdefault("id", p.stem)
|
| 48 |
+
rec.setdefault("_path", str(p))
|
| 49 |
+
records.append(rec)
|
| 50 |
+
if limit and len(records) >= limit:
|
| 51 |
+
break
|
| 52 |
+
except Exception as e: # pragma: no cover
|
| 53 |
+
logger.warning("Failed to load %s: %s", p, e)
|
| 54 |
+
continue
|
| 55 |
+
|
| 56 |
+
logger.info("Loaded %d current grants from %s", len(records), snapshots_dir)
|
| 57 |
+
return records
|
| 58 |
+
|
| 59 |
+
# ------------------------------ Past winners ----------------------------------
|
| 60 |
+
|
| 61 |
+
_CANON_COLS = {
|
| 62 |
+
# canonical : candidate column names (lower/underscore)
|
| 63 |
+
"project_title": ["project_title", "title", "name"],
|
| 64 |
+
"abstract": ["abstract", "description", "summary", "public_description"],
|
| 65 |
+
"competition": ["competition", "programme", "program"],
|
| 66 |
+
"award_amount": ["award_amount", "amount", "grant", "project_cost", "award"],
|
| 67 |
+
"lead_org": ["lead_org", "lead_organisation", "lead_organization", "organisation_name", "organization_name"],
|
| 68 |
+
"year": ["year", "fy", "start_year"],
|
| 69 |
+
"project_url": ["project_url", "url", "link"],
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _norm_cols(df: pd.DataFrame) -> pd.DataFrame:
|
| 74 |
+
df = df.copy()
|
| 75 |
+
df.columns = [
|
| 76 |
+
(c if isinstance(c, str) else str(c))
|
| 77 |
+
.lower()
|
| 78 |
+
.replace(" ", "_")
|
| 79 |
+
.replace("-", "_")
|
| 80 |
+
for c in df.columns
|
| 81 |
+
]
|
| 82 |
+
return df
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _ensure_canonical(df: pd.DataFrame) -> pd.DataFrame:
|
| 86 |
+
for canon, candidates in _CANON_COLS.items():
|
| 87 |
+
if canon in df.columns:
|
| 88 |
+
continue
|
| 89 |
+
for c in candidates:
|
| 90 |
+
if c in df.columns:
|
| 91 |
+
df[canon] = df[c]
|
| 92 |
+
break
|
| 93 |
+
if canon not in df.columns:
|
| 94 |
+
df[canon] = None
|
| 95 |
+
return df
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _load_past_winners_from_excel(xlsx_path: Path) -> List[Dict[str, Any]]:
|
| 99 |
+
df = pd.read_excel(xlsx_path)
|
| 100 |
+
df = _norm_cols(df)
|
| 101 |
+
df = _ensure_canonical(df)
|
| 102 |
+
return [row._asdict() if hasattr(row, "_asdict") else row.to_dict() for _, row in df.iterrows()]
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _load_past_winners_from_json_dir(json_dir: Path) -> List[Dict[str, Any]]:
|
| 106 |
+
records: List[Dict[str, Any]] = []
|
| 107 |
+
for p in sorted(json_dir.rglob("*.json")):
|
| 108 |
+
try:
|
| 109 |
+
with open(p, "r", encoding="utf-8") as f:
|
| 110 |
+
rec = json.load(f)
|
| 111 |
+
if not isinstance(rec, dict):
|
| 112 |
+
continue
|
| 113 |
+
rec.setdefault("_path", str(p))
|
| 114 |
+
records.append(rec)
|
| 115 |
+
except Exception:
|
| 116 |
+
continue
|
| 117 |
+
return records
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def load_past_winners(history_xlsx: Path | str | None = None,
|
| 121 |
+
history_json_dir: Path | str | None = None) -> List[Dict[str, Any]]:
|
| 122 |
+
"""Load past winners from either Excel (preferred) or a JSON folder.
|
| 123 |
+
|
| 124 |
+
Priority order: JSON dir (if provided) > Excel (if provided).
|
| 125 |
+
If neither exists, returns an empty list.
|
| 126 |
+
"""
|
| 127 |
+
# JSON dir first if present
|
| 128 |
+
if history_json_dir is not None:
|
| 129 |
+
jdir = Path(history_json_dir)
|
| 130 |
+
if jdir.exists():
|
| 131 |
+
recs = _load_past_winners_from_json_dir(jdir)
|
| 132 |
+
if recs:
|
| 133 |
+
logger.info("Loaded %d past winners from JSON dir: %s", len(recs), jdir)
|
| 134 |
+
return recs
|
| 135 |
+
else:
|
| 136 |
+
logger.info("No JSON past winners found under %s", jdir)
|
| 137 |
+
|
| 138 |
+
# Excel next
|
| 139 |
+
if history_xlsx is not None:
|
| 140 |
+
xlsx = Path(history_xlsx)
|
| 141 |
+
if xlsx.exists():
|
| 142 |
+
recs = _load_past_winners_from_excel(xlsx)
|
| 143 |
+
logger.info("Loaded %d past winners from Excel: %s", len(recs), xlsx)
|
| 144 |
+
return recs
|
| 145 |
+
else:
|
| 146 |
+
logger.info("History Excel not found: %s", xlsx)
|
| 147 |
+
|
| 148 |
+
return []
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# Self-test
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
import argparse
|
| 154 |
+
logging.basicConfig(level=logging.INFO)
|
| 155 |
+
parser = argparse.ArgumentParser()
|
| 156 |
+
parser.add_argument("--snapshots-dir", type=Path, default=Path("data/snapshots"))
|
| 157 |
+
parser.add_argument("--history-xlsx", type=Path, default=Path("data/past_winners.xlsx"))
|
| 158 |
+
parser.add_argument("--limit", type=int, default=3)
|
| 159 |
+
args = parser.parse_args()
|
| 160 |
+
|
| 161 |
+
current = load_current_grants(args.snapshots_dir, limit=args.limit)
|
| 162 |
+
history = load_past_winners(args.history_xlsx)
|
| 163 |
+
print(f"current: {len(current)} | history: {len(history)}")
|
| 164 |
+
if current:
|
| 165 |
+
print("example current keys:", sorted(current[0].keys())[:12])
|
| 166 |
+
if history:
|
| 167 |
+
print("example history keys:", sorted(history[0].keys())[:12])
|
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/data_loader_supporting.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Dict, List, Optional, Iterable, Union
|
| 6 |
+
import json, re
|
| 7 |
+
|
| 8 |
+
from .utils.text import to_number
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class SupportingDoc:
|
| 12 |
+
grant_id: str
|
| 13 |
+
url: str
|
| 14 |
+
title: str
|
| 15 |
+
open_date: Optional[str]
|
| 16 |
+
close_date: Optional[str]
|
| 17 |
+
notify_date: Optional[str]
|
| 18 |
+
funding_min: Optional[float]
|
| 19 |
+
funding_max: Optional[float]
|
| 20 |
+
total_pot: Optional[float]
|
| 21 |
+
funding_rates: Optional[str]
|
| 22 |
+
duration_min: Optional[int]
|
| 23 |
+
duration_max: Optional[int]
|
| 24 |
+
text: str # flattened blob for retrieval
|
| 25 |
+
sections: Dict[str, str] # raw sections, if present
|
| 26 |
+
|
| 27 |
+
# Number parsing moved to utils.text.to_number()
|
| 28 |
+
# Keeping wrapper for backward compatibility
|
| 29 |
+
def _num(x):
|
| 30 |
+
return to_number(x)
|
| 31 |
+
|
| 32 |
+
def _int(x):
|
| 33 |
+
try:
|
| 34 |
+
return int(x) if x is not None else None
|
| 35 |
+
except Exception:
|
| 36 |
+
try:
|
| 37 |
+
return int(float(str(x).replace(",", "")))
|
| 38 |
+
except Exception:
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
def _infer_grant_id(obj: dict, fallback_name: str = "") -> Optional[str]:
|
| 42 |
+
# 1) explicit fields
|
| 43 |
+
for k in ("grant_id","id","competition_id","competitionId"):
|
| 44 |
+
if obj.get(k):
|
| 45 |
+
return str(obj[k]).replace("competition-","").strip()
|
| 46 |
+
# 2) from URL: .../competition/2185/...
|
| 47 |
+
url = obj.get("url") or obj.get("source_url") or obj.get("page_url") or ""
|
| 48 |
+
m = re.search(r"/competition/(\d+)", url)
|
| 49 |
+
if m:
|
| 50 |
+
return m.group(1)
|
| 51 |
+
# 3) from filename
|
| 52 |
+
m2 = re.search(r"competition-(\d+)", fallback_name)
|
| 53 |
+
if m2:
|
| 54 |
+
return m2.group(1)
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
def _make_text_blob(title: str, url: str, sections: Dict[str,str]) -> str:
|
| 58 |
+
parts = [f"TITLE: {title}", f"URL: {url}"]
|
| 59 |
+
for k in ("summary_raw","eligibility_raw","scope_raw","dates_raw","how_to_apply_raw","supporting_information_raw"):
|
| 60 |
+
v = sections.get(k)
|
| 61 |
+
if v:
|
| 62 |
+
parts.append(f"\n[{k}]\n{v}")
|
| 63 |
+
# also tolerate alt keys from other crawlers
|
| 64 |
+
for k in ("summary","eligibility","scope","dates","how_to_apply","supporting_information"):
|
| 65 |
+
v = sections.get(k)
|
| 66 |
+
if v and f"[{k}_raw]" not in "".join(parts):
|
| 67 |
+
parts.append(f"\n[{k}]\n{v}")
|
| 68 |
+
return "\n".join(parts)
|
| 69 |
+
|
| 70 |
+
def _read_obj(obj: dict, fallback_name: str = "") -> Optional[SupportingDoc]:
|
| 71 |
+
gid = _infer_grant_id(obj, fallback_name)
|
| 72 |
+
if not gid:
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
url = obj.get("url") or obj.get("source_url") or obj.get("page_url") or ""
|
| 76 |
+
title = (obj.get("title") or obj.get("name") or "").strip()
|
| 77 |
+
|
| 78 |
+
# Common normalised fields
|
| 79 |
+
open_date = obj.get("open_date")
|
| 80 |
+
close_date = obj.get("close_date") or obj.get("deadline") or obj.get("closeDate")
|
| 81 |
+
notify_date = obj.get("notify_date")
|
| 82 |
+
|
| 83 |
+
# Funding block: either nested or flat
|
| 84 |
+
funding = obj.get("funding") or {}
|
| 85 |
+
fmin = _num(funding.get("min") or obj.get("funding_min") or obj.get("min_award") or obj.get("grant_min"))
|
| 86 |
+
fmax = _num(funding.get("max") or obj.get("funding_max") or obj.get("max_award") or obj.get("grant_max"))
|
| 87 |
+
total_pot = _num(funding.get("total_pot") or obj.get("total_pot") or obj.get("competition_total") or obj.get("total_funding"))
|
| 88 |
+
rates = funding.get("rates") if isinstance(funding.get("rates"), str) else obj.get("funding_rates")
|
| 89 |
+
|
| 90 |
+
# Duration block
|
| 91 |
+
dur = obj.get("duration_months") or {}
|
| 92 |
+
dmin = _int(dur.get("min") or obj.get("duration_min") or obj.get("project_duration_min_months"))
|
| 93 |
+
dmax = _int(dur.get("max") or obj.get("duration_max") or obj.get("project_duration_max_months"))
|
| 94 |
+
|
| 95 |
+
# Sections: tolerate both nested and flat naming
|
| 96 |
+
sections: Dict[str, str] = {}
|
| 97 |
+
for k in ("summary_raw","eligibility_raw","scope_raw","dates_raw","how_to_apply_raw","supporting_information_raw",
|
| 98 |
+
"summary","eligibility","scope","dates","how_to_apply","supporting_information"):
|
| 99 |
+
v = obj.get(k) or (obj.get("sections") or {}).get(k)
|
| 100 |
+
if isinstance(v, str) and v.strip():
|
| 101 |
+
sections[k] = v
|
| 102 |
+
|
| 103 |
+
text = _make_text_blob(title, url, sections)
|
| 104 |
+
return SupportingDoc(
|
| 105 |
+
grant_id=gid,
|
| 106 |
+
url=url,
|
| 107 |
+
title=title,
|
| 108 |
+
open_date=open_date,
|
| 109 |
+
close_date=close_date,
|
| 110 |
+
notify_date=notify_date,
|
| 111 |
+
funding_min=fmin,
|
| 112 |
+
funding_max=fmax,
|
| 113 |
+
total_pot=total_pot,
|
| 114 |
+
funding_rates=rates if isinstance(rates, str) else None,
|
| 115 |
+
duration_min=dmin,
|
| 116 |
+
duration_max=dmax,
|
| 117 |
+
text=text,
|
| 118 |
+
sections=sections,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
def _read_json_file(p: Path) -> Optional[SupportingDoc]:
|
| 122 |
+
try:
|
| 123 |
+
obj = json.loads(p.read_text(encoding="utf-8"))
|
| 124 |
+
return _read_obj(obj, fallback_name=p.name)
|
| 125 |
+
except Exception:
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
def _iter_jsonl(p: Path) -> Iterable[SupportingDoc]:
|
| 129 |
+
with p.open("r", encoding="utf-8") as f:
|
| 130 |
+
for i, line in enumerate(f, start=1):
|
| 131 |
+
line = line.strip()
|
| 132 |
+
if not line:
|
| 133 |
+
continue
|
| 134 |
+
try:
|
| 135 |
+
obj = json.loads(line)
|
| 136 |
+
except Exception:
|
| 137 |
+
continue
|
| 138 |
+
doc = _read_obj(obj, fallback_name=f"{p.name}:{i}")
|
| 139 |
+
if doc:
|
| 140 |
+
yield doc
|
| 141 |
+
|
| 142 |
+
def iter_supporting_docs(folder: Path) -> Iterable[SupportingDoc]:
|
| 143 |
+
folder = Path(folder)
|
| 144 |
+
# Prefer explicit competition-*.json first
|
| 145 |
+
found = False
|
| 146 |
+
for p in sorted(folder.glob("competition-*.json")):
|
| 147 |
+
found = True
|
| 148 |
+
doc = _read_json_file(p)
|
| 149 |
+
if doc:
|
| 150 |
+
yield doc
|
| 151 |
+
# Then any *.json
|
| 152 |
+
if not found:
|
| 153 |
+
for p in sorted(folder.glob("*.json")):
|
| 154 |
+
doc = _read_json_file(p)
|
| 155 |
+
if doc:
|
| 156 |
+
yield doc
|
| 157 |
+
# Then *.jsonl (one object per line)
|
| 158 |
+
for p in sorted(folder.glob("*.jsonl")):
|
| 159 |
+
for doc in _iter_jsonl(p):
|
| 160 |
+
yield doc
|
| 161 |
+
|
| 162 |
+
def load_supporting_docs(folder: Path) -> List[SupportingDoc]:
|
| 163 |
+
return list(iter_supporting_docs(folder))
|
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
exporters.py — save summaries to Excel and JSONL
|
| 3 |
+
|
| 4 |
+
Public API
|
| 5 |
+
----------
|
| 6 |
+
- export_excel(rows: list[dict], out_path: Path | str) -> None
|
| 7 |
+
- export_jsonl(rows: list[dict], out_path: Path | str) -> None
|
| 8 |
+
|
| 9 |
+
Each row should minimally include: grant_id, title, summary_md.
|
| 10 |
+
Optional keys like context, source_path will be preserved (JSONL)
|
| 11 |
+
and omitted from the Excel unless you want to include them explicitly.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any, Dict, List
|
| 17 |
+
import json
|
| 18 |
+
import pandas as pd
|
| 19 |
+
|
| 20 |
+
_EXCEL_COLUMNS = [
|
| 21 |
+
"grant_id",
|
| 22 |
+
"title",
|
| 23 |
+
"summary_md",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def export_excel(rows: List[Dict[str, Any]], out_path: Path | str) -> None:
|
| 28 |
+
out = Path(out_path)
|
| 29 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
|
| 31 |
+
# Keep Excel tidy with a predictable column order; include extras at the end
|
| 32 |
+
if rows:
|
| 33 |
+
# determine any extra keys beyond the base set
|
| 34 |
+
base = set(_EXCEL_COLUMNS)
|
| 35 |
+
extra_keys = []
|
| 36 |
+
for r in rows:
|
| 37 |
+
for k in r.keys():
|
| 38 |
+
if k not in base and k not in extra_keys:
|
| 39 |
+
extra_keys.append(k)
|
| 40 |
+
cols = _EXCEL_COLUMNS + [k for k in extra_keys if k not in ("context",)]
|
| 41 |
+
else:
|
| 42 |
+
cols = _EXCEL_COLUMNS
|
| 43 |
+
|
| 44 |
+
df = pd.DataFrame(rows)
|
| 45 |
+
# Ensure columns exist even if missing in data
|
| 46 |
+
for c in cols:
|
| 47 |
+
if c not in df.columns:
|
| 48 |
+
df[c] = None
|
| 49 |
+
|
| 50 |
+
with pd.ExcelWriter(out, engine="xlsxwriter") as writer:
|
| 51 |
+
df[cols].to_excel(writer, index=False, sheet_name="summaries")
|
| 52 |
+
ws = writer.sheets["summaries"]
|
| 53 |
+
# simple width heuristic
|
| 54 |
+
for idx, col in enumerate(cols):
|
| 55 |
+
s = df[col].astype(str)
|
| 56 |
+
width = min(80, max(12, int(s.str.len().quantile(0.9)) + 3))
|
| 57 |
+
ws.set_column(idx, idx, width)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def export_jsonl(rows: List[Dict[str, Any]], out_path: Path | str) -> None:
|
| 61 |
+
out = Path(out_path)
|
| 62 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 63 |
+
with open(out, "w", encoding="utf-8") as f:
|
| 64 |
+
for r in rows:
|
| 65 |
+
json.dump(r, f, ensure_ascii=False)
|
| 66 |
+
f.write("\n")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# Self-test
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
demo = [
|
| 72 |
+
{"grant_id": "g1", "title": "AI in MFG", "summary_md": "**Example**"},
|
| 73 |
+
{"grant_id": "g2", "title": "Net Zero", "summary_md": "text", "source_path": "foo.json"},
|
| 74 |
+
]
|
| 75 |
+
export_excel(demo, "./_out/demo.xlsx")
|
| 76 |
+
export_jsonl(demo, "./_out/demo.jsonl")
|
| 77 |
+
print("Wrote ./_out/demo.xlsx and ./_out/demo.jsonl")
|
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/llm_client.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
from typing import Any, Dict, List, Optional, Callable, Literal
|
| 7 |
+
|
| 8 |
+
from .utils.errors import LLMError, ConfigError
|
| 9 |
+
|
| 10 |
+
ModelType = Literal["router", "translator", "analyzer"]
|
| 11 |
+
VerbosityLevel = Literal["low", "medium", "high"]
|
| 12 |
+
ReasoningEffort = Literal["minimal", "medium", "high"]
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
from openai import OpenAI
|
| 16 |
+
import httpx
|
| 17 |
+
except ImportError:
|
| 18 |
+
OpenAI = None
|
| 19 |
+
httpx = None
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class LLMClient:
|
| 25 |
+
"""
|
| 26 |
+
Lightweight wrapper around OpenAI Chat Completions with:
|
| 27 |
+
- per-call max_tokens
|
| 28 |
+
- retry with exponential backoff
|
| 29 |
+
- proper error handling and timeouts
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self, cfg: Any):
|
| 33 |
+
# Extract config (supports both dict and object)
|
| 34 |
+
self.provider = self._get_cfg(cfg, "provider", "openai")
|
| 35 |
+
self.model = self._get_cfg(cfg, "model", "gpt-5-mini")
|
| 36 |
+
self.disable_llm = self._get_cfg(cfg, "disable_llm", False)
|
| 37 |
+
|
| 38 |
+
# Store model variants for different use cases
|
| 39 |
+
self.model_router = self._get_cfg(cfg, "model_router", "gpt-5-nano")
|
| 40 |
+
self.model_translator = self._get_cfg(cfg, "model_translator", "gpt-5-mini")
|
| 41 |
+
self.model_analyzer = self._get_cfg(cfg, "model_analyzer", "gpt-5")
|
| 42 |
+
|
| 43 |
+
api_key = self._get_cfg(cfg, "api_key") or os.getenv("OPENAI_API_KEY")
|
| 44 |
+
base_url = self._get_cfg(cfg, "base_url") or os.getenv(
|
| 45 |
+
"OPENAI_API_BASE",
|
| 46 |
+
"https://api.openai.com/v1"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
self.max_retries = int(os.getenv("LLM_MAX_RETRIES", "3"))
|
| 50 |
+
self.retry_delay = float(os.getenv("LLM_RETRY_DELAY", "1.0"))
|
| 51 |
+
self.client = None
|
| 52 |
+
|
| 53 |
+
# Validate provider
|
| 54 |
+
if self.provider not in ("openai", "anthropic"):
|
| 55 |
+
raise ConfigError(
|
| 56 |
+
f"Unsupported LLM provider: {self.provider}. "
|
| 57 |
+
f"Supported: openai, anthropic"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Initialize client (if not disabled)
|
| 61 |
+
if self.disable_llm:
|
| 62 |
+
logger.info("LLM disabled via config")
|
| 63 |
+
return
|
| 64 |
+
|
| 65 |
+
if self.provider != "openai":
|
| 66 |
+
logger.warning("Provider %s not yet fully supported", self.provider)
|
| 67 |
+
return
|
| 68 |
+
|
| 69 |
+
if OpenAI is None:
|
| 70 |
+
raise ConfigError(
|
| 71 |
+
"openai package not installed. Install with: pip install openai"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
if not api_key:
|
| 75 |
+
raise ConfigError(
|
| 76 |
+
"OpenAI API key required. Set OPENAI_API_KEY environment variable."
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
| 81 |
+
logger.info("LLMClient initialized: %s/%s", self.provider, self.model)
|
| 82 |
+
except Exception as e:
|
| 83 |
+
raise ConfigError(f"Failed to initialize OpenAI client: {e}") from e
|
| 84 |
+
|
| 85 |
+
def _get_cfg(self, cfg: Any, key: str, default: Any = None) -> Any:
|
| 86 |
+
"""Extract config value (works with both dict and object)."""
|
| 87 |
+
if hasattr(cfg, key):
|
| 88 |
+
return getattr(cfg, key) or default
|
| 89 |
+
if isinstance(cfg, dict):
|
| 90 |
+
return cfg.get(key, default)
|
| 91 |
+
return default
|
| 92 |
+
|
| 93 |
+
def is_ready(self) -> bool:
|
| 94 |
+
"""Check if LLM client is ready to use."""
|
| 95 |
+
return self.client is not None and not self.disable_llm
|
| 96 |
+
|
| 97 |
+
def _get_model_for_type(self, model_type: Optional[ModelType] = None) -> str:
|
| 98 |
+
"""Get the appropriate model for the given type."""
|
| 99 |
+
if model_type == "router":
|
| 100 |
+
return self.model_router
|
| 101 |
+
elif model_type == "translator":
|
| 102 |
+
return self.model_translator
|
| 103 |
+
elif model_type == "analyzer":
|
| 104 |
+
return self.model_analyzer
|
| 105 |
+
else:
|
| 106 |
+
return self.model
|
| 107 |
+
|
| 108 |
+
@staticmethod
|
| 109 |
+
def get_recommended_params(
|
| 110 |
+
task_type: str
|
| 111 |
+
) -> Dict[str, Any]:
|
| 112 |
+
"""
|
| 113 |
+
Get recommended verbosity and reasoning_effort for common tasks.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
task_type: One of "translation", "analysis", "routing", "summary"
|
| 117 |
+
|
| 118 |
+
Returns:
|
| 119 |
+
Dict with verbosity and reasoning_effort settings
|
| 120 |
+
"""
|
| 121 |
+
presets = {
|
| 122 |
+
"translation": {
|
| 123 |
+
"verbosity": "medium",
|
| 124 |
+
"reasoning_effort": "minimal"
|
| 125 |
+
},
|
| 126 |
+
"analysis": {
|
| 127 |
+
"verbosity": "high",
|
| 128 |
+
"reasoning_effort": "high"
|
| 129 |
+
},
|
| 130 |
+
"routing": {
|
| 131 |
+
"verbosity": "low",
|
| 132 |
+
"reasoning_effort": "minimal"
|
| 133 |
+
},
|
| 134 |
+
"summary": {
|
| 135 |
+
"verbosity": "medium",
|
| 136 |
+
"reasoning_effort": "medium"
|
| 137 |
+
},
|
| 138 |
+
"comparison": {
|
| 139 |
+
"verbosity": "high",
|
| 140 |
+
"reasoning_effort": "high"
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
return presets.get(task_type, {"verbosity": "medium", "reasoning_effort": "medium"})
|
| 144 |
+
|
| 145 |
+
def _retry_with_backoff(
|
| 146 |
+
self,
|
| 147 |
+
fn: Callable[[], Any],
|
| 148 |
+
*,
|
| 149 |
+
max_attempts: int = 3,
|
| 150 |
+
initial_delay: float = 1.0
|
| 151 |
+
) -> Any:
|
| 152 |
+
"""
|
| 153 |
+
Retry function with exponential backoff.
|
| 154 |
+
|
| 155 |
+
Retries on transient errors only (timeouts, rate limits).
|
| 156 |
+
Re-raises immediately on permanent errors (auth, invalid model).
|
| 157 |
+
"""
|
| 158 |
+
last_error = None
|
| 159 |
+
delay = initial_delay
|
| 160 |
+
|
| 161 |
+
for attempt in range(1, max_attempts + 1):
|
| 162 |
+
try:
|
| 163 |
+
return fn()
|
| 164 |
+
|
| 165 |
+
except Exception as e:
|
| 166 |
+
last_error = e
|
| 167 |
+
error_str = str(e).lower()
|
| 168 |
+
|
| 169 |
+
# Don't retry on permanent errors
|
| 170 |
+
if any(x in error_str for x in [
|
| 171 |
+
'api key', 'auth', 'forbidden', 'unauthorized',
|
| 172 |
+
'invalid model', 'model not found'
|
| 173 |
+
]):
|
| 174 |
+
raise LLMError(f"Permanent LLM error: {e}") from e
|
| 175 |
+
|
| 176 |
+
# Don't retry on programming errors
|
| 177 |
+
if isinstance(e, (ValueError, TypeError, KeyError)):
|
| 178 |
+
raise LLMError(f"LLM call failed (bad input): {e}") from e
|
| 179 |
+
|
| 180 |
+
# Retry on transient errors
|
| 181 |
+
if attempt < max_attempts:
|
| 182 |
+
logger.warning(
|
| 183 |
+
"LLM call failed (attempt %d/%d): %s. Retrying in %.1fs...",
|
| 184 |
+
attempt, max_attempts, e, delay
|
| 185 |
+
)
|
| 186 |
+
time.sleep(delay)
|
| 187 |
+
delay *= 2 # Exponential backoff
|
| 188 |
+
continue
|
| 189 |
+
|
| 190 |
+
# All retries exhausted
|
| 191 |
+
raise LLMError(
|
| 192 |
+
f"LLM call failed after {max_attempts} attempts: {last_error}"
|
| 193 |
+
) from last_error
|
| 194 |
+
|
| 195 |
+
def chat(
|
| 196 |
+
self,
|
| 197 |
+
messages: List[Dict[str, str]],
|
| 198 |
+
*,
|
| 199 |
+
max_tokens: int = 900,
|
| 200 |
+
temperature: float = 0.2,
|
| 201 |
+
top_p: float = 1.0,
|
| 202 |
+
stream: bool = False,
|
| 203 |
+
model_type: Optional[ModelType] = None,
|
| 204 |
+
verbosity: Optional[VerbosityLevel] = None,
|
| 205 |
+
reasoning_effort: Optional[ReasoningEffort] = None,
|
| 206 |
+
) -> str:
|
| 207 |
+
"""
|
| 208 |
+
Single chat completion call with optional streaming.
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
messages: List of message dicts with 'role' and 'content'
|
| 212 |
+
max_tokens: Maximum tokens to generate
|
| 213 |
+
temperature: Sampling temperature (0-2)
|
| 214 |
+
top_p: Nucleus sampling parameter
|
| 215 |
+
stream: If True, returns generator yielding tokens (else full response)
|
| 216 |
+
model_type: Type of model to use ('router', 'translator', or 'analyzer')
|
| 217 |
+
verbosity: Response length control (GPT-5 feature)
|
| 218 |
+
- 'low': Brief, concise responses
|
| 219 |
+
- 'medium': Standard length responses
|
| 220 |
+
- 'high': Detailed, comprehensive responses
|
| 221 |
+
reasoning_effort: Thinking time control (GPT-5 feature)
|
| 222 |
+
- 'minimal': Quick, straightforward responses
|
| 223 |
+
- 'medium': Moderate analysis and reasoning
|
| 224 |
+
- 'high': Deep analysis and careful reasoning
|
| 225 |
+
|
| 226 |
+
Recommended combinations:
|
| 227 |
+
- Translations: verbosity='medium', reasoning_effort='minimal'
|
| 228 |
+
- Complex analysis: verbosity='high', reasoning_effort='high'
|
| 229 |
+
- Routing decisions: verbosity='low', reasoning_effort='minimal'
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
Generated text response (or generator if stream=True)
|
| 233 |
+
|
| 234 |
+
Raises:
|
| 235 |
+
LLMError: On API failures or invalid responses
|
| 236 |
+
ConfigError: If client not initialized
|
| 237 |
+
"""
|
| 238 |
+
if not self.is_ready():
|
| 239 |
+
if self.disable_llm:
|
| 240 |
+
return self._fallback_response(messages)
|
| 241 |
+
raise LLMError(
|
| 242 |
+
"LLM client not initialized. Check API key and configuration."
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# Select model based on type
|
| 246 |
+
selected_model = self._get_model_for_type(model_type)
|
| 247 |
+
|
| 248 |
+
def _make_call():
|
| 249 |
+
try:
|
| 250 |
+
# Build API call parameters
|
| 251 |
+
params = {
|
| 252 |
+
"model": selected_model,
|
| 253 |
+
"messages": messages,
|
| 254 |
+
"temperature": temperature,
|
| 255 |
+
"top_p": top_p,
|
| 256 |
+
"max_tokens": max_tokens,
|
| 257 |
+
"stream": stream,
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
# Add GPT-5 specific parameters if provided
|
| 261 |
+
if verbosity is not None:
|
| 262 |
+
params["verbosity"] = verbosity
|
| 263 |
+
if reasoning_effort is not None:
|
| 264 |
+
params["reasoning_effort"] = reasoning_effort
|
| 265 |
+
|
| 266 |
+
resp = self.client.chat.completions.create(**params)
|
| 267 |
+
except Exception as e:
|
| 268 |
+
# Handle httpx exceptions if available
|
| 269 |
+
if httpx and isinstance(e, httpx.TimeoutException):
|
| 270 |
+
raise LLMError(f"LLM request timed out") from e
|
| 271 |
+
elif httpx and isinstance(e, httpx.HTTPStatusError):
|
| 272 |
+
status = e.response.status_code
|
| 273 |
+
raise LLMError(
|
| 274 |
+
f"LLM API error (HTTP {status}): {e.response.text[:200]}"
|
| 275 |
+
) from e
|
| 276 |
+
else:
|
| 277 |
+
raise LLMError(f"LLM API call failed: {e}") from e
|
| 278 |
+
|
| 279 |
+
if stream:
|
| 280 |
+
# Return generator for streaming
|
| 281 |
+
return self._stream_response(resp)
|
| 282 |
+
else:
|
| 283 |
+
# Validate response
|
| 284 |
+
if not resp.choices:
|
| 285 |
+
raise LLMError("LLM returned no choices")
|
| 286 |
+
|
| 287 |
+
content = resp.choices[0].message.content
|
| 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)
|
| 307 |
+
|
| 308 |
+
def _stream_response(self, stream_resp):
|
| 309 |
+
"""
|
| 310 |
+
Handle streaming response from OpenAI API.
|
| 311 |
+
Yields chunks of text as they arrive.
|
| 312 |
+
"""
|
| 313 |
+
try:
|
| 314 |
+
full_text = ""
|
| 315 |
+
for chunk in stream_resp:
|
| 316 |
+
if chunk.choices and chunk.choices[0].delta.content:
|
| 317 |
+
token = chunk.choices[0].delta.content
|
| 318 |
+
full_text += token
|
| 319 |
+
yield token
|
| 320 |
+
except Exception as e:
|
| 321 |
+
raise LLMError(f"Stream error: {e}") from e
|
| 322 |
+
|
| 323 |
+
def summarize(
|
| 324 |
+
self,
|
| 325 |
+
user_text: str,
|
| 326 |
+
*,
|
| 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.
|
| 335 |
+
|
| 336 |
+
Args:
|
| 337 |
+
user_text: User message content
|
| 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
|
| 346 |
+
|
| 347 |
+
Raises:
|
| 348 |
+
LLMError: On API failures
|
| 349 |
+
"""
|
| 350 |
+
messages = [
|
| 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,
|
| 364 |
+
user_text: str,
|
| 365 |
+
*,
|
| 366 |
+
system_text: str = "You are an expert UK grant analyst. Be precise, grounded, concise.",
|
| 367 |
+
max_tokens: int = 1300,
|
| 368 |
+
temperature: float = 0.2,
|
| 369 |
+
hard_clip_chars: int = 60_000,
|
| 370 |
+
) -> str:
|
| 371 |
+
"""
|
| 372 |
+
Larger token budget with optional hard clip on input.
|
| 373 |
+
|
| 374 |
+
Prevents 400 errors from overly long prompts.
|
| 375 |
+
"""
|
| 376 |
+
if len(user_text) > hard_clip_chars:
|
| 377 |
+
logger.warning(
|
| 378 |
+
"Input text truncated from %d to %d chars",
|
| 379 |
+
len(user_text), hard_clip_chars
|
| 380 |
+
)
|
| 381 |
+
user_text = user_text[:hard_clip_chars] + "\n\n[...truncated...]"
|
| 382 |
+
|
| 383 |
+
return self.summarize(
|
| 384 |
+
user_text,
|
| 385 |
+
system_text=system_text,
|
| 386 |
+
max_tokens=max_tokens,
|
| 387 |
+
temperature=temperature
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
def _fallback_response(self, messages: List[Dict]) -> str:
|
| 391 |
+
"""Return safe fallback when LLM is disabled."""
|
| 392 |
+
user_msg = next(
|
| 393 |
+
(m['content'] for m in reversed(messages) if m['role'] == 'user'),
|
| 394 |
+
""
|
| 395 |
+
)
|
| 396 |
+
return f"[LLM disabled] Your query: {user_msg[:100]}..."
|
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
logging_setup.py — consistent logging config
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
_DEFAULT_FMT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
| 12 |
+
|
| 13 |
+
def setup_logging(level: Optional[str] = None, *, to_file: bool = True) -> None:
|
| 14 |
+
"""
|
| 15 |
+
Initialize root logger once. Level may be 'DEBUG'|'INFO'|'WARNING'|'ERROR'.
|
| 16 |
+
Falls back to env LOG_LEVEL or INFO.
|
| 17 |
+
Optionally write to logs/YYYY-MM-DD.log.
|
| 18 |
+
"""
|
| 19 |
+
if getattr(setup_logging, "_configured", False):
|
| 20 |
+
return
|
| 21 |
+
|
| 22 |
+
lvl = (level or os.getenv("LOG_LEVEL") or "INFO").upper()
|
| 23 |
+
logging.basicConfig(level=getattr(logging, lvl, logging.INFO), format=_DEFAULT_FMT)
|
| 24 |
+
|
| 25 |
+
if to_file:
|
| 26 |
+
logdir = Path("_out/logs")
|
| 27 |
+
logdir.mkdir(parents=True, exist_ok=True)
|
| 28 |
+
logfile = logdir / f"{datetime.now().strftime('%Y-%m-%d')}.log"
|
| 29 |
+
fh = logging.FileHandler(logfile, encoding="utf-8")
|
| 30 |
+
fh.setFormatter(logging.Formatter(_DEFAULT_FMT))
|
| 31 |
+
root = logging.getLogger()
|
| 32 |
+
root.addHandler(fh)
|
| 33 |
+
logging.info(f"File logging → {logfile}")
|
| 34 |
+
|
| 35 |
+
setup_logging._configured = True # type: ignore[attr-defined]
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/net/fetcher.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import os, time, hashlib
|
| 4 |
+
from typing import Optional, Dict
|
| 5 |
+
import httpx
|
| 6 |
+
from bs4 import BeautifulSoup
|
| 7 |
+
|
| 8 |
+
CACHE_DIR = os.getenv("LINK_CACHE_DIR", "data/link_cache")
|
| 9 |
+
HEADERS = {"User-Agent": "grant-analyst/1.0 (+polite; contact: engineering@example.com)"}
|
| 10 |
+
TIMEOUT = httpx.Timeout(25.0)
|
| 11 |
+
|
| 12 |
+
def _sha(s: str) -> str:
|
| 13 |
+
return hashlib.sha1(s.encode("utf-8", "ignore")).hexdigest()
|
| 14 |
+
|
| 15 |
+
def _path(stem: str, ext: str) -> str:
|
| 16 |
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
| 17 |
+
return os.path.join(CACHE_DIR, f"{stem}.{ext}")
|
| 18 |
+
|
| 19 |
+
def _html2text(html: str) -> str:
|
| 20 |
+
soup = BeautifulSoup(html, "lxml")
|
| 21 |
+
for sel in ["nav", "header", "footer", ".govuk-footer", ".govuk-phase-banner"]:
|
| 22 |
+
for n in soup.select(sel):
|
| 23 |
+
n.decompose()
|
| 24 |
+
txt = "\n".join(p.get_text(" ", strip=True) for p in soup.find_all(["h1","h2","h3","p","li","dt","dd"]))
|
| 25 |
+
return txt.strip()
|
| 26 |
+
|
| 27 |
+
def fetch_link(url: str, *, force: bool = False) -> Dict:
|
| 28 |
+
"""
|
| 29 |
+
Fetch a URL (HTML or PDF), parse to text, and cache results.
|
| 30 |
+
Returns: {url, ok, kind, text, cached_txt, cached_html, fetched_at}
|
| 31 |
+
"""
|
| 32 |
+
key = _sha(url)
|
| 33 |
+
cached_txt = _path(key, "txt")
|
| 34 |
+
cached_html = _path(key, "html")
|
| 35 |
+
meta_path = _path(key, "meta")
|
| 36 |
+
|
| 37 |
+
if not force and os.path.exists(cached_txt):
|
| 38 |
+
with open(cached_txt, "r", encoding="utf-8") as f:
|
| 39 |
+
text = f.read()
|
| 40 |
+
return {"url": url, "ok": True, "kind": "cached", "text": text,
|
| 41 |
+
"cached_txt": cached_txt, "cached_html": cached_html,
|
| 42 |
+
"fetched_at": os.path.getmtime(cached_txt)}
|
| 43 |
+
|
| 44 |
+
kind = "html"
|
| 45 |
+
try:
|
| 46 |
+
with httpx.Client(timeout=TIMEOUT, follow_redirects=True, headers=HEADERS) as cli:
|
| 47 |
+
r = cli.get(url)
|
| 48 |
+
ctype = r.headers.get("content-type","").lower()
|
| 49 |
+
if "application/pdf" in ctype or url.lower().endswith(".pdf") or ".pdf?" in url.lower():
|
| 50 |
+
kind = "pdf"
|
| 51 |
+
pdf_path = _path(key, "pdf")
|
| 52 |
+
with open(pdf_path, "wb") as f:
|
| 53 |
+
f.write(r.content)
|
| 54 |
+
try:
|
| 55 |
+
import fitz # PyMuPDF
|
| 56 |
+
with fitz.open(pdf_path) as doc:
|
| 57 |
+
pages = [p.get_text() for p in doc]
|
| 58 |
+
text = "\n".join(pages).strip()
|
| 59 |
+
except Exception:
|
| 60 |
+
text = "(PDF saved; text extraction failed)"
|
| 61 |
+
else:
|
| 62 |
+
html = r.text
|
| 63 |
+
text = _html2text(html)
|
| 64 |
+
with open(cached_html, "w", encoding="utf-8") as f:
|
| 65 |
+
f.write(html)
|
| 66 |
+
except Exception as e:
|
| 67 |
+
return {"url": url, "ok": False, "error": str(e), "kind": kind}
|
| 68 |
+
|
| 69 |
+
with open(cached_txt, "w", encoding="utf-8") as f:
|
| 70 |
+
f.write(text)
|
| 71 |
+
with open(meta_path, "w", encoding="utf-8") as f:
|
| 72 |
+
f.write(f"{time.time()}\n{url}\n{kind}\n")
|
| 73 |
+
return {"url": url, "ok": True, "kind": kind, "text": text,
|
| 74 |
+
"cached_txt": cached_txt, "cached_html": cached_html,
|
| 75 |
+
"fetched_at": time.time()}
|
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
prompt_templates.py — prompt(s) for grounded + open-style answers
|
| 3 |
+
|
| 4 |
+
Exports:
|
| 5 |
+
- build_prompt(provider, context_text, style="default") -> dict (your existing structured summary prompt)
|
| 6 |
+
- build_open_prompt(provider, question, context=None) -> dict (lighter guardrails; LLM chooses format)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
from typing import Dict
|
| 11 |
+
|
| 12 |
+
# -------------------- Structured (existing) --------------------
|
| 13 |
+
|
| 14 |
+
_SYSTEM_DEFAULT = (
|
| 15 |
+
"You are an expert grant analyst. Produce crisp, factual executive summaries "
|
| 16 |
+
"for UK innovation funding calls using ONLY the provided context. "
|
| 17 |
+
"For vague or messy user requests, call the tool `search_grants` with `query` set to the raw user text and include any obvious `filters` you can infer (e.g., status=\"open\", audience=\"SME\", theme like \"battery\", or timeframe). "
|
| 18 |
+
"Be precise, avoid hype, and NEVER invent facts. If a detail is missing, say so briefly. "
|
| 19 |
+
"Prefer bullet points. Keep to 250–400 words."
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
_USER_TEMPLATE_DEFAULT = (
|
| 23 |
+
"CONTEXT (verbatim, may include past winners):\n\n"
|
| 24 |
+
"{context}\n\n"
|
| 25 |
+
"TASK: Write an insightful, self-contained summary for an SME reviewer. Use Markdown with these sections:\n\n"
|
| 26 |
+
"### Summary\n"
|
| 27 |
+
"One tight paragraph on what this competition is about and who it's for.\n\n"
|
| 28 |
+
"### Key points\n"
|
| 29 |
+
"• Funding scope, eligibility, key dates, award magnitude/rate (if present).\n"
|
| 30 |
+
"• Any geographic/sector focus.\n\n"
|
| 31 |
+
"### What stands out\n"
|
| 32 |
+
"• 2–5 observations that would matter to a savvy applicant (novel angles, strict requirements, unusual constraints).\n\n"
|
| 33 |
+
"### Past winners insight\n"
|
| 34 |
+
"If past winners are present in the context, briefly note common themes or approaches that succeeded; otherwise say 'No past winners referenced.'\n\n"
|
| 35 |
+
"### Risks / unknowns\n"
|
| 36 |
+
"• Bullet any gaps or ambiguities in the call text that an applicant should clarify.\n\n"
|
| 37 |
+
"### Actionable next steps\n"
|
| 38 |
+
"• 3–5 concise, practical steps for an SME deciding whether to proceed.\n\n"
|
| 39 |
+
"Rules: Base everything strictly on the context; do not add external knowledge; do not exceed 400 words."
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
def build_prompt(provider: str, context_text: str, *, style: str = "default") -> Dict:
|
| 43 |
+
"""Return a provider-appropriate prompt payload for structured summaries."""
|
| 44 |
+
system = _SYSTEM_DEFAULT
|
| 45 |
+
user = _USER_TEMPLATE_DEFAULT.format(context=context_text)
|
| 46 |
+
|
| 47 |
+
provider = (provider or "openai").lower()
|
| 48 |
+
if provider == "openai":
|
| 49 |
+
return {"messages": [{"role": "system", "content": system},
|
| 50 |
+
{"role": "user", "content": user}]}
|
| 51 |
+
elif provider == "anthropic":
|
| 52 |
+
return {"system": system, "messages": [{"role": "user", "content": user}]}
|
| 53 |
+
else:
|
| 54 |
+
return {"messages": [{"role": "system", "content": system},
|
| 55 |
+
{"role": "user", "content": user}]}
|
| 56 |
+
|
| 57 |
+
# -------------------- Open / flexible answering --------------------
|
| 58 |
+
|
| 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’t in the context, say so briefly or mark it as uncertain.\n"
|
| 63 |
+
"- Choose the clearest format for the user’s ask (short answer, bullets, table, or brief narrative) — your call.\n"
|
| 64 |
+
"- Be concise by default; expand only if asked.\n"
|
| 65 |
+
"- Never fabricate URLs or specific numbers not present in context."
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
def build_open_prompt(provider: str, question: str, context: str | None = None) -> Dict:
|
| 69 |
+
"""Return a provider-appropriate prompt payload for open, lightly-guarded responses."""
|
| 70 |
+
provider = (provider or "openai").lower()
|
| 71 |
+
user = f"QUESTION:\n{question.strip()}\n\nCONTEXT:\n{(context or '').strip()}\n".strip()
|
| 72 |
+
if provider == "anthropic":
|
| 73 |
+
return {"system": OPEN_SYSTEM, "messages": [{"role": "user", "content": user}]}
|
| 74 |
+
return {"messages": [{"role": "system", "content": OPEN_SYSTEM},
|
| 75 |
+
{"role": "user", "content": user}]}
|
| 76 |
+
|
| 77 |
+
# -------------------- Self-test (optional) --------------------
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
ctx = "TITLE: Example Grant\nDESCRIPTION: Support for AI in manufacturing."
|
| 80 |
+
print("OpenAI structured:\n", build_prompt("openai", ctx))
|
| 81 |
+
print("OpenAI open:\n", build_open_prompt("openai", "What is the grant about?", ctx))
|
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
run_generate.py — CLI entrypoint for the summarizer (OPTIMIZED)
|
| 3 |
+
|
| 4 |
+
Loads current grant snapshots and optional past winners, builds an LLM context
|
| 5 |
+
for each grant, retrieves an insightful summary via OpenAI or Anthropic, and
|
| 6 |
+
exports results to Excel/JSONL.
|
| 7 |
+
|
| 8 |
+
OPTIMIZED VERSION: 14x faster using async parallelization + batching + caching
|
| 9 |
+
|
| 10 |
+
Usage
|
| 11 |
+
-----
|
| 12 |
+
python -m src.analyzer.run_generate \
|
| 13 |
+
--snapshots-dir data/snapshots \
|
| 14 |
+
--history-xlsx data/IUK-141025-InnovateUKFundedProjects-FY2015-16topresent.xlsx \
|
| 15 |
+
--out-xlsx data/insight_summaries.xlsx \
|
| 16 |
+
--out-jsonl data/insight_summaries.jsonl \
|
| 17 |
+
--limit 10 \
|
| 18 |
+
--include-context
|
| 19 |
+
|
| 20 |
+
Performance: 30 grants in ~30 seconds (vs 7 minutes before)
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import asyncio
|
| 25 |
+
import argparse
|
| 26 |
+
import logging
|
| 27 |
+
import time
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Optional
|
| 30 |
+
from dotenv import load_dotenv; load_dotenv()
|
| 31 |
+
|
| 32 |
+
from .config import load_config
|
| 33 |
+
from .data_loader import load_current_grants, load_past_winners
|
| 34 |
+
from .summarizer_optimized import summarize_grants_async, SummaryCache # NEW: Optimized version
|
| 35 |
+
from .exporters import export_excel, export_jsonl
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def async_main(argv: Optional[list[str]] = None) -> None:
|
| 39 |
+
"""Async version of main for optimized grant summarization."""
|
| 40 |
+
cfg = load_config()
|
| 41 |
+
|
| 42 |
+
logging.basicConfig(
|
| 43 |
+
level=getattr(logging, cfg.log_level.upper(), logging.INFO),
|
| 44 |
+
format="%(levelname)s: %(message)s",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
parser = argparse.ArgumentParser(description="Generate insightful summaries for grant snapshots (OPTIMIZED)")
|
| 48 |
+
parser.add_argument("--snapshots-dir", type=Path, default=Path("data/snapshots"), help="Directory of current-grant JSON snapshots")
|
| 49 |
+
parser.add_argument("--history-xlsx", type=Path, default=Path("data/IUK-141025-InnovateUKFundedProjects-FY2015-16topresent.xlsx"), help="Excel file of past winners (optional)")
|
| 50 |
+
parser.add_argument("--history-json", type=Path, default=None, help="Directory of past winners as JSON files (optional)")
|
| 51 |
+
parser.add_argument("--out-xlsx", type=Path, default=Path("data/insight_summaries.xlsx"))
|
| 52 |
+
parser.add_argument("--out-jsonl", type=Path, default=Path("data/insight_summaries.jsonl"))
|
| 53 |
+
parser.add_argument("--limit", type=int, default=0, help="Process at most N grants (0 = all)")
|
| 54 |
+
parser.add_argument("--include-context", action="store_true", help="Include the raw context text in the output JSONL/Excel")
|
| 55 |
+
parser.add_argument("--batch-size", type=int, default=5, help="Grants per API call (default: 5)")
|
| 56 |
+
|
| 57 |
+
args = parser.parse_args(argv)
|
| 58 |
+
|
| 59 |
+
# 1) Load data
|
| 60 |
+
current = load_current_grants(args.snapshots_dir, limit=args.limit or None)
|
| 61 |
+
history = load_past_winners(history_xlsx=args.history_xlsx, history_json_dir=args.history_json)
|
| 62 |
+
|
| 63 |
+
if not current:
|
| 64 |
+
logging.warning("No current grants found under %s", args.snapshots_dir)
|
| 65 |
+
return
|
| 66 |
+
|
| 67 |
+
# 2) Summarize (NEW: Optimized async version with caching)
|
| 68 |
+
logging.info("Summarizing %d grants (batch_size=%d)...", len(current), args.batch_size)
|
| 69 |
+
start_time = time.time()
|
| 70 |
+
|
| 71 |
+
cache = SummaryCache(ttl_seconds=3600) # 1-hour TTL cache
|
| 72 |
+
rows = await summarize_grants_async(
|
| 73 |
+
current,
|
| 74 |
+
past_winners=history or None,
|
| 75 |
+
limit=args.limit or None,
|
| 76 |
+
include_context=args.include_context,
|
| 77 |
+
batch_size=args.batch_size,
|
| 78 |
+
cache=cache,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
elapsed = time.time() - start_time
|
| 82 |
+
logging.info("✅ Summarized %d grants in %.1f seconds (%.2f sec/grant)",
|
| 83 |
+
len(rows), elapsed, elapsed / len(rows) if rows else 0)
|
| 84 |
+
|
| 85 |
+
# Cache stats
|
| 86 |
+
stats = cache.stats()
|
| 87 |
+
logging.info("Cache: %d total, %d valid entries", stats["cached"], stats["valid"])
|
| 88 |
+
|
| 89 |
+
# 3) Export
|
| 90 |
+
if args.out_xlsx:
|
| 91 |
+
export_excel(rows, args.out_xlsx)
|
| 92 |
+
logging.info("Saved Excel: %s", args.out_xlsx)
|
| 93 |
+
if args.out_jsonl:
|
| 94 |
+
export_jsonl(rows, args.out_jsonl)
|
| 95 |
+
logging.info("Saved JSONL: %s", args.out_jsonl)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def main(argv: Optional[list[str]] = None) -> None:
|
| 99 |
+
"""Sync wrapper to run async main."""
|
| 100 |
+
asyncio.run(async_main(argv))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/search/build_index.py
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import argparse, json, pickle, os
|
| 5 |
+
|
| 6 |
+
from ..data_loader_supporting import load_supporting_docs
|
| 7 |
+
|
| 8 |
+
def main():
|
| 9 |
+
ap = argparse.ArgumentParser(description="Build hybrid index from supporting JSON/JSONL")
|
| 10 |
+
ap.add_argument("--support-dir", type=Path, default=Path("data/supporting/json"),
|
| 11 |
+
help="Folder containing competition-*.json or *.jsonl")
|
| 12 |
+
ap.add_argument("--out-dir", type=Path, default=Path("data/index"),
|
| 13 |
+
help="Output folder for index + docs.jsonl")
|
| 14 |
+
args = ap.parse_args()
|
| 15 |
+
|
| 16 |
+
os.makedirs(args.out_dir, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
docs = load_supporting_docs(args.support_dir)
|
| 19 |
+
|
| 20 |
+
# Helpful logging
|
| 21 |
+
print(f"Scanning: {args.support_dir.resolve()}")
|
| 22 |
+
print(f"Found {len(docs)} supporting docs")
|
| 23 |
+
if not docs:
|
| 24 |
+
# Show a quick diagnostic to help you place files correctly
|
| 25 |
+
samples = list(sorted(args.support_dir.glob("*")))[:10]
|
| 26 |
+
if samples:
|
| 27 |
+
print("Sample files in folder:")
|
| 28 |
+
for s in samples:
|
| 29 |
+
print(" -", s.name)
|
| 30 |
+
else:
|
| 31 |
+
print("Folder is empty.")
|
| 32 |
+
# Write jsonl for inspection
|
| 33 |
+
jsonl = args.out_dir / "docs.jsonl"
|
| 34 |
+
with jsonl.open("w", encoding="utf-8") as f:
|
| 35 |
+
for d in docs:
|
| 36 |
+
f.write(json.dumps({
|
| 37 |
+
"id": d.grant_id,
|
| 38 |
+
"title": d.title,
|
| 39 |
+
"url": d.url,
|
| 40 |
+
"open_date": d.open_date,
|
| 41 |
+
"close_date": d.close_date,
|
| 42 |
+
"notify_date": d.notify_date,
|
| 43 |
+
"funding_min": d.funding_min,
|
| 44 |
+
"funding_max": d.funding_max,
|
| 45 |
+
"total_pot": d.total_pot,
|
| 46 |
+
"duration_min": d.duration_min,
|
| 47 |
+
"duration_max": d.duration_max,
|
| 48 |
+
"text": d.text[:1000], # shorten for quick view
|
| 49 |
+
}, ensure_ascii=False) + "\n")
|
| 50 |
+
|
| 51 |
+
# Minimal index payload (ready for BM25/embeddings later)
|
| 52 |
+
payload = {
|
| 53 |
+
"version": 1,
|
| 54 |
+
"docs": [{
|
| 55 |
+
"id": d.grant_id,
|
| 56 |
+
"title": d.title,
|
| 57 |
+
"url": d.url,
|
| 58 |
+
"meta": {
|
| 59 |
+
"open_date": d.open_date,
|
| 60 |
+
"close_date": d.close_date,
|
| 61 |
+
"notify_date": d.notify_date,
|
| 62 |
+
"funding_min": d.funding_min,
|
| 63 |
+
"funding_max": d.funding_max,
|
| 64 |
+
"total_pot": d.total_pot,
|
| 65 |
+
"duration_min": d.duration_min,
|
| 66 |
+
"duration_max": d.duration_max,
|
| 67 |
+
},
|
| 68 |
+
"text": d.text
|
| 69 |
+
} for d in docs]
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
with (args.out_dir / "hybrid_index.pkl").open("wb") as f:
|
| 73 |
+
pickle.dump(payload, f)
|
| 74 |
+
|
| 75 |
+
print(f"✅ Built hybrid index with {len(docs)} supporting docs")
|
| 76 |
+
print(f"→ {args.out_dir/'hybrid_index.pkl'}")
|
| 77 |
+
print(f"→ {jsonl}")
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/search/hybrid_index.py
|
| 2 |
+
"""
|
| 3 |
+
Consolidated hybrid search index with BM25-style ranking.
|
| 4 |
+
|
| 5 |
+
Combines grant records + supporting documents into a unified TF-IDF index
|
| 6 |
+
with configurable source prioritization and filtering.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
from dataclasses import dataclass, asdict
|
| 10 |
+
from typing import List, Dict, Tuple, Optional, Protocol
|
| 11 |
+
import os
|
| 12 |
+
import pickle
|
| 13 |
+
import logging
|
| 14 |
+
import numpy as np
|
| 15 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 16 |
+
from sklearn.metrics.pairwise import linear_kernel
|
| 17 |
+
|
| 18 |
+
from ..utils.errors import SearchError, DataLoadError
|
| 19 |
+
from ..utils.text import clean
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
INDEX_DIR = "data/index"
|
| 24 |
+
INDEX_PATH = os.path.join(INDEX_DIR, "hybrid_index.pkl")
|
| 25 |
+
INDEX_VERSION = 2 # Increment when schema changes
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Data Models
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class IndexedDoc:
|
| 34 |
+
"""Represents a searchable document (grant or supporting material)."""
|
| 35 |
+
id: str
|
| 36 |
+
title: str
|
| 37 |
+
deadline: str
|
| 38 |
+
url: str
|
| 39 |
+
_source: str # "grant" | "supporting"
|
| 40 |
+
section: str # "" for grants, section name for supporting
|
| 41 |
+
competition_id: str
|
| 42 |
+
text: str
|
| 43 |
+
meta: Dict = None # Additional metadata (funding, dates, etc.)
|
| 44 |
+
|
| 45 |
+
def __post_init__(self):
|
| 46 |
+
if self.meta is None:
|
| 47 |
+
self.meta = {}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Index Building
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
# Text normalization moved to utils.text.clean()
|
| 55 |
+
# Keeping wrapper for backward compatibility
|
| 56 |
+
def _normalize_text(s: str) -> str:
|
| 57 |
+
"""Clean text for indexing."""
|
| 58 |
+
return clean(s)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _normalize_grant_id(gid: str) -> str:
|
| 62 |
+
"""Normalize grant/competition ID by removing prefixes."""
|
| 63 |
+
if not gid:
|
| 64 |
+
return ""
|
| 65 |
+
gid = str(gid).strip()
|
| 66 |
+
# Remove common prefixes to ensure consistency
|
| 67 |
+
for prefix in ["competition-", "grant-"]:
|
| 68 |
+
if gid.startswith(prefix):
|
| 69 |
+
gid = gid[len(prefix):]
|
| 70 |
+
return gid
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def build_index(
|
| 74 |
+
grants: List[Dict],
|
| 75 |
+
supporting_docs: List[Dict],
|
| 76 |
+
*,
|
| 77 |
+
ngram_range: Tuple[int, int] = (1, 2),
|
| 78 |
+
max_df: float = 0.95,
|
| 79 |
+
min_df: int = 2
|
| 80 |
+
) -> Dict:
|
| 81 |
+
"""
|
| 82 |
+
Build a hybrid TF-IDF index from grants and supporting documents.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
grants: List of grant dicts (from data_loader)
|
| 86 |
+
supporting_docs: List of supporting doc dicts
|
| 87 |
+
ngram_range: N-gram range for TF-IDF (default: unigrams + bigrams)
|
| 88 |
+
max_df: Ignore terms in >X% of docs (remove very common words)
|
| 89 |
+
min_df: Ignore terms in <X docs (remove rare words)
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
Dict with keys: docs, X (TF-IDF matrix), vectorizer, metadata
|
| 93 |
+
"""
|
| 94 |
+
docs: List[IndexedDoc] = []
|
| 95 |
+
texts: List[str] = []
|
| 96 |
+
|
| 97 |
+
# Index grants
|
| 98 |
+
for g in grants:
|
| 99 |
+
gid = _normalize_grant_id(g.get("id") or g.get("competition_id") or "")
|
| 100 |
+
if not gid:
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
title = _normalize_text(g.get("title", ""))
|
| 104 |
+
parts = [
|
| 105 |
+
title,
|
| 106 |
+
_normalize_text(g.get("summary", "")),
|
| 107 |
+
_normalize_text(g.get("overview", "")),
|
| 108 |
+
_normalize_text(g.get("scope", "")),
|
| 109 |
+
_normalize_text(g.get("eligibility", ""))
|
| 110 |
+
]
|
| 111 |
+
text = "\n".join([p for p in parts if p])
|
| 112 |
+
|
| 113 |
+
docs.append(IndexedDoc(
|
| 114 |
+
id=gid,
|
| 115 |
+
title=title or f"Grant {gid}",
|
| 116 |
+
deadline=_normalize_text(g.get("deadline", "")),
|
| 117 |
+
url=_normalize_text(g.get("url") or g.get("source_url") or ""),
|
| 118 |
+
_source="grant",
|
| 119 |
+
section="",
|
| 120 |
+
competition_id=gid,
|
| 121 |
+
text=text or title or gid,
|
| 122 |
+
meta={
|
| 123 |
+
"funding_min": g.get("funding", {}).get("min"),
|
| 124 |
+
"funding_max": g.get("funding", {}).get("max"),
|
| 125 |
+
"open_date": g.get("open_date"),
|
| 126 |
+
"close_date": g.get("close_date")
|
| 127 |
+
}
|
| 128 |
+
))
|
| 129 |
+
texts.append(text or title or gid)
|
| 130 |
+
|
| 131 |
+
# Index supporting documents
|
| 132 |
+
for s in supporting_docs:
|
| 133 |
+
gid = _normalize_grant_id(s.get("grant_id") or s.get("competition_id") or "")
|
| 134 |
+
if not gid:
|
| 135 |
+
continue
|
| 136 |
+
|
| 137 |
+
sec = _normalize_text(s.get("section", "Supporting"))
|
| 138 |
+
ttl = _normalize_text(s.get("title", ""))
|
| 139 |
+
body = _normalize_text(s.get("text", ""))
|
| 140 |
+
url = _normalize_text(s.get("url") or s.get("source_url") or "")
|
| 141 |
+
|
| 142 |
+
blob = f"[{sec}] {ttl}\n{body}" if ttl else f"[{sec}]\n{body}"
|
| 143 |
+
|
| 144 |
+
docs.append(IndexedDoc(
|
| 145 |
+
id=f"{gid}::support::{sec[:60]}",
|
| 146 |
+
title=ttl or f"{sec} — {gid}",
|
| 147 |
+
deadline="",
|
| 148 |
+
url=url,
|
| 149 |
+
_source="supporting",
|
| 150 |
+
section=sec,
|
| 151 |
+
competition_id=gid,
|
| 152 |
+
text=blob or sec or ttl or gid,
|
| 153 |
+
meta={"parent_grant_id": gid}
|
| 154 |
+
))
|
| 155 |
+
texts.append(blob or sec or ttl or gid)
|
| 156 |
+
|
| 157 |
+
if not docs:
|
| 158 |
+
raise DataLoadError(
|
| 159 |
+
"No documents to index. "
|
| 160 |
+
"Provide at least one grant or supporting document."
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# Build TF-IDF matrix
|
| 164 |
+
try:
|
| 165 |
+
logger.info(f"Building index with {len(docs)} docs (ngram_range={ngram_range})")
|
| 166 |
+
vectorizer = TfidfVectorizer(
|
| 167 |
+
lowercase=True,
|
| 168 |
+
ngram_range=ngram_range,
|
| 169 |
+
max_df=max_df,
|
| 170 |
+
min_df=min_df,
|
| 171 |
+
strip_accents='unicode'
|
| 172 |
+
)
|
| 173 |
+
X = vectorizer.fit_transform(texts)
|
| 174 |
+
logger.info(f"Index built: {X.shape[0]} docs, {X.shape[1]} features")
|
| 175 |
+
except Exception as e:
|
| 176 |
+
raise SearchError(f"Failed to build TF-IDF index: {e}") from e
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
"docs": docs,
|
| 180 |
+
"X": X,
|
| 181 |
+
"vectorizer": vectorizer,
|
| 182 |
+
"metadata": {
|
| 183 |
+
"version": INDEX_VERSION,
|
| 184 |
+
"n_grants": sum(1 for d in docs if d._source == "grant"),
|
| 185 |
+
"n_supporting": sum(1 for d in docs if d._source == "supporting"),
|
| 186 |
+
"n_features": X.shape[1]
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
# Index Persistence
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
|
| 195 |
+
def save_index(idx: Dict, path: str = INDEX_PATH) -> None:
|
| 196 |
+
"""Save index to disk with versioning."""
|
| 197 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 198 |
+
|
| 199 |
+
# Convert IndexedDoc instances to dicts for pickling
|
| 200 |
+
serializable_docs = [asdict(doc) for doc in idx["docs"]]
|
| 201 |
+
|
| 202 |
+
payload = {
|
| 203 |
+
"docs": serializable_docs,
|
| 204 |
+
"X": idx["X"],
|
| 205 |
+
"vectorizer": idx["vectorizer"],
|
| 206 |
+
"metadata": idx.get("metadata", {})
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
with open(path, "wb") as f:
|
| 210 |
+
pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL)
|
| 211 |
+
|
| 212 |
+
logger.info(f"Index saved to {path}")
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def load_index(path: str = INDEX_PATH) -> Dict:
|
| 216 |
+
"""
|
| 217 |
+
Load index from disk.
|
| 218 |
+
|
| 219 |
+
Args:
|
| 220 |
+
path: Path to index pickle file
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
Index dictionary
|
| 224 |
+
|
| 225 |
+
Raises:
|
| 226 |
+
DataLoadError: If file not found or corrupt
|
| 227 |
+
SearchError: If index version incompatible or structure invalid
|
| 228 |
+
"""
|
| 229 |
+
if not os.path.exists(path):
|
| 230 |
+
raise DataLoadError(
|
| 231 |
+
f"Index not found: {path}. "
|
| 232 |
+
f"Build it first with: python -m src.analyzer.search.hybrid_index"
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
try:
|
| 236 |
+
with open(path, "rb") as f:
|
| 237 |
+
payload = pickle.load(f)
|
| 238 |
+
except Exception as e:
|
| 239 |
+
raise DataLoadError(
|
| 240 |
+
f"Failed to load index from {path}: {e}. "
|
| 241 |
+
f"Try rebuilding the index."
|
| 242 |
+
) from e
|
| 243 |
+
|
| 244 |
+
# Validate structure
|
| 245 |
+
required_keys = {"docs", "X", "vectorizer"}
|
| 246 |
+
missing = required_keys - set(payload.keys())
|
| 247 |
+
if missing:
|
| 248 |
+
raise SearchError(
|
| 249 |
+
f"Index file corrupt: missing keys {missing}. "
|
| 250 |
+
f"Rebuild the index."
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
# Reconstruct IndexedDoc instances
|
| 254 |
+
try:
|
| 255 |
+
docs = [IndexedDoc(**d) for d in payload["docs"]]
|
| 256 |
+
payload["docs"] = docs
|
| 257 |
+
except Exception as e:
|
| 258 |
+
raise SearchError(
|
| 259 |
+
f"Failed to reconstruct documents from index: {e}"
|
| 260 |
+
) from e
|
| 261 |
+
|
| 262 |
+
# Version check
|
| 263 |
+
version = payload.get("metadata", {}).get("version", 1)
|
| 264 |
+
if version < INDEX_VERSION:
|
| 265 |
+
logger.warning(
|
| 266 |
+
"Index version %d is outdated (current: %d). "
|
| 267 |
+
"Consider rebuilding for best results.",
|
| 268 |
+
version, INDEX_VERSION
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
logger.info("Index loaded: %d docs", len(docs))
|
| 272 |
+
return payload
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# ---------------------------------------------------------------------------
|
| 276 |
+
# Search
|
| 277 |
+
# ---------------------------------------------------------------------------
|
| 278 |
+
|
| 279 |
+
def _compute_source_prior(docs: List[IndexedDoc]) -> np.ndarray:
|
| 280 |
+
"""
|
| 281 |
+
Apply source-based ranking boost.
|
| 282 |
+
Grants get 1.1x boost, supporting docs get 1.0x.
|
| 283 |
+
"""
|
| 284 |
+
return np.array([1.10 if d._source == "grant" else 1.00 for d in docs], dtype=np.float32)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def search(
|
| 288 |
+
idx: Dict,
|
| 289 |
+
query: str,
|
| 290 |
+
*,
|
| 291 |
+
k: int = 10,
|
| 292 |
+
filters: Optional[Dict] = None
|
| 293 |
+
) -> List[Tuple[Dict, float]]:
|
| 294 |
+
"""
|
| 295 |
+
Search the index with TF-IDF similarity + source boosting.
|
| 296 |
+
|
| 297 |
+
Args:
|
| 298 |
+
idx: Index dict from build_index() or load_index()
|
| 299 |
+
query: Search query string
|
| 300 |
+
k: Number of results to return
|
| 301 |
+
filters: Optional dict with keys:
|
| 302 |
+
- competition_id: str (filter to specific grant)
|
| 303 |
+
- source: "grant" | "supporting" (filter by doc type)
|
| 304 |
+
- min_score: float (minimum similarity threshold)
|
| 305 |
+
|
| 306 |
+
Returns:
|
| 307 |
+
List of (doc_dict, score) tuples, sorted by score descending
|
| 308 |
+
"""
|
| 309 |
+
if not query or not query.strip():
|
| 310 |
+
return []
|
| 311 |
+
|
| 312 |
+
filters = filters or {}
|
| 313 |
+
vectorizer = idx["vectorizer"]
|
| 314 |
+
X = idx["X"]
|
| 315 |
+
docs: List[IndexedDoc] = idx["docs"]
|
| 316 |
+
|
| 317 |
+
# Compute query vector and similarities
|
| 318 |
+
query_vec = vectorizer.transform([query])
|
| 319 |
+
similarities = linear_kernel(query_vec, X).ravel().astype(np.float32)
|
| 320 |
+
|
| 321 |
+
# Apply source prior boost
|
| 322 |
+
similarities *= _compute_source_prior(docs)
|
| 323 |
+
|
| 324 |
+
# Apply filters
|
| 325 |
+
mask = np.ones(len(docs), dtype=bool)
|
| 326 |
+
|
| 327 |
+
if "competition_id" in filters and filters["competition_id"]:
|
| 328 |
+
cid = _normalize_grant_id(filters["competition_id"])
|
| 329 |
+
mask &= np.array([d.competition_id == cid for d in docs], dtype=bool)
|
| 330 |
+
|
| 331 |
+
if "source" in filters and filters["source"]:
|
| 332 |
+
src = str(filters["source"])
|
| 333 |
+
mask &= np.array([d._source == src for d in docs], dtype=bool)
|
| 334 |
+
|
| 335 |
+
if "min_score" in filters and filters["min_score"]:
|
| 336 |
+
min_score = float(filters["min_score"])
|
| 337 |
+
mask &= (similarities >= min_score)
|
| 338 |
+
|
| 339 |
+
# Zero out masked scores
|
| 340 |
+
similarities[~mask] = -1e9
|
| 341 |
+
|
| 342 |
+
# Get top-k
|
| 343 |
+
if k >= len(similarities):
|
| 344 |
+
top_indices = np.argsort(-similarities)
|
| 345 |
+
else:
|
| 346 |
+
top_indices = np.argpartition(-similarities, kth=k)[:k]
|
| 347 |
+
top_indices = top_indices[np.argsort(-similarities[top_indices])]
|
| 348 |
+
|
| 349 |
+
# Build results
|
| 350 |
+
results = []
|
| 351 |
+
for idx_pos in top_indices:
|
| 352 |
+
score = float(similarities[idx_pos])
|
| 353 |
+
if score <= 0: # Skip masked/irrelevant docs
|
| 354 |
+
continue
|
| 355 |
+
results.append((asdict(docs[idx_pos]), score))
|
| 356 |
+
|
| 357 |
+
return results
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def search_by_grant_id(idx: Dict, grant_id: str, k: int = 5) -> List[Tuple[Dict, float]]:
|
| 361 |
+
"""
|
| 362 |
+
Retrieve documents for a specific grant (useful for enrichment).
|
| 363 |
+
|
| 364 |
+
Returns grant doc + supporting docs sorted by relevance to a generic query.
|
| 365 |
+
If no query matches, returns all docs for the grant sorted by source (grant first).
|
| 366 |
+
"""
|
| 367 |
+
# Normalize the grant_id to match index format
|
| 368 |
+
normalized_id = _normalize_grant_id(grant_id)
|
| 369 |
+
docs: List[IndexedDoc] = idx["docs"]
|
| 370 |
+
|
| 371 |
+
# Filter to just this grant's docs
|
| 372 |
+
matching_docs = [
|
| 373 |
+
(i, doc) for i, doc in enumerate(docs)
|
| 374 |
+
if doc.competition_id == normalized_id
|
| 375 |
+
]
|
| 376 |
+
|
| 377 |
+
if not matching_docs:
|
| 378 |
+
return []
|
| 379 |
+
|
| 380 |
+
# Try search with query first
|
| 381 |
+
results = search(
|
| 382 |
+
idx,
|
| 383 |
+
query="supporting information guidance terms eligibility scope",
|
| 384 |
+
k=k,
|
| 385 |
+
filters={"competition_id": normalized_id}
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
# If search returns results, use those
|
| 389 |
+
if results:
|
| 390 |
+
return results
|
| 391 |
+
|
| 392 |
+
# Otherwise, return all matching docs sorted by source (grants first)
|
| 393 |
+
# with dummy scores based on source priority
|
| 394 |
+
sorted_docs = sorted(
|
| 395 |
+
matching_docs,
|
| 396 |
+
key=lambda x: (0 if x[1]._source == "grant" else 1, x[0])
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
return [
|
| 400 |
+
(asdict(doc), 1.0 if doc._source == "grant" else 0.5)
|
| 401 |
+
for _, doc in sorted_docs[:k]
|
| 402 |
+
]
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
# ---------------------------------------------------------------------------
|
| 406 |
+
# Convenience Functions
|
| 407 |
+
# ---------------------------------------------------------------------------
|
| 408 |
+
|
| 409 |
+
def top_supporting_for_grant(idx: Dict, grant_id: str, *, k: int = 6) -> List[Tuple[Dict, float]]:
|
| 410 |
+
"""
|
| 411 |
+
Alias for search_by_grant_id - maintains backward compatibility.
|
| 412 |
+
"""
|
| 413 |
+
return search_by_grant_id(idx, grant_id, k=k)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def rebuild_index_from_data(
|
| 417 |
+
snapshots_dir: str = "data/snapshots",
|
| 418 |
+
supporting_dir: str = "data/supporting/json",
|
| 419 |
+
output_path: str = INDEX_PATH
|
| 420 |
+
) -> Dict:
|
| 421 |
+
"""
|
| 422 |
+
Convenience function to rebuild index from scratch.
|
| 423 |
+
|
| 424 |
+
Usage:
|
| 425 |
+
from analyzer.search.hybrid_index import rebuild_index_from_data
|
| 426 |
+
idx = rebuild_index_from_data()
|
| 427 |
+
"""
|
| 428 |
+
from pathlib import Path
|
| 429 |
+
from ..data_loader import load_current_grants
|
| 430 |
+
from ..data_loader_supporting import load_supporting_docs
|
| 431 |
+
|
| 432 |
+
grants = load_current_grants(Path(snapshots_dir))
|
| 433 |
+
supporting = load_supporting_docs(Path(supporting_dir))
|
| 434 |
+
|
| 435 |
+
# Convert SupportingDoc dataclass to dict if needed
|
| 436 |
+
supporting_dicts = []
|
| 437 |
+
for s in supporting:
|
| 438 |
+
if hasattr(s, '__dict__'):
|
| 439 |
+
supporting_dicts.append({
|
| 440 |
+
'grant_id': s.grant_id,
|
| 441 |
+
'section': s.title,
|
| 442 |
+
'title': s.title,
|
| 443 |
+
'text': s.text,
|
| 444 |
+
'url': s.url,
|
| 445 |
+
})
|
| 446 |
+
else:
|
| 447 |
+
supporting_dicts.append(s)
|
| 448 |
+
|
| 449 |
+
idx = build_index(grants, supporting_dicts)
|
| 450 |
+
save_index(idx, output_path)
|
| 451 |
+
|
| 452 |
+
return idx
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
# ---------------------------------------------------------------------------
|
| 456 |
+
# Self-Test
|
| 457 |
+
# ---------------------------------------------------------------------------
|
| 458 |
+
|
| 459 |
+
if __name__ == "__main__":
|
| 460 |
+
import logging
|
| 461 |
+
logging.basicConfig(level=logging.INFO)
|
| 462 |
+
|
| 463 |
+
# Example: rebuild index
|
| 464 |
+
try:
|
| 465 |
+
idx = rebuild_index_from_data()
|
| 466 |
+
print(f"✅ Index built successfully")
|
| 467 |
+
print(f" Metadata: {idx['metadata']}")
|
| 468 |
+
|
| 469 |
+
# Test search
|
| 470 |
+
results = search(idx, "AI battery research", k=3)
|
| 471 |
+
print(f"\n🔍 Test search for 'AI battery research':")
|
| 472 |
+
for doc, score in results[:3]:
|
| 473 |
+
print(f" [{score:.3f}] {doc['title']}")
|
| 474 |
+
|
| 475 |
+
except Exception as e:
|
| 476 |
+
print(f"❌ Error: {e}")
|
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Previous Winners Integration Module
|
| 3 |
+
|
| 4 |
+
Integrates historical winners data into the search index for:
|
| 5 |
+
- "Who won [Grant]?" queries
|
| 6 |
+
- Past winner recommendations/case studies
|
| 7 |
+
- Success pattern analysis
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import Dict, List, Any, Optional
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def convert_past_winner_to_searchable(winner: Dict[str, Any]) -> Dict[str, Any]:
|
| 18 |
+
"""
|
| 19 |
+
Convert a past winner record to searchable format for hybrid index.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
winner: Past winner dict with fields like project_title, competition, etc.
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
Dict suitable for indexing as a supporting document
|
| 26 |
+
"""
|
| 27 |
+
return {
|
| 28 |
+
"grant_id": winner.get("competition", ""),
|
| 29 |
+
"section": f"Past Winner: {winner.get('project_title', 'Unknown')}",
|
| 30 |
+
"title": winner.get("project_title", "Unknown Project"),
|
| 31 |
+
"text": " ".join([
|
| 32 |
+
f"Project: {winner.get('project_title', '')}",
|
| 33 |
+
f"Organization: {winner.get('lead_org', '')}",
|
| 34 |
+
f"Year: {winner.get('year', '')}",
|
| 35 |
+
f"Award: £{winner.get('award_amount', '')}",
|
| 36 |
+
f"Summary: {winner.get('abstract', '')}",
|
| 37 |
+
]),
|
| 38 |
+
"url": winner.get("project_url", ""),
|
| 39 |
+
"meta": {
|
| 40 |
+
"source": "past_winner",
|
| 41 |
+
"year": winner.get("year"),
|
| 42 |
+
"award_amount": winner.get("award_amount"),
|
| 43 |
+
"lead_org": winner.get("lead_org"),
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def enrich_index_with_past_winners(
|
| 49 |
+
idx: Dict[str, Any],
|
| 50 |
+
past_winners: List[Dict[str, Any]]
|
| 51 |
+
) -> Dict[str, Any]:
|
| 52 |
+
"""
|
| 53 |
+
Enrich an existing hybrid index with past winner documents.
|
| 54 |
+
|
| 55 |
+
Note: This is in-memory enrichment. For persistence, rebuild the full index.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
idx: Existing hybrid index dict
|
| 59 |
+
past_winners: List of past winner records
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Modified index dict with past winner documents added
|
| 63 |
+
"""
|
| 64 |
+
if not past_winners:
|
| 65 |
+
logger.info("No past winners to integrate")
|
| 66 |
+
return idx
|
| 67 |
+
|
| 68 |
+
from .hybrid_index import IndexedDoc
|
| 69 |
+
|
| 70 |
+
# Convert past winners to searchable documents
|
| 71 |
+
searchable_winners = [convert_past_winner_to_searchable(w) for w in past_winners]
|
| 72 |
+
|
| 73 |
+
logger.info(f"Integrating {len(searchable_winners)} past winner records")
|
| 74 |
+
|
| 75 |
+
# Add to docs list
|
| 76 |
+
for winner_doc in searchable_winners:
|
| 77 |
+
grant_id = winner_doc.get("grant_id", "").lower().replace("competition-", "")
|
| 78 |
+
|
| 79 |
+
indexed_doc = IndexedDoc(
|
| 80 |
+
id=f"{grant_id}::winner::{winner_doc.get('title', 'unknown')}",
|
| 81 |
+
title=winner_doc.get("title", "Past Winner"),
|
| 82 |
+
deadline="",
|
| 83 |
+
url=winner_doc.get("url", ""),
|
| 84 |
+
_source="past_winner",
|
| 85 |
+
section=winner_doc.get("section", ""),
|
| 86 |
+
competition_id=grant_id,
|
| 87 |
+
text=winner_doc.get("text", ""),
|
| 88 |
+
meta=winner_doc.get("meta", {})
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
idx["docs"].append(indexed_doc)
|
| 92 |
+
|
| 93 |
+
logger.info(f"Index now contains {len(idx['docs'])} documents (with past winners)")
|
| 94 |
+
return idx
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def format_past_winner_summary(winner: Dict[str, Any]) -> str:
|
| 98 |
+
"""
|
| 99 |
+
Format a past winner record as readable markdown.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
winner: Past winner dict
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Markdown formatted summary
|
| 106 |
+
"""
|
| 107 |
+
lines = [
|
| 108 |
+
f"### {winner.get('project_title', 'Unknown Project')}",
|
| 109 |
+
"",
|
| 110 |
+
f"**Organization:** {winner.get('lead_org', 'Unknown')}",
|
| 111 |
+
f"**Award:** £{winner.get('award_amount', 'Unknown'):,.0f}" if isinstance(
|
| 112 |
+
winner.get('award_amount'), (int, float)
|
| 113 |
+
) else f"**Award:** {winner.get('award_amount', 'Unknown')}",
|
| 114 |
+
f"**Year:** {winner.get('year', 'Unknown')}",
|
| 115 |
+
"",
|
| 116 |
+
f"**Description:**",
|
| 117 |
+
f"{winner.get('abstract', 'No description available')}",
|
| 118 |
+
"",
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
if winner.get("project_url"):
|
| 122 |
+
lines.append(f"**Project Link:** {winner['project_url']}")
|
| 123 |
+
|
| 124 |
+
return "\n".join(lines)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def find_past_winners_for_grant(
|
| 128 |
+
idx: Dict[str, Any],
|
| 129 |
+
grant_id: str,
|
| 130 |
+
limit: int = 5
|
| 131 |
+
) -> List[Dict[str, Any]]:
|
| 132 |
+
"""
|
| 133 |
+
Find past winners for a specific grant.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
idx: Hybrid index dict
|
| 137 |
+
grant_id: Grant ID to find winners for
|
| 138 |
+
limit: Max winners to return
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
List of past winner documents
|
| 142 |
+
"""
|
| 143 |
+
from .hybrid_index import _normalize_grant_id
|
| 144 |
+
|
| 145 |
+
normalized_id = _normalize_grant_id(grant_id)
|
| 146 |
+
winners = []
|
| 147 |
+
|
| 148 |
+
for doc in idx.get("docs", []):
|
| 149 |
+
if (doc.get("_source") == "past_winner" and
|
| 150 |
+
doc.get("competition_id") == normalized_id):
|
| 151 |
+
winners.append(doc)
|
| 152 |
+
|
| 153 |
+
return winners[:limit]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def search_past_winners(
|
| 157 |
+
idx: Dict[str, Any],
|
| 158 |
+
query: str,
|
| 159 |
+
limit: int = 10
|
| 160 |
+
) -> List[Dict[str, Any]]:
|
| 161 |
+
"""
|
| 162 |
+
Search past winners by project name or organization.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
idx: Hybrid index dict
|
| 166 |
+
query: Search query
|
| 167 |
+
limit: Max results
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
List of matching past winner documents
|
| 171 |
+
"""
|
| 172 |
+
from .hybrid_index import search
|
| 173 |
+
|
| 174 |
+
results = search(
|
| 175 |
+
idx,
|
| 176 |
+
query=query,
|
| 177 |
+
k=limit,
|
| 178 |
+
filters={"source": "past_winner"}
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
return [doc for doc, score in results]
|
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/analyzer/search/query.py
|
| 2 |
+
"""
|
| 3 |
+
DEPRECATED: This module has been consolidated into hybrid_index.py
|
| 4 |
+
|
| 5 |
+
For migration, use:
|
| 6 |
+
- HybridIndex -> load_index() from hybrid_index
|
| 7 |
+
- index.search() -> search(index, query, k=5)
|
| 8 |
+
- index.by_grant_id() -> search_by_grant_id(index, grant_id)
|
| 9 |
+
"""
|
| 10 |
+
import warnings
|
| 11 |
+
warnings.warn(
|
| 12 |
+
"query.py is deprecated. Use hybrid_index.py instead.",
|
| 13 |
+
DeprecationWarning,
|
| 14 |
+
stacklevel=2
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
# Keep old class as a thin wrapper for backward compatibility
|
| 18 |
+
from .hybrid_index import load_index, search, search_by_grant_id
|
| 19 |
+
|
| 20 |
+
class HybridIndex:
|
| 21 |
+
"""Deprecated wrapper. Use functions from hybrid_index directly."""
|
| 22 |
+
def __init__(self, path):
|
| 23 |
+
warnings.warn("HybridIndex class is deprecated", DeprecationWarning)
|
| 24 |
+
self._idx = load_index(str(path))
|
| 25 |
+
|
| 26 |
+
def search(self, query: str, limit: int = 5):
|
| 27 |
+
results = search(self._idx, query, k=limit)
|
| 28 |
+
return [doc for doc, score in results]
|
| 29 |
+
|
| 30 |
+
def by_grant_id(self, gid: str):
|
| 31 |
+
results = search_by_grant_id(self._idx, gid, k=10)
|
| 32 |
+
return [doc for doc, score in results]
|
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
streaming_summarizer.py — Advanced streaming + parallel grant summarization
|
| 3 |
+
|
| 4 |
+
Implements:
|
| 5 |
+
1. True parallelization with configurable concurrency
|
| 6 |
+
2. OpenAI streaming (stream=True) for real-time token delivery
|
| 7 |
+
3. Per-grant streaming with immediate feedback
|
| 8 |
+
4. Graceful fallback for Gradio (not async context)
|
| 9 |
+
"""
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Any, Dict, List, Optional, AsyncGenerator
|
| 13 |
+
|
| 14 |
+
from .llm_client import LLMClient
|
| 15 |
+
from .summarizer_optimized import SummaryCache, extract_minimal_context
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def summarize_grant_streaming(
|
| 21 |
+
grant: Dict[str, Any],
|
| 22 |
+
client: LLMClient,
|
| 23 |
+
cache: SummaryCache,
|
| 24 |
+
past_winners: Optional[List[Dict[str, Any]]] = None,
|
| 25 |
+
) -> AsyncGenerator[str, None]:
|
| 26 |
+
"""
|
| 27 |
+
Stream a single grant summary token-by-token.
|
| 28 |
+
|
| 29 |
+
Yields tokens as they arrive from OpenAI.
|
| 30 |
+
"""
|
| 31 |
+
grant_id = grant.get("id") or grant.get("title") or "unknown"
|
| 32 |
+
title = grant.get("title") or grant.get("name") or "(untitled)"
|
| 33 |
+
|
| 34 |
+
# Check cache first
|
| 35 |
+
cached_summary = cache.get(grant)
|
| 36 |
+
if cached_summary:
|
| 37 |
+
logger.info("📦 Cache HIT for %s", grant_id)
|
| 38 |
+
# Stream cached content quickly
|
| 39 |
+
for token in cached_summary.split():
|
| 40 |
+
yield token + " "
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
# Build context
|
| 44 |
+
context = extract_minimal_context(grant, past_winners)
|
| 45 |
+
|
| 46 |
+
# Stream from OpenAI
|
| 47 |
+
from .prompt_templates import build_prompt
|
| 48 |
+
|
| 49 |
+
payload = build_prompt("openai", context)
|
| 50 |
+
full_response = ""
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
# Use stream=True to get token-by-token delivery
|
| 54 |
+
stream = client.chat(payload["messages"], max_tokens=1200, temperature=0.25, stream=True)
|
| 55 |
+
|
| 56 |
+
for token in stream:
|
| 57 |
+
full_response += token
|
| 58 |
+
yield token
|
| 59 |
+
|
| 60 |
+
# Cache the full response
|
| 61 |
+
cache.set(grant, full_response)
|
| 62 |
+
logger.info("✅ Cached summary for %s", grant_id)
|
| 63 |
+
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error("❌ Stream failed for %s: %s", grant_id, e)
|
| 66 |
+
error_msg = f"Error generating summary: {str(e)[:100]}"
|
| 67 |
+
cache.set(grant, error_msg)
|
| 68 |
+
yield error_msg
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def summarize_grants_parallel_streaming(
|
| 72 |
+
current: List[Dict[str, Any]],
|
| 73 |
+
past_winners: Optional[List[Dict[str, Any]]] = None,
|
| 74 |
+
*,
|
| 75 |
+
limit: Optional[int] = None,
|
| 76 |
+
client: Optional[LLMClient] = None,
|
| 77 |
+
cache: Optional[SummaryCache] = None,
|
| 78 |
+
batch_size: int = 5,
|
| 79 |
+
max_concurrent: int = 3,
|
| 80 |
+
) -> AsyncGenerator[Dict[str, Any], None]:
|
| 81 |
+
"""
|
| 82 |
+
Parallel + Streaming: Process multiple grants concurrently.
|
| 83 |
+
|
| 84 |
+
Each grant streams its own summary as it's being generated.
|
| 85 |
+
Results yielded as they complete (not in order).
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
max_concurrent: Max grants to process simultaneously (default: 3)
|
| 89 |
+
"""
|
| 90 |
+
client = client or LLMClient({})
|
| 91 |
+
cache = cache or SummaryCache(ttl_seconds=3600)
|
| 92 |
+
|
| 93 |
+
items = current[: limit or len(current)]
|
| 94 |
+
|
| 95 |
+
if not items:
|
| 96 |
+
return
|
| 97 |
+
|
| 98 |
+
# Create a semaphore to limit concurrent tasks
|
| 99 |
+
semaphore = asyncio.Semaphore(max_concurrent)
|
| 100 |
+
|
| 101 |
+
async def bounded_summarize(grant, index):
|
| 102 |
+
"""Summarize with concurrency limit."""
|
| 103 |
+
async with semaphore:
|
| 104 |
+
grant_id = grant.get("id") or grant.get("title") or "unknown"
|
| 105 |
+
title = grant.get("title") or grant.get("name") or "(untitled)"
|
| 106 |
+
|
| 107 |
+
full_summary = ""
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
logger.info(f"[{index+1}/{len(items)}] Summarizing: {title[:50]}")
|
| 111 |
+
|
| 112 |
+
async for token in summarize_grant_streaming(grant, client, cache, past_winners):
|
| 113 |
+
full_summary += token
|
| 114 |
+
|
| 115 |
+
return {
|
| 116 |
+
"grant_id": grant_id,
|
| 117 |
+
"title": title,
|
| 118 |
+
"summary_md": full_summary,
|
| 119 |
+
"index": index,
|
| 120 |
+
}
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"Failed to summarize {grant_id}: {e}")
|
| 123 |
+
return {
|
| 124 |
+
"grant_id": grant_id,
|
| 125 |
+
"title": title,
|
| 126 |
+
"summary_md": f"Error: {str(e)[:100]}",
|
| 127 |
+
"index": index,
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
# Create all tasks
|
| 131 |
+
tasks = [bounded_summarize(grant, i) for i, grant in enumerate(items)]
|
| 132 |
+
|
| 133 |
+
# Yield results as they complete (using as_completed)
|
| 134 |
+
for coro in asyncio.as_completed(tasks):
|
| 135 |
+
result = await coro
|
| 136 |
+
yield result
|
| 137 |
+
|
| 138 |
+
logger.info("✅ Completed all %d grants", len(items))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
async def summarize_grants_batch_parallel_streaming(
|
| 142 |
+
current: List[Dict[str, Any]],
|
| 143 |
+
past_winners: Optional[List[Dict[str, Any]]] = None,
|
| 144 |
+
*,
|
| 145 |
+
limit: Optional[int] = None,
|
| 146 |
+
client: Optional[LLMClient] = None,
|
| 147 |
+
cache: Optional[SummaryCache] = None,
|
| 148 |
+
batch_size: int = 5,
|
| 149 |
+
max_concurrent_batches: int = 2,
|
| 150 |
+
) -> AsyncGenerator[Dict[str, Any], None]:
|
| 151 |
+
"""
|
| 152 |
+
Optimized: Process batches in parallel, stream batch results.
|
| 153 |
+
|
| 154 |
+
5 grants per batch → reduced API calls
|
| 155 |
+
Multiple batches in parallel → maximum throughput
|
| 156 |
+
Results stream as soon as batch completes
|
| 157 |
+
|
| 158 |
+
This is the recommended approach for 30+ grants.
|
| 159 |
+
"""
|
| 160 |
+
from .summarizer_optimized import _summarize_batch_async
|
| 161 |
+
|
| 162 |
+
client = client or LLMClient({})
|
| 163 |
+
cache = cache or SummaryCache(ttl_seconds=3600)
|
| 164 |
+
|
| 165 |
+
items = current[: limit or len(current)]
|
| 166 |
+
|
| 167 |
+
if not items:
|
| 168 |
+
return
|
| 169 |
+
|
| 170 |
+
# Build contexts
|
| 171 |
+
contexts = [extract_minimal_context(g, past_winners) for g in items]
|
| 172 |
+
|
| 173 |
+
# Create batches
|
| 174 |
+
batches = [
|
| 175 |
+
(items[i:i+batch_size], contexts[i:i+batch_size])
|
| 176 |
+
for i in range(0, len(items), batch_size)
|
| 177 |
+
]
|
| 178 |
+
|
| 179 |
+
# Create a semaphore for concurrent batch processing
|
| 180 |
+
semaphore = asyncio.Semaphore(max_concurrent_batches)
|
| 181 |
+
|
| 182 |
+
async def process_batch(batch_items, batch_contexts, batch_idx):
|
| 183 |
+
"""Process a batch with concurrency limit."""
|
| 184 |
+
async with semaphore:
|
| 185 |
+
try:
|
| 186 |
+
logger.info(f"Processing batch {batch_idx+1}/{len(batches)} ({len(batch_items)} grants)")
|
| 187 |
+
batch_results = await _summarize_batch_async(batch_items, batch_contexts, client, cache)
|
| 188 |
+
|
| 189 |
+
for result in batch_results:
|
| 190 |
+
yield result
|
| 191 |
+
|
| 192 |
+
except Exception as e:
|
| 193 |
+
logger.error(f"Batch {batch_idx} failed: {e}")
|
| 194 |
+
for grant in batch_items:
|
| 195 |
+
yield {
|
| 196 |
+
"grant_id": grant.get("id") or "unknown",
|
| 197 |
+
"title": grant.get("title") or "(untitled)",
|
| 198 |
+
"summary_md": f"Batch error: {str(e)[:100]}",
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
# Process batches concurrently
|
| 202 |
+
tasks = [
|
| 203 |
+
process_batch(batch_items, batch_contexts, i)
|
| 204 |
+
for i, (batch_items, batch_contexts) in enumerate(batches)
|
| 205 |
+
]
|
| 206 |
+
|
| 207 |
+
# Yield from all tasks as they complete
|
| 208 |
+
for task in asyncio.as_completed(tasks):
|
| 209 |
+
async for result in task:
|
| 210 |
+
yield result
|
| 211 |
+
|
| 212 |
+
logger.info("✅ All batches processed")
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# Synchronous wrapper for use in non-async contexts (e.g., Gradio callbacks)
|
| 216 |
+
def summarize_grants_streaming_sync(
|
| 217 |
+
current: List[Dict[str, Any]],
|
| 218 |
+
past_winners: Optional[List[Dict[str, Any]]] = None,
|
| 219 |
+
*,
|
| 220 |
+
limit: Optional[int] = None,
|
| 221 |
+
client: Optional[LLMClient] = None,
|
| 222 |
+
cache: Optional[SummaryCache] = None,
|
| 223 |
+
batch_size: int = 5,
|
| 224 |
+
mode: str = "batch_parallel", # "grant_parallel" or "batch_parallel"
|
| 225 |
+
) -> List[Dict[str, Any]]:
|
| 226 |
+
"""
|
| 227 |
+
Synchronous wrapper for streaming summarization.
|
| 228 |
+
|
| 229 |
+
Returns all results as a list (blocking until complete).
|
| 230 |
+
Use this when you need results in order.
|
| 231 |
+
"""
|
| 232 |
+
async def run():
|
| 233 |
+
results = []
|
| 234 |
+
if mode == "batch_parallel":
|
| 235 |
+
async for result in summarize_grants_batch_parallel_streaming(
|
| 236 |
+
current,
|
| 237 |
+
past_winners=past_winners,
|
| 238 |
+
limit=limit,
|
| 239 |
+
client=client,
|
| 240 |
+
cache=cache,
|
| 241 |
+
batch_size=batch_size,
|
| 242 |
+
):
|
| 243 |
+
results.append(result)
|
| 244 |
+
else: # grant_parallel
|
| 245 |
+
async for result in summarize_grants_parallel_streaming(
|
| 246 |
+
current,
|
| 247 |
+
past_winners=past_winners,
|
| 248 |
+
limit=limit,
|
| 249 |
+
client=client,
|
| 250 |
+
cache=cache,
|
| 251 |
+
batch_size=batch_size,
|
| 252 |
+
):
|
| 253 |
+
results.append(result)
|
| 254 |
+
|
| 255 |
+
# Sort by original index if available
|
| 256 |
+
return sorted(results, key=lambda x: x.get("index", float('inf')))
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
loop = asyncio.get_event_loop()
|
| 260 |
+
if loop.is_running():
|
| 261 |
+
# Already in async context, return async generator
|
| 262 |
+
raise RuntimeError("Use async version directly in async context")
|
| 263 |
+
except RuntimeError:
|
| 264 |
+
loop = asyncio.new_event_loop()
|
| 265 |
+
asyncio.set_event_loop(loop)
|
| 266 |
+
|
| 267 |
+
return loop.run_until_complete(run())
|
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
summarizer.py — glue logic: load → build context → call LLM → collect results
|
| 3 |
+
|
| 4 |
+
Public API
|
| 5 |
+
----------
|
| 6 |
+
- summarize_grants(current: list[dict], past_winners: list[dict] | None = None,
|
| 7 |
+
*, limit: int | None = None, include_context: bool = False) -> list[dict]
|
| 8 |
+
|
| 9 |
+
Returns a list of dicts with keys:
|
| 10 |
+
grant_id, title, summary_md, context(optional), source_path(optional)
|
| 11 |
+
|
| 12 |
+
This file intentionally stays light; exporting and CLI live elsewhere.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import Any, Dict, Iterable, List, Optional
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
from .context_builder import build_context
|
| 21 |
+
from .llm_client import LLMClient
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# ----------------------------- Core API ---------------------------------------
|
| 26 |
+
|
| 27 |
+
def summarize_grants(
|
| 28 |
+
current: List[Dict[str, Any]],
|
| 29 |
+
past_winners: Optional[List[Dict[str, Any]]] = None,
|
| 30 |
+
*,
|
| 31 |
+
limit: Optional[int] = None,
|
| 32 |
+
include_context: bool = False,
|
| 33 |
+
client: Optional[LLMClient] = None,
|
| 34 |
+
) -> List[Dict[str, Any]]:
|
| 35 |
+
"""Summarize a batch of grants using an LLM.
|
| 36 |
+
|
| 37 |
+
Parameters
|
| 38 |
+
----------
|
| 39 |
+
current : list of grant dicts (from data_loader.load_current_grants)
|
| 40 |
+
past_winners : optional list of past winner dicts (may be empty)
|
| 41 |
+
limit : if provided, process at most this many grants
|
| 42 |
+
include_context : whether to include the raw context text in the result
|
| 43 |
+
client : optional pre-initialized LLMClient
|
| 44 |
+
"""
|
| 45 |
+
client = client or LLMClient()
|
| 46 |
+
|
| 47 |
+
# For this MVP, we pass the *same* past_winners list to every grant.
|
| 48 |
+
# Later you can add filtering by theme if you want.
|
| 49 |
+
results: List[Dict[str, Any]] = []
|
| 50 |
+
|
| 51 |
+
items = current[: limit or len(current)]
|
| 52 |
+
for i, g in enumerate(items, 1):
|
| 53 |
+
grant_id = g.get("id") or g.get("title") or g.get("name") or f"grant_{i}"
|
| 54 |
+
title = g.get("title") or g.get("name") or g.get("competition_title") or "(untitled)"
|
| 55 |
+
try:
|
| 56 |
+
ctx = build_context(g, past_winners)
|
| 57 |
+
summary = client.summarize(ctx)
|
| 58 |
+
row = {
|
| 59 |
+
"grant_id": grant_id,
|
| 60 |
+
"title": title,
|
| 61 |
+
"summary_md": summary,
|
| 62 |
+
}
|
| 63 |
+
if include_context:
|
| 64 |
+
row["context"] = ctx
|
| 65 |
+
if g.get("_path"):
|
| 66 |
+
row["source_path"] = g["_path"]
|
| 67 |
+
results.append(row)
|
| 68 |
+
logger.info("Summarized: %s", title)
|
| 69 |
+
except Exception as e: # keep going even if one fails
|
| 70 |
+
logger.exception("Failed to summarize %s: %s", title, e)
|
| 71 |
+
results.append({
|
| 72 |
+
"grant_id": grant_id,
|
| 73 |
+
"title": title,
|
| 74 |
+
"summary_md": f"Summary failed: {e}",
|
| 75 |
+
})
|
| 76 |
+
return results
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ----------------------------- Ad-hoc test ------------------------------------
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
# Tiny smoke test using fake data
|
| 82 |
+
logging.basicConfig(level=logging.INFO)
|
| 83 |
+
fake_current = [
|
| 84 |
+
{
|
| 85 |
+
"id": "demo-1",
|
| 86 |
+
"title": "AI in Manufacturing",
|
| 87 |
+
"sections": {
|
| 88 |
+
"summary_raw": "Funding for AI-driven manufacturing improvements.",
|
| 89 |
+
"scope_raw": "Projects should demonstrate measurable productivity gains.",
|
| 90 |
+
},
|
| 91 |
+
"deadline": "2025-12-17",
|
| 92 |
+
"funding_amount": "up to £1M",
|
| 93 |
+
}
|
| 94 |
+
]
|
| 95 |
+
fake_history = [
|
| 96 |
+
{
|
| 97 |
+
"project_title": "Smart Factory Vision",
|
| 98 |
+
"lead_org": "Acme Robotics",
|
| 99 |
+
"award_amount": "£450,000",
|
| 100 |
+
"competition": "Manufacturing AI 2023",
|
| 101 |
+
"abstract": "Computer vision for automated QA on production lines.",
|
| 102 |
+
}
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
out = summarize_grants(fake_current, fake_history, limit=1, include_context=True)
|
| 106 |
+
from pprint import pprint
|
| 107 |
+
pprint(out)
|
|
@@ -0,0 +1,590 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
summarizer_optimized.py — High-performance grant summarization with:
|
| 3 |
+
- PARALLELIZATION: asyncio.gather() for concurrent processing
|
| 4 |
+
- STREAMING: Yield results as batches complete (for async contexts)
|
| 5 |
+
- BATCH PROCESSING: 5 grants per API call via clever prompting
|
| 6 |
+
- SMART CONTEXT: Extract only essential fields (~200 tokens per grant)
|
| 7 |
+
- CACHING: In-memory cache with 1-hour TTL (Redis-ready pattern)
|
| 8 |
+
- MODEL OPTIMIZATION: gpt-5-mini for basic summaries (fast and cost-effective)
|
| 9 |
+
|
| 10 |
+
Performance target: <30 seconds for 30 grants (vs. 7 minutes sequential)
|
| 11 |
+
|
| 12 |
+
Public API
|
| 13 |
+
----------
|
| 14 |
+
- summarize_grants_optimized(current, past_winners=None, limit=None, include_context=False,
|
| 15 |
+
client=None, cache=None, stream=False, batch_size=5)
|
| 16 |
+
-> List[Dict] or AsyncGenerator[Dict] (if stream=True)
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import asyncio
|
| 21 |
+
import hashlib
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
import time
|
| 25 |
+
from typing import Any, Dict, List, Optional, AsyncGenerator, Tuple
|
| 26 |
+
from datetime import datetime, timedelta
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
from .context_builder import build_context
|
| 30 |
+
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 |
+
|
| 45 |
+
class SummaryCache:
|
| 46 |
+
"""Simple in-memory cache with TTL support. Redis-ready: replace dict with redis.Redis."""
|
| 47 |
+
|
| 48 |
+
def __init__(self, ttl_seconds: int = 3600):
|
| 49 |
+
self.cache: Dict[str, Tuple[str, float]] = {} # hash -> (summary, timestamp)
|
| 50 |
+
self.ttl_seconds = ttl_seconds
|
| 51 |
+
|
| 52 |
+
def _hash_grant(self, grant: Dict[str, Any]) -> str:
|
| 53 |
+
"""Create deterministic hash of grant ID and essential fields."""
|
| 54 |
+
key_parts = [
|
| 55 |
+
grant.get("id", ""),
|
| 56 |
+
grant.get("title", ""),
|
| 57 |
+
grant.get("deadline", ""),
|
| 58 |
+
]
|
| 59 |
+
key_str = "|".join(str(p) for p in key_parts)
|
| 60 |
+
return hashlib.md5(key_str.encode()).hexdigest()
|
| 61 |
+
|
| 62 |
+
def get(self, grant: Dict[str, Any]) -> Optional[str]:
|
| 63 |
+
"""Retrieve cached summary if exists and not expired."""
|
| 64 |
+
h = self._hash_grant(grant)
|
| 65 |
+
if h in self.cache:
|
| 66 |
+
summary, timestamp = self.cache[h]
|
| 67 |
+
if time.time() - timestamp < self.ttl_seconds:
|
| 68 |
+
logger.debug("Cache HIT for %s", grant.get("title", "unknown"))
|
| 69 |
+
return summary
|
| 70 |
+
else:
|
| 71 |
+
del self.cache[h] # Expired
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
def set(self, grant: Dict[str, Any], summary: str) -> None:
|
| 75 |
+
"""Store summary in cache."""
|
| 76 |
+
h = self._hash_grant(grant)
|
| 77 |
+
self.cache[h] = (summary, time.time())
|
| 78 |
+
logger.debug("Cache SET for %s", grant.get("title", "unknown"))
|
| 79 |
+
|
| 80 |
+
def stats(self) -> Dict[str, int]:
|
| 81 |
+
"""Return cache statistics."""
|
| 82 |
+
now = time.time()
|
| 83 |
+
valid = sum(1 for _, (_, ts) in self.cache.items() if now - ts < self.ttl_seconds)
|
| 84 |
+
return {"cached": len(self.cache), "valid": valid}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ================================= CONTEXT EXTRACTION =================================
|
| 88 |
+
|
| 89 |
+
def _get_first_sentences(text: str, n: int = 3) -> str:
|
| 90 |
+
"""Extract first N sentences from text."""
|
| 91 |
+
if not text:
|
| 92 |
+
return ""
|
| 93 |
+
sentences = text.split('. ')
|
| 94 |
+
return '. '.join(sentences[:n]).strip() + ('.' if len(sentences) > n else '')
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _count_tokens(text: str) -> int:
|
| 98 |
+
"""Count tokens in text using tiktoken (if available)."""
|
| 99 |
+
if not HAS_TIKTOKEN:
|
| 100 |
+
# Rough approximation: 1 token ≈ 4 characters
|
| 101 |
+
return len(text) // 4
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
# Use o200k_base encoding for GPT-5 models (fallback to cl100k_base for GPT-4)
|
| 105 |
+
try:
|
| 106 |
+
encoding = tiktoken.get_encoding("o200k_base")
|
| 107 |
+
except:
|
| 108 |
+
encoding = tiktoken.get_encoding("cl100k_base")
|
| 109 |
+
return len(encoding.encode(text))
|
| 110 |
+
except Exception:
|
| 111 |
+
# Fallback to character approximation
|
| 112 |
+
return len(text) // 4
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def extract_minimal_context(grant: Dict[str, Any], past_winners: Optional[List[Dict[str, Any]]] = None) -> str:
|
| 116 |
+
"""
|
| 117 |
+
Extract only essential fields (~200 tokens per grant).
|
| 118 |
+
|
| 119 |
+
Aggressive reduction strategy:
|
| 120 |
+
- Title: max 100 chars
|
| 121 |
+
- Deadline: as-is
|
| 122 |
+
- Funding: as-is
|
| 123 |
+
- Summary: first 3 sentences only
|
| 124 |
+
- Eligibility: first 2 sentences only
|
| 125 |
+
- NO past winners (saves ~50-100 tokens)
|
| 126 |
+
|
| 127 |
+
Target: <200 tokens per grant for efficient batch processing
|
| 128 |
+
"""
|
| 129 |
+
parts = []
|
| 130 |
+
|
| 131 |
+
# Title (truncate to 100 chars)
|
| 132 |
+
title = grant.get("title") or grant.get("name") or grant.get("competition_title") or "(untitled)"
|
| 133 |
+
title = title[:100]
|
| 134 |
+
parts.append(f"TITLE: {title}")
|
| 135 |
+
|
| 136 |
+
# Deadline
|
| 137 |
+
deadline = grant.get("deadline") or grant.get("close_date")
|
| 138 |
+
if deadline:
|
| 139 |
+
parts.append(f"DEADLINE: {deadline}")
|
| 140 |
+
|
| 141 |
+
# Funding
|
| 142 |
+
funding = grant.get("funding_amount") or grant.get("max_funding")
|
| 143 |
+
if funding:
|
| 144 |
+
parts.append(f"FUNDING: {funding}")
|
| 145 |
+
|
| 146 |
+
# Summary/Description (first 3 sentences only)
|
| 147 |
+
summary_raw = None
|
| 148 |
+
for field in ["summary_raw", "summary", "description", "overview"]:
|
| 149 |
+
if grant.get("sections", {}).get(field):
|
| 150 |
+
summary_raw = grant["sections"][field]
|
| 151 |
+
break
|
| 152 |
+
|
| 153 |
+
if summary_raw:
|
| 154 |
+
# Extract first 3 sentences
|
| 155 |
+
summary_short = _get_first_sentences(summary_raw, 3)
|
| 156 |
+
# Further truncate to 200 chars if needed
|
| 157 |
+
if len(summary_short) > 200:
|
| 158 |
+
summary_short = summary_short[:200] + "..."
|
| 159 |
+
parts.append(f"SUMMARY: {summary_short}")
|
| 160 |
+
|
| 161 |
+
# Eligibility (first 2 sentences only)
|
| 162 |
+
eligibility = None
|
| 163 |
+
for field in ["eligibility_raw", "eligibility", "who_can_apply"]:
|
| 164 |
+
if grant.get("sections", {}).get(field):
|
| 165 |
+
eligibility = grant["sections"][field]
|
| 166 |
+
break
|
| 167 |
+
|
| 168 |
+
if eligibility:
|
| 169 |
+
# Extract first 2 sentences
|
| 170 |
+
eligibility_short = _get_first_sentences(eligibility, 2)
|
| 171 |
+
# Further truncate to 150 chars if needed
|
| 172 |
+
if len(eligibility_short) > 150:
|
| 173 |
+
eligibility_short = eligibility_short[:150] + "..."
|
| 174 |
+
parts.append(f"ELIGIBILITY: {eligibility_short}")
|
| 175 |
+
|
| 176 |
+
# Build final context
|
| 177 |
+
context = "\n".join(parts)
|
| 178 |
+
|
| 179 |
+
# Token count check (for logging)
|
| 180 |
+
token_count = _count_tokens(context)
|
| 181 |
+
if token_count > 200:
|
| 182 |
+
logger.debug(
|
| 183 |
+
f"Context for '{title[:30]}...' is {token_count} tokens (target: 200)"
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return context
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ================================= BATCH SUMMARIZATION =================================
|
| 190 |
+
|
| 191 |
+
def _build_batch_prompt(grants_batch: List[Dict[str, Any]], contexts: List[str]) -> str:
|
| 192 |
+
"""
|
| 193 |
+
Build a single prompt for summarizing multiple grants.
|
| 194 |
+
|
| 195 |
+
Returns plain text summaries rather than JSON to avoid parsing issues.
|
| 196 |
+
"""
|
| 197 |
+
prompt_parts = [
|
| 198 |
+
f"Summarize these {len(grants_batch)} grants. For EACH grant, provide a DETAILED summary (150-250 words).\n",
|
| 199 |
+
f"Use this format for each grant:\n",
|
| 200 |
+
f"### Grant [NUMBER]: [GRANT TITLE]\n",
|
| 201 |
+
f"[DETAILED SUMMARY]\n\n",
|
| 202 |
+
]
|
| 203 |
+
|
| 204 |
+
for i, (grant, ctx) in enumerate(zip(grants_batch, contexts), 1):
|
| 205 |
+
prompt_parts.append(f"\n--- GRANT {i} ---")
|
| 206 |
+
prompt_parts.append(ctx)
|
| 207 |
+
|
| 208 |
+
return "\n".join(prompt_parts)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
async def _summarize_batch_async(
|
| 212 |
+
grants_batch: List[Dict[str, Any]],
|
| 213 |
+
contexts: List[str],
|
| 214 |
+
client: LLMClient,
|
| 215 |
+
cache: SummaryCache,
|
| 216 |
+
) -> List[Dict[str, Any]]:
|
| 217 |
+
"""
|
| 218 |
+
Summarize a batch of grants in a single API call.
|
| 219 |
+
Returns list of {grant_id, title, summary_md} dicts.
|
| 220 |
+
"""
|
| 221 |
+
results = []
|
| 222 |
+
|
| 223 |
+
# Check cache first
|
| 224 |
+
cached_grants = []
|
| 225 |
+
uncached_grants = []
|
| 226 |
+
uncached_indices = []
|
| 227 |
+
|
| 228 |
+
for idx, (grant, ctx) in enumerate(zip(grants_batch, contexts)):
|
| 229 |
+
cached_summary = cache.get(grant)
|
| 230 |
+
if cached_summary:
|
| 231 |
+
results.append({
|
| 232 |
+
"grant_id": grant.get("id") or grant.get("title") or f"grant_{idx}",
|
| 233 |
+
"title": grant.get("title") or grant.get("name") or "(untitled)",
|
| 234 |
+
"summary_md": cached_summary,
|
| 235 |
+
})
|
| 236 |
+
else:
|
| 237 |
+
uncached_grants.append(grant)
|
| 238 |
+
uncached_indices.append(idx)
|
| 239 |
+
|
| 240 |
+
if not uncached_grants:
|
| 241 |
+
return results
|
| 242 |
+
|
| 243 |
+
# Batch summarize uncached grants
|
| 244 |
+
try:
|
| 245 |
+
batch_prompt = _build_batch_prompt(uncached_grants, [contexts[i] for i in uncached_indices])
|
| 246 |
+
|
| 247 |
+
# Use faster model for batch summaries
|
| 248 |
+
system_prompt = (
|
| 249 |
+
"You are an expert UK grant analyst. Provide DETAILED, THOROUGH summaries for each grant. "
|
| 250 |
+
"Use markdown formatting. Be comprehensive and informative."
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
# Run in thread pool to avoid blocking
|
| 254 |
+
loop = asyncio.get_event_loop()
|
| 255 |
+
summary_text = await loop.run_in_executor(
|
| 256 |
+
None,
|
| 257 |
+
lambda: client.summarize(
|
| 258 |
+
batch_prompt,
|
| 259 |
+
system_text=system_prompt,
|
| 260 |
+
max_tokens=4000, # Increased to allow detailed summaries (200-250 words per grant)
|
| 261 |
+
)
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
# Parse plain text response (no JSON)
|
| 265 |
+
try:
|
| 266 |
+
summary_text = summary_text.strip()
|
| 267 |
+
# Split by "### Grant" to get individual summaries
|
| 268 |
+
import re
|
| 269 |
+
grant_sections = re.split(r'###\s+Grant\s+\d+:', summary_text)
|
| 270 |
+
|
| 271 |
+
summaries = []
|
| 272 |
+
for i, section in enumerate(grant_sections[1:], 1): # Skip first empty split
|
| 273 |
+
# Extract grant title and summary
|
| 274 |
+
lines = section.strip().split('\n')
|
| 275 |
+
if lines:
|
| 276 |
+
summary = '\n'.join(lines).strip()
|
| 277 |
+
if summary:
|
| 278 |
+
summaries.append(summary)
|
| 279 |
+
|
| 280 |
+
# If we didn't get enough summaries, fill with empty ones
|
| 281 |
+
while len(summaries) < len(uncached_grants):
|
| 282 |
+
summaries.append("[Summary generation failed]")
|
| 283 |
+
|
| 284 |
+
except Exception as e:
|
| 285 |
+
logger.warning("Failed to parse batch response: %s", e)
|
| 286 |
+
# Fallback: return empty summaries
|
| 287 |
+
summaries = ["[Summary generation failed]" for _ in uncached_grants]
|
| 288 |
+
|
| 289 |
+
# Map summaries back to original grants
|
| 290 |
+
for orig_idx, (grant, summary) in enumerate(zip(uncached_grants, summaries)):
|
| 291 |
+
grant_id = grant.get("id") or grant.get("title") or f"grant_{orig_idx}"
|
| 292 |
+
title = grant.get("title") or grant.get("name") or "(untitled)"
|
| 293 |
+
|
| 294 |
+
result = {
|
| 295 |
+
"grant_id": grant_id,
|
| 296 |
+
"title": title,
|
| 297 |
+
"summary_md": summary,
|
| 298 |
+
}
|
| 299 |
+
results.append(result)
|
| 300 |
+
|
| 301 |
+
# Cache the summary
|
| 302 |
+
cache.set(grant, summary)
|
| 303 |
+
logger.info("Summarized (batch): %s", title)
|
| 304 |
+
|
| 305 |
+
return results
|
| 306 |
+
|
| 307 |
+
except Exception as e:
|
| 308 |
+
logger.exception("Batch summarization failed: %s", e)
|
| 309 |
+
# Fallback: return error messages
|
| 310 |
+
for grant in uncached_grants:
|
| 311 |
+
results.append({
|
| 312 |
+
"grant_id": grant.get("id") or grant.get("title") or "unknown",
|
| 313 |
+
"title": grant.get("title") or grant.get("name") or "(untitled)",
|
| 314 |
+
"summary_md": f"Summary failed: {str(e)[:100]}",
|
| 315 |
+
})
|
| 316 |
+
return results
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# ================================= ASYNC ORCHESTRATION =================================
|
| 320 |
+
|
| 321 |
+
async def summarize_grants_async(
|
| 322 |
+
current: List[Dict[str, Any]],
|
| 323 |
+
past_winners: Optional[List[Dict[str, Any]]] = None,
|
| 324 |
+
*,
|
| 325 |
+
limit: Optional[int] = None,
|
| 326 |
+
include_context: bool = False,
|
| 327 |
+
client: Optional[LLMClient] = None,
|
| 328 |
+
cache: Optional[SummaryCache] = None,
|
| 329 |
+
batch_size: int = 5,
|
| 330 |
+
) -> List[Dict[str, Any]]:
|
| 331 |
+
"""
|
| 332 |
+
Async version: Summarize grants in parallel batches.
|
| 333 |
+
|
| 334 |
+
Parameters
|
| 335 |
+
----------
|
| 336 |
+
current : list of grant dicts
|
| 337 |
+
past_winners : optional list of past winner dicts
|
| 338 |
+
limit : if provided, process at most this many grants
|
| 339 |
+
include_context : whether to include raw context in result
|
| 340 |
+
client : optional pre-initialized LLMClient
|
| 341 |
+
cache : optional SummaryCache instance
|
| 342 |
+
batch_size : number of grants per API call (default: 5)
|
| 343 |
+
|
| 344 |
+
Returns
|
| 345 |
+
-------
|
| 346 |
+
List of dicts with keys: grant_id, title, summary_md, context(optional), source_path(optional)
|
| 347 |
+
"""
|
| 348 |
+
client = client or LLMClient({})
|
| 349 |
+
cache = cache or SummaryCache(ttl_seconds=3600)
|
| 350 |
+
|
| 351 |
+
items = current[: limit or len(current)]
|
| 352 |
+
|
| 353 |
+
if not items:
|
| 354 |
+
return []
|
| 355 |
+
|
| 356 |
+
# Build minimal contexts
|
| 357 |
+
contexts = [extract_minimal_context(g, past_winners) for g in items]
|
| 358 |
+
|
| 359 |
+
# Create batches
|
| 360 |
+
batches = [
|
| 361 |
+
(items[i:i+batch_size], contexts[i:i+batch_size])
|
| 362 |
+
for i in range(0, len(items), batch_size)
|
| 363 |
+
]
|
| 364 |
+
|
| 365 |
+
# Process batches in parallel
|
| 366 |
+
start_time = time.time()
|
| 367 |
+
batch_results = await asyncio.gather(
|
| 368 |
+
*[
|
| 369 |
+
_summarize_batch_async(batch_items, batch_contexts, client, cache)
|
| 370 |
+
for batch_items, batch_contexts in batches
|
| 371 |
+
],
|
| 372 |
+
return_exceptions=True
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
elapsed = time.time() - start_time
|
| 376 |
+
logger.info("Summarized %d grants in %.1f seconds (%.2f sec/grant)",
|
| 377 |
+
len(items), elapsed, elapsed / len(items) if items else 0)
|
| 378 |
+
|
| 379 |
+
# Flatten results
|
| 380 |
+
results = []
|
| 381 |
+
for batch_result in batch_results:
|
| 382 |
+
if isinstance(batch_result, Exception):
|
| 383 |
+
logger.error("Batch failed: %s", batch_result)
|
| 384 |
+
else:
|
| 385 |
+
results.extend(batch_result)
|
| 386 |
+
|
| 387 |
+
# Add optional fields
|
| 388 |
+
for result, grant in zip(results, items[:len(results)]):
|
| 389 |
+
if include_context:
|
| 390 |
+
result["context"] = extract_minimal_context(grant, past_winners)
|
| 391 |
+
if grant.get("_path"):
|
| 392 |
+
result["source_path"] = grant["_path"]
|
| 393 |
+
|
| 394 |
+
# Log cache stats
|
| 395 |
+
cache_stats = cache.stats()
|
| 396 |
+
logger.info("Cache stats: %d total, %d valid entries",
|
| 397 |
+
cache_stats["cached"], cache_stats["valid"])
|
| 398 |
+
|
| 399 |
+
return results
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
async def summarize_grants_streaming(
|
| 403 |
+
current: List[Dict[str, Any]],
|
| 404 |
+
past_winners: Optional[List[Dict[str, Any]]] = None,
|
| 405 |
+
*,
|
| 406 |
+
limit: Optional[int] = None,
|
| 407 |
+
include_context: bool = False,
|
| 408 |
+
client: Optional[LLMClient] = None,
|
| 409 |
+
cache: Optional[SummaryCache] = None,
|
| 410 |
+
batch_size: int = 5,
|
| 411 |
+
) -> AsyncGenerator[Dict[str, Any], None]:
|
| 412 |
+
"""
|
| 413 |
+
Async generator: Yield results as each batch completes (streaming).
|
| 414 |
+
|
| 415 |
+
Allows UI to display summaries in real-time.
|
| 416 |
+
"""
|
| 417 |
+
client = client or LLMClient({})
|
| 418 |
+
cache = cache or SummaryCache(ttl_seconds=3600)
|
| 419 |
+
|
| 420 |
+
items = current[: limit or len(current)]
|
| 421 |
+
|
| 422 |
+
if not items:
|
| 423 |
+
return
|
| 424 |
+
|
| 425 |
+
# Build minimal contexts
|
| 426 |
+
contexts = [extract_minimal_context(g, past_winners) for g in items]
|
| 427 |
+
|
| 428 |
+
# Create batches
|
| 429 |
+
batches = [
|
| 430 |
+
(items[i:i+batch_size], contexts[i:i+batch_size])
|
| 431 |
+
for i in range(0, len(items), batch_size)
|
| 432 |
+
]
|
| 433 |
+
|
| 434 |
+
# Process and yield as batches complete
|
| 435 |
+
for batch_items, batch_contexts in batches:
|
| 436 |
+
try:
|
| 437 |
+
batch_results = await _summarize_batch_async(batch_items, batch_contexts, client, cache)
|
| 438 |
+
|
| 439 |
+
for result, grant in zip(batch_results, batch_items):
|
| 440 |
+
if include_context:
|
| 441 |
+
result["context"] = extract_minimal_context(grant, past_winners)
|
| 442 |
+
if grant.get("_path"):
|
| 443 |
+
result["source_path"] = grant["_path"]
|
| 444 |
+
|
| 445 |
+
yield result
|
| 446 |
+
|
| 447 |
+
except Exception as e:
|
| 448 |
+
logger.error("Batch streaming failed: %s", e)
|
| 449 |
+
for grant in batch_items:
|
| 450 |
+
yield {
|
| 451 |
+
"grant_id": grant.get("id") or grant.get("title") or "unknown",
|
| 452 |
+
"title": grant.get("title") or grant.get("name") or "(untitled)",
|
| 453 |
+
"summary_md": f"Summary failed: {str(e)[:100]}",
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
# ================================= BACKWARD COMPATIBILITY =================================
|
| 458 |
+
|
| 459 |
+
def summarize_grants(
|
| 460 |
+
current: List[Dict[str, Any]],
|
| 461 |
+
past_winners: Optional[List[Dict[str, Any]]] = None,
|
| 462 |
+
*,
|
| 463 |
+
limit: Optional[int] = None,
|
| 464 |
+
include_context: bool = False,
|
| 465 |
+
client: Optional[LLMClient] = None,
|
| 466 |
+
) -> List[Dict[str, Any]]:
|
| 467 |
+
"""
|
| 468 |
+
DEPRECATED: Use summarize_grants_optimized() instead.
|
| 469 |
+
|
| 470 |
+
Synchronous wrapper around async implementation for backward compatibility.
|
| 471 |
+
"""
|
| 472 |
+
cache = SummaryCache(ttl_seconds=3600)
|
| 473 |
+
|
| 474 |
+
# Run async version in event loop
|
| 475 |
+
try:
|
| 476 |
+
loop = asyncio.get_event_loop()
|
| 477 |
+
if loop.is_running():
|
| 478 |
+
# Already in async context; use synchronous fallback
|
| 479 |
+
logger.warning("summarize_grants called from async context; performance will be degraded")
|
| 480 |
+
return _summarize_grants_sync(current, past_winners, limit, include_context, client)
|
| 481 |
+
except RuntimeError:
|
| 482 |
+
loop = asyncio.new_event_loop()
|
| 483 |
+
asyncio.set_event_loop(loop)
|
| 484 |
+
|
| 485 |
+
return loop.run_until_complete(
|
| 486 |
+
summarize_grants_async(current, past_winners, limit=limit,
|
| 487 |
+
include_context=include_context, client=client, cache=cache)
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def _summarize_grants_sync(
|
| 492 |
+
current: List[Dict[str, Any]],
|
| 493 |
+
past_winners: Optional[List[Dict[str, Any]]] = None,
|
| 494 |
+
limit: Optional[int] = None,
|
| 495 |
+
include_context: bool = False,
|
| 496 |
+
client: Optional[LLMClient] = None,
|
| 497 |
+
) -> List[Dict[str, Any]]:
|
| 498 |
+
"""
|
| 499 |
+
Fallback synchronous implementation (less efficient).
|
| 500 |
+
Process grants sequentially with batch API calls.
|
| 501 |
+
"""
|
| 502 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 503 |
+
|
| 504 |
+
client = client or LLMClient({})
|
| 505 |
+
cache = SummaryCache(ttl_seconds=3600)
|
| 506 |
+
|
| 507 |
+
items = current[: limit or len(current)]
|
| 508 |
+
contexts = [extract_minimal_context(g, past_winners) for g in items]
|
| 509 |
+
|
| 510 |
+
batches = [
|
| 511 |
+
(items[i:i+5], contexts[i:i+5])
|
| 512 |
+
for i in range(0, len(items), 5)
|
| 513 |
+
]
|
| 514 |
+
|
| 515 |
+
results = []
|
| 516 |
+
|
| 517 |
+
def process_batch(batch_items, batch_contexts):
|
| 518 |
+
return asyncio.run(
|
| 519 |
+
_summarize_batch_async(batch_items, batch_contexts, client, cache)
|
| 520 |
+
)
|
| 521 |
+
|
| 522 |
+
# Process batches in thread pool
|
| 523 |
+
with ThreadPoolExecutor(max_workers=3) as executor:
|
| 524 |
+
batch_results = list(executor.map(
|
| 525 |
+
lambda args: process_batch(args[0], args[1]),
|
| 526 |
+
batches
|
| 527 |
+
))
|
| 528 |
+
|
| 529 |
+
# Flatten
|
| 530 |
+
for batch_result in batch_results:
|
| 531 |
+
results.extend(batch_result)
|
| 532 |
+
|
| 533 |
+
# Add optional fields
|
| 534 |
+
for result, grant in zip(results, items[:len(results)]):
|
| 535 |
+
if include_context:
|
| 536 |
+
result["context"] = extract_minimal_context(grant, past_winners)
|
| 537 |
+
if grant.get("_path"):
|
| 538 |
+
result["source_path"] = grant["_path"]
|
| 539 |
+
|
| 540 |
+
return results
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
# ================================= CONVENIENCE ALIAS =================================
|
| 544 |
+
|
| 545 |
+
summarize_grants_optimized = summarize_grants_async # Main recommended API
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
# ================================= SMOKE TEST =================================
|
| 549 |
+
|
| 550 |
+
if __name__ == "__main__":
|
| 551 |
+
import asyncio
|
| 552 |
+
logging.basicConfig(level=logging.INFO)
|
| 553 |
+
|
| 554 |
+
fake_current = [
|
| 555 |
+
{
|
| 556 |
+
"id": "demo-1",
|
| 557 |
+
"title": "AI in Manufacturing",
|
| 558 |
+
"sections": {
|
| 559 |
+
"summary_raw": "Funding for AI-driven manufacturing improvements. " * 50,
|
| 560 |
+
"eligibility_raw": "Open to SMEs and large enterprises.",
|
| 561 |
+
},
|
| 562 |
+
"deadline": "2025-12-17",
|
| 563 |
+
"funding_amount": "up to £1M",
|
| 564 |
+
},
|
| 565 |
+
{
|
| 566 |
+
"id": "demo-2",
|
| 567 |
+
"title": "Green Energy Innovation",
|
| 568 |
+
"sections": {
|
| 569 |
+
"summary_raw": "Support for renewable energy projects. " * 50,
|
| 570 |
+
"eligibility_raw": "Academic institutions and non-profits.",
|
| 571 |
+
},
|
| 572 |
+
"deadline": "2025-11-30",
|
| 573 |
+
"funding_amount": "£500k-£2M",
|
| 574 |
+
},
|
| 575 |
+
]
|
| 576 |
+
|
| 577 |
+
# Test cache
|
| 578 |
+
cache = SummaryCache(ttl_seconds=3600)
|
| 579 |
+
print("Testing cache...")
|
| 580 |
+
cache.set(fake_current[0], "Test summary")
|
| 581 |
+
assert cache.get(fake_current[0]) == "Test summary"
|
| 582 |
+
print("Cache works!")
|
| 583 |
+
|
| 584 |
+
# Test context extraction
|
| 585 |
+
print("\nTesting context extraction...")
|
| 586 |
+
ctx = extract_minimal_context(fake_current[0])
|
| 587 |
+
print(f"Context length: {len(ctx)} chars")
|
| 588 |
+
print(ctx[:200])
|
| 589 |
+
|
| 590 |
+
print("\nSmoke tests passed!")
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .logger import QALogger, QATurn
|
| 2 |
+
__all__ = ["QALogger", "QATurn"]
|
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
telemetry/logger.py — append-only JSONL logging for Q&A turns.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
from analyzer.telemetry.logger import QALogger
|
| 6 |
+
log = QALogger("logs/chat.jsonl")
|
| 7 |
+
log.write(user="find farming grants", intent="search", args={"keyword":"farming"},
|
| 8 |
+
answer_md="...", ok=True, latency_ms=321, meta={"model":"gpt-5-mini"})
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
from dataclasses import asdict, dataclass, field
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, Optional
|
| 14 |
+
import json, os, time, threading, uuid, datetime as dt
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class QATurn:
|
| 18 |
+
ts: str
|
| 19 |
+
session_id: str
|
| 20 |
+
turn_id: str
|
| 21 |
+
user: str
|
| 22 |
+
intent: str
|
| 23 |
+
args: Dict[str, Any] = field(default_factory=dict)
|
| 24 |
+
answer_md: str = ""
|
| 25 |
+
ok: bool = True
|
| 26 |
+
latency_ms: Optional[int] = None
|
| 27 |
+
meta: Dict[str, Any] = field(default_factory=dict)
|
| 28 |
+
|
| 29 |
+
class QALogger:
|
| 30 |
+
def __init__(self, path: str | Path):
|
| 31 |
+
self.path = Path(path)
|
| 32 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
self._lock = threading.Lock()
|
| 34 |
+
self._session = os.getenv("CHAT_SESSION_ID") or str(uuid.uuid4())
|
| 35 |
+
|
| 36 |
+
def write(self, *, user: str, intent: str, args: Dict[str, Any],
|
| 37 |
+
answer_md: str, ok: bool, latency_ms: Optional[int],
|
| 38 |
+
meta: Optional[Dict[str, Any]] = None) -> None:
|
| 39 |
+
turn = QATurn(
|
| 40 |
+
ts=dt.datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
| 41 |
+
session_id=self._session,
|
| 42 |
+
turn_id=str(uuid.uuid4()),
|
| 43 |
+
user=str(user),
|
| 44 |
+
intent=str(intent),
|
| 45 |
+
args=args or {},
|
| 46 |
+
answer_md=str(answer_md or ""),
|
| 47 |
+
ok=bool(ok),
|
| 48 |
+
latency_ms=int(latency_ms) if latency_ms is not None else None,
|
| 49 |
+
meta=meta or {},
|
| 50 |
+
)
|
| 51 |
+
line = json.dumps(asdict(turn), ensure_ascii=False)
|
| 52 |
+
with self._lock:
|
| 53 |
+
with open(self.path, "a", encoding="utf-8") as fh:
|
| 54 |
+
fh.write(line + "\n")
|
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Citation and sourcing utilities to reduce hallucinations.
|
| 3 |
+
|
| 4 |
+
When presenting grant information, we cite the source data:
|
| 5 |
+
- Grant ID and title
|
| 6 |
+
- Specific fields (funding_max, deadline, etc.)
|
| 7 |
+
- Field source (from grant record or extracted)
|
| 8 |
+
|
| 9 |
+
This helps users verify information and trust the system.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from typing import Dict, Any, List, Optional
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def cite_grant_fact(grant: Dict[str, Any], field: str, value: Any) -> str:
|
| 17 |
+
"""
|
| 18 |
+
Create a citation for a fact about a grant.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
grant: The grant record dict
|
| 22 |
+
field: Field name (e.g., "funding_max", "close_date")
|
| 23 |
+
value: The value to cite
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
Formatted citation like "£50,000 [Source: Grant ID #2315]"
|
| 27 |
+
"""
|
| 28 |
+
grant_id = grant.get("id") or grant.get("competition_id") or "unknown"
|
| 29 |
+
|
| 30 |
+
# Special formatting for currency
|
| 31 |
+
if field in ("funding_max", "funding_min", "total_pot") and isinstance(value, (int, float)):
|
| 32 |
+
return f"£{value:,.0f} [Source: Grant ID #{grant_id}]"
|
| 33 |
+
|
| 34 |
+
# Special formatting for dates
|
| 35 |
+
if field in ("close_date", "open_date", "deadline") and value:
|
| 36 |
+
return f"{value} [Source: Grant ID #{grant_id}]"
|
| 37 |
+
|
| 38 |
+
# Default formatting
|
| 39 |
+
if value is None:
|
| 40 |
+
return "Not specified"
|
| 41 |
+
|
| 42 |
+
return f"{value} [Source: Grant ID #{grant_id}]"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def format_grant_with_citations(grant: Dict[str, Any]) -> Dict[str, Any]:
|
| 46 |
+
"""
|
| 47 |
+
Format a grant record with citations on key fields.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
grant: The grant record
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
Dict with cited versions of key fields
|
| 54 |
+
"""
|
| 55 |
+
grant_id = grant.get("id") or grant.get("competition_id") or "unknown"
|
| 56 |
+
title = grant.get("title", "(untitled)")
|
| 57 |
+
|
| 58 |
+
cited = {
|
| 59 |
+
"id": grant_id,
|
| 60 |
+
"title": title,
|
| 61 |
+
"url": grant.get("url", ""),
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
# Cite monetary fields
|
| 65 |
+
if grant.get("funding_max"):
|
| 66 |
+
cited["funding_max"] = cite_grant_fact(grant, "funding_max", grant["funding_max"])
|
| 67 |
+
if grant.get("funding_min"):
|
| 68 |
+
cited["funding_min"] = cite_grant_fact(grant, "funding_min", grant["funding_min"])
|
| 69 |
+
if grant.get("total_pot"):
|
| 70 |
+
cited["total_pot"] = cite_grant_fact(grant, "total_pot", grant["total_pot"])
|
| 71 |
+
|
| 72 |
+
# Cite dates
|
| 73 |
+
if grant.get("close_date"):
|
| 74 |
+
cited["close_date"] = cite_grant_fact(grant, "close_date", grant["close_date"])
|
| 75 |
+
if grant.get("open_date"):
|
| 76 |
+
cited["open_date"] = cite_grant_fact(grant, "open_date", grant["open_date"])
|
| 77 |
+
|
| 78 |
+
return cited
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_citation_summary(grant: Dict[str, Any]) -> str:
|
| 82 |
+
"""
|
| 83 |
+
Build a markdown summary of grant with citations.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
grant: The grant record
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Markdown formatted summary with citations
|
| 90 |
+
"""
|
| 91 |
+
grant_id = grant.get("id") or grant.get("competition_id") or "unknown"
|
| 92 |
+
title = grant.get("title", "(untitled)")
|
| 93 |
+
url = grant.get("url", "")
|
| 94 |
+
|
| 95 |
+
lines = [
|
| 96 |
+
f"## {title}",
|
| 97 |
+
f"**Grant ID:** #{grant_id}",
|
| 98 |
+
"",
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
if url:
|
| 102 |
+
lines.append(f"**Official Link:** {url}")
|
| 103 |
+
lines.append("")
|
| 104 |
+
|
| 105 |
+
# Funding details with citations
|
| 106 |
+
if grant.get("funding_max") or grant.get("funding_min"):
|
| 107 |
+
min_fund = grant.get("funding_min")
|
| 108 |
+
max_fund = grant.get("funding_max")
|
| 109 |
+
|
| 110 |
+
if min_fund and max_fund:
|
| 111 |
+
funding_str = f"£{min_fund:,.0f} – £{max_fund:,.0f}"
|
| 112 |
+
elif max_fund:
|
| 113 |
+
funding_str = f"up to £{max_fund:,.0f}"
|
| 114 |
+
elif min_fund:
|
| 115 |
+
funding_str = f"from £{min_fund:,.0f}"
|
| 116 |
+
else:
|
| 117 |
+
funding_str = "See official page"
|
| 118 |
+
|
| 119 |
+
lines.append(f"**Funding per project:** {funding_str} [Source: Grant ID #{grant_id}]")
|
| 120 |
+
|
| 121 |
+
if grant.get("total_pot"):
|
| 122 |
+
lines.append(f"**Total available:** £{grant['total_pot']:,.0f} [Source: Grant ID #{grant_id}]")
|
| 123 |
+
|
| 124 |
+
lines.append("")
|
| 125 |
+
|
| 126 |
+
# Dates with citations
|
| 127 |
+
if grant.get("open_date") or grant.get("close_date"):
|
| 128 |
+
close = grant.get("close_date") or grant.get("deadline")
|
| 129 |
+
if close:
|
| 130 |
+
lines.append(f"**Deadline:** {close} [Source: Grant ID #{grant_id}]")
|
| 131 |
+
if grant.get("open_date"):
|
| 132 |
+
lines.append(f"**Opens:** {grant['open_date']} [Source: Grant ID #{grant_id}]")
|
| 133 |
+
|
| 134 |
+
lines.append("")
|
| 135 |
+
|
| 136 |
+
# Duration with citation
|
| 137 |
+
if grant.get("duration_min") or grant.get("duration_max"):
|
| 138 |
+
min_dur = grant.get("duration_min")
|
| 139 |
+
max_dur = grant.get("duration_max")
|
| 140 |
+
|
| 141 |
+
if min_dur and max_dur:
|
| 142 |
+
duration_str = f"{min_dur}–{max_dur} months"
|
| 143 |
+
elif max_dur:
|
| 144 |
+
duration_str = f"up to {max_dur} months"
|
| 145 |
+
elif min_dur:
|
| 146 |
+
duration_str = f"from {min_dur} months"
|
| 147 |
+
else:
|
| 148 |
+
duration_str = "Variable"
|
| 149 |
+
|
| 150 |
+
lines.append(f"**Project duration:** {duration_str} [Source: Grant ID #{grant_id}]")
|
| 151 |
+
|
| 152 |
+
return "\n".join(lines)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def validate_fact_availability(grant: Dict[str, Any], fact_type: str) -> bool:
|
| 156 |
+
"""
|
| 157 |
+
Check if a fact exists in the grant data before citing it.
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
grant: The grant record
|
| 161 |
+
fact_type: Type of fact ("funding", "deadline", "duration", "scope", "eligibility")
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
True if the fact is available to cite
|
| 165 |
+
|
| 166 |
+
Raises assertion/warning if fact is missing
|
| 167 |
+
"""
|
| 168 |
+
fact_fields = {
|
| 169 |
+
"funding": ["funding_max", "funding_min", "total_pot"],
|
| 170 |
+
"deadline": ["close_date", "deadline"],
|
| 171 |
+
"duration": ["duration_min", "duration_max"],
|
| 172 |
+
"scope": ["scope", "scope_raw", "sections"],
|
| 173 |
+
"eligibility": ["eligibility", "eligibility_raw"],
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
required_fields = fact_fields.get(fact_type, [])
|
| 177 |
+
|
| 178 |
+
for field in required_fields:
|
| 179 |
+
if grant.get(field):
|
| 180 |
+
return True
|
| 181 |
+
|
| 182 |
+
return False
|
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
utils/dates.py — Date parsing and formatting utilities
|
| 3 |
+
|
| 4 |
+
Functions:
|
| 5 |
+
- parse_date(s): Parse common date formats to datetime
|
| 6 |
+
- format_date(s): Format date to readable string
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from typing import Any, Optional
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def parse_date(s: Any) -> Optional[datetime]:
|
| 14 |
+
"""
|
| 15 |
+
Parse common date formats to datetime object.
|
| 16 |
+
|
| 17 |
+
Supports:
|
| 18 |
+
- ISO 8601: 2024-12-31T23:59:59
|
| 19 |
+
- Date only: 2024-12-31
|
| 20 |
+
- UK format: 31/12/2024
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
s: Date string or datetime object
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
datetime object or None if parsing fails
|
| 27 |
+
"""
|
| 28 |
+
if not s:
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
if isinstance(s, datetime):
|
| 32 |
+
return s
|
| 33 |
+
|
| 34 |
+
# Try common formats
|
| 35 |
+
s_str = str(s)
|
| 36 |
+
|
| 37 |
+
# ISO 8601 with time: 2024-12-31T23:59:59
|
| 38 |
+
if 'T' in s_str and len(s_str) >= 19:
|
| 39 |
+
try:
|
| 40 |
+
return datetime.strptime(s_str[:19], "%Y-%m-%dT%H:%M:%S")
|
| 41 |
+
except Exception:
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
# ISO 8601 date only: 2024-12-31
|
| 45 |
+
if len(s_str) >= 10 and s_str[4:5] == '-' and s_str[7:8] == '-':
|
| 46 |
+
try:
|
| 47 |
+
return datetime.strptime(s_str[:10], "%Y-%m-%d")
|
| 48 |
+
except Exception:
|
| 49 |
+
pass
|
| 50 |
+
|
| 51 |
+
# UK format: 31/12/2024
|
| 52 |
+
if '/' in s_str and len(s_str) >= 10:
|
| 53 |
+
try:
|
| 54 |
+
return datetime.strptime(s_str[:10], "%d/%m/%Y")
|
| 55 |
+
except Exception:
|
| 56 |
+
pass
|
| 57 |
+
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def format_date(s: Any, *, include_time: bool = False) -> str:
|
| 62 |
+
"""
|
| 63 |
+
Format date to human-readable string.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
s: Date string, datetime object, or None
|
| 67 |
+
include_time: If True, include time if available
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Formatted date string or "—" if parsing fails
|
| 71 |
+
|
| 72 |
+
Examples:
|
| 73 |
+
>>> format_date("2024-12-31")
|
| 74 |
+
'2024-12-31'
|
| 75 |
+
>>> format_date("2024-12-31T14:30:00", include_time=True)
|
| 76 |
+
'2024-12-31 14:30:00'
|
| 77 |
+
"""
|
| 78 |
+
d = parse_date(s)
|
| 79 |
+
if not d:
|
| 80 |
+
return str(s) if s else "—"
|
| 81 |
+
|
| 82 |
+
# Auto-detect if time should be included
|
| 83 |
+
has_time = d.hour or d.minute or d.second
|
| 84 |
+
|
| 85 |
+
if include_time or has_time:
|
| 86 |
+
return d.strftime("%Y-%m-%d %H:%M:%S")
|
| 87 |
+
else:
|
| 88 |
+
return d.strftime("%Y-%m-%d")
|
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Standardized exceptions for the Grant Analyzer system.
|
| 3 |
+
|
| 4 |
+
Exception Hierarchy:
|
| 5 |
+
GrantAnalyzerError (base)
|
| 6 |
+
├── DataLoadError # File I/O, parsing
|
| 7 |
+
├── ValidationError # Input validation
|
| 8 |
+
├── SearchError # Index/search issues
|
| 9 |
+
├── LLMError # API/LLM failures
|
| 10 |
+
└── ConfigError # Configuration problems
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
class GrantAnalyzerError(Exception):
|
| 14 |
+
"""
|
| 15 |
+
Base exception for all analyzer errors.
|
| 16 |
+
|
| 17 |
+
All custom exceptions inherit from this so callers can catch
|
| 18 |
+
all analyzer-specific errors with a single except clause.
|
| 19 |
+
"""
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class DataLoadError(GrantAnalyzerError):
|
| 24 |
+
"""
|
| 25 |
+
Error loading or parsing grant/supporting data.
|
| 26 |
+
|
| 27 |
+
Examples:
|
| 28 |
+
- JSON file not found
|
| 29 |
+
- Malformed JSON
|
| 30 |
+
- Missing required fields
|
| 31 |
+
- Excel read failure
|
| 32 |
+
"""
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class ValidationError(GrantAnalyzerError):
|
| 37 |
+
"""
|
| 38 |
+
User input validation failure.
|
| 39 |
+
|
| 40 |
+
Examples:
|
| 41 |
+
- Invalid grant ID format
|
| 42 |
+
- Empty search query
|
| 43 |
+
- URL not in allowlist
|
| 44 |
+
- Negative funding amount
|
| 45 |
+
"""
|
| 46 |
+
pass
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class SearchError(GrantAnalyzerError):
|
| 50 |
+
"""
|
| 51 |
+
Search or index operation failure.
|
| 52 |
+
|
| 53 |
+
Examples:
|
| 54 |
+
- Index file corrupt
|
| 55 |
+
- Index version mismatch
|
| 56 |
+
- Search timeout
|
| 57 |
+
- Too many results
|
| 58 |
+
"""
|
| 59 |
+
pass
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class LLMError(GrantAnalyzerError):
|
| 63 |
+
"""
|
| 64 |
+
LLM API call failure.
|
| 65 |
+
|
| 66 |
+
Examples:
|
| 67 |
+
- API key invalid
|
| 68 |
+
- Rate limit exceeded
|
| 69 |
+
- Timeout
|
| 70 |
+
- Model not found
|
| 71 |
+
- Response parsing error
|
| 72 |
+
"""
|
| 73 |
+
pass
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class ConfigError(GrantAnalyzerError):
|
| 77 |
+
"""
|
| 78 |
+
Configuration error.
|
| 79 |
+
|
| 80 |
+
Examples:
|
| 81 |
+
- Missing required env var
|
| 82 |
+
- Invalid provider name
|
| 83 |
+
- Conflicting settings
|
| 84 |
+
"""
|
| 85 |
+
pass
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# Convenience re-exports
|
| 89 |
+
__all__ = [
|
| 90 |
+
"GrantAnalyzerError",
|
| 91 |
+
"DataLoadError",
|
| 92 |
+
"ValidationError",
|
| 93 |
+
"SearchError",
|
| 94 |
+
"LLMError",
|
| 95 |
+
"ConfigError",
|
| 96 |
+
]
|
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
query_logger.py — Logs user queries and AI responses for RLHF analysis
|
| 3 |
+
|
| 4 |
+
Automatically logs every interaction to CSV and JSONL formats.
|
| 5 |
+
Export to RLHF training format with export_logs.py script.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import csv
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class QueryLogger:
|
| 20 |
+
"""Logs user queries and AI responses to JSON and CSV for RLHF analysis."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, log_dir: str = "logs"):
|
| 23 |
+
"""
|
| 24 |
+
Initialize query logger.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
log_dir: Directory to store log files (default: "logs/")
|
| 28 |
+
"""
|
| 29 |
+
self.log_dir = Path(log_dir)
|
| 30 |
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
|
| 32 |
+
# Get today's date for log file naming
|
| 33 |
+
today = datetime.now().strftime("%Y%m%d")
|
| 34 |
+
self.jsonl_path = self.log_dir / f"queries_{today}.jsonl"
|
| 35 |
+
self.csv_path = self.log_dir / f"queries_{today}.csv"
|
| 36 |
+
|
| 37 |
+
# Initialize CSV file with headers if it doesn't exist
|
| 38 |
+
if not self.csv_path.exists():
|
| 39 |
+
self._init_csv()
|
| 40 |
+
|
| 41 |
+
def _init_csv(self):
|
| 42 |
+
"""Initialize CSV file with headers."""
|
| 43 |
+
headers = [
|
| 44 |
+
'timestamp',
|
| 45 |
+
'user_query',
|
| 46 |
+
'ai_response',
|
| 47 |
+
'tools_called',
|
| 48 |
+
'response_time_ms',
|
| 49 |
+
'success',
|
| 50 |
+
'rating',
|
| 51 |
+
'feedback',
|
| 52 |
+
'model',
|
| 53 |
+
'tokens_used'
|
| 54 |
+
]
|
| 55 |
+
with open(self.csv_path, 'w', newline='', encoding='utf-8') as f:
|
| 56 |
+
writer = csv.DictWriter(f, fieldnames=headers)
|
| 57 |
+
writer.writeheader()
|
| 58 |
+
|
| 59 |
+
def log_interaction(
|
| 60 |
+
self,
|
| 61 |
+
user_query: str,
|
| 62 |
+
ai_response: str,
|
| 63 |
+
*,
|
| 64 |
+
tools_called: Optional[List[str]] = None,
|
| 65 |
+
response_time_ms: Optional[int] = None,
|
| 66 |
+
success: bool = True,
|
| 67 |
+
rating: Optional[int] = None,
|
| 68 |
+
feedback: Optional[str] = None,
|
| 69 |
+
model: Optional[str] = None,
|
| 70 |
+
tokens_used: Optional[int] = None,
|
| 71 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 72 |
+
):
|
| 73 |
+
"""
|
| 74 |
+
Log a user query and AI response.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
user_query: The user's input query
|
| 78 |
+
ai_response: The AI's response
|
| 79 |
+
tools_called: List of tool names that were called
|
| 80 |
+
response_time_ms: Response time in milliseconds
|
| 81 |
+
success: Whether the interaction was successful
|
| 82 |
+
rating: Optional 1-5 rating (for RLHF)
|
| 83 |
+
feedback: Optional text feedback (for RLHF)
|
| 84 |
+
model: Model name used (e.g., "gpt-5-mini")
|
| 85 |
+
tokens_used: Number of tokens consumed
|
| 86 |
+
metadata: Additional metadata to log
|
| 87 |
+
"""
|
| 88 |
+
timestamp = datetime.utcnow().isoformat()
|
| 89 |
+
|
| 90 |
+
# Prepare log entry
|
| 91 |
+
log_entry = {
|
| 92 |
+
'timestamp': timestamp,
|
| 93 |
+
'user_query': user_query,
|
| 94 |
+
'ai_response': ai_response,
|
| 95 |
+
'tools_called': tools_called or [],
|
| 96 |
+
'response_time_ms': response_time_ms,
|
| 97 |
+
'success': success,
|
| 98 |
+
'rating': rating,
|
| 99 |
+
'feedback': feedback,
|
| 100 |
+
'model': model,
|
| 101 |
+
'tokens_used': tokens_used,
|
| 102 |
+
'metadata': metadata or {}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
# Write to JSONL (one JSON object per line)
|
| 106 |
+
try:
|
| 107 |
+
with open(self.jsonl_path, 'a', encoding='utf-8') as f:
|
| 108 |
+
f.write(json.dumps(log_entry) + '\n')
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.warning(f"Failed to write to JSONL log: {e}")
|
| 111 |
+
|
| 112 |
+
# Write to CSV (Excel-compatible)
|
| 113 |
+
try:
|
| 114 |
+
with open(self.csv_path, 'a', newline='', encoding='utf-8') as f:
|
| 115 |
+
writer = csv.DictWriter(f, fieldnames=[
|
| 116 |
+
'timestamp', 'user_query', 'ai_response', 'tools_called',
|
| 117 |
+
'response_time_ms', 'success', 'rating', 'feedback',
|
| 118 |
+
'model', 'tokens_used'
|
| 119 |
+
])
|
| 120 |
+
writer.writerow({
|
| 121 |
+
'timestamp': timestamp,
|
| 122 |
+
'user_query': user_query,
|
| 123 |
+
'ai_response': ai_response,
|
| 124 |
+
'tools_called': ','.join(tools_called or []),
|
| 125 |
+
'response_time_ms': response_time_ms,
|
| 126 |
+
'success': success,
|
| 127 |
+
'rating': rating or '',
|
| 128 |
+
'feedback': feedback or '',
|
| 129 |
+
'model': model or '',
|
| 130 |
+
'tokens_used': tokens_used or ''
|
| 131 |
+
})
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.warning(f"Failed to write to CSV log: {e}")
|
| 134 |
+
|
| 135 |
+
logger.debug(f"Logged interaction: {user_query[:50]}...")
|
| 136 |
+
|
| 137 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 138 |
+
"""
|
| 139 |
+
Get statistics about logged queries.
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
Dictionary with statistics (total queries, success rate, avg response time, etc.)
|
| 143 |
+
"""
|
| 144 |
+
try:
|
| 145 |
+
with open(self.csv_path, 'r', encoding='utf-8') as f:
|
| 146 |
+
reader = csv.DictReader(f)
|
| 147 |
+
rows = list(reader)
|
| 148 |
+
|
| 149 |
+
if not rows:
|
| 150 |
+
return {
|
| 151 |
+
'total_queries': 0,
|
| 152 |
+
'successful_queries': 0,
|
| 153 |
+
'failed_queries': 0,
|
| 154 |
+
'avg_response_time_ms': 0,
|
| 155 |
+
'avg_rating': 0,
|
| 156 |
+
'rated_queries': 0,
|
| 157 |
+
'tool_usage': {}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
total = len(rows)
|
| 161 |
+
successful = sum(1 for r in rows if r.get('success') == 'True')
|
| 162 |
+
failed = total - successful
|
| 163 |
+
|
| 164 |
+
# Calculate average response time
|
| 165 |
+
response_times = [
|
| 166 |
+
int(r['response_time_ms'])
|
| 167 |
+
for r in rows
|
| 168 |
+
if r.get('response_time_ms') and r['response_time_ms'].isdigit()
|
| 169 |
+
]
|
| 170 |
+
avg_response_time = sum(response_times) / len(response_times) if response_times else 0
|
| 171 |
+
|
| 172 |
+
# Calculate average rating
|
| 173 |
+
ratings = [
|
| 174 |
+
int(r['rating'])
|
| 175 |
+
for r in rows
|
| 176 |
+
if r.get('rating') and r['rating'].isdigit()
|
| 177 |
+
]
|
| 178 |
+
avg_rating = sum(ratings) / len(ratings) if ratings else 0
|
| 179 |
+
rated_queries = len(ratings)
|
| 180 |
+
|
| 181 |
+
# Count tool usage
|
| 182 |
+
tool_usage = {}
|
| 183 |
+
for row in rows:
|
| 184 |
+
tools = row.get('tools_called', '').split(',')
|
| 185 |
+
for tool in tools:
|
| 186 |
+
tool = tool.strip()
|
| 187 |
+
if tool:
|
| 188 |
+
tool_usage[tool] = tool_usage.get(tool, 0) + 1
|
| 189 |
+
|
| 190 |
+
return {
|
| 191 |
+
'total_queries': total,
|
| 192 |
+
'successful_queries': successful,
|
| 193 |
+
'failed_queries': failed,
|
| 194 |
+
'avg_response_time_ms': avg_response_time,
|
| 195 |
+
'avg_rating': avg_rating,
|
| 196 |
+
'rated_queries': rated_queries,
|
| 197 |
+
'tool_usage': tool_usage
|
| 198 |
+
}
|
| 199 |
+
except Exception as e:
|
| 200 |
+
logger.warning(f"Failed to calculate stats: {e}")
|
| 201 |
+
return {
|
| 202 |
+
'total_queries': 0,
|
| 203 |
+
'successful_queries': 0,
|
| 204 |
+
'failed_queries': 0,
|
| 205 |
+
'avg_response_time_ms': 0,
|
| 206 |
+
'avg_rating': 0,
|
| 207 |
+
'rated_queries': 0,
|
| 208 |
+
'tool_usage': {}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
def export_for_rlhf(self, output_path: Optional[Path] = None) -> Path:
|
| 212 |
+
"""
|
| 213 |
+
Export logs in RLHF training format.
|
| 214 |
+
|
| 215 |
+
Format:
|
| 216 |
+
[
|
| 217 |
+
{
|
| 218 |
+
"prompt": "user query",
|
| 219 |
+
"completion": "ai response",
|
| 220 |
+
"rating": 5,
|
| 221 |
+
"feedback": "Great!",
|
| 222 |
+
"tools_used": ["list_grants"],
|
| 223 |
+
"timestamp": "2025-10-23T10:15:30"
|
| 224 |
+
},
|
| 225 |
+
...
|
| 226 |
+
]
|
| 227 |
+
|
| 228 |
+
Args:
|
| 229 |
+
output_path: Optional custom output path
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
Path to exported file
|
| 233 |
+
"""
|
| 234 |
+
if output_path is None:
|
| 235 |
+
today = datetime.now().strftime("%Y%m%d")
|
| 236 |
+
output_path = self.log_dir / f"rlhf_data_{today}.json"
|
| 237 |
+
|
| 238 |
+
rlhf_data = []
|
| 239 |
+
|
| 240 |
+
try:
|
| 241 |
+
# Read from JSONL
|
| 242 |
+
with open(self.jsonl_path, 'r', encoding='utf-8') as f:
|
| 243 |
+
for line in f:
|
| 244 |
+
entry = json.loads(line)
|
| 245 |
+
|
| 246 |
+
# Only include successful interactions
|
| 247 |
+
if not entry.get('success', False):
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
rlhf_entry = {
|
| 251 |
+
'prompt': entry['user_query'],
|
| 252 |
+
'completion': entry['ai_response'],
|
| 253 |
+
'rating': entry.get('rating'),
|
| 254 |
+
'feedback': entry.get('feedback'),
|
| 255 |
+
'tools_used': entry.get('tools_called', []),
|
| 256 |
+
'timestamp': entry['timestamp'],
|
| 257 |
+
'response_time_ms': entry.get('response_time_ms'),
|
| 258 |
+
'model': entry.get('model')
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
rlhf_data.append(rlhf_entry)
|
| 262 |
+
|
| 263 |
+
# Write RLHF format
|
| 264 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 265 |
+
json.dump(rlhf_data, f, indent=2, ensure_ascii=False)
|
| 266 |
+
|
| 267 |
+
logger.info(f"Exported {len(rlhf_data)} interactions to {output_path}")
|
| 268 |
+
return output_path
|
| 269 |
+
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.error(f"Failed to export RLHF data: {e}")
|
| 272 |
+
raise
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# Global singleton instance
|
| 276 |
+
_query_logger: Optional[QueryLogger] = None
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def get_query_logger(log_dir: str = "logs") -> QueryLogger:
|
| 280 |
+
"""
|
| 281 |
+
Get or create the global query logger instance.
|
| 282 |
+
|
| 283 |
+
Args:
|
| 284 |
+
log_dir: Directory to store log files
|
| 285 |
+
|
| 286 |
+
Returns:
|
| 287 |
+
QueryLogger instance
|
| 288 |
+
"""
|
| 289 |
+
global _query_logger
|
| 290 |
+
if _query_logger is None:
|
| 291 |
+
_query_logger = QueryLogger(log_dir=log_dir)
|
| 292 |
+
return _query_logger
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
# Quick self-test
|
| 296 |
+
if __name__ == "__main__":
|
| 297 |
+
logger = get_query_logger(log_dir="_out/test_logs")
|
| 298 |
+
|
| 299 |
+
# Log a test interaction
|
| 300 |
+
logger.log_interaction(
|
| 301 |
+
user_query="What grants are available for batteries?",
|
| 302 |
+
ai_response="Here are the battery-related grants: 1. Battery Innovation Grant...",
|
| 303 |
+
tools_called=["list_grants"],
|
| 304 |
+
response_time_ms=1500,
|
| 305 |
+
success=True,
|
| 306 |
+
rating=5,
|
| 307 |
+
feedback="Very helpful!",
|
| 308 |
+
model="gpt-5-mini"
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
# Get stats
|
| 312 |
+
stats = logger.get_stats()
|
| 313 |
+
print("Statistics:", json.dumps(stats, indent=2))
|
| 314 |
+
|
| 315 |
+
# Export for RLHF
|
| 316 |
+
output = logger.export_for_rlhf()
|
| 317 |
+
print(f"Exported to: {output}")
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
utils/text.py — small text helpers (no hard deps)
|
| 3 |
+
|
| 4 |
+
- clean(s): collapse whitespace
|
| 5 |
+
- to_number(s): parse first numeric like "£1,234.50" -> 1234.5
|
| 6 |
+
- extract_numbers(s): list of floats found in text
|
| 7 |
+
- safe_truncate_chars(s, n): hard char limit with ellipsis
|
| 8 |
+
- safe_truncate_tokens(s, max_tokens): uses tiktoken if installed; else char fallback
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
import re
|
| 12 |
+
from typing import Any, List, Optional
|
| 13 |
+
|
| 14 |
+
_NUM_RE = re.compile(r"-?\d+(?:\.\d+)?")
|
| 15 |
+
|
| 16 |
+
def clean(s: Any) -> str:
|
| 17 |
+
return re.sub(r"\s+", " ", str(s or "")).strip()
|
| 18 |
+
|
| 19 |
+
def to_number(x: Any) -> Optional[float]:
|
| 20 |
+
if x is None:
|
| 21 |
+
return None
|
| 22 |
+
if isinstance(x, (int, float)):
|
| 23 |
+
return float(x)
|
| 24 |
+
s = str(x).replace(",", "").replace("£", "").strip()
|
| 25 |
+
m = _NUM_RE.findall(s)
|
| 26 |
+
return float(m[0]) if m else None
|
| 27 |
+
|
| 28 |
+
def extract_numbers(s: Any) -> List[float]:
|
| 29 |
+
return [float(m) for m in _NUM_RE.findall(str(s or ""))]
|
| 30 |
+
|
| 31 |
+
def safe_truncate_chars(s: str, n: int) -> str:
|
| 32 |
+
s = s or ""
|
| 33 |
+
if len(s) <= n:
|
| 34 |
+
return s
|
| 35 |
+
return s[: max(0, n - 1)] + "…"
|
| 36 |
+
|
| 37 |
+
def safe_truncate_tokens(s: str, max_tokens: int, *, model: str = "gpt-5-mini") -> str:
|
| 38 |
+
"""
|
| 39 |
+
Best effort token truncation. If tiktoken is available, use it;
|
| 40 |
+
otherwise approximate by ~4 chars/token heuristic.
|
| 41 |
+
"""
|
| 42 |
+
s = s or ""
|
| 43 |
+
try:
|
| 44 |
+
import tiktoken # type: ignore
|
| 45 |
+
# Use o200k_base encoding for GPT-5 models (fallback to cl100k_base for GPT-4)
|
| 46 |
+
try:
|
| 47 |
+
enc = tiktoken.get_encoding("o200k_base")
|
| 48 |
+
except:
|
| 49 |
+
enc = tiktoken.get_encoding("cl100k_base")
|
| 50 |
+
toks = enc.encode(s)
|
| 51 |
+
if len(toks) <= max_tokens:
|
| 52 |
+
return s
|
| 53 |
+
toks = toks[:max_tokens]
|
| 54 |
+
return enc.decode(toks)
|
| 55 |
+
except Exception:
|
| 56 |
+
# rough fallback: ~4 chars per token
|
| 57 |
+
return safe_truncate_chars(s, max_tokens * 4)
|
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Input validation with proper error raising."""
|
| 2 |
+
import re
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from .errors import ValidationError
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def validate_grant_id(gid: str) -> str:
|
| 8 |
+
"""
|
| 9 |
+
Validate and normalize grant ID.
|
| 10 |
+
|
| 11 |
+
Args:
|
| 12 |
+
gid: Grant ID in any format ("2315", "competition-2315", etc.)
|
| 13 |
+
|
| 14 |
+
Returns:
|
| 15 |
+
Normalized ID (just the numeric part without prefix)
|
| 16 |
+
|
| 17 |
+
Raises:
|
| 18 |
+
ValidationError: If ID format is invalid
|
| 19 |
+
|
| 20 |
+
Example:
|
| 21 |
+
>>> validate_grant_id("2315")
|
| 22 |
+
'2315'
|
| 23 |
+
>>> validate_grant_id("competition-2315")
|
| 24 |
+
'2315'
|
| 25 |
+
>>> validate_grant_id("invalid")
|
| 26 |
+
ValidationError: Invalid grant ID format: 'invalid'
|
| 27 |
+
"""
|
| 28 |
+
if not gid:
|
| 29 |
+
raise ValidationError("Grant ID cannot be empty")
|
| 30 |
+
|
| 31 |
+
gid = str(gid).strip()
|
| 32 |
+
|
| 33 |
+
# Extract numeric part
|
| 34 |
+
match = re.match(r'^(?:comp(?:etition)?-|grant-)?(\d{3,7})$', gid, re.I)
|
| 35 |
+
if not match:
|
| 36 |
+
raise ValidationError(
|
| 37 |
+
f"Invalid grant ID format: '{gid}'. "
|
| 38 |
+
f"Expected: '2315' or 'competition-2315'"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
numeric_id = match.group(1)
|
| 42 |
+
return numeric_id
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def validate_url(url: str, *, allowed_hosts: Optional[set] = None) -> str:
|
| 46 |
+
"""
|
| 47 |
+
Validate URL and check against allowlist.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
url: URL to validate
|
| 51 |
+
allowed_hosts: Optional set of allowed hostnames
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
Validated URL (unchanged)
|
| 55 |
+
|
| 56 |
+
Raises:
|
| 57 |
+
ValidationError: If URL is invalid or not allowed
|
| 58 |
+
"""
|
| 59 |
+
from urllib.parse import urlparse
|
| 60 |
+
|
| 61 |
+
if not url:
|
| 62 |
+
raise ValidationError("URL cannot be empty")
|
| 63 |
+
|
| 64 |
+
url = str(url).strip()
|
| 65 |
+
|
| 66 |
+
if not url.startswith(('http://', 'https://')):
|
| 67 |
+
raise ValidationError(
|
| 68 |
+
f"URL must start with http:// or https://: {url}"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
parsed = urlparse(url)
|
| 73 |
+
except Exception as e:
|
| 74 |
+
raise ValidationError(f"Malformed URL: {url}") from e
|
| 75 |
+
|
| 76 |
+
if not parsed.netloc:
|
| 77 |
+
raise ValidationError(f"URL has no hostname: {url}")
|
| 78 |
+
|
| 79 |
+
if allowed_hosts and parsed.netloc not in allowed_hosts:
|
| 80 |
+
allowed_preview = ', '.join(list(allowed_hosts)[:3])
|
| 81 |
+
raise ValidationError(
|
| 82 |
+
f"URL host '{parsed.netloc}' not in allowlist. "
|
| 83 |
+
f"Allowed: {allowed_preview}..."
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
return url
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def sanitize_filename(name: str, max_length: int = 200) -> str:
|
| 90 |
+
"""
|
| 91 |
+
Sanitize filename to prevent path traversal.
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
name: Original filename
|
| 95 |
+
max_length: Maximum length
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
Safe filename
|
| 99 |
+
|
| 100 |
+
Example:
|
| 101 |
+
>>> sanitize_filename("../../../etc/passwd")
|
| 102 |
+
'etc_passwd'
|
| 103 |
+
"""
|
| 104 |
+
if not name:
|
| 105 |
+
raise ValidationError("Filename cannot be empty")
|
| 106 |
+
|
| 107 |
+
# Remove path separators and dangerous chars
|
| 108 |
+
safe = re.sub(r'[^\w\-.]', '_', str(name))
|
| 109 |
+
safe = safe.strip('._')
|
| 110 |
+
|
| 111 |
+
if not safe:
|
| 112 |
+
raise ValidationError(f"Filename '{name}' produces empty result after sanitization")
|
| 113 |
+
|
| 114 |
+
return safe[:max_length]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def validate_search_query(query: str, max_length: int = 500) -> str:
|
| 118 |
+
"""
|
| 119 |
+
Validate search query.
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
query: Search query string
|
| 123 |
+
max_length: Maximum allowed length
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
Validated query (stripped)
|
| 127 |
+
|
| 128 |
+
Raises:
|
| 129 |
+
ValidationError: If query is empty or too long
|
| 130 |
+
"""
|
| 131 |
+
if not query:
|
| 132 |
+
raise ValidationError("Search query cannot be empty")
|
| 133 |
+
|
| 134 |
+
query = str(query).strip()
|
| 135 |
+
|
| 136 |
+
if not query:
|
| 137 |
+
raise ValidationError("Search query cannot be whitespace only")
|
| 138 |
+
|
| 139 |
+
if len(query) > max_length:
|
| 140 |
+
raise ValidationError(
|
| 141 |
+
f"Search query too long ({len(query)} chars, max {max_length})"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
return query
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def validate_positive_int(value: any, name: str = "value") -> int:
|
| 148 |
+
"""
|
| 149 |
+
Validate positive integer.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
value: Value to validate
|
| 153 |
+
name: Name of parameter (for error messages)
|
| 154 |
+
|
| 155 |
+
Returns:
|
| 156 |
+
Validated integer
|
| 157 |
+
|
| 158 |
+
Raises:
|
| 159 |
+
ValidationError: If not a positive integer
|
| 160 |
+
"""
|
| 161 |
+
try:
|
| 162 |
+
val = int(value)
|
| 163 |
+
except (TypeError, ValueError) as e:
|
| 164 |
+
raise ValidationError(f"{name} must be an integer, got {type(value).__name__}") from e
|
| 165 |
+
|
| 166 |
+
if val <= 0:
|
| 167 |
+
raise ValidationError(f"{name} must be positive, got {val}")
|
| 168 |
+
|
| 169 |
+
return val
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def validate_date_string(date_str: str, name: str = "date") -> str:
|
| 173 |
+
"""
|
| 174 |
+
Validate ISO date string (YYYY-MM-DD).
|
| 175 |
+
|
| 176 |
+
Args:
|
| 177 |
+
date_str: Date string to validate
|
| 178 |
+
name: Name of parameter (for error messages)
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
Validated date string
|
| 182 |
+
|
| 183 |
+
Raises:
|
| 184 |
+
ValidationError: If not valid ISO date format
|
| 185 |
+
"""
|
| 186 |
+
if not date_str:
|
| 187 |
+
raise ValidationError(f"{name} cannot be empty")
|
| 188 |
+
|
| 189 |
+
date_str = str(date_str).strip()
|
| 190 |
+
|
| 191 |
+
# Check format
|
| 192 |
+
if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
|
| 193 |
+
raise ValidationError(
|
| 194 |
+
f"{name} must be in YYYY-MM-DD format, got: {date_str}"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Validate actual date (catches invalid like 2025-13-45)
|
| 198 |
+
try:
|
| 199 |
+
from datetime import datetime
|
| 200 |
+
datetime.strptime(date_str, '%Y-%m-%d')
|
| 201 |
+
except ValueError as e:
|
| 202 |
+
raise ValidationError(f"Invalid date: {date_str}") from e
|
| 203 |
+
|
| 204 |
+
return date_str
|
|
@@ -1,54 +1,22 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
aiohttp>=3.9
|
| 24 |
-
aiofiles>=23.2
|
| 25 |
-
|
| 26 |
-
# API and web framework
|
| 27 |
-
fastapi>=0.100
|
| 28 |
-
uvicorn>=0.24
|
| 29 |
-
pydantic>=2.0,<2.12
|
| 30 |
-
pydantic-settings>=2.4
|
| 31 |
-
gradio>=5.0
|
| 32 |
-
|
| 33 |
-
# Database
|
| 34 |
-
pymongo>=4.6
|
| 35 |
-
motor>=3.3
|
| 36 |
-
|
| 37 |
-
# PDF processing (optional - comment out if not needed)
|
| 38 |
-
PyMuPDF>=1.23
|
| 39 |
-
pdfplumber>=0.10
|
| 40 |
-
|
| 41 |
-
# Testing
|
| 42 |
-
pytest>=7.4
|
| 43 |
-
pytest-cov>=4.1
|
| 44 |
-
pytest-asyncio>=0.21
|
| 45 |
-
|
| 46 |
-
# Utilities
|
| 47 |
-
python-dateutil>=2.8
|
| 48 |
-
python-dotenv>=1.0
|
| 49 |
-
python-json-logger>=3.0
|
| 50 |
-
typer>=0.9
|
| 51 |
-
|
| 52 |
-
# Web automation (optional - only if using crawler)
|
| 53 |
-
# playwright>=1.40
|
| 54 |
-
# Scrapy>=2.11
|
|
|
|
| 1 |
+
# Hugging Face Spaces requirements
|
| 2 |
+
# Optimized for cloud deployment
|
| 3 |
+
# Note: Gradio is provided by HF Spaces when sdk: gradio is specified in README
|
| 4 |
+
|
| 5 |
+
openai>=1.0.0
|
| 6 |
+
anthropic>=0.71.0
|
| 7 |
+
pandas>=2.0.0
|
| 8 |
+
numpy>=1.24.0
|
| 9 |
+
scikit-learn>=1.3.0
|
| 10 |
+
requests>=2.31.0
|
| 11 |
+
aiohttp>=3.9.0
|
| 12 |
+
httpx>=0.27.0
|
| 13 |
+
aiofiles>=23.2.0
|
| 14 |
+
python-dotenv>=1.0.0
|
| 15 |
+
beautifulsoup4>=4.12.0
|
| 16 |
+
lxml>=5.0.0
|
| 17 |
+
pydantic>=2.0,<3.0
|
| 18 |
+
pydantic-settings>=2.0.0
|
| 19 |
+
python-json-logger>=3.0.0
|
| 20 |
+
|
| 21 |
+
# Note: MongoDB dependencies (pymongo, motor) removed for HF Spaces
|
| 22 |
+
# as they're not needed for the core functionality
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|