grant-radar / src /analyzer /chat /insight_tools.py
Riley Coleman
feat: add PDF & supporting materials integration to context
bfcc872
Raw
History Blame Contribute Delete
3.72 kB
# src/analyzer/chat/insight_tools.py
from __future__ import annotations
from typing import Dict, Any, List, Optional
from ..config import load_config
try:
from ..llm_client import LLMClient
except Exception:
LLMClient = None # type: ignore
try:
from ..search.hybrid_index import load_index, search, top_supporting_for_grant
except Exception:
load_index = None # type: ignore
search = None # type: ignore
top_supporting_for_grant = None # type: ignore
from ..prompt_templates import build_open_prompt
class InsightTools:
"""
Retrieval + LLM glue for open, lightly-guarded answers.
- Accepts an existing LLMClient (preferred) to ensure identical config across entrypoints
- Falls back gracefully if LLM or index is unavailable
"""
def __init__(self, llm_client: Optional["LLMClient"] = None):
self._cfg = load_config()
self._client = llm_client
if self._client is None and LLMClient is not None:
try:
self._client = LLMClient(self._cfg)
except Exception:
self._client = None
self._idx = None
if callable(load_index):
try:
self._idx = load_index()
except Exception:
self._idx = None
# Optional helper some callers use to append snippets under a summary
def supporting_snippets_md(self, grant_id: str, k: int = 5) -> str:
if not self._idx or not callable(top_supporting_for_grant):
return ""
try:
hits = top_supporting_for_grant(self._idx, grant_id, k=k)
except Exception:
return ""
items: List[str] = []
for doc, score in hits:
if doc.get("_source") != "supporting":
continue
sec = doc.get("section") or "(Supporting)"
url = doc.get("url","")
txt = (doc.get("text","") or "").replace("\n"," ")
snippet = (txt[:400] + "…") if len(txt) > 400 else txt
items.append(f"- **{sec}** — {url}\n > {snippet}")
if not items:
return ""
return "\n\n---\n**Supporting info (top snippets)**\n" + "\n".join(items)
def insight_search(self, question: str, *, k: int = 8, use_llm: bool = True) -> Dict[str, Any]:
"""Open-style grounded QA: supply question + top snippets; let the LLM pick format and length."""
if not self._idx or not callable(search):
return {"answer_md": "Search index not available."}
hits = search(self._idx, question, k=k, filters=None)
ev_lines: List[str] = []
raw_context: List[str] = []
for doc, score in hits[:k]:
sec = doc.get("section") or doc.get("title","")
url = doc.get("url","")
txt = (doc.get("text","") or "")
ev_lines.append(f"- **{sec}** — {url}")
raw_context.append(f"[{sec}] {url}\n{txt}")
evidence_md = "**Sources**\n" + "\n".join(ev_lines) if ev_lines else ""
if use_llm and self._client:
payload = build_open_prompt(
provider=getattr(self._cfg, "provider", "openai") if self._cfg else "openai",
question=question,
context="\n\n---\n".join(raw_context[:6]),
)
try:
# Slightly higher budget; model decides format naturally
answer = self._client.chat(payload["messages"], max_tokens=1200, temperature=0.3)
return {"answer_md": f"{answer}\n\n---\n{evidence_md}"}
except Exception:
pass
return {"answer_md": "No LLM available. See sources below.\n\n---\n" + evidence_md}