grant-radar / src /analyzer /chat /run_chat_llm.py
Riley
feat: Merge GPT-5 enhancements with working c72a240 base + restore crawler
cf9b3dc
Raw
History Blame Contribute Delete
10.5 kB
#!/usr/bin/env python3
"""
run_chat_llm.py — Natural language chat with LLM-driven function calling
This version uses the LLM's native function calling to handle ALL queries,
making it much better at understanding complex natural language requests.
Usage:
python -m src.analyzer.chat.run_chat_llm
python -m src.analyzer.chat.run_chat_llm --verbose
"""
from __future__ import annotations
import argparse
import logging
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import load_config
from ..data_loader import load_current_grants, load_past_winners
from ..llm_client import LLMClient
from ..search.hybrid_index import load_index
from ..utils.errors import (
GrantAnalyzerError,
ValidationError,
DataLoadError,
SearchError,
LLMError,
ConfigError
)
from .chat_tools import ChatTools
from .tool_schemas import openai_tools, detect_extended_features
def _startup_diagnostics(cfg: Dict, llm_ok: bool, idx_ok: bool) -> str:
"""Generate startup diagnostics display."""
provider = cfg.get("llm_provider", "unknown")
model = cfg.get("llm_model", "unknown")
api_key = cfg.get("openai_api_key") or cfg.get("anthropic_api_key")
diag = [
"=== Grant Analyst Chat — LLM Function Calling Mode ===",
f"Provider: {provider}",
f"Model: {model}",
f"LLM Ready: {llm_ok}",
f"Index OK: {idx_ok}",
f"API Key: {'✓' if api_key else '✗'}",
""
]
return "\n".join(diag)
def _dispatch_tool_call(tools: ChatTools, tool_name: str, tool_args: Dict[str, Any]) -> Any:
"""
Dispatch a tool call to the appropriate ChatTools method.
Args:
tools: ChatTools instance
tool_name: Name of the tool to call
tool_args: Arguments for the tool
Returns:
Tool result (dict or string)
"""
try:
if tool_name == "list_grants":
return tools.list_grants(
keyword=tool_args.get("keyword"),
max_award=tool_args.get("max_award"),
audience=tool_args.get("audience"),
status=tool_args.get("status"), # NEW: Add status filter
limit=tool_args.get("limit") # FIXED: Don't default to 5, pass None for all
)
elif tool_name == "get_grant":
return tools.get_grant(tool_args["grant_id"])
elif tool_name == "summarize_grant":
return tools.summarize_grant(
tool_args["grant_id"],
)
elif tool_name == "compare_grants":
return tools.compare_grants(
tool_args["grant_id_a"],
tool_args["grant_id_b"]
)
elif tool_name == "deadlines_overview":
return tools.deadlines_overview(tool_args.get("n", 5))
elif tool_name == "analyze_company_for_grants":
return tools.analyze_company_for_grants(
tool_args["company_url"],
limit=tool_args.get("limit", 3)
)
elif tool_name == "search_grants":
# This would need to be implemented in ChatTools or InsightTools
return {
"results": tools.list_grants(
keyword=tool_args.get("query"),
status=tool_args.get("status"), # NEW: Add status filter
limit=tool_args.get("limit") # FIXED: Don't default to 10, pass None for all
),
"query": tool_args.get("query")
}
elif tool_name == "search_past_winners":
return {
"results": tools.search_past_winners(
keyword=tool_args.get("keyword"),
competition=tool_args.get("competition"),
limit=tool_args.get("limit")
),
"query": tool_args.get("keyword") or tool_args.get("competition") or "all"
}
else:
return {"error": f"Unknown tool: {tool_name}"}
except Exception as e:
logging.error(f"Tool {tool_name} failed: {e}", exc_info=True)
return {"error": str(e)}
def main(argv: Optional[List[str]] = None):
"""Main chat loop with LLM function calling."""
# Parse arguments
ap = argparse.ArgumentParser(description="Grant Analyst Chat with LLM function calling")
ap.add_argument("--verbose", action="store_true", help="Show startup diagnostics")
ap.add_argument("--limit", type=int, help="Limit number of grants to load")
ap.add_argument("--extended-tools", action="store_true", help="Enable extended tools")
args = ap.parse_args(argv)
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.FileHandler("_out/logs/chat_llm.log"),
logging.StreamHandler(sys.stderr)
]
)
# Load configuration
try:
cfg = load_config()
except ConfigError as e:
print(f"⚠️ Configuration error: {e}")
sys.exit(1)
# Load data
logging.info("Loading data...")
try:
current = load_current_grants(Path("data/snapshots"), limit=args.limit)
past = load_past_winners(
history_xlsx=Path("data/IUK-141025-InnovateUKFundedProjects-FY2015-16topresent.xlsx")
)
logging.info(f"Loaded {len(current)} current grants; {len(past)} past winners")
except DataLoadError as e:
print(f"⚠️ Data loading error: {e}")
sys.exit(1)
# Initialize LLM client
try:
llm_client = LLMClient(cfg)
llm_ok = llm_client.is_ready()
if not llm_ok:
print("⚠️ LLM client not ready. Check your API key configuration.")
sys.exit(1)
except Exception as e:
print(f"⚠️ LLM initialization failed: {e}")
sys.exit(1)
# Load search index
idx_ok = False
try:
idx_path = Path("data/index/hybrid_index.pkl")
if idx_path.exists():
_ = load_index()
idx_ok = True
except Exception as e:
logging.warning(f"Could not load search index: {e}")
# Initialize ChatTools
tools = ChatTools(current, past)
# Tool registration
extended_mode = args.extended_tools or detect_extended_features()
available_tools = openai_tools(extended=extended_mode)
logging.info(f"Registered {len(available_tools)} tools (extended: {extended_mode})")
# Startup diagnostics
if args.verbose:
print(_startup_diagnostics(cfg, llm_ok, idx_ok))
print("\n💬 Grant Analyst Chat (LLM Function Calling Mode)")
print(" Ask me anything in natural language! Type 'exit' to quit.\n")
# Conversation history
messages = [
{
"role": "system",
"content": (
"You are a helpful grant analyst assistant for Innovate UK grants. "
"Use the available tools to answer user questions about grants, funding, "
"deadlines, eligibility, and comparisons. Always use tools when you need "
"to look up grant data - don't make up information. When listing grants, "
"format them clearly with bullets. When comparing, use tables. Be concise "
"but informative. Current date: 2025-10-22."
)
}
]
# REPL loop
turn_count = 0
while True:
try:
user_input = input("You: ").strip()
except (EOFError, KeyboardInterrupt):
print("\n👋 Goodbye.")
break
if not user_input:
continue
if user_input.lower() in {"exit", "quit"}:
print("👋 Goodbye.")
break
turn_count += 1
t0 = time.time()
# Add user message
messages.append({"role": "user", "content": user_input})
try:
# Call LLM with function calling
response = llm_client.client.chat.completions.create(
model=llm_client.model,
messages=messages,
tools=available_tools,
tool_choice="auto",
temperature=0.1,
)
response_message = response.choices[0].message
tool_calls = response_message.tool_calls
# If LLM wants to call tools
if tool_calls:
# Add assistant's response with tool calls
messages.append(response_message)
# Execute each tool call
for tool_call in tool_calls:
function_name = tool_call.function.name
function_args = eval(tool_call.function.arguments)
logging.info(f"Calling tool: {function_name} with args: {function_args}")
# Execute the tool
tool_result = _dispatch_tool_call(tools, function_name, function_args)
# Add tool result to conversation
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": function_name,
"content": str(tool_result)
})
# Get final response from LLM
final_response = llm_client.client.chat.completions.create(
model=llm_client.model,
messages=messages,
temperature=0.1,
)
assistant_message = final_response.choices[0].message.content
messages.append({"role": "assistant", "content": assistant_message})
else:
# No tool calls, just use the response
assistant_message = response_message.content
messages.append({"role": "assistant", "content": assistant_message})
# Display response
print(f"\n{assistant_message}\n")
latency_ms = int((time.time() - t0) * 1000)
logging.info(f"Turn {turn_count} completed in {latency_ms}ms")
except LLMError as e:
print(f"\n⚠️ LLM error: {e}\n")
logging.error(f"LLM error: {e}")
except Exception as e:
print(f"\n❌ Error: {e}\n")
logging.error(f"Unexpected error: {e}", exc_info=True)
if __name__ == "__main__":
main()