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

fix: Restore exact Gradio UI from working commit c72a240

Browse files

Restored the exact demo_app.py, chat_tools.py, and tool_schemas.py
from c72a240 which was the last known working Hugging Face deployment.

Key differences from current version:
- 7 preset questions (includes past winners questions)
- Clean, simpler system prompt
- No emojis, no websocket code
- Working tool integration

This is the exact state that was deployed and working on HF Spaces
before all the merge/push issues started.

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

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

analyzer/chat/chat_tools.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import asyncio
4
  import logging
5
  import re
 
6
  from dataclasses import dataclass
7
  from datetime import datetime
8
  from pathlib import Path
@@ -55,6 +56,51 @@ def _norm(s: Any) -> str:
55
  return clean(str(s)) # Use utils.text.clean() for final normalization
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  # ---------------------------------------------------------------------
59
  # Main ChatTools class
60
  # ---------------------------------------------------------------------
@@ -86,17 +132,6 @@ class ChatTools:
86
  # Initialize cache for summaries (NEW: Optimized caching)
87
  self.summary_cache = SummaryCache(ttl_seconds=3600)
88
 
89
- # -----------------------------------------------------------------
90
- # Cache stats logging helper
91
- # -----------------------------------------------------------------
92
- def _log_cache_stats(self) -> None:
93
- """Log cache statistics for monitoring."""
94
- stats = self.summary_cache.stats()
95
- logging.info(
96
- f"📊 Cache stats: {stats['valid']}/{stats['cached']} valid entries "
97
- f"({stats['valid']/max(stats['cached'], 1)*100:.1f}% hit rate)"
98
- )
99
-
100
  # -----------------------------------------------------------------
101
  # Status calculation (NEW)
102
  # -----------------------------------------------------------------
@@ -154,7 +189,8 @@ class ChatTools:
154
  results = []
155
  for r in self.current:
156
  txt = _norm(r)
157
- 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"))
@@ -183,6 +219,76 @@ class ChatTools:
183
  return results
184
  return results[:limit]
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  # -----------------------------------------------------------------
187
  # Retrieve a single grant
188
  # -----------------------------------------------------------------
@@ -254,13 +360,8 @@ class ChatTools:
254
  # Yield each result as it's ready
255
  for result in results:
256
  yield result
257
-
258
- # Log cache stats after batch completion
259
- self._log_cache_stats()
260
  except Exception as e:
261
  logging.error(f"Batch summarization failed: {e}")
262
- # Log cache stats even on error
263
- self._log_cache_stats()
264
  raise
265
 
266
  async def get_all_grant_summaries(self, batch_size: int = 5):
@@ -292,20 +393,16 @@ class ChatTools:
292
  async for result in self.summarize_grants_batch(all_grant_ids, batch_size=batch_size):
293
  yield result
294
 
295
- # Log final cache stats
296
- self._log_cache_stats()
297
-
298
  # -----------------------------------------------------------------
299
  # Summarize a grant
300
  # -----------------------------------------------------------------
