finrag / backend /app /agent /tools.py
fardeen16's picture
Upload FinRAG source for Render Docker build
e6c104f verified
Raw History Blame Contribute Delete
6.91 kB
"""The four specialist tools, ported to the local model.
Behaviour matches `agent_tools.py`; only the model behind each call changed.
Print statements are gone — node progress now travels over the event stream.
"""
from __future__ import annotations
import asyncio
import sqlite3
from typing import Any, Dict, List
import pandas as pd
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from ..config import get_settings
from .embeddings import assert_dimension_matches, get_embeddings
from .llm import chat_model, structured_model
from .resources import collection_dimension, get_qdrant, get_reranker, get_sql_database
_QUERY_REWRITE = (
'Rewrite the following query for a semantic search system. Respond with ONLY '
'the rewritten query, no preamble.\nQuery: "{query}"\nRewritten:'
)
async def optimize_query(query: str) -> str:
"""Rewrite a user query for retrieval.
Falls back to the original query on failure: a bad rewrite should degrade
retrieval, not fail the request.
"""
try:
response = await chat_model(temperature=0.0, max_tokens=128).ainvoke(
_QUERY_REWRITE.format(query=query)
)
rewritten = (response.content or "").strip().strip('"')
return rewritten or query
except Exception:
return query
@tool
async def librarian_rag_tool(query: str) -> List[Dict[str, Any]]:
"""Retrieves deep, contextual information from Alphabet's financial filings."""
settings = get_settings()
assert_dimension_matches(collection_dimension())
optimized = await optimize_query(query)
embedding = await asyncio.to_thread(get_embeddings().embed_query, optimized)
hits = await asyncio.to_thread(
lambda: get_qdrant().query_points(
collection_name=settings.collection_name,
query=embedding,
limit=settings.retrieval_candidates,
with_payload=True,
).points
)
if not hits:
return []
# CrossEncoder is CPU-bound; keep it off the event loop.
pairs = [[optimized, hit.payload.get("content", "")] for hit in hits]
scores = await asyncio.to_thread(get_reranker().predict, pairs)
ranked = sorted(zip(hits, scores), key=lambda pair: pair[1], reverse=True)
return [
{
"source": hit.payload.get("source"),
"content": hit.payload.get("content"),
"summary": hit.payload.get("summary"),
"rerank_score": float(score),
}
for hit, score in ranked[: settings.retrieval_top_k]
]
class _SqlQuery(BaseModel):
sql: str = Field(description="A single read-only SQLite SELECT statement.")
_SQL_PROMPT = """Write one SQLite SELECT query answering the question.
Schema:
{schema}
Rules:
- SELECT only. No INSERT, UPDATE, DELETE, DROP or PRAGMA.
- Use only the columns shown above.
- SQLite syntax: no YEAR(), no TOP. Use LIMIT.
- Return the query only.
Question: {question}
"""
# Only a bare SELECT/WITH is allowed through. The question text reaches the model
# that writes this SQL, so the generated statement is untrusted input: a prompt
# injection that talks the model into a DROP must not reach the database.
_FORBIDDEN_SQL = (
"insert", "update", "delete", "drop", "alter", "create",
"attach", "detach", "pragma", "replace", "vacuum",
)
def _run_select(sql: str) -> str:
settings = get_settings()
stripped = sql.strip().rstrip(";").strip()
lowered = stripped.lower()
if not (lowered.startswith("select") or lowered.startswith("with")):
return f"Refused: only SELECT queries are allowed, got: {stripped[:120]}"
if any(word in lowered.split() for word in _FORBIDDEN_SQL):
return f"Refused: statement contains a write operation: {stripped[:120]}"
if ";" in stripped:
return "Refused: multiple statements are not allowed."
with sqlite3.connect(f"file:{settings.sqlite_path}?mode=ro", uri=True) as conn:
df = pd.read_sql_query(stripped, conn)
if df.empty:
return f"Query returned no rows.\nSQL: {stripped}"
return f"SQL: {stripped}\n\n{df.to_string(index=False, max_rows=40)}"
@tool
async def analyst_sql_tool(query: str) -> str:
"""Answers specific questions about revenue, net income, etc., from a SQL database."""
# Replaces langchain's `create_sql_agent`, which was an agent loop costing
# several LLM round trips and was the slowest node in the graph. One
# constrained-decoding call plus a direct execution is faster, and it drops a
# dependency on the legacy langchain-community agent stack.
try:
schema = await asyncio.to_thread(get_sql_database().get_table_info)
except Exception as exc:
return f"Could not read database schema: {type(exc).__name__}: {exc}"
try:
generated = await structured_model(_SqlQuery).ainvoke(
_SQL_PROMPT.format(schema=schema, question=query)
)
except Exception as exc:
return f"Could not generate SQL: {type(exc).__name__}: {exc}"
try:
return await asyncio.to_thread(_run_select, generated.sql)
except Exception as exc:
return f"SQL failed: {type(exc).__name__}: {exc}\nSQL: {generated.sql}"
def _trend_sync(_: str) -> str:
settings = get_settings()
with sqlite3.connect(settings.sqlite_path) as conn:
df = pd.read_sql_query(
f"SELECT * FROM {settings.table_name} ORDER BY year, quarter", conn
)
if df.empty:
return "No rows in the financial summary table."
df["period"] = df["year"].astype(str) + "-" + df["quarter"].astype(str)
df = df.set_index("period")
metric = "revenue_usd_billions"
qoq = df[metric].pct_change()
yoy = df[metric].pct_change(4)
return (
f"Analysis of {metric} from {df.index[0]} to {df.index[-1]}: "
f"${df[metric].iloc[0]:.1f}B -> ${df[metric].iloc[-1]:.1f}B. "
f"Latest QoQ {qoq.iloc[-1]:.1%}, latest YoY {yoy.iloc[-1]:.1%}."
)
@tool
async def analyst_trend_tool(query: str) -> str:
"""Analyzes financial trends like QoQ or YoY growth from the SQL database."""
return await asyncio.to_thread(_trend_sync, query)
@tool
async def scout_web_search_tool(query: str) -> str:
"""Searches the web for real-time information like stock prices or recent news."""
from langchain_community.tools import DuckDuckGoSearchRun
try:
return await DuckDuckGoSearchRun().arun(query)
except Exception as exc:
return f"Web search failed: {type(exc).__name__}: {exc}"
ALL_TOOLS = [
librarian_rag_tool,
analyst_sql_tool,
analyst_trend_tool,
scout_web_search_tool,
]
TOOL_MAP = {t.name: t for t in ALL_TOOLS}