| import os
|
| import sqlite3
|
| import tempfile
|
| import time
|
| from typing import Annotated, TypedDict, Dict, Optional
|
| from dotenv import load_dotenv
|
| from langchain_groq import ChatGroq
|
| from langchain_huggingface import HuggingFaceEmbeddings
|
| from langchain_community.document_loaders import PyPDFLoader
|
| from langchain_community.vectorstores import FAISS
|
| from langchain_community.tools import DuckDuckGoSearchRun
|
| from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage, AIMessage
|
| from langchain_core.tools import tool
|
| from langgraph.graph import START, END, StateGraph
|
| from langgraph.graph.message import add_messages
|
| from langgraph.prebuilt import ToolNode, tools_condition
|
| from langgraph.checkpoint.sqlite import SqliteSaver
|
|
|
| from src.agents.router import get_model
|
| from src.memory.summariser import build_messages
|
| from src.tools.code_executor import code_executor_tool
|
|
|
| load_dotenv()
|
|
|
|
|
| embeddings = HuggingFaceEmbeddings(
|
| model_name="sentence-transformers/all-MiniLM-L6-v2",
|
| model_kwargs={"device": "cpu"},
|
| encode_kwargs={"normalize_embeddings": True},
|
| )
|
|
|
|
|
| _THREAD_RETRIEVERS: Dict[str, any] = {}
|
| _THREAD_META: Dict[str, dict] = {}
|
|
|
|
|
| search_tool = DuckDuckGoSearchRun()
|
|
|
|
|
| def ingest_pdf(file_bytes: bytes, thread_id: str, filename: str):
|
| with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as f:
|
| f.write(file_bytes)
|
| path = f.name
|
|
|
| loader = PyPDFLoader(path)
|
| docs = loader.load()
|
| splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
|
| chunks = splitter.split_documents(docs)
|
|
|
| store = FAISS.from_documents(chunks, embeddings)
|
| _THREAD_RETRIEVERS[thread_id] = store.as_retriever(search_kwargs={"k": 4})
|
| _THREAD_META[thread_id] = {
|
| "filename": filename,
|
| "pages": len(docs),
|
| "chunks": len(chunks),
|
| }
|
| os.remove(path)
|
| return _THREAD_META[thread_id]
|
|
|
| def get_thread_meta(thread_id: str):
|
| return _THREAD_META.get(thread_id)
|
|
|
|
|
| class State(TypedDict):
|
| messages: Annotated[list[BaseMessage], add_messages]
|
| query_type: str
|
| model_used: str
|
| latency_ms: float
|
| SYSTEM_PROMPT = """You are a helpful research assistant.
|
| When context is provided below, use it directly to answer the question.
|
| Do not say "there is no document" if context is provided — just use it.
|
| If no context is provided, answer from your own knowledge.
|
| Never fabricate document sources.
|
| Cite the source filename when answering from documents.
|
| Be concise and accurate."""
|
|
|
|
|
| def agent_node(state: State, config=None) -> dict:
|
| messages = state["messages"]
|
| thread_id = config["configurable"]["thread_id"] if config else "default"
|
|
|
| last_human = next(
|
| (m for m in reversed(messages) if isinstance(m, HumanMessage)), None
|
| )
|
| query = last_human.content if last_human else ""
|
| model_name, qtype = get_model(query)
|
|
|
|
|
| context_block = ""
|
| source = ""
|
| retriever = _THREAD_RETRIEVERS.get(thread_id)
|
| if retriever:
|
| try:
|
| docs = retriever.invoke(query)
|
| if docs:
|
| context_block = "\n\n".join(d.page_content for d in docs)
|
| source = _THREAD_META.get(thread_id, {}).get("filename", "document")
|
| except Exception:
|
| pass
|
|
|
| global search_tool
|
| if not context_block and search_tool is not None:
|
| try:
|
| web_result = search_tool.run(query)
|
| if web_result:
|
| context_block = web_result
|
| source = "web search"
|
| except Exception:
|
| pass
|
|
|
|
|
| calc_result = ""
|
| if qtype == "calc":
|
| try:
|
| result = code_executor_tool.invoke({"code": f"print({query})"})
|
| if result.get("success"):
|
| calc_result = f"\nCalculation: {result['output']}"
|
| except Exception:
|
| pass
|
|
|
|
|
| context_section = ""
|
| if context_block:
|
| context_section = f"\nSource: {source}\nContext:\n{context_block}\n"
|
| if calc_result:
|
| context_section += calc_result
|
|
|
| system = SystemMessage(content=SYSTEM_PROMPT + context_section)
|
|
|
| history = build_messages(messages[:-1], "")
|
| history = [m for m in history if not isinstance(m, SystemMessage)]
|
| final_messages = [system] + history + [HumanMessage(content=query)]
|
|
|
| llm = ChatGroq(
|
| model=model_name,
|
| api_key=os.getenv("GROQ_API_KEY"),
|
| temperature=0,
|
| max_tokens=1024,
|
| )
|
|
|
| t0 = time.perf_counter()
|
| response = llm.invoke(final_messages)
|
| latency_ms = (time.perf_counter() - t0) * 1000
|
|
|
| return {
|
| "messages": [response],
|
| "query_type": qtype,
|
| "model_used": model_name,
|
| "latency_ms": round(latency_ms, 1),
|
| }
|
|
|
|
|
| def build_graph():
|
| conn = sqlite3.connect("memory.db", check_same_thread=False)
|
| checkpointer = SqliteSaver(conn)
|
|
|
| graph = StateGraph(State)
|
| graph.add_node("agent", agent_node)
|
| graph.add_edge(START, "agent")
|
| graph.add_edge("agent", END)
|
|
|
| return graph.compile(checkpointer=checkpointer)
|
|
|
| _graph = None
|
|
|
| def get_graph():
|
| global _graph
|
| if _graph is None:
|
| _graph = build_graph()
|
| return _graph |