301
- def summarize_grant(self, gid: str, include_supporting: bool = True, 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
@@ -314,27 +411,10 @@ class ChatTools:
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,
@@ -372,9 +452,6 @@ class ChatTools:
372
  # NEW: Cache the summary
373
  self.summary_cache.set(row, text)
374
 
375
- # Log cache stats
376
- self._log_cache_stats()
377
-
378
  return {"summary_md": text, "title": title, "id": grant_id}
379
 
380
  # -----------------------------------------------------------------
@@ -405,150 +482,6 @@ class ChatTools:
405
  parts.append(f"{k.upper()}:\n{_norm(v)}")
406
  return "\n".join(parts)
407
 
408
- # -----------------------------------------------------------------
409
- # Batch process multiple grants
410
- # -----------------------------------------------------------------
411
- def batch_process_grants(
412
- self,
413
- grant_ids: List[str],
414
- operation_type: str = "summarize",
415
- batch_size: int = 5
416
- ) -> Dict[str, str]:
417
- """
418
- Batch process multiple grants with a single LLM call per batch.
419
-
420
- This method groups grants into batches and sends them as a single prompt
421
- with numbered sections, then parses the response to extract individual results.
422
-
423
- Args:
424
- grant_ids: List of grant IDs to process
425
- operation_type: Type of operation - "summarize", "translate", "simplify", etc.
426
- batch_size: Number of grants per batch (default 5)
427
-
428
- Returns:
429
- Dictionary mapping grant_id to result text
430
-
431
- Example:
432
- results = tools.batch_process_grants(
433
- ["competition-2315", "competition-2316"],
434
- operation_type="summarize"
435
- )
436
- # Returns: {"competition-2315": "summary text...", "competition-2316": "..."}
437
- """
438
- import hashlib
439
-
440
- if not grant_ids:
441
- return {}
442
-
443
- if not self.client or not self.client.is_ready():
444
- logging.warning("LLM client not available for batch processing")
445
- return {gid: "LLM unavailable" for gid in grant_ids}
446
-
447
- # Build operation-specific instruction
448
- operation_instructions = {
449
- "summarize": "Provide a concise summary highlighting key information, deadlines, and funding details.",
450
- "translate": "Translate the grant information into simple, everyday language that anyone can understand.",
451
- "simplify": "Explain this grant in layman's terms, avoiding technical jargon.",
452
- "analyze": "Analyze this grant's strengths, requirements, and suitability for different applicants.",
453
- }
454
- instruction = operation_instructions.get(operation_type, "Process this grant information.")
455
-
456
- results = {}
457
-
458
- # Process grants in batches
459
- for batch_start in range(0, len(grant_ids), batch_size):
460
- batch_ids = grant_ids[batch_start:batch_start + batch_size]
461
-
462
- # Build batch prompt with numbered sections
463
- prompt_parts = [
464
- f"Process the following {len(batch_ids)} grants. {instruction}",
465
- "\nFor each grant, start your response with '### Grant N:' where N is the grant number.",
466
- "\n---\n"
467
- ]
468
-
469
- # Add each grant with its context
470
- grant_contexts = []
471
- for idx, grant_id in enumerate(batch_ids, 1):
472
- try:
473
- grant = self.get_grant(grant_id)
474
- # Use extract_minimal_context for efficiency
475
- from ..summarizer_optimized import extract_minimal_context
476
- context = extract_minimal_context(grant)
477
-
478
- prompt_parts.append(f"### Grant {idx}:")
479
- prompt_parts.append(f"ID: {grant_id}")
480
- prompt_parts.append(context)
481
- prompt_parts.append("\n---\n")
482
-
483
- grant_contexts.append((idx, grant_id))
484
-
485
- except Exception as e:
486
- logging.error(f"Failed to load grant {grant_id}: {e}")
487
- results[grant_id] = f"Error loading grant: {e}"
488
-
489
- if not grant_contexts:
490
- continue
491
-
492
- # Build final prompt
493
- full_prompt = "\n".join(prompt_parts)
494
-
495
- # Check cache for batch
496
- cache_key = hashlib.md5(full_prompt.encode()).hexdigest()[:12]
497
- cache_dict = {"id": f"batch_{operation_type}_{cache_key}"}
498
- cached_response = self.summary_cache.get(cache_dict)
499
-
500
- if cached_response:
501
- logging.info(f"📦 Cache HIT for batch {cache_key}")
502
- response_text = cached_response
503
- else:
504
- # Call LLM with batch prompt
505
- try:
506
- messages = [
507
- {"role": "system", "content": "You are a grant analyst. Process each grant separately and clearly mark each response with the grant number."},
508
- {"role": "user", "content": full_prompt}
509
- ]
510
- response_text = self.client.chat(
511
- messages,
512
- max_tokens=batch_size * 400, # ~400 tokens per grant
513
- temperature=0.3
514
- )
515
-
516
- # Cache the response
517
- self.summary_cache.set(cache_dict, response_text)
518
- logging.info(f"✅ Batch processed {len(batch_ids)} grants")
519
-
520
- except Exception as e:
521
- logging.error(f"Batch processing failed: {e}")
522
- for _, grant_id in grant_contexts:
523
- results[grant_id] = f"Batch processing error: {e}"
524
- continue
525
-
526
- # Parse response to extract individual results
527
- # Use regex to split by "### Grant N:" markers
528
- import re
529
- pattern = r'### Grant (\d+):(.*?)(?=### Grant \d+:|$)'
530
- matches = re.findall(pattern, response_text, re.DOTALL)
531
-
532
- # Map results back to grant IDs
533
- for grant_num, result_text in matches:
534
- grant_idx = int(grant_num)
535
- # Find corresponding grant_id
536
- for idx, grant_id in grant_contexts:
537
- if idx == grant_idx:
538
- results[grant_id] = result_text.strip()
539
- break
540
-
541
- # Handle any grants that didn't get matched
542
- for idx, grant_id in grant_contexts:
543
- if grant_id not in results:
544
- logging.warning(f"No result found for grant {grant_id} (index {idx})")
545
- results[grant_id] = "No response generated"
546
-
547
- # Log cache stats
548
- self._log_cache_stats()
549
-
550
- return results
551
-
552
  # -----------------------------------------------------------------
553
  # Compare two grants (deterministic)
554
  # -----------------------------------------------------------------
@@ -617,30 +550,18 @@ class ChatTools:
617
 
618
  insight = ""
619
  if self.client and self.client.is_ready() and context_text.strip():
620
- # 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",
@@ -650,9 +571,6 @@ class ChatTools:
650
  if insight:
651
  md += ["\n### Key differences", insight]
652
 
653
- # Log cache stats
654
- self._log_cache_stats()
655
-
656
  return {"comparison_md": "\n".join(md)}
657
 
658
  # -----------------------------------------------------------------
@@ -818,22 +736,8 @@ RECOMMENDED GRANTS:
818
  - Why: [1-2 sentence explanation of fit]
819
  """
820
 
821
- # 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 = []
@@ -861,9 +765,6 @@ RECOMMENDED GRANTS:
861
  except KeyError:
862
  continue
863
 
864
- # Log cache stats
865
- self._log_cache_stats()
866
-
867
  return {
868
  "company_url": company_url,
869
  "analysis": analysis_text,
@@ -872,8 +773,6 @@ RECOMMENDED GRANTS:
872
 
873
  except Exception as e:
874
  logging.error(f"Failed to analyze company: {e}")
875
- # Log cache stats even on error
876
- self._log_cache_stats()
877
  return {
878
  "error": f"Analysis failed: {str(e)}",
879
  "company_url": company_url,
 
3
  import asyncio
4
  import logging
5
  import re
6
+ import difflib
7
  from dataclasses import dataclass
8
  from datetime import datetime
9
  from pathlib import Path
 
56
  return clean(str(s)) # Use utils.text.clean() for final normalization
57
 
58
 
59
+ # Fuzzy matching helper - supports partial/typo matches
60
+ def _fuzzy_match(keyword: str, text: str, threshold: float = 0.6) -> bool:
61
+ """
62
+ Check if keyword matches text using fuzzy matching.
63
+
64
+ Returns True if:
65
+ - Exact substring match (e.g., "ai" in "agentic ai")
66
+ - Fuzzy word match with high similarity (e.g., "agent" vs "agentic" @ 86%)
67
+ - Any word in text starts with the keyword
68
+
69
+ Args:
70
+ keyword: The search keyword
71
+ text: The text to search in
72
+ threshold: Minimum similarity score (0-1) for fuzzy match
73
+
74
+ Returns:
75
+ True if match found, False otherwise
76
+ """
77
+ keyword_lower = keyword.lower()
78
+ text_lower = text.lower()
79
+
80
+ # Exact substring match (fastest, most common case)
81
+ if keyword_lower in text_lower:
82
+ return True
83
+
84
+ # Split into words and try fuzzy matching on individual words
85
+ text_words = text_lower.split()
86
+ keyword_words = keyword_lower.split()
87
+
88
+ for kw_word in keyword_words:
89
+ # Check if any text word starts with the keyword word
90
+ for text_word in text_words:
91
+ # Allow leading punctuation in text_word
92
+ clean_text_word = re.sub(r'^[^a-z0-9]+', '', text_word)
93
+ if clean_text_word.startswith(kw_word):
94
+ return True
95
+
96
+ # Fuzzy match single words (e.g., "agent" vs "agentic")
97
+ ratio = difflib.SequenceMatcher(None, kw_word, clean_text_word).ratio()
98
+ if ratio >= threshold:
99
+ return True
100
+
101
+ return False
102
+
103
+
104
  # ---------------------------------------------------------------------
105
  # Main ChatTools class
106
  # ---------------------------------------------------------------------
 
132
  # Initialize cache for summaries (NEW: Optimized caching)
133
  self.summary_cache = SummaryCache(ttl_seconds=3600)
134
 
 
 
 
 
 
 
 
 
 
 
 
135
  # -----------------------------------------------------------------
136
  # Status calculation (NEW)
137
  # -----------------------------------------------------------------
 
189
  results = []
190
  for r in self.current:
191
  txt = _norm(r)
192
+ # Use fuzzy matching instead of exact substring match
193
+ if kw and not _fuzzy_match(kw, txt):
194
  continue
195
  if max_award is not None:
196
  ma = to_number(r.get("max_award") or r.get("funding_max"))
 
219
  return results
220
  return results[:limit]
221
 
222
+ # -----------------------------------------------------------------
223
+ # Search past winners
224
+ # -----------------------------------------------------------------
225
+ def search_past_winners(
226
+ self,
227
+ keyword: Optional[str] = None,
228
+ competition: Optional[str] = None,
229
+ limit: Optional[int] = None,
230
+ ) -> List[Dict[str, Any]]:
231
+ """
232
+ Search past winners by keyword or competition name.
233
+
234
+ Args:
235
+ keyword: Filter by keyword in project title, organization, or description
236
+ competition: Filter by competition/programme name
237
+ limit: Max results to return. If None, returns ALL matching winners.
238
+
239
+ Returns:
240
+ List of past winner records sorted by year (newest first)
241
+ """
242
+ kw = (keyword or "").lower()
243
+ comp = (competition or "").lower()
244
+ results = []
245
+
246
+ for r in self.past:
247
+ # Filter by keyword (searches project title, org name, and description)
248
+ if kw:
249
+ searchable_text = _norm({
250
+ "project_title": r.get("project_title"),
251
+ "lead_org": r.get("lead_org"),
252
+ "participant_name": r.get("participant_name"),
253
+ "public_description": r.get("public_description"),
254
+ "abstract": r.get("abstract"),
255
+ })
256
+ # Use fuzzy matching instead of exact substring match
257
+ if not _fuzzy_match(kw, searchable_text):
258
+ continue
259
+
260
+ # Filter by competition name
261
+ if comp:
262
+ comp_text = _norm({
263
+ "competition": r.get("competition"),
264
+ "competition_title": r.get("competition_title"),
265
+ "programme_title": r.get("programme_title"),
266
+ })
267
+ # Use fuzzy matching instead of exact substring match
268
+ if not _fuzzy_match(comp, comp_text):
269
+ continue
270
+
271
+ # Extract key fields for display
272
+ results.append({
273
+ "project_title": r.get("project_title") or "(untitled)",
274
+ "lead_org": r.get("lead_org") or r.get("participant_name") or "(unknown)",
275
+ "competition": r.get("competition_title") or r.get("competition") or "(unknown)",
276
+ "award_amount": r.get("award_amount"),
277
+ "year": r.get("year") or r.get("project_start_date"),
278
+ "abstract": r.get("public_description") or r.get("abstract"),
279
+ })
280
+
281
+ # Sort by year (newest first)
282
+ results.sort(
283
+ key=lambda x: _parse_date(x.get("year")) or datetime.min,
284
+ reverse=True
285
+ )
286
+
287
+ # Return all results if limit is None
288
+ if limit is None:
289
+ return results
290
+ return results[:limit]
291
+
292
  # -----------------------------------------------------------------
293
  # Retrieve a single grant
294
  # -----------------------------------------------------------------
 
360
  # Yield each result as it's ready
361
  for result in results:
362
  yield result
 
 
 
363
  except Exception as e:
364
  logging.error(f"Batch summarization failed: {e}")
 
 
365
  raise
366
 
367
  async def get_all_grant_summaries(self, batch_size: int = 5):
 
393
  async for result in self.summarize_grants_batch(all_grant_ids, batch_size=batch_size):
394
  yield result
395
 
 
 
 
396
  # -----------------------------------------------------------------
397
  # Summarize a grant
398
  # -----------------------------------------------------------------
399
+ def summarize_grant(self, gid: str, include_supporting: bool = True) -> Dict[str, Any]:
400
  """
401
+ Summarize a grant using LLM with caching.
402
 
403
  Args:
404
  gid: Grant ID
405
  include_supporting: If True, include supporting PDFs and materials in context
 
406
 
407
  Returns:
408
  Dict with summary_md, title, id
 
411
  title = row.get("title", "(untitled)")
412
  grant_id = row.get("id") or gid
413
 
414
+ # NEW: Check cache first
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  cached_summary = self.summary_cache.get(row)
416
  if cached_summary:
417
+ logging.info("📦 Cache HIT for grant %s", grant_id)
418
  return {
419
  "summary_md": cached_summary,
420
  "title": title,
 
452
  # NEW: Cache the summary
453
  self.summary_cache.set(row, text)
454
 
 
 
 
455
  return {"summary_md": text, "title": title, "id": grant_id}
456
 
457
  # -----------------------------------------------------------------
 
482
  parts.append(f"{k.upper()}:\n{_norm(v)}")
483
  return "\n".join(parts)
484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  # -----------------------------------------------------------------
486
  # Compare two grants (deterministic)
487
  # -----------------------------------------------------------------
 
550
 
551
  insight = ""
552
  if self.client and self.client.is_ready() and context_text.strip():
553
+ try:
554
+ prompt = (
555
+ "Given the factual table and context below, write 3-5 bullet points "
556
+ "highlighting *meaningful differences* that matter to SMEs (funding size, "
557
+ "duration, eligibility, etc.). Do not restate identical facts.\n\n"
558
+ + "\n".join(table)
559
+ + "\n\n"
560
+ + context_text
561
+ )
562
+ insight = self.client.summarize(prompt)
563
+ except Exception as e:
564
+ logging.warning("compare_grants insight failed: %s", e)
 
 
 
 
 
 
 
 
 
 
 
 
565
 
566
  md = [
567
  "### Comparison",
 
571
  if insight:
572
  md += ["\n### Key differences", insight]
573
 
 
 
 
574
  return {"comparison_md": "\n".join(md)}
575
 
576
  # -----------------------------------------------------------------
 
736
  - Why: [1-2 sentence explanation of fit]
737
  """
738
 
739
+ # Get LLM analysis
740
+ analysis_text = self.client.summarize(prompt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
 
742
  # Extract recommended grant IDs from the response
743
  recommended_ids = []
 
765
  except KeyError:
766
  continue
767
 
 
 
 
768
  return {
769
  "company_url": company_url,
770
  "analysis": analysis_text,
 
773
 
774
  except Exception as e:
775
  logging.error(f"Failed to analyze company: {e}")
 
 
776
  return {
777
  "error": f"Analysis failed: {str(e)}",
778
  "company_url": company_url,
analyzer/chat/demo_app.py CHANGED
@@ -23,13 +23,6 @@ except ImportError:
23
  print("ERROR: Gradio not installed. Install with: pip install gradio")
24
  sys.exit(1)
25
 
26
- try:
27
- import httpx
28
- except ImportError:
29
- print("ERROR: httpx not installed. Install with: pip install httpx")
30
- httpx = None
31
-
32
-
33
  from ..config import load_config
34
  from ..data_loader import load_current_grants, load_past_winners
35
  from ..llm_client import LLMClient
@@ -47,7 +40,9 @@ PRESET_QUESTIONS = {
47
  "Show upcoming deadlines": "What are the upcoming grant deadlines?",
48
  "Compare two grants": "Compare competition-2313 and competition-2314",
49
  "Grant details": "Tell me about competition-2317 in detail",
50
- "SME funding options": "What grants are available for SMEs with funding over £100k?"
 
 
51
  }
52
 
53
 
@@ -92,7 +87,7 @@ class GrantAnalystDemo:
92
  # Initialize LLM
93
  self.llm_client = LLMClient(self.cfg)
94
  if not self.llm_client.is_ready():
95
- return False, "ERROR: LLM client not ready. Check API key configuration."
96
 
97
  # Load index
98
  try:
@@ -122,38 +117,50 @@ class GrantAnalystDemo:
122
  {
123
  "role": "system",
124
  "content": (
125
- "You are an expert UK grant analyst assistant. Your PRIMARY DIRECTIVE is to 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
  ]
@@ -189,9 +196,9 @@ class GrantAnalystDemo:
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 = []
@@ -221,7 +228,7 @@ class GrantAnalystDemo:
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):
@@ -238,7 +245,7 @@ class GrantAnalystDemo:
238
  # Get summaries of ALL grants in one batch
239
  batch_size = tool_args.get("batch_size", 5)
240
 
241
- logging.info(f"Starting to get summaries for ALL grants (batch_size={batch_size})")
242
 
243
  # Collect results from async generator
244
  results = []
@@ -263,7 +270,7 @@ class GrantAnalystDemo:
263
 
264
  # Format results for display
265
  if not results:
266
- return "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):
@@ -309,6 +316,37 @@ class GrantAnalystDemo:
309
  ])
310
  return formatted
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  elif tool_name == "fetch_link":
313
  # NEW: Handle external link fetching
314
  try:
@@ -340,168 +378,9 @@ class GrantAnalystDemo:
340
  logging.error(f"Tool {tool_name} failed: {e}", exc_info=True)
341
  return {"error": str(e)}
342
 
343
- def chat_stream(self, user_message: str, history: List, use_sse: bool = False, use_websocket: bool = False):
344
- """
345
- Process a chat message with optional SSE or WebSocket streaming.
346
-
347
- Args:
348
- user_message: User's input
349
- history: Gradio chat history
350
- use_sse: If True, use SSE streaming from API endpoint
351
- use_websocket: If True, use WebSocket streaming (takes precedence over SSE)
352
-
353
- Yields:
354
- Updated history for streaming response
355
- """
356
- import time
357
- import json
358
-
359
- if not self.initialized:
360
- yield history + [[user_message, "WARNING: System not initialized. Please restart the app."]]
361
- return
362
-
363
- # WebSocket streaming takes precedence
364
- if use_websocket and HAS_WEBSOCKET:
365
- try:
366
- accumulated_response = ""
367
- intent = None
368
- citations = []
369
-
370
- # Connect to WebSocket endpoint
371
- ws = websocket.create_connection("ws://localhost:8000/ws/query", timeout=60)
372
-
373
- # Send query
374
- message = {
375
- "query": user_message,
376
- "session_id": "gradio_session"
377
- }
378
- ws.send(json.dumps(message))
379
-
380
- # Receive and process stream
381
- while True:
382
- try:
383
- msg = ws.recv()
384
- data = json.loads(msg)
385
- msg_type = data.get("type")
386
-
387
- if msg_type == "metadata":
388
- # Initial metadata received
389
- logging.info(f"WebSocket session: {data.get('session_id')}")
390
-
391
- elif msg_type == "intent":
392
- intent = data.get("intent")
393
- # Show intent in response
394
- status_msg = f"*Detected intent: {intent}*\n\n"
395
- yield history + [[user_message, status_msg]]
396
-
397
- elif msg_type == "token":
398
- # Stream token to UI
399
- token = data.get("token", "")
400
- accumulated_response += token
401
- # Yield updated history with partial response
402
- status_prefix = f"*Intent: {intent}*\n\n" if intent else ""
403
- yield history + [[user_message, status_prefix + accumulated_response]]
404
-
405
- elif msg_type == "citations":
406
- citations = data.get("citations", [])
407
-
408
- elif msg_type == "done":
409
- latency_ms = data.get("latency_ms")
410
- logging.info(f"WebSocket stream completed in {latency_ms}ms")
411
- break
412
-
413
- elif msg_type == "error":
414
- error_msg = data.get("error", "Unknown error")
415
- yield history + [[user_message, f"ERROR: Error: {error_msg}"]]
416
- ws.close()
417
- return
418
-
419
- except websocket.WebSocketTimeoutException:
420
- logging.warning("WebSocket timeout")
421
- break
422
- except json.JSONDecodeError as e:
423
- logging.error(f"Failed to parse WebSocket message: {e}")
424
- continue
425
-
426
- ws.close()
427
-
428
- # Add citations to final response
429
- if citations:
430
- accumulated_response += "\n\n**Citations:**\n"
431
- for cite in citations:
432
- title = cite.get("title", "Unknown")
433
- grant_id = cite.get("grant_id", "N/A")
434
- accumulated_response += f"- **{title}** (ID: {grant_id})\n"
435
-
436
- # Add intent badge if available
437
- if intent:
438
- final_response = f"*Intent: {intent}*\n\n{accumulated_response}"
439
- else:
440
- final_response = accumulated_response
441
-
442
- yield history + [[user_message, final_response]]
443
-
444
- except Exception as e:
445
- logging.error(f"WebSocket streaming error: {e}")
446
- yield history + [[user_message, f"ERROR: WebSocket error: {e}"]]
447
- return
448
-
449
- if use_sse and httpx:
450
- # Use SSE streaming from API endpoint
451
- try:
452
- accumulated_response = ""
453
- citations = []
454
-
455
- with httpx.Client(timeout=60.0) as client:
456
- with client.stream(
457
- "POST",
458
- "http://localhost:8000/qa/stream", # Adjust URL as needed
459
- json={"query": user_message, "use_llm_routing": True}
460
- ) as response:
461
- for line in response.iter_lines():
462
- if line.startswith("data: "):
463
- data_str = line[6:] # Remove "data: " prefix
464
- try:
465
- data = json.loads(data_str)
466
- event_type = data.get("type")
467
-
468
- if event_type == "token":
469
- token = data.get("token", "")
470
- accumulated_response += token
471
- # Yield updated history with partial response
472
- yield history + [[user_message, accumulated_response]]
473
-
474
- elif event_type == "citations":
475
- citations = data.get("citations", [])
476
-
477
- elif event_type == "error":
478
- error = data.get("error", "Unknown error")
479
- yield history + [[user_message, f"ERROR: Error: {error}"]]
480
- return
481
-
482
- except json.JSONDecodeError:
483
- continue
484
-
485
- # Add citations to final response
486
- if citations:
487
- accumulated_response += "\n\n**Citations:**\n"
488
- for cite in citations:
489
- accumulated_response += f"- {cite.get('title')} (ID: {cite.get('grant_id')})\n"
490
-
491
- yield history + [[user_message, accumulated_response]]
492
-
493
- except Exception as e:
494
- logging.error(f"SSE streaming error: {e}")
495
- yield history + [[user_message, f"ERROR: Streaming error: {e}"]]
496
- return
497
-
498
- # Fallback to original non-streaming chat
499
- response, updated_history = self.chat(user_message, history)
500
- yield updated_history
501
-
502
  def chat(self, user_message: str, history: List) -> Tuple[str, List]:
503
  """
504
- Process a chat message (non-streaming).
505
 
506
  Args:
507
  user_message: User's input
@@ -513,7 +392,7 @@ class GrantAnalystDemo:
513
  import time
514
 
515
  if not self.initialized:
516
- return "WARNING: System not initialized. Please restart the app.", history
517
 
518
  start_time = time.time()
519
  tools_called = []
@@ -530,7 +409,7 @@ class GrantAnalystDemo:
530
  messages=self.messages,
531
  tools=self.available_tools,
532
  tool_choice="auto",
533
- temperature=0.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
@@ -557,7 +436,7 @@ class GrantAnalystDemo:
557
  tool_start = time.time()
558
  tool_result = self._dispatch_tool(function_name, function_args)
559
  tool_time = time.time() - tool_start
560
- logging.info(f"{function_name} took {tool_time:.2f}s")
561
  timing_info[f"tool_{function_name}"] = tool_time
562
 
563
  # Add tool result
@@ -573,7 +452,7 @@ class GrantAnalystDemo:
573
  final_response = self.llm_client.client.chat.completions.create(
574
  model=self.llm_client.model,
575
  messages=self.messages,
576
- temperature=0.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
@@ -588,7 +467,7 @@ class GrantAnalystDemo:
588
  # Log timing info
589
  response_time_ms = int((time.time() - start_time) * 1000)
590
  timing_str = " | ".join([f"{k}:{v:.2f}s" for k, v in timing_info.items()])
591
- logging.info(f"Total: {response_time_ms}ms | {timing_str}")
592
 
593
  # Direct logging to CSV (simpler, more reliable)
594
  try:
@@ -630,12 +509,12 @@ class GrantAnalystDemo:
630
  logging.info(f"Query logged to {csv_path}")
631
 
632
  except Exception as log_error:
633
- logging.error(f"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
@@ -681,7 +560,7 @@ class GrantAnalystDemo:
681
  logging.info(f"Failed query logged to {csv_path}")
682
 
683
  except Exception as log_error:
684
- logging.error(f"ERROR: Logging failed: {log_error}", exc_info=True)
685
 
686
  return error_msg, history + [[user_message, error_msg]]
687
 
@@ -693,8 +572,17 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
693
  title="Grant Analyst Demo",
694
  theme=gr.themes.Soft(),
695
  css="""
696
- .contain { max-width: 1200px; margin: auto; }
 
 
 
 
 
 
 
697
  #status-box { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
 
 
698
  """
699
  ) as app:
700
 
@@ -720,7 +608,7 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
720
  if success:
721
  return f"**System Ready** — {msg}"
722
  else:
723
- return f"ERROR: **Initialization Failed** — {msg}"
724
 
725
  # Chatbot interface
726
  with gr.Row():
@@ -739,8 +627,7 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
739
  )
740
  send_btn = gr.Button("Send", scale=1, variant="primary")
741
 
742
- with gr.Row():
743
- clear_btn = gr.Button("Clear Conversation")
744
 
745
  with gr.Column(scale=1):
746
  gr.Markdown("### Preset Questions")
@@ -778,27 +665,15 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
778
 
779
  # Event handlers
780
  def respond(message, chat_history):
781
- """Handle user message."""
782
- # Use non-streaming response
783
  bot_response, updated_history = demo.chat(message, chat_history)
784
- 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:
@@ -816,12 +691,16 @@ def create_demo_ui(demo: GrantAnalystDemo) -> gr.Blocks:
816
 
817
  def main():
818
  """Launch the demo application."""
 
819
 
820
  parser = argparse.ArgumentParser(description="Grant Analyst Demo App")
821
  parser.add_argument("--share", action="store_true", help="Create public shareable link")
822
- parser.add_argument("--port", type=int, default=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:
@@ -845,12 +724,12 @@ def main():
845
  app = create_demo_ui(demo)
846
 
847
  print("\n" + "="*60)
848
- print("Launching Grant Analyst Demo...")
849
  print("="*60)
850
 
851
  app.launch(
852
  server_name="0.0.0.0",
853
- server_port=args.port,
854
  share=args.share,
855
  show_error=True,
856
  )
 
23
  print("ERROR: Gradio not installed. Install with: pip install gradio")
24
  sys.exit(1)
25
 
 
 
 
 
 
 
 
26
  from ..config import load_config
27
  from ..data_loader import load_current_grants, load_past_winners
28
  from ..llm_client import LLMClient
 
40
  "Show upcoming deadlines": "What are the upcoming grant deadlines?",
41
  "Compare two grants": "Compare competition-2313 and competition-2314",
42
  "Grant details": "Tell me about competition-2317 in detail",
43
+ "SME funding options": "What grants are available for SMEs with funding over £100k?",
44
+ "Past winners of open grants": "Of the grants that are currently open, are there any past winners listed as reference?",
45
+ "Past AI winners": "Show me past winners related to AI projects"
46
  }
47
 
48
 
 
87
  # Initialize LLM
88
  self.llm_client = LLMClient(self.cfg)
89
  if not self.llm_client.is_ready():
90
+ return False, " LLM client not ready. Check API key configuration."
91
 
92
  # Load index
93
  try:
 
117
  {
118
  "role": "system",
119
  "content": (
120
+ "You are an expert UK grant analyst assistant. Your PRIMARY DIRECTIVE is to PROVIDE COMPLETE, THOROUGH RESPONSES.\n\n"
121
+ "CORE BEHAVIOR:\n"
122
+ "- When user asks about a grant, ALWAYS call tools to get the information\n"
123
+ "- When tools return data, ALWAYS present ALL the data to the user\n"
124
+ "- NEVER say 'I don't have information' without first calling tools\n"
125
+ "- NEVER give partial answers - if more information exists, include it\n"
126
+ "- If user challenges your answer, you likely gave incomplete information - call tools again\n\n"
127
+ "TOOL USAGE - WHEN USER ASKS FOR:\n"
128
+ "- 'tell me about [grant name]' → call search_grants with the grant name, then summarize_grant with the ID\n"
129
+ "- 'biomedical catalyst grant' → call search_grants with query='biomedical catalyst'\n"
130
+ "- 'description/summaries of all/every grant' → call get_all_grant_summaries\n"
131
+ "- 'list all grants' (NO descriptions) → call list_grants with limit=None\n"
132
+ "- 'find grants about [topic]' → call search_grants with query=[topic]\n"
133
+ "- 'who won', 'past winners' → call search_past_winners\n"
134
+ "- 'competition-XXXX details' → call get_grant then summarize_grant\n\n"
135
  "CRITICAL RULES:\n"
136
+ "- ALWAYS call tools first, respond second\n"
137
+ "- If tool returns data, present ALL of it - don't truncate or paraphrase\n"
138
+ "- If user asks 'tell me more' or 'is that all', you missed information - call tools again\n"
139
+ "- search_grants finds grants by fuzzy matching - use it liberally\n"
140
+ "- If search finds 0 results, try a simpler/shorter query\n"
141
+ "- NEVER assume a grant doesn't exist - always search first\n\n"
142
+ "AVAILABLE TOOLS:\n"
143
+ "- search_grants: Find grants by keyword/topic (fuzzy matching, searches titles + descriptions)\n"
144
+ "- list_grants: List all grants with optional filters (keyword, status, max_award)\n"
145
+ "- get_grant: Get full structured data for one grant ID\n"
146
+ "- summarize_grant: Get AI-generated summary of one grant\n"
147
+ "- summarize_grants_batch: Get summaries for multiple grants\n"
148
+ "- get_all_grant_summaries: Get summaries for ALL grants at once\n"
149
+ "- compare_grants: Side-by-side comparison of two grants\n"
150
+ "- search_past_winners: Search historical winners (10+ years of data)\n"
151
+ "- deadlines_overview: Show upcoming deadlines\n\n"
152
  "RESPONSE FORMAT:\n"
153
+ "- Use markdown: headers (##), numbered lists (1. 2. 3.), bold (**text**)\n"
154
+ "- Include ALL details from tool results: funding amounts, dates, eligibility, scope\n"
155
+ "- Be comprehensive and thorough - no length limits\n"
156
+ "- Present complete information on first response, not piecemeal\n\n"
157
+ "WRITING STYLE:\n"
158
+ "- Write in a tight, conversational voice — confident, warm, and clear\n"
159
+ "- Avoid fluff, filler, or over-formality\n"
160
+ "- Skip headings, bullet points, or 'as an AI' disclaimers\n"
161
+ "- Prefer short, active sentences\n"
162
+ "- Be bold, human, and efficient\n\n"
163
+ "Current date: 2025-11-05"
164
  )
165
  }
166
  ]
 
196
  batch_size = tool_args.get("batch_size", 5)
197
 
198
  if not grant_ids:
199
+ return " No grant IDs provided for batch summarization"
200
 
201
+ logging.info(f"📦 Starting batch summarization of {len(grant_ids)} grants")
202
 
203
  # Collect results from async generator
204
  results = []
 
228
 
229
  # Format results for display
230
  if not results:
231
+ return "No grants could be summarized"
232
 
233
  formatted = f"Batch summarization complete for {len(results)} grants:\n\n"
234
  for i, result in enumerate(results, 1):
 
245
  # Get summaries of ALL grants in one batch
246
  batch_size = tool_args.get("batch_size", 5)
247
 
248
+ logging.info(f"📦 Starting to get summaries for ALL grants (batch_size={batch_size})")
249
 
250
  # Collect results from async generator
251
  results = []
 
270
 
271
  # Format results for display
272
  if not results:
273
+ return "No grants could be summarized"
274
 
275
  formatted = f"Summaries for ALL {len(results)} grants:\n\n"
276
  for i, result in enumerate(results, 1):
 
316
  ])
