Riley Coleman commited on
Commit
6ffc895
·
1 Parent(s): 8806ac4

feat: add get_all_grant_summaries tool for efficient batch grant summarization

Browse files
src/analyzer/chat/chat_tools.py CHANGED
@@ -1,5 +1,6 @@
1
  # src/analyzer/chat/chat_tools.py
2
  from __future__ import annotations
 
3
  import logging
4
  import re
5
  from dataclasses import dataclass
@@ -15,6 +16,11 @@ 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
 
19
 
20
  # ---------------------------------------------------------------------
@@ -67,6 +73,9 @@ class ChatTools:
67
  logging.warning("Could not load search index: %s", e)
68
  self.support_idx = None
69
 
 
 
 
70
  # -----------------------------------------------------------------
71
  # Status calculation (NEW)
72
  # -----------------------------------------------------------------
@@ -165,12 +174,104 @@ class ChatTools:
165
  return r
166
  raise KeyError(f"Grant not found: {gid}")
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  # -----------------------------------------------------------------
169
  # Summarize a grant
170
  # -----------------------------------------------------------------
171
  def summarize_grant(self, gid: str, include_supporting: bool = True) -> Dict[str, Any]:
172
  """
173
- Summarize a grant using LLM.
174
 
175
  Args:
176
  gid: Grant ID
@@ -181,6 +282,17 @@ class ChatTools:
181
  """
182
  row = self.get_grant(gid)
183
  title = row.get("title", "(untitled)")
 
 
 
 
 
 
 
 
 
 
 
184
 
185
  # Use enhanced context builder that includes supporting materials
186
  if include_supporting:
@@ -194,18 +306,26 @@ class ChatTools:
194
  context = self._build_basic_context(row)
195
 
196
  if not self.client or not self.client.is_ready():
197
- return {
198
  "summary_md": f"LLM unavailable — context excerpt:\n\n{context[:1000]}",
199
  "title": title,
200
- "id": row.get("id"),
201
  }
 
 
202
 
203
  payload = build_prompt("openai", context)
204
  try:
205
  text = self.client.chat(payload["messages"], max_tokens=1200, temperature=0.25)
 
206
  except Exception as e:
207
  text = f"LLM error: {e}\n\n{context[:800]}"
208
- return {"summary_md": text, "title": title, "id": row.get("id")}
 
 
 
 
 
209
 
210
  # -----------------------------------------------------------------
211
  # Helper method for basic context (without supporting materials)
 
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
 
16
  from ..utils.errors import DataLoadError, ValidationError, LLMError
17
  from ..utils.text import clean, to_number
18
  from ..utils.dates import parse_date, format_date
19
+ from ..summarizer_optimized import ( # NEW: Optimized caching + batch processing
20
+ SummaryCache,
21
+ summarize_grants_async,
22
+ extract_minimal_context,
23
+ )
24
 
25
 
26
  # ---------------------------------------------------------------------
 
73
  logging.warning("Could not load search index: %s", e)
74
  self.support_idx = None
75
 
76
+ # Initialize cache for summaries (NEW: Optimized caching)
77
+ self.summary_cache = SummaryCache(ttl_seconds=3600)
78
+
79
  # -----------------------------------------------------------------
80
  # Status calculation (NEW)
81
  # -----------------------------------------------------------------
 
174
  return r
175
  raise KeyError(f"Grant not found: {gid}")
176
 
