Riley Claude commited on
Commit
e616a87
·
1 Parent(s): fc9de37

fix: Restore exact working structure from commit c72a240

Browse files

Major 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>

Files changed (48) hide show
  1. README.md +1 -1
  2. analyzer +0 -1
  3. analyzer/__init__.py +0 -0
  4. analyzer/cache/__init__.py +0 -0
  5. analyzer/cache/memo.py +36 -0
  6. analyzer/chat/__init__.py +0 -0
  7. analyzer/chat/chat_tools.py +881 -0
  8. analyzer/chat/company_analyzer.py +384 -0
  9. analyzer/chat/demo_app.py +860 -0
  10. analyzer/chat/insight_tools.py +94 -0
  11. analyzer/chat/memory.py +95 -0
  12. analyzer/chat/query_router.py +345 -0
  13. analyzer/chat/run_chat.py +408 -0
  14. analyzer/chat/run_chat_llm.py +297 -0
  15. analyzer/chat/run_chat_plus.py +39 -0
  16. analyzer/chat/tool_schemas.py +487 -0
  17. analyzer/chat/tool_schemas_plus.py +30 -0
  18. analyzer/config.py +120 -0
  19. analyzer/context_builder.py +274 -0
  20. analyzer/crawler/discover_grants.py +306 -0
  21. analyzer/crawler/scheduler.py +514 -0
  22. analyzer/crawler/snapshot.py +626 -0
  23. analyzer/data_loader.py +167 -0
  24. analyzer/data_loader_supporting.py +163 -0
  25. analyzer/exporters.py +77 -0
  26. analyzer/llm_client.py +396 -0
  27. analyzer/logging_setup.py +35 -0
  28. analyzer/net/fetcher.py +75 -0
  29. analyzer/prompt_templates.py +81 -0
  30. analyzer/run_generate.py +104 -0
  31. analyzer/search/__init__.py +0 -0
  32. analyzer/search/build_index.py +80 -0
  33. analyzer/search/hybrid_index.py +476 -0
  34. analyzer/search/past_winners_integration.py +181 -0
  35. analyzer/search/query.py +32 -0
  36. analyzer/streaming_summarizer.py +267 -0
  37. analyzer/summarizer.py +107 -0
  38. analyzer/summarizer_optimized.py +590 -0
  39. analyzer/telemetry/__init__.py +2 -0
  40. analyzer/telemetry/logger.py +54 -0
  41. analyzer/utils/__init__.py +0 -0
  42. analyzer/utils/citations.py +182 -0
  43. analyzer/utils/dates.py +88 -0
  44. analyzer/utils/errors.py +96 -0
  45. analyzer/utils/query_logger.py +317 -0
  46. analyzer/utils/text.py +57 -0
  47. analyzer/utils/validation.py +204 -0
  48. requirements.txt +22 -54
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🎯
4
  colorFrom: blue
5
  colorTo: green
6
  sdk: gradio
7
- sdk_version: 5.49.0
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
analyzer DELETED
@@ -1 +0,0 @@
1
- src/analyzer
 
 
analyzer/__init__.py ADDED
File without changes
analyzer/cache/__init__.py ADDED
File without changes
analyzer/cache/memo.py ADDED
@@ -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
analyzer/chat/__init__.py ADDED
File without changes
analyzer/chat/chat_tools.py ADDED
@@ -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
+ }
analyzer/chat/company_analyzer.py ADDED
@@ -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
+ }
analyzer/chat/demo_app.py ADDED
@@ -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()
analyzer/chat/insight_tools.py ADDED
@@ -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}
analyzer/chat/memory.py ADDED
@@ -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()
analyzer/chat/query_router.py ADDED
@@ -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))
analyzer/chat/run_chat.py ADDED
@@ -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()
analyzer/chat/run_chat_llm.py ADDED
@@ -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()
analyzer/chat/run_chat_plus.py ADDED
@@ -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)
analyzer/chat/tool_schemas.py ADDED
@@ -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()}")
analyzer/chat/tool_schemas_plus.py ADDED
@@ -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)
analyzer/config.py ADDED
@@ -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)
analyzer/context_builder.py ADDED
@@ -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))
analyzer/crawler/discover_grants.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Discover and fetch new grants from Innovate UK website.
3
+
4
+ This module:
5
+ 1. Fetches the Innovate UK competition search/listing page
6
+ 2. Extracts all available grant URLs
7
+ 3. Fetches each grant's details using snapshot.py
8
+ 4. Saves to snapshots directory
9
+ """
10
+
11
+ import logging
12
+ import asyncio
13
+ import json
14
+ import re
15
+ from pathlib import Path
16
+ from typing import List, Tuple, Optional, Set
17
+ from urllib.parse import urljoin, urlparse
18
+ from datetime import datetime, UTC
19
+ from playwright.async_api import async_playwright
20
+ from bs4 import BeautifulSoup
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Innovate UK service base URL
25
+ IUK_BASE = "https://apply-for-innovation-funding.service.gov.uk"
26
+ COMPETITIONS_URL = f"{IUK_BASE}/competition/search"
27
+
28
+
29
+ async def discover_grant_urls(max_retries: int = 2) -> List[str]:
30
+ """
31
+ Discover all available grant overview URLs from Innovate UK.
32
+
33
+ Returns:
34
+ List of grant overview URLs
35
+ """
36
+ logger.info(f"Discovering grants from {COMPETITIONS_URL}")
37
+
38
+ async with async_playwright() as pw:
39
+ browser = await pw.chromium.launch(
40
+ headless=True,
41
+ args=["--disable-dev-shm-usage"]
42
+ )
43
+ context = await browser.new_context(
44
+ user_agent=(
45
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
46
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
47
+ ),
48
+ locale="en-GB",
49
+ timezone_id="Europe/London",
50
+ )
51
+ page = await context.new_page()
52
+ page.set_default_timeout(30000)
53
+
54
+ for attempt in range(max_retries):
55
+ try:
56
+ await page.goto(COMPETITIONS_URL, wait_until="domcontentloaded")
57
+ await page.wait_for_load_state("networkidle")
58
+ break
59
+ except Exception as e:
60
+ if attempt == max_retries - 1:
61
+ logger.error(f"Failed to fetch competitions page: {e}")
62
+ await context.close()
63
+ await browser.close()
64
+ return []
65
+ logger.warning(f"Attempt {attempt + 1} failed, retrying...")
66
+
67
+ html = await page.content()
68
+ await context.close()
69
+ await browser.close()
70
+
71
+ # Parse URLs from HTML
72
+ urls = _extract_grant_urls(html)
73
+ logger.info(f"Discovered {len(urls)} grants")
74
+
75
+ return urls
76
+
77
+
78
+ def _extract_grant_urls(html: str) -> List[str]:
79
+ """
80
+ Extract all grant overview URLs from the competitions listing page.
81
+
82
+ Looks for links matching pattern: /competition/{id}/overview/{uuid}
83
+ """
84
+ soup = BeautifulSoup(html, "lxml")
85
+ urls = []
86
+
87
+ # Find all links that match the overview pattern
88
+ pattern = re.compile(r'/competition/(\d+)/overview/([0-9a-f\-]{8,})', re.I)
89
+
90
+ for link in soup.find_all("a", href=True):
91
+ href = link.get("href", "")
92
+ if pattern.search(href):
93
+ # Make absolute URL
94
+ full_url = urljoin(IUK_BASE, href)
95
+ if full_url not in urls:
96
+ urls.append(full_url)
97
+
98
+ # Also check for links in data attributes or javascript
99
+ for elem in soup.find_all(["a", "div", "li"], {"data-href": True}):
100
+ href = elem.get("data-href", "")
101
+ if pattern.search(href):
102
+ full_url = urljoin(IUK_BASE, href)
103
+ if full_url not in urls:
104
+ urls.append(full_url)
105
+
106
+ return urls
107
+
108
+
109
+ async def fetch_grant_snapshot(url: str, output_dir: Path) -> Optional[str]:
110
+ """
111
+ Fetch a single grant's snapshot and save to JSON.
112
+
113
+ Args:
114
+ url: Grant overview URL
115
+ output_dir: Directory to save snapshot JSON
116
+
117
+ Returns:
118
+ Filename of saved snapshot, or None if failed
119
+ """
120
+ try:
121
+ from .snapshot import fetch_sections_from_overview, parse_deeplink
122
+
123
+ # Extract grant ID from URL
124
+ match = re.search(r'/competition/(\d+)/', url)
125
+ if not match:
126
+ logger.warning(f"Could not extract grant ID from {url}")
127
+ return None
128
+
129
+ grant_id = match.group(1)
130
+ output_path = output_dir / f"competition-{grant_id}.json"
131
+
132
+ # Skip if already exists
133
+ if output_path.exists():
134
+ logger.debug(f"Grant {grant_id} already exists, skipping")
135
+ return None
136
+
137
+ logger.info(f"Fetching grant {grant_id}...")
138
+ html, sections = await fetch_sections_from_overview(url)
139
+
140
+ # Parse dates and funding from the fetched content
141
+ from .snapshot import (
142
+ parse_dates_singleline,
143
+ extract_funding,
144
+ _find_duration_months,
145
+ pick_open_close_from_milestones,
146
+ derive_aux_dates,
147
+ clean_title
148
+ )
149
+
150
+ # Parse dates
151
+ dates_text = sections.get("dates_raw", "") or ""
152
+ milestones = parse_dates_singleline(dates_text)
153
+
154
+ # Derive open/close from milestones
155
+ open_date, close_date = pick_open_close_from_milestones(milestones)
156
+
157
+ # If either missing, try scanning all text
158
+ if not open_date or not close_date:
159
+ all_text = " ".join(v for v in sections.values() if isinstance(v, str) and v)
160
+ extra = parse_dates_singleline(all_text)
161
+ seen = {(m["label_raw"], m["date_iso"], m.get("date_iso_end")) for m in milestones}
162
+ for m2 in extra:
163
+ key = (m2["label_raw"], m2["date_iso"], m2.get("date_iso_end"))
164
+ if key not in seen:
165
+ milestones.append(m2)
166
+ seen.add(key)
167
+ od2, cd2 = pick_open_close_from_milestones(milestones)
168
+ open_date = open_date or od2
169
+ close_date = close_date or cd2
170
+
171
+ notify_date, project_start_from = derive_aux_dates(milestones)
172
+
173
+ # Parse funding
174
+ funding = extract_funding(sections)
175
+
176
+ # Parse duration
177
+ dur_min, dur_max = _find_duration_months(sections.get("eligibility_raw", "") or "")
178
+ if dur_min is None or dur_max is None:
179
+ all_text_for_duration = " ".join(v for v in sections.values() if isinstance(v, str) and v)
180
+ dur_min, dur_max = _find_duration_months(all_text_for_duration)
181
+ duration_months = {"min": dur_min, "max": dur_max}
182
+
183
+ # Extract title
184
+ raw_title = sections.get("summary_raw", "").split("\n")[0] if sections.get("summary_raw") else ""
185
+ title = clean_title(raw_title)
186
+
187
+ # Create snapshot
188
+ snapshot = {
189
+ "id": f"competition-{grant_id}",
190
+ "competition_id": grant_id,
191
+ "url": url,
192
+ "title": title,
193
+ "programme": "",
194
+ "round": "",
195
+ "open_date": open_date,
196
+ "close_date": close_date,
197
+ "notify_date": notify_date,
198
+ "project_start_from": project_start_from,
199
+ "funding": funding,
200
+ "duration_months": duration_months,
201
+ "sections": sections,
202
+ "pdfs": [],
203
+ "summaries": {},
204
+ "extracted": {"milestones": milestones},
205
+ "wonky": {"score": 0.0, "reasons": []},
206
+ "prev_round_refs": [],
207
+ "diff_summary": "",
208
+ "history_stats": {},
209
+ "created_at": datetime.now(UTC).isoformat(),
210
+ "updated_at": datetime.now(UTC).isoformat(),
211
+ }
212
+
213
+ # Save snapshot
214
+ output_path.write_text(
215
+ json.dumps(snapshot, indent=2),
216
+ encoding="utf-8"
217
+ )
218
+ logger.info(f"Saved grant {grant_id} snapshot")
219
+
220
+ return output_path.name
221
+
222
+ except Exception as e:
223
+ logger.error(f"Failed to fetch grant from {url}: {e}")
224
+ return None
225
+
226
+
227
+ async def discover_and_fetch_grants(
228
+ output_dir: Path,
229
+ skip_existing: bool = True
230
+ ) -> Tuple[int, int, List[str]]:
231
+ """
232
+ Discover all grants and fetch new ones.
233
+
234
+ Args:
235
+ output_dir: Directory to save snapshots
236
+ skip_existing: Skip grants that already exist
237
+
238
+ Returns:
239
+ Tuple of (total_discovered, newly_fetched, new_filenames)
240
+ """
241
+ output_dir.mkdir(parents=True, exist_ok=True)
242
+
243
+ # Step 1: Discover all grant URLs
244
+ urls = await discover_grant_urls()
245
+ if not urls:
246
+ logger.warning("No grants discovered")
247
+ return 0, 0, []
248
+
249
+ # Step 2: Get existing grant IDs if skipping
250
+ existing_ids: Set[str] = set()
251
+ if skip_existing:
252
+ for json_file in output_dir.glob("competition-*.json"):
253
+ match = re.search(r'competition-(\d+)', json_file.name)
254
+ if match:
255
+ existing_ids.add(match.group(1))
256
+
257
+ # Step 3: Fetch new grants concurrently
258
+ new_files = []
259
+ tasks = []
260
+
261
+ for url in urls:
262
+ match = re.search(r'/competition/(\d+)/', url)
263
+ if match and match.group(1) in existing_ids:
264
+ logger.debug(f"Grant {match.group(1)} already exists")
265
+ continue
266
+
267
+ tasks.append(fetch_grant_snapshot(url, output_dir))
268
+
269
+ if tasks:
270
+ logger.info(f"Fetching {len(tasks)} new grants concurrently...")
271
+ results = await asyncio.gather(*tasks, return_exceptions=False)
272
+ new_files = [f for f in results if f is not None]
273
+
274
+ logger.info(
275
+ f"Discovery complete: {len(urls)} total, "
276
+ f"{len(new_files)} newly fetched"
277
+ )
278
+
279
+ return len(urls), len(new_files), new_files
280
+
281
+
282
+ def main_sync(
283
+ output_dir: str = "data/snapshots",
284
+ skip_existing: bool = True
285
+ ) -> Tuple[int, int, List[str]]:
286
+ """
287
+ Synchronous wrapper for discovering and fetching grants.
288
+ """
289
+ output_path = Path(output_dir)
290
+ return asyncio.run(
291
+ discover_and_fetch_grants(output_path, skip_existing)
292
+ )
293
+
294
+
295
+ if __name__ == "__main__":
296
+ logging.basicConfig(
297
+ level=logging.INFO,
298
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
299
+ )
300
+
301
+ total, new, files = main_sync()
302
+ print(f"\n✓ Discovery complete:")
303
+ print(f" Total discovered: {total}")
304
+ print(f" Newly fetched: {new}")
305
+ if files:
306
+ print(f" Files: {', '.join(files)}")
analyzer/crawler/scheduler.py ADDED
@@ -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)
analyzer/crawler/snapshot.py ADDED
@@ -0,0 +1,626 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ import pathlib
4
+ import asyncio
5
+ from urllib.parse import urlparse
6
+ from datetime import datetime, UTC
7
+ from typing import List, Optional, Tuple, Dict
8
+ from playwright.async_api import async_playwright
9
+ from bs4 import BeautifulSoup, Tag, NavigableString
10
+ import typer
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # HELPERS
14
+ # ---------------------------------------------------------------------------
15
+
16
+ EXPECTED_TITLES = [
17
+ "Summary",
18
+ "Eligibility",
19
+ "Scope",
20
+ "Dates",
21
+ "How to apply",
22
+ "Supporting information",
23
+ ]
24
+
25
+ # Accept close-enough labels and alias them to canonical 6
26
+ SECTION_ALIASES = {
27
+ "who can apply": "Eligibility",
28
+ "who’s eligible": "Eligibility",
29
+ "who is eligible": "Eligibility",
30
+ "applicant eligibility": "Eligibility",
31
+ "what we ask you": "How to apply",
32
+ "apply": "How to apply",
33
+ "application process": "How to apply",
34
+ "supporting info": "Supporting information",
35
+ "key dates": "Dates",
36
+ "timeline": "Dates",
37
+ "competition dates": "Dates",
38
+ "overview": "Summary",
39
+ "summary": "Summary",
40
+ "scope": "Scope",
41
+ }
42
+
43
+ def canonical_label(label: str) -> Optional[str]:
44
+ l = label.strip().lower()
45
+ for t in EXPECTED_TITLES:
46
+ if l == t.lower():
47
+ return t
48
+ return SECTION_ALIASES.get(l, None)
49
+
50
+ def norm_key(label: str) -> str:
51
+ return re.sub(r"[^\w\s-]", "", label).strip().lower().replace(" ", "_") + "_raw"
52
+
53
+ def parse_deeplink(url: str):
54
+ u = urlparse(url)
55
+ m = re.search(r"/competition/(\d+)/overview/([0-9a-f-]{8,})", u.path, re.I)
56
+ if not m:
57
+ raise ValueError("Expected: .../competition/{id}/overview/{uuid}")
58
+ return f"{u.scheme}://{u.netloc}", m.group(1), m.group(2)
59
+
60
+ def get_competition_nav_anchors(soup: BeautifulSoup) -> List[tuple[str, str]]:
61
+ anchors: List[tuple[str, str]] = []
62
+
63
+ headings = soup.find_all(["h2", "h3", "h4"], string=lambda s: isinstance(s, str) and "competition sections" in s.lower())
64
+ nav_root: Optional[Tag] = None
65
+ for h in headings:
66
+ for sib in h.next_siblings:
67
+ if isinstance(sib, Tag) and sib.name in ("nav", "ul", "ol", "div"):
68
+ nav_root = sib
69
+ break
70
+ if nav_root:
71
+ break
72
+
73
+ if not nav_root:
74
+ for candidate in soup.find_all("nav"):
75
+ if candidate.find("a", href=True):
76
+ nav_root = candidate
77
+ break
78
+
79
+ if nav_root:
80
+ seen = set()
81
+ for a in nav_root.find_all("a", href=True):
82
+ href = a.get("href", "")
83
+ if not href.startswith("#"):
84
+ continue
85
+ frag = href[1:].strip()
86
+ raw = (a.get_text(" ", strip=True) or "").strip()
87
+ if not frag or not raw:
88
+ continue
89
+ canon = canonical_label(raw) or raw
90
+ if canon in EXPECTED_TITLES and frag not in seen:
91
+ anchors.append((canon, frag))
92
+ seen.add(frag)
93
+
94
+ if not anchors:
95
+ anchors = [
96
+ ("Summary", "summary"),
97
+ ("Eligibility", "eligibility"),
98
+ ("Scope", "scope"),
99
+ ("Dates", "dates"),
100
+ ("How to apply", "how-to-apply"),
101
+ ("Supporting information", "supporting-information"),
102
+ ]
103
+ return anchors
104
+
105
+ # -----------------------------
106
+ # Footer / cookie / consent trimmer
107
+ # -----------------------------
108
+
109
+ _FOOTER_STOPS = [
110
+ "Need help with this service?",
111
+ "Support links",
112
+ "GOV.UK uses cookies",
113
+ "Create one update function for each consent parameter",
114
+ "© Crown copyright",
115
+ "All content is available under the Open Government Licence",
116
+ ]
117
+
118
+ def trim_footer(text: str) -> str:
119
+ if not text:
120
+ return text
121
+ for marker in _FOOTER_STOPS:
122
+ i = text.find(marker)
123
+ if i != -1:
124
+ return text[:i].rstrip()
125
+ return text
126
+
127
+ def _strip_boilerplate(soup: BeautifulSoup):
128
+ selectors = [
129
+ "#global-cookie-message", ".cookie-banner", "#ccc-notify", "#onetrust-banner-sdk",
130
+ "footer", ".govuk-footer",
131
+ ".govuk-prototype-kit-warning",
132
+ ]
133
+ for sel in selectors:
134
+ for el in soup.select(sel):
135
+ el.decompose()
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # TITLE
139
+ # ---------------------------------------------------------------------------
140
+
141
+ def clean_title(raw: str) -> str:
142
+ if not raw:
143
+ return raw
144
+ raw = raw.strip()
145
+ return re.sub(r"^\s*Funding competition\s+", "", raw, flags=re.I).strip()
146
+
147
+ def extract_between_ids(soup: BeautifulSoup, start_id: str, end_id: Optional[str]) -> str:
148
+ start = soup.find(id=start_id)
149
+ if not start:
150
+ return ""
151
+ out_chunks: List[str] = []
152
+ for el in start.next_elements:
153
+ if isinstance(el, Tag):
154
+ if end_id and el.get("id") == end_id:
155
+ break
156
+ if el.name in ("script", "style", "noscript"):
157
+ continue
158
+ if isinstance(el, NavigableString):
159
+ txt = el.strip()
160
+ if txt:
161
+ out_chunks.append(txt)
162
+ text = " ".join(out_chunks)
163
+ text = re.sub(r"\s+", " ", text).strip()
164
+ text = trim_footer(text)
165
+ return text
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # PARSING
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def slice_by_anchors(html: str) -> dict:
172
+ soup = BeautifulSoup(html, "html.parser")
173
+ _strip_boilerplate(soup)
174
+ anchors = get_competition_nav_anchors(soup)
175
+ ids_in_order = [aid for _, aid in anchors]
176
+ id_to_next = {ids_in_order[i]: (ids_in_order[i + 1] if i + 1 < len(ids_in_order) else None)
177
+ for i in range(len(ids_in_order))}
178
+ out = {}
179
+ for label, start_id in anchors:
180
+ next_id = id_to_next.get(start_id)
181
+ key = norm_key(label)
182
+ out[key] = extract_between_ids(soup, start_id, next_id)
183
+ return out
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # MAIN SCRAPER
187
+ # ---------------------------------------------------------------------------
188
+
189
+ async def fetch_sections_from_overview(url: str) -> tuple[str, dict]:
190
+ scheme_host, comp_id, uuid = parse_deeplink(url)
191
+ overview_url = f"{scheme_host}/competition/{comp_id}/overview/{uuid}"
192
+
193
+ async with async_playwright() as pw:
194
+ browser = await pw.chromium.launch(headless=True, args=["--disable-dev-shm-usage"])
195
+ context = await browser.new_context(
196
+ user_agent=("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
197
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
198
+ locale="en-GB",
199
+ timezone_id="Europe/London",
200
+ )
201
+ page = await context.new_page()
202
+ page.set_default_timeout(30000)
203
+
204
+ for attempt in range(2):
205
+ try:
206
+ await page.goto(overview_url, wait_until="domcontentloaded")
207
+ await page.wait_for_load_state("networkidle")
208
+ break
209
+ except Exception:
210
+ if attempt == 1:
211
+ raise
212
+ html = await page.content()
213
+ await context.close()
214
+ await browser.close()
215
+
216
+ return html, slice_by_anchors(html)
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # DATE PARSING — SINGLE-LINE CHUNKS
220
+ # ---------------------------------------------------------------------------
221
+
222
+ # tokens
223
+ _DATE_WORD = r"(?:\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}|[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4})"
224
+ _TIME_WORD = r"(?:\d{1,2}:\d{2}\s*[ap]m|\d{1,2}\s*[ap]m)"
225
+
226
+ _DATE_ONLY_RX = re.compile(_DATE_WORD, re.I)
227
+ _TIME_RX = re.compile(_TIME_WORD, re.I)
228
+
229
+ # e.g. "9 to 20 March 2026", "9–20 March 2026", "9 - 20 March 2026"
230
+ _DATE_RANGE_RX = re.compile(r"(\d{1,2})\s*(?:to|-|–)\s*(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})", re.I)
231
+
232
+ _MONTHS = {m.lower(): i for i, m in enumerate(
233
+ ["January","February","March","April","May","June","July","August","September","October","November","December"], 1
234
+ )}
235
+
236
+ _EXCLUDE_SENTENCE_CUES = ["briefing event", "briefing", "webinar", "register to attend", "register", "info session"]
237
+
238
+ _LABEL_RULES = [
239
+ ("opens", ["competition opens", "opens"]),
240
+ ("closes", ["competition closes", "closes", "deadline"]),
241
+ ("notify", ["applicants notified", "applicants will be notified", "notification"]),
242
+ ("project_start", ["project start from", "project starts from", "project start date", "project start"]),
243
+ ("assessment", ["interview", "assessment", "panel"]),
244
+ ("results", ["results published", "winners announced"]),
245
+ ("eligibility_cutoff", ["eligibility closes", "registration closes"]),
246
+ ("info_session", ["briefing", "webinar", "register"]),
247
+ ]
248
+
249
+ def _classify_label(sent_lower: str) -> str:
250
+ for norm, cues in _LABEL_RULES:
251
+ if any(c in sent_lower for c in cues):
252
+ return norm
253
+ return "other"
254
+
255
+ def _parse_single_date(token: str, time_hint: Optional[str]) -> Optional[str]:
256
+ token = token.strip()
257
+ m_comma = re.match(r"([A-Za-z]{3,9})\s+(\d{1,2}),?\s+(\d{4})", token)
258
+ if m_comma:
259
+ month_name, day, year = m_comma.groups()
260
+ else:
261
+ m = re.match(r"(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})", token)
262
+ if not m:
263
+ return None
264
+ day, month_name, year = m.groups()
265
+ month = _MONTHS.get(month_name.lower())
266
+ if not month:
267
+ return None
268
+ if time_hint:
269
+ t = time_hint.lower().replace(" ", "")
270
+ mm = re.match(r"(\d{1,2})(?::(\d{2}))?([ap]m)", t)
271
+ if mm:
272
+ hh = int(mm.group(1))
273
+ mins = int(mm.group(2) or 0)
274
+ ampm = mm.group(3)
275
+ if ampm == "pm" and hh != 12: hh += 12
276
+ if ampm == "am" and hh == 12: hh = 0
277
+ return f"{int(year):04d}-{month:02d}-{int(day):02d}T{hh:02d}:{mins:02d}:00"
278
+ return f"{int(year):04d}-{month:02d}-{int(day):02d}"
279
+
280
+ def parse_dates_singleline(text: str) -> List[dict]:
281
+ """
282
+ Split the Dates section into *one milestone per line/chunk*:
283
+ chunk := from each DATE token up to the next DATE token (or end).
284
+ Keeps the entire chunk in label_raw.
285
+ """
286
+ milestones: List[dict] = []
287
+ if not text:
288
+ return milestones
289
+
290
+ # find all date token positions
291
+ matches = list(_DATE_ONLY_RX.finditer(text))
292
+ if not matches:
293
+ return milestones
294
+
295
+ spans = []
296
+ for i, m in enumerate(matches):
297
+ start = m.start()
298
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
299
+ spans.append((start, end))
300
+
301
+ for (start, end) in spans:
302
+ chunk = text[start:end].strip()
303
+ if not chunk:
304
+ continue
305
+ low = chunk.lower()
306
+ excluded = any(k in low for k in _EXCLUDE_SENTENCE_CUES)
307
+
308
+ # primary date + optional time in this chunk
309
+ first_date = _DATE_ONLY_RX.search(chunk)
310
+ time_hint_match = _TIME_RX.search(chunk)
311
+ iso = _parse_single_date(first_date.group(0), time_hint_match.group(0) if time_hint_match else None) if first_date else None
312
+
313
+ # optional same-month day range inside the chunk
314
+ r = _DATE_RANGE_RX.search(chunk)
315
+ date_end_iso = None
316
+ if r:
317
+ d1, d2, mon_name, year = r.groups()
318
+ month = _MONTHS.get(mon_name.lower())
319
+ if month:
320
+ date_end_iso = f"{int(year):04d}-{month:02d}-{int(d2):02d}"
321
+ # if the start of the range equals first_date, keep iso as start;
322
+ # otherwise we still keep iso from first_date (which begins the chunk)
323
+
324
+ if iso:
325
+ milestones.append({
326
+ "label_raw": chunk,
327
+ "label_norm": _classify_label(low),
328
+ "date_iso": iso,
329
+ "date_iso_end": date_end_iso,
330
+ "has_time": bool(time_hint_match),
331
+ "excluded_from_open_close": excluded,
332
+ })
333
+
334
+ return milestones
335
+
336
+ def pick_open_close_from_milestones(milestones: List[dict]) -> Tuple[Optional[str], Optional[str]]:
337
+ open_iso = close_iso = None
338
+ for m in milestones:
339
+ if m["excluded_from_open_close"]:
340
+ continue
341
+ if m["label_norm"] == "opens" and open_iso is None:
342
+ open_iso = m["date_iso"].split("T")[0]
343
+ if m["label_norm"] == "closes" and close_iso is None:
344
+ close_iso = m["date_iso"]
345
+ if open_iso is None:
346
+ for m in milestones:
347
+ if m["label_norm"] == "opens":
348
+ open_iso = m["date_iso"].split("T")[0]
349
+ break
350
+ if close_iso is None:
351
+ for m in milestones:
352
+ if m["label_norm"] == "closes":
353
+ close_iso = m["date_iso"]
354
+ break
355
+ return open_iso, close_iso
356
+
357
+ def derive_aux_dates(milestones: List[dict]) -> Tuple[Optional[str], Optional[str]]:
358
+ notify = project_start_from = None
359
+ for m in milestones:
360
+ if notify is None and m["label_norm"] == "notify":
361
+ notify = m["date_iso"].split("T")[0]
362
+ if project_start_from is None and m["label_norm"] == "project_start":
363
+ project_start_from = m["date_iso"].split("T")[0]
364
+ if notify and project_start_from:
365
+ break
366
+ return notify, project_start_from
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # FUNDING / COMPENSATION PARSING
370
+ # ---------------------------------------------------------------------------
371
+
372
+ _MONEY_TOKEN = re.compile(r"(£|\bGBP\s*)([\d,]+(?:\.\d+)?)(?:\s*(million|m|billion|bn|k))?", re.I)
373
+
374
+ def _money_to_int(sign: str, num_str: str, mag: Optional[str]) -> int:
375
+ val = float(num_str.replace(",", ""))
376
+ if mag:
377
+ m = mag.lower()
378
+ if m in ("million", "m"):
379
+ val *= 1_000_000
380
+ elif m in ("billion", "bn"):
381
+ val *= 1_000_000_000
382
+ elif m in ("k",):
383
+ val *= 1_000
384
+ return int(round(val))
385
+
386
+ _TOTAL_CUES = [
387
+ "total prize fund", "total prize pot", "total funding available", "available in total",
388
+ "total pot", "prize fund", "funding pot", "overall budget", "total budget",
389
+ "total allocation", "in total across", "total amount available",
390
+ ]
391
+ _AWARD_CUES = [
392
+ "per project", "each project", "you can apply for", "can apply for", "apply for up to",
393
+ "grant of up to", "awards of up to", "awards between", "awards of between",
394
+ "fund between", "we will fund", "we can fund", "project costs between",
395
+ "total eligible project costs between", "your project must have total costs between",
396
+ "maximum grant", "minimum grant", "maximum funding", "minimum funding",
397
+ "up to", "no more than", "at least",
398
+ "grant funding request", "eligible grant funding", "eligible grant", "funding request must be between",
399
+ ]
400
+ _EXCLUDE_CUES = [
401
+ "market", "industry", "global", "worldwide", "valuation", "addressable", "gdp",
402
+ "economy", "sector value", "turnover", "revenue", "jobs", "headcount",
403
+ ]
404
+
405
+ _RANGE_PATTERNS = [
406
+ re.compile(rf"(?:between|from)\s+{_MONEY_TOKEN.pattern}\s+(?:and|to)\s+{_MONEY_TOKEN.pattern}", re.I),
407
+ re.compile(rf"{_MONEY_TOKEN.pattern}\s*(?:to|-)\s*{_MONEY_TOKEN.pattern}", re.I),
408
+ ]
409
+ _MAX_PATTERNS = [
410
+ re.compile(rf"(?:up to|no more than|max(?:imum)?(?:\s+grant|\s+funding)?)\s+{_MONEY_TOKEN.pattern}", re.I),
411
+ ]
412
+ _MIN_PATTERNS = [
413
+ re.compile(rf"(?:at least|minimum(?:\s+grant|\s+funding)?)\s+{_MONEY_TOKEN.pattern}", re.I),
414
+ ]
415
+
416
+ def _contains_any(text: str, cues: List[str]) -> bool:
417
+ low = text.lower()
418
+ return any(c in low for c in cues)
419
+
420
+ def _is_excluded_sentence(sent: str) -> bool:
421
+ return _contains_any(sent, _EXCLUDE_CUES)
422
+
423
+ def _split_sentences_generic(text: str) -> List[str]:
424
+ parts = re.split(r"(?:\n+|(?<=[\.\!\?])\s+)", text)
425
+ return [p.strip() for p in parts if p and p.strip()]
426
+
427
+ def _find_total_pot(text: str) -> Optional[int]:
428
+ if not text:
429
+ return None
430
+ best = None
431
+ for sent in _split_sentences_generic(text):
432
+ if _is_excluded_sentence(sent):
433
+ continue
434
+ if _contains_any(sent, _TOTAL_CUES):
435
+ vals = []
436
+ for m in _MONEY_TOKEN.finditer(sent):
437
+ _, num_str, mag = m.groups()
438
+ vals.append(_money_to_int("£", num_str, mag))
439
+ if vals:
440
+ v = max(vals)
441
+ best = v if best is None or v > best else best
442
+ return best
443
+
444
+ def _find_award_range(text: str) -> Tuple[Optional[int], Optional[int]]:
445
+ if not text:
446
+ return None, None
447
+
448
+ # Strong: explicit ranges in a sentence that has award cues
449
+ for sent in _split_sentences_generic(text):
450
+ if _is_excluded_sentence(sent):
451
+ continue
452
+ if not _contains_any(sent, _AWARD_CUES):
453
+ continue
454
+ for rx in _RANGE_PATTERNS:
455
+ m = rx.search(sent)
456
+ if not m:
457
+ continue
458
+ monies = list(_MONEY_TOKEN.finditer(m.group(0)))
459
+ if len(monies) >= 2:
460
+ v1 = _money_to_int(*("£", monies[-2].group(2), monies[-2].group(3)))
461
+ v2 = _money_to_int(*("£", monies[-1].group(2), monies[-1].group(3)))
462
+ lo, hi = sorted([v1, v2])
463
+ return lo, hi
464
+
465
+ # Next: max-only / min-only with cues
466
+ chosen_min = None
467
+ chosen_max = None
468
+ for sent in _split_sentences_generic(text):
469
+ if _is_excluded_sentence(sent):
470
+ continue
471
+ if not _contains_any(sent, _AWARD_CUES):
472
+ continue
473
+
474
+ if chosen_max is None:
475
+ for rx in _MAX_PATTERNS:
476
+ m = rx.search(sent)
477
+ if m:
478
+ money = _MONEY_TOKEN.search(m.group(0))
479
+ if money:
480
+ chosen_max = _money_to_int(*("£", money.group(2), money.group(3)))
481
+ break
482
+
483
+ if chosen_min is None:
484
+ for rx in _MIN_PATTERNS:
485
+ m = rx.search(sent)
486
+ if m:
487
+ money = _MONEY_TOKEN.search(m.group(0))
488
+ if money:
489
+ chosen_min = _money_to_int(*("£", money.group(2), money.group(3)))
490
+ break
491
+
492
+ if chosen_min is not None and chosen_max is not None:
493
+ break
494
+
495
+ return chosen_min, chosen_max
496
+
497
+ # NEW: funding rates & duration
498
+ _RATE_LINE = re.compile(
499
+ r"up to\s*(\d{1,3})%\s*if you are a\s*(?:micro|small).*?up to\s*(\d{1,3})%\s*if you are a\s*medium.*?up to\s*(\d{1,3})%\s*if you are a\s*large",
500
+ re.I | re.S,
501
+ )
502
+ def _find_funding_rates(text: str) -> Optional[dict]:
503
+ if not text:
504
+ return None
505
+ m = _RATE_LINE.search(text)
506
+ if not m:
507
+ return None
508
+ small, medium, large = map(int, m.groups())
509
+ return {"micro_small": small, "medium": medium, "large": large}
510
+
511
+ _DURATION_RX = re.compile(r"last\s+between\s+(\d{1,3})\s*(?:and|to|–|-)\s*(\d{1,3})\s+months", re.I)
512
+ def _find_duration_months(text: str) -> Tuple[Optional[int], Optional[int]]:
513
+ if not text:
514
+ return None, None
515
+ m = _DURATION_RX.search(text)
516
+ if not m:
517
+ return None, None
518
+ lo, hi = map(int, m.groups())
519
+ return (lo if lo <= hi else hi), (hi if hi >= lo else lo)
520
+
521
+ def extract_funding(sections: dict) -> dict:
522
+ summary = sections.get("summary_raw", "") or ""
523
+ support = sections.get("supporting_information_raw", "") or ""
524
+ scope = sections.get("scope_raw", "") or ""
525
+ eligibility = sections.get("eligibility_raw", "") or ""
526
+ all_text = " ".join(v for v in sections.values() if isinstance(v, str) and v)
527
+
528
+ total_pot = _find_total_pot(summary) or _find_total_pot(support) or _find_total_pot(all_text)
529
+
530
+ min_award = max_award = None
531
+ for candidate in (summary, eligibility, support, scope, all_text):
532
+ lo, hi = _find_award_range(candidate)
533
+ if lo is not None or hi is not None:
534
+ if lo is not None: min_award = lo
535
+ if hi is not None: max_award = hi
536
+ break
537
+
538
+ rates = None
539
+ for candidate in (eligibility, support, all_text):
540
+ rates = _find_funding_rates(candidate or "")
541
+ if rates:
542
+ break
543
+
544
+ return {"min": min_award, "max": max_award, "total_pot": total_pot, "rates": rates}
545
+
546
+ # ---------------------------------------------------------------------------
547
+ # MAIN
548
+ # ---------------------------------------------------------------------------
549
+
550
+ async def _main_async(url: str):
551
+ m = re.search(r"/competition/(\d+)", url)
552
+ slug = f"competition-{m.group(1)}" if m else re.sub(r"[^a-z0-9]+", "-", url.lower()).strip("-")[-60:]
553
+ out_path = pathlib.Path("data/snapshots") / f"{slug}.json"
554
+ out_path.parent.mkdir(parents=True, exist_ok=True)
555
+
556
+ html, sections = await fetch_sections_from_overview(url)
557
+ soup = BeautifulSoup(html, "html.parser")
558
+ h1 = soup.find("h1")
559
+ raw_title = h1.get_text(" ", strip=True) if h1 else ""
560
+ title = clean_title(raw_title)
561
+
562
+ # Dates -> single-line chunks
563
+ dates_text = sections.get("dates_raw", "") or ""
564
+ milestones = parse_dates_singleline(dates_text)
565
+
566
+ # Derive open/close from milestones (with exclusions for briefing lines)
567
+ open_date, close_date = pick_open_close_from_milestones(milestones)
568
+
569
+ # If either missing, try scanning all text but still as single-line chunks
570
+ if not open_date or not close_date:
571
+ all_text = " ".join(v for v in sections.values() if isinstance(v, str) and v)
572
+ extra = parse_dates_singleline(all_text)
573
+ # merge de-duped by (label_raw, date_iso, date_iso_end)
574
+ seen = {(m["label_raw"], m["date_iso"], m.get("date_iso_end")) for m in milestones}
575
+ for m2 in extra:
576
+ key = (m2["label_raw"], m2["date_iso"], m2.get("date_iso_end"))
577
+ if key not in seen:
578
+ milestones.append(m2)
579
+ seen.add(key)
580
+ od2, cd2 = pick_open_close_from_milestones(milestones)
581
+ open_date = open_date or od2
582
+ close_date = close_date or cd2
583
+
584
+ notify_date, project_start_from = derive_aux_dates(milestones)
585
+
586
+ # Funding
587
+ funding = extract_funding(sections)
588
+
589
+ # Duration months
590
+ dur_min, dur_max = _find_duration_months(sections.get("eligibility_raw", "") or "")
591
+ if dur_min is None or dur_max is None:
592
+ all_text_for_duration = " ".join(v for v in sections.values() if isinstance(v, str) and v)
593
+ dur_min, dur_max = _find_duration_months(all_text_for_duration)
594
+ duration_months = {"min": dur_min, "max": dur_max}
595
+
596
+ snapshot = {
597
+ "url": url,
598
+ "title": title,
599
+ "programme": "",
600
+ "round": "",
601
+ "open_date": open_date,
602
+ "close_date": close_date,
603
+ "notify_date": notify_date,
604
+ "project_start_from": project_start_from,
605
+ "funding": funding,
606
+ "duration_months": duration_months,
607
+ "sections": sections,
608
+ "pdfs": [],
609
+ "summaries": {},
610
+ "extracted": {"milestones": milestones},
611
+ "wonky": {"score": 0.0, "reasons": []},
612
+ "prev_round_refs": [],
613
+ "diff_summary": "",
614
+ "history_stats": {},
615
+ "created_at": datetime.now(UTC).isoformat(),
616
+ "updated_at": datetime.now(UTC).isoformat(),
617
+ }
618
+
619
+ out_path.write_text(json.dumps(snapshot, indent=2), encoding="utf-8")
620
+ print(f"Snapshot saved to {out_path}")
621
+
622
+ def main(url: str = typer.Argument(..., help="IFS overview URL e.g. .../competition/{id}/overview/{uuid}")):
623
+ asyncio.run(_main_async(url))
624
+
625
+ if __name__ == "__main__":
626
+ typer.run(main)
analyzer/data_loader.py ADDED
@@ -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])
analyzer/data_loader_supporting.py ADDED
@@ -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))
analyzer/exporters.py ADDED
@@ -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")
analyzer/llm_client.py ADDED
@@ -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]}..."
analyzer/logging_setup.py ADDED
@@ -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]
analyzer/net/fetcher.py ADDED
@@ -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()}
analyzer/prompt_templates.py ADDED
@@ -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))
analyzer/run_generate.py ADDED
@@ -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()
analyzer/search/__init__.py ADDED
File without changes
analyzer/search/build_index.py ADDED
@@ -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()
analyzer/search/hybrid_index.py ADDED
@@ -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}")
analyzer/search/past_winners_integration.py ADDED
@@ -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]
analyzer/search/query.py ADDED
@@ -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]
analyzer/streaming_summarizer.py ADDED
@@ -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())
analyzer/summarizer.py ADDED
@@ -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)
analyzer/summarizer_optimized.py ADDED
@@ -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!")
analyzer/telemetry/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from .logger import QALogger, QATurn
2
+ __all__ = ["QALogger", "QATurn"]
analyzer/telemetry/logger.py ADDED
@@ -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")
analyzer/utils/__init__.py ADDED
File without changes
analyzer/utils/citations.py ADDED
@@ -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
analyzer/utils/dates.py ADDED
@@ -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")
analyzer/utils/errors.py ADDED
@@ -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
+ ]
analyzer/utils/query_logger.py ADDED
@@ -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}")
analyzer/utils/text.py ADDED
@@ -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)
analyzer/utils/validation.py ADDED
@@ -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
requirements.txt CHANGED
@@ -1,54 +1,22 @@
1
- # Core dependencies
2
- pandas>=2.0
3
- numpy>=1.26,<2.0
4
- openpyxl>=3.1
5
- xlsxwriter>=3.1
6
-
7
- # Machine learning / retrieval
8
- scikit-learn>=1.4
9
- rapidfuzz>=3.9
10
-
11
- # LLM providers
12
- openai>=1.40
13
- anthropic>=0.71
14
-
15
- # Token utilities
16
- tiktoken>=0.7
17
-
18
- # Web scraping and async
19
- beautifulsoup4>=4.12
20
- lxml>=4.9
21
- httpx>=0.27
22
- requests>=2.31
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