317
  return formatted
318
 
319
+ elif tool_name == "search_past_winners":
320
+ results = self.tools.search_past_winners(
321
+ keyword=tool_args.get("keyword"),
322
+ competition=tool_args.get("competition"),
323
+ limit=tool_args.get("limit") # If None, returns ALL
324
+ )
325
+ # Format results for better conversation flow
326
+ if not results:
327
+ return "No past winners found matching those criteria."
328
+
329
+ if len(results) > 50:
330
+ # If too many, return summary + first 20
331
+ summary = f"Found {len(results)} past winners matching the criteria. Showing first 20:\n\n"
332
+ display = results[:20]
333
+ else:
334
+ summary = f"Found {len(results)} past winner(s):\n\n"
335
+ display = results
336
+
337
+ formatted_items = []
338
+ for i, r in enumerate(display, 1):
339
+ item = f"{i}. **{r['project_title']}**\n"
340
+ item += f" Organization: {r['lead_org']}\n"
341
+ item += f" Competition: {r['competition']}\n"
342
+ if r.get('award_amount'):
343
+ item += f" Award: £{r['award_amount']}\n"
344
+ if r.get('year'):
345
+ item += f" Year: {r['year']}\n"
346
+ formatted_items.append(item)
347
+
348
+ return summary + "\n".join(formatted_items)
349
+
350
  elif tool_name == "fetch_link":