177
+ # -----------------------------------------------------------------
178
+ # Batch Summarize Multiple Grants (NEW - Parallelized)
179
+ # -----------------------------------------------------------------
180
+ async def summarize_grants_batch(
181
+ self,
182
+ grant_ids: List[str],
183
+ include_supporting: bool = False,
184
+ batch_size: int = 5,
185
+ ):
186
+ """
187
+ Batch summarize multiple grants efficiently using parallel processing.
188
+
189
+ This method:
190
+ 1. Resolves grant IDs to grant objects
191
+ 2. Processes them in parallel batches (5 per batch by default)
192
+ 3. Caches results for future use
193
+ 4. Yields results as they complete (parallelized)
194
+
195
+ Args:
196
+ grant_ids: List of grant IDs to summarize
197
+ include_supporting: If True, include supporting materials (slower)
198
+ batch_size: Number of grants per batch (default 5)
199
+
200
+ Yields:
201
+ Dict with grant_id, title, summary_md as each completes
202
+ """
203
+ import asyncio
204
+
205
+ # Resolve all grant IDs to actual grant objects
206
+ grants_to_summarize = []
207
+ for gid in grant_ids:
208
+ try:
209
+ grant = self.get_grant(gid)
210
+ grants_to_summarize.append(grant)
211
+ except KeyError:
212
+ logging.warning(f"Grant not found: {gid}")
213
+ continue
214
+
215
+ if not grants_to_summarize:
216
+ logging.warning("No valid grants found to summarize")
217
+ return
218
+
219
+ logging.info(
220
+ f"📦 Starting batch summarization of {len(grants_to_summarize)} grants "
221
+ f"(batch_size={batch_size})"
222
+ )
223
+
224
+ # Use the optimized async batch processing function
225
+ try:
226
+ results = await summarize_grants_async(
227
+ grants_to_summarize,
228
+ past_winners=self.past,
229
+ client=self.client,
230
+ cache=self.summary_cache,
231
+ batch_size=batch_size,
232
+ )
233
+ # Yield each result as it's ready
234
+ for result in results:
235
+ yield result
236
+ except Exception as e:
237
+ logging.error(f"Batch summarization failed: {e}")
238
+ raise
239
+
240
+ async def get_all_grant_summaries(self, batch_size: int = 5):
241
+ """
242
+ Get summaries of ALL grants in a single efficient batch operation.
243
+
244
+ This method:
245
+ 1. Extracts all grant IDs from current database
246
+ 2. Summarizes them all in parallel batches
247
+ 3. Returns all results formatted for display
248
+
249
+ Args:
250
+ batch_size: Number of grants per batch (default 5)
251
+
252
+ Yields:
253
+ Dict with grant_id, title, summary_md as each completes
254
+ """
255
+ # Get all grant IDs
256
+ all_grants = self.list_grants(limit=None) # Get ALL grants
257
+ all_grant_ids = [g["id"] for g in all_grants]
258
+
259
+ if not all_grant_ids:
260
+ logging.warning("No grants found in database")
261
+ return
262
+
263
+ logging.info(f"📦 Getting summaries for ALL {len(all_grant_ids)} grants in batch")
264
+
265
+ # Use batch summarization with all IDs
266
+ async for result in self.summarize_grants_batch(all_grant_ids, batch_size=batch_size):
267
+ yield result
268
+
269
  # -----------------------------------------------------------------
270
  # Summarize a grant
271
  # -----------------------------------------------------------------
272
  def summarize_grant(self, gid: str, include_supporting: bool = True) -> Dict[str, Any]:
273
  """
274
+ Summarize a grant using LLM with caching.
275
 
276
  Args:
277
  gid: Grant ID
 
282
  """
283
  row = self.get_grant(gid)
284
  title = row.get("title", "(untitled)")
285
+ grant_id = row.get("id") or gid
286
+
287
+ # NEW: Check cache first
288
+ cached_summary = self.summary_cache.get(row)
289
+ if cached_summary:
290
+ logging.info("📦 Cache HIT for grant %s", grant_id)
291
+ return {
292
+ "summary_md": cached_summary,
293
+ "title": title,
294
+ "id": grant_id,
295
+ }
296
 
297
  # Use enhanced context builder that includes supporting materials
298
  if include_supporting:
 
306
  context = self._build_basic_context(row)
307
 
308
  if not self.client or not self.client.is_ready():
309
+ result = {
310
  "summary_md": f"LLM unavailable — context excerpt:\n\n{context[:1000]}",
311
  "title": title,
312
+ "id": grant_id,
313
  }
314
+ self.summary_cache.set(row, result["summary_md"])
315
+ return result
316
 
317
  payload = build_prompt("openai", context)
318
  try:
319
  text = self.client.chat(payload["messages"], max_tokens=1200, temperature=0.25)
320
+ logging.info("✅ Generated summary for grant %s", grant_id)
321
  except Exception as e:
322
  text = f"LLM error: {e}\n\n{context[:800]}"
323
+ logging.error(" Failed to summarize %s: %s", grant_id, e)
324
+
325
+ # NEW: Cache the summary
326
+ self.summary_cache.set(row, text)
327
+
328
+ return {"summary_md": text, "title": title, "id": grant_id}
329
 
330
  # -----------------------------------------------------------------
331
  # Helper method for basic context (without supporting materials)
src/analyzer/chat/demo_app.py CHANGED
@@ -30,6 +30,8 @@ from ..search.hybrid_index import load_index
30
  from .chat_tools import ChatTools
31
  from .tool_schemas import openai_tools, detect_extended_features
32
  from ..utils.query_logger import get_query_logger
 
 
33
 
34
 
35
  # Preset questions that are guaranteed to work
@@ -53,6 +55,7 @@ class GrantAnalystDemo:
53
  self.available_tools = []
54
  self.messages = []
55
  self.initialized = False
 
56
 
57
  def initialize(self) -> Tuple[bool, str]:
58
  """
@@ -99,16 +102,50 @@ class GrantAnalystDemo:
99
  extended_mode = detect_extended_features()
100
  self.available_tools = openai_tools(extended=extended_mode)
101
 
 
 
 
 
 
 
 
 
102
  # Initialize conversation
103
  self.messages = [
104
  {
105
  "role": "system",
106
  "content": (
107
- "You are a helpful grant analyst assistant for Innovate UK grants. "
108
- "Use the available tools to answer user questions about grants, funding, "
109
- "deadlines, eligibility, and comparisons. Always use tools when you need "
110
- "to look up grant data. Format your responses with markdown for clarity. "
111
- "Current date: 2025-10-22."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  )
113
  }
114
  ]
@@ -138,6 +175,96 @@ class GrantAnalystDemo:
138
  elif tool_name == "summarize_grant":
139
  return self.tools.summarize_grant(tool_args["grant_id"])
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  elif tool_name == "compare_grants":
142
  return self.tools.compare_grants(
143
  tool_args["grant_id_a"],
@@ -154,13 +281,49 @@ class GrantAnalystDemo:
154
  )
155
 
156
  elif tool_name == "search_grants":
157
- return {
158
- "results": self.tools.list_grants(
159
- keyword=tool_args.get("query"),
160
- status=tool_args.get("status"), # NEW: Add status filter
161
- limit=tool_args.get("limit") # FIXED: Don't default to 10, pass None for all
162
- )
163
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
  else:
166
  return {"error": f"Unknown tool: {tool_name}"}
@@ -187,19 +350,23 @@ class GrantAnalystDemo:
187
 
188
  start_time = time.time()
189
  tools_called = []
 
190
 
191
  try:
192
  # Add user message
193
  self.messages.append({"role": "user", "content": user_message})
194
 
195
  # Call LLM with function calling
 
196
  response = self.llm_client.client.chat.completions.create(
197
  model=self.llm_client.model,
198
  messages=self.messages,
199
  tools=self.available_tools,
200
  tool_choice="auto",
201
- temperature=0.1,
 
202
  )
 
203
 
204
  response_message = response.choices[0].message
205
  tool_calls = response_message.tool_calls
@@ -210,15 +377,21 @@ class GrantAnalystDemo:
210
  self.messages.append(response_message)
211
 
212
  # Execute each tool call
 
213
  for tool_call in tool_calls:
214
  function_name = tool_call.function.name
215
- function_args = eval(tool_call.function.arguments)
 
216
 
217
  logging.info(f"Calling: {function_name}({function_args})")
218
  tools_called.append(function_name) # Track for logging
219
 
220
  # Execute tool
 
221
  tool_result = self._dispatch_tool(function_name, function_args)
 
 
 
222
 
223
  # Add tool result
224
  self.messages.append({
@@ -229,11 +402,14 @@ class GrantAnalystDemo:
229
  })
230
 
231
  # Get final response
 
232
  final_response = self.llm_client.client.chat.completions.create(
233
  model=self.llm_client.model,
234
  messages=self.messages,
235
- temperature=0.1,
 
236
  )
 
237
 
238
  assistant_message = final_response.choices[0].message.content
239
  self.messages.append({"role": "assistant", "content": assistant_message})
@@ -242,8 +418,10 @@ class GrantAnalystDemo:
242
  assistant_message = response_message.content
243
  self.messages.append({"role": "assistant", "content": assistant_message})
244
 
245
- # Log the interaction (for ALL users, not just you!)
246
  response_time_ms = int((time.time() - start_time) * 1000)
 
 
247
 
248
  # Direct logging to CSV (simpler, more reliable)
249
  try:
@@ -494,7 +672,7 @@ def main():
494
  server_name="0.0.0.0",
495
  server_port=args.port,
496
  share=args.share,
497
- show_error=True
498
  )
499
 
500
 
 
30
  from .chat_tools import ChatTools
31
  from .tool_schemas import openai_tools, detect_extended_features
32
  from ..utils.query_logger import get_query_logger
33
+ from ..summarizer_optimized import SummaryCache # NEW: Optimized caching
34
+ import asyncio # NEW: For batch processing
35
 
36
 
37
  # Preset questions that are guaranteed to work
 
55
  self.available_tools = []
56
  self.messages = []
57
  self.initialized = False
58
+ self.summary_cache = SummaryCache(ttl_seconds=3600) # NEW: Cache summaries for 1 hour
59
 
60
  def initialize(self) -> Tuple[bool, str]:
61
  """
 
102
  extended_mode = detect_extended_features()
103
  self.available_tools = openai_tools(extended=extended_mode)
104
 
105
+ # Log which tools are available
106
+ tool_names = [t["function"]["name"] for t in self.available_tools]
107
+ logging.info(f"✅ Loaded {len(tool_names)} tools: {', '.join(tool_names)}")
108
+ if extended_mode:
109
+ logging.info("✨ Extended tools ENABLED (fetch_link, insight_search)")
110
+ else:
111
+ logging.info("ℹ️ Extended tools DISABLED (run with ENABLE_EXTENDED_TOOLS=1 to enable)")
112
+
113
  # Initialize conversation
114
  self.messages = [
115
  {
116
  "role": "system",
117
  "content": (
118
+ "You are an expert UK grant analyst assistant. Your PRIMARY DIRECTIVE is to EXECUTE user requests.\n\n"
119
+ "WHEN USER ASKS FOR:\n"
120
+ "- 'description/summaries of all/every grant' IMMEDIATELY call get_all_grant_summaries (ONE SINGLE TOOL CALL)\n"
121
+ "- 'description/summaries of grants' IMMEDIATELY call summarize_grants_batch\n"
122
+ "- 'list all grants' (NO descriptions) → IMMEDIATELY call list_grants with limit=None\n"
123
+ "- 'find grants about [topic]' → IMMEDIATELY call search_grants\n"
124
+ "- ANY REQUEST FOR INFORMATION → DO NOT DESCRIBE WHAT YOU WILL DO, JUST DO IT\n\n"
125
+ "CRITICAL RULES:\n"
126
+ "- DO NOT make multiple tool calls. Make ONE tool call and wait for results.\n"
127
+ "- DO NOT return raw JSON lists when user asks for descriptions/summaries\n"
128
+ "- When user asks for 'all grants', use get_all_grant_summaries (NOT list_grants + summarize)\n"
129
+ "- DO NOT say 'I will do X' and then stop. ACTUALLY CALL THE TOOL.\n"
130
+ "- DO NOT provide preliminary responses. CALL TOOLS FIRST, THEN RESPOND.\n"
131
+ "- If user asks for information, ALWAYS use tools - NEVER make up answers.\n"
132
+ "- Never promise to do something later. Do it immediately.\n\n"
133
+ "SPECIFIC TOOL USAGE:\n"
134
+ "- get_all_grant_summaries: For 'all grants', 'every grant', 'all grant opportunities' (ONE SINGLE CALL - most efficient)\n"
135
+ "- summarize_grants_batch: For summaries/descriptions of specific grant groups\n"
136
+ "- summarize_grant: Only for single grant details\n"
137
+ "- list_grants: To get IDs/titles only (NOT for descriptions)\n"
138
+ "- search_grants: For finding grants by topic/keyword\n"
139
+ "- get_grant: For full structured data on one grant\n"
140
+ "- compare_grants: For side-by-side comparisons\n\n"
141
+ "RESPONSE FORMAT:\n"
142
+ "- ALWAYS include complete tool results in your response\n"
143
+ "- Do NOT paraphrase or summarize tool results - display them exactly as provided\n"
144
+ "- Use markdown formatting (headers, bullets, tables)\n"
145
+ "- Include all details: funding, eligibility, deadlines, scope\n"
146
+ "- No length limits - be comprehensive\n"
147
+ "- NEVER omit tool results from your response\n\n"
148
+ "Current date: 2025-10-27"
149
  )
150
  }
151
  ]
 
175
  elif tool_name == "summarize_grant":
176
  return self.tools.summarize_grant(tool_args["grant_id"])
177
 
178
+ elif tool_name == "summarize_grants_batch":
179
+ # NEW: Batch summarization with parallel processing
180
+ grant_ids = tool_args.get("grant_ids", [])
181
+ batch_size = tool_args.get("batch_size", 5)
182
+
183
+ if not grant_ids:
184
+ return "❌ No grant IDs provided for batch summarization"
185
+
186
+ logging.info(f"📦 Starting batch summarization of {len(grant_ids)} grants")
187
+
188
+ # Collect results from async generator
189
+ results = []
190
+ try:
191
+ loop = asyncio.get_event_loop()
192
+ except RuntimeError:
193
+ loop = asyncio.new_event_loop()
194
+ asyncio.set_event_loop(loop)
195
+
196
+ async def collect_batch_results():
197
+ """Collect all batch results."""
198
+ async for result in self.tools.summarize_grants_batch(
199
+ grant_ids,
200
+ batch_size=batch_size
201
+ ):
202
+ results.append(result)
203
+
204
+ try:
205
+ loop.run_until_complete(collect_batch_results())
206
+ except RuntimeError as e:
207
+ if "already running" in str(e):
208
+ # If loop is already running (shouldn't happen in Gradio), use current loop
209
+ logging.warning(f"Event loop already running, using current loop")
210
+ # In this case, we need to return a message instead
211
+ return "⚠️ Batch summarization not available in this context. Please try individual summaries."
212
+ raise
213
+
214
+ # Format results for display
215
+ if not results:
216
+ return "❌ No grants could be summarized"
217
+
218
+ formatted = f"✅ Batch summarization complete for {len(results)} grants:\n\n"
219
+ for i, result in enumerate(results, 1):
220
+ title = result.get("title", "(untitled)")
221
+ summary = result.get("summary_md", "No summary")
222
+ # Truncate long summaries for display
223
+ if len(summary) > 500:
224
+ summary = summary[:500] + "\n\n[... truncated ...]"
225
+ formatted += f"**{i}. {title}**\n{summary}\n\n---\n\n"
226
+
227
+ return formatted
228
+
229
+ elif tool_name == "get_all_grant_summaries":
230
+ # Get summaries of ALL grants in one batch
231
+ batch_size = tool_args.get("batch_size", 5)
232
+
233
+ logging.info(f"📦 Starting to get summaries for ALL grants (batch_size={batch_size})")
234
+
235
+ # Collect results from async generator
236
+ results = []
237
+ try:
238
+ loop = asyncio.get_event_loop()
239
+ except RuntimeError:
240
+ loop = asyncio.new_event_loop()
241
+ asyncio.set_event_loop(loop)
242
+
243
+ async def collect_all_summaries():
244
+ """Collect all grant summaries."""
245
+ async for result in self.tools.get_all_grant_summaries(batch_size=batch_size):
246
+ results.append(result)
247
+
248
+ try:
249
+ loop.run_until_complete(collect_all_summaries())
250
+ except RuntimeError as e:
251
+ if "already running" in str(e):
252
+ logging.warning(f"Event loop already running, using current loop")
253
+ return "⚠️ Cannot get all summaries in this context. Please try specific summaries."
254
+ raise
255
+
256
+ # Format results for display
257
+ if not results:
258
+ return "❌ No grants could be summarized"
259
+
260
+ formatted = f"✅ Summaries for ALL {len(results)} grants:\n\n"
261
+ for i, result in enumerate(results, 1):
262
+ title = result.get("title", "(untitled)")
263
+ summary = result.get("summary_md", "No summary")
264
+ formatted += f"**{i}. {title}**\n{summary}\n\n---\n\n"
265
+
266
+ return formatted
267
+
268
  elif tool_name == "compare_grants":
269
  return self.tools.compare_grants(
270
  tool_args["grant_id_a"],
 
281
  )
282
 
283
  elif tool_name == "search_grants":
284
+ results = self.tools.list_grants(
285
+ keyword=tool_args.get("query"),
286
+ status=tool_args.get("status"),
287
+ limit=tool_args.get("limit") # If None, returns ALL
288
+ )
289
+ # Format results for better conversation flow (avoid bloating history)
290
+ if len(results) > 50:
291
+ # If too many, return summary + first 20
292
+ summary = f"Found {len(results)} grants matching the criteria. Showing first 20:\n"
293
+ display = results[:20]
294
+ else:
295
+ summary = f"Found {len(results)} grants:\n"
296
+ display = results
297
+
298
+ formatted = summary + "\n".join([
299
+ f" • {r['title'][:60]} (ID: {r['id']}, Deadline: {r['deadline']}, Status: {r['status']})"
300
+ for r in display
301
+ ])
302
+ return formatted
303
+
304
+ elif tool_name == "fetch_link":
305
+ # NEW: Handle external link fetching
306
+ try:
307
+ from ..net.fetcher import fetch_link
308
+ url = tool_args.get("url")
309
+ if not url:
310
+ return {"error": "No URL provided"}
311
+
312
+ logging.info(f"Fetching external link: {url}")
313
+ content = fetch_link(url)
314
+ if not content:
315
+ return {"error": f"Could not fetch content from {url}"}
316
+
317
+ # Truncate very long content to avoid bloating conversation
318
+ if len(content) > 10000:
319
+ content = content[:10000] + "\n\n[... content truncated ...]"
320
+
321
+ return content
322
+ except ImportError:
323
+ return {"error": "Link fetching not available"}
324
+ except Exception as e:
325
+ logging.error(f"Failed to fetch link {tool_args.get('url')}: {e}")
326
+ return {"error": f"Failed to fetch link: {str(e)[:200]}"}
327
 
328
  else:
329
  return {"error": f"Unknown tool: {tool_name}"}
 
350
 
351
  start_time = time.time()
352
  tools_called = []
353
+ timing_info = {}
354
 
355
  try:
356
  # Add user message
357
  self.messages.append({"role": "user", "content": user_message})
358
 
359
  # Call LLM with function calling
360
+ llm_start = time.time()
361
  response = self.llm_client.client.chat.completions.create(
362
  model=self.llm_client.model,
363
  messages=self.messages,
364
  tools=self.available_tools,
365
  tool_choice="auto",
366
+ temperature=0.5, # INCREASED from 0.1 to allow more thorough, creative responses
367
+ max_tokens=4096, # INCREASED to allow detailed summaries without truncation
368
  )
369
+ timing_info["llm_call"] = time.time() - llm_start
370
 
371
  response_message = response.choices[0].message
372
  tool_calls = response_message.tool_calls
 
377
  self.messages.append(response_message)
378
 
379
  # Execute each tool call
380
+ tools_start = time.time()
381
  for tool_call in tool_calls:
382
  function_name = tool_call.function.name
383
+ import json
384
+ function_args = json.loads(tool_call.function.arguments)
385
 
386
  logging.info(f"Calling: {function_name}({function_args})")
387
  tools_called.append(function_name) # Track for logging
388
 
389
  # Execute tool
390
+ tool_start = time.time()
391
  tool_result = self._dispatch_tool(function_name, function_args)
392
+ tool_time = time.time() - tool_start
393
+ logging.info(f"⏱️ {function_name} took {tool_time:.2f}s")
394
+ timing_info[f"tool_{function_name}"] = tool_time
395
 
396
  # Add tool result
397
  self.messages.append({
 
402
  })
403
 
404
  # Get final response
405
+ final_start = time.time()
406
  final_response = self.llm_client.client.chat.completions.create(
407
  model=self.llm_client.model,
408
  messages=self.messages,
409
+ temperature=0.5, # INCREASED from 0.1 for thorough final responses
410
+ max_tokens=4096, # INCREASED to allow complete answers without truncation
411
  )
412
+ timing_info["final_llm_call"] = time.time() - final_start
413
 
414
  assistant_message = final_response.choices[0].message.content
415
  self.messages.append({"role": "assistant", "content": assistant_message})
 
418
  assistant_message = response_message.content
419
  self.messages.append({"role": "assistant", "content": assistant_message})
420
 
421
+ # Log timing info
422
  response_time_ms = int((time.time() - start_time) * 1000)
423
+ timing_str = " | ".join([f"{k}:{v:.2f}s" for k, v in timing_info.items()])
424
+ logging.info(f"⏱️ Total: {response_time_ms}ms | {timing_str}")
425
 
426
  # Direct logging to CSV (simpler, more reliable)
427
  try:
 
672
  server_name="0.0.0.0",
673
  server_port=args.port,
674
  share=args.share,
675
+ show_error=True,
676
  )
677
 
678
 
src/analyzer/chat/tool_schemas.py CHANGED
@@ -67,7 +67,8 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
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
  ),
72
  "parameters": {
73
  "type": "object",
@@ -79,8 +80,7 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
79
  "filters": _filters_schema(),
80
  "limit": {
81
  "type": "integer",
82
- "description": "Maximum results to return.",
83
- "default": 10
84
  },
85
  },
86
  "required": ["query"],
@@ -170,6 +170,56 @@ def openai_tools(*, extended: bool = False) -> List[Dict[str, Any]]:
170
  },
171
  },
172
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  {
174
  "type": "function",
175
  "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",
 
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"],
 
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": {