351
  # NEW: Handle external link fetching
352
  try:
 
378
  logging.error(f"Tool {tool_name} failed: {e}", exc_info=True)
379
  return {"error": str(e)}
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  def chat(self, user_message: str, history: List) -> Tuple[str, List]:
382
  """
383
+ Process a chat message.
384
 
385
  Args:
386
  user_message: User's input
 
392
  import time
393
 
394
  if not self.initialized:
395
+ return "⚠️ System not initialized. Please restart the app.", history
396
 
397
  start_time = time.time()
398
  tools_called = []
 
409
  messages=self.messages,
410
  tools=self.available_tools,
411
  tool_choice="auto",
412
+ temperature=0.2, # Lower temperature for more consistent, thorough responses
413
  max_tokens=4096, # INCREASED to allow detailed summaries without truncation
414
  )
415
  timing_info["llm_call"] = time.time() - llm_start
 
436
  tool_start = time.time()
437
  tool_result = self._dispatch_tool(function_name, function_args)
438
  tool_time = time.time() - tool_start
439
+ logging.info(f"⏱️ {function_name} took {tool_time:.2f}s")
440
  timing_info[f"tool_{function_name}"] = tool_time
441
 
442
  # Add tool result
 
452
  final_response = self.llm_client.client.chat.completions.create(
453
  model=self.llm_client.model,
454
  messages=self.messages,
455
+ temperature=0.2, # Lower temperature for more complete, consistent responses
456
  max_tokens=4096, # INCREASED to allow complete answers without truncation
457
  )
458
  timing_info["final_llm_call"] = time.time() - final_start
 
467
  # Log timing info
468
  response_time_ms = int((time.time() - start_time) * 1000)
469
  timing_str = " | ".join([f"{k}:{v:.2f}s" for k, v in timing_info.items()])
470
+ logging.info(f"⏱️ Total: {response_time_ms}ms | {timing_str}")
471
 
472
  # Direct logging to CSV (simpler, more reliable)
473
  try:
 
509
  logging.info(f"Query logged to {csv_path}")
510
 
511
  except Exception as log_error:
512
+ logging.error(f"Logging failed: {log_error}", exc_info=True)
513
 
514
  return assistant_message, history + [[user_message, assistant_message]]
515
 
516
  except Exception as e:
517
+ error_msg = f" Error: {e}"
518
  logging.error(f"Chat error: {e}", exc_info=True)
519
 
520
  # Log failed interactions too
 
560
  logging.info(f"Failed query logged to {csv_path}")
561
 
562
  except Exception as log_error:
563
+ logging.error(f"Logging failed: {log_error}", exc_info=True)
564
 
565
  return error_msg, history + [[user_message, error_msg]]
566
 
 
572
  title="Grant Analyst Demo",
573
  theme=gr.themes.Soft(),
574
  css="""
575
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
576
+
577
+ * {
578
+ font-family: 'Inter', system-ui, -apple-system, sans-serif !important;
579
+ }
580
+ .gradio-container { max-width: 100% !important; padding-left: 0 !important; padding-right: 0 !important; }
581
+ .main { max-width: 100% !important; }
582
+ .contain { max-width: 100% !important; }
583
  #status-box { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
584
+ .chatbot { min-height: 500px; }
585
+ code, pre { font-family: 'Fira Code', 'Consolas', monospace !important; }
586
  """
587
  ) as app:
588
 
 
608
  if success:
609
  return f"**System Ready** — {msg}"
610
  else:
611
+ return f"**Initialization Failed** — {msg}"
612
 
613
  # Chatbot interface
614
  with gr.Row():
 
627
  )
628
  send_btn = gr.Button("Send", scale=1, variant="primary")
629
 
630
+ clear_btn = gr.Button("Clear Conversation")
 
631
 
632
  with gr.Column(scale=1):
633
  gr.Markdown("### Preset Questions")
 
665
 
666
  # Event handlers
667
  def respond(message, chat_history):
 
 
668
  bot_response, updated_history = demo.chat(message, chat_history)
669
+ return "", updated_history
670
 
671
  def use_preset(question, chat_history):
672
+ return respond(question, chat_history)
 
 
673
 
674
  # Wire up events
675
+ msg_input.submit(respond, [msg_input, chatbot], [msg_input, chatbot])
676
+ send_btn.click(respond, [msg_input, chatbot], [msg_input, chatbot])
 
 
 
 
 
 
 
 
677
  clear_btn.click(lambda: [], None, chatbot)
678
 
679
  for btn, question in preset_btns:
 
691
 
692
  def main():
693
  """Launch the demo application."""
694
+ import os
695
 
696
  parser = argparse.ArgumentParser(description="Grant Analyst Demo App")
697
  parser.add_argument("--share", action="store_true", help="Create public shareable link")
698
+ parser.add_argument("--port", type=int, default=None, help="Port to run on")
699
  args = parser.parse_args()
700
 
701
+ # Use PORT environment variable if set (for AWS/cloud deployments), otherwise use CLI arg or default
702
+ port = int(os.environ.get("PORT", args.port or 7860))
703
+
704
  # Setup logging (create log directory if needed)
705
  log_handlers = [logging.StreamHandler(sys.stderr)]
706
  try:
 
724
  app = create_demo_ui(demo)
725
 
726
  print("\n" + "="*60)
727
+ print("🚀 Launching Grant Analyst Demo...")
728
  print("="*60)
729
 
730
  app.launch(
731
  server_name="0.0.0.0",
732
+ server_port=port,
733
  share=args.share,
734
  show_error=True,
735
  )
analyzer/chat/tool_schemas.py CHANGED
@@ -92,9 +92,10 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
92
  "function": {
93
  "name": "list_grants",
94
  "description": (
95
- "List all 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",
@@ -125,6 +126,36 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
125
  },
126
  },
127
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  {
129
  "type": "function",
130
  "function": {
@@ -203,9 +234,10 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
203
  "function": {
204
  "name": "get_all_grant_summaries",
205
  "description": (
206
- "Get detailed summaries of ALL 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",
@@ -394,7 +426,7 @@ def get_tools(provider: str = "openai", *, extended: bool = False) -> List[Dict[
394
  Example:
395
  tools = get_tools("openai", extended=True)
396
  response = client.chat.completions.create(
397
- model="gpt-5",
398
  messages=[...],
399
  tools=tools
400
  )
 
92
  "function": {
93
  "name": "list_grants",
94
  "description": (
95
+ "List all grant IDs and titles (no descriptions) with optional filters. "
96
+ "Use this when user asks 'list all grants' or 'show all grants'. "
97
+ "Returns simple list with ID, title, deadline, status ONLY (fast, no summaries). "
98
+ "For full grant descriptions/summaries, use get_all_grant_summaries instead."
99
  ),
100
  "parameters": {
101
  "type": "object",
 
126
  },
127
  },
128
  },
129
+ {
130
+ "type": "function",
131
+ "function": {
132
+ "name": "search_past_winners",
133
+ "description": (
134
+ "Search past winners from 10+ years of UK innovation funding history. "
135
+ "Use this to find previous winners of a grant or search by organization/project name. "
136
+ "Returns past projects with their funding amounts and winning organizations. "
137
+ "If limit is not specified, returns ALL matching winners."
138
+ ),
139
+ "parameters": {
140
+ "type": "object",
141
+ "properties": {
142
+ "keyword": {
143
+ "type": "string",
144
+ "description": "Search by project title, organization name, or description."
145
+ },
146
+ "competition": {
147
+ "type": "string",
148
+ "description": "Filter by grant/competition name to find past winners of a specific grant."
149
+ },
150
+ "limit": {
151
+ "type": "integer",
152
+ "description": "Maximum results to return. If omitted, returns all matching winners."
153
+ },
154
+ },
155
+ "required": [],
156
+ },
157
+ },
158
+ },
159
  {
160
  "type": "function",
161
  "function": {
 
234
  "function": {
235
  "name": "get_all_grant_summaries",
236
  "description": (
237
+ "Get detailed summaries and descriptions of ALL grants. "
238
+ "Use ONLY when user asks 'describe all grants', 'summarize all grants', or wants 'details/descriptions'. "
239
+ "Do NOT use for simple 'list all grants' requests (use list_grants instead). "
240
+ "Returns comprehensive summaries with descriptions and analysis for each grant."
241
  ),
242
  "parameters": {
243
  "type": "object",
 
426
  Example:
427
  tools = get_tools("openai", extended=True)
428
  response = client.chat.completions.create(
429
+ model="gpt-4",
430
  messages=[...],
431
  tools=tools
432
  )