| """FinChat retrieval-augmented generation (RAG) chain. |
| |
| Public entry point: answer(question) -> {"answer", "routed_to", "sources"} |
| """ |
| from __future__ import annotations |
|
|
| import os |
| import re |
| from collections import defaultdict |
| from functools import lru_cache |
|
|
| from dotenv import load_dotenv |
| from langchain_chroma import Chroma |
| from langchain_huggingface import HuggingFaceEmbeddings |
| from langchain_groq import ChatGroq |
| from langchain_core.prompts import ChatPromptTemplate |
|
|
| from src import config |
|
|
| |
| |
| |
| load_dotenv(config.PROJECT_ROOT / ".env") |
|
|
| SYSTEM_PROMPT = """You are FinChat, a financial analyst assistant. Answer the \ |
| question using ONLY the excerpts from SEC 10-K filings provided below. |
| |
| Rules: |
| - Use ONLY the provided context. Do NOT rely on outside knowledge. |
| - If the answer is not in the context, reply exactly: |
| "I couldn't find that in the filings I have." |
| - Be concise and precise with numbers. State the company and fiscal year. |
| - End with a short "Sources:" list referencing the excerpts you used. |
| |
| Context: |
| {context} |
| """ |
|
|
| PROMPT = ChatPromptTemplate.from_messages( |
| [("system", SYSTEM_PROMPT), ("human", "{question}")] |
| ) |
|
|
|
|
| @lru_cache(maxsize=1) |
| def get_vectorstore() -> Chroma: |
| embeddings = HuggingFaceEmbeddings(model_name=config.EMBEDDING_MODEL) |
| return Chroma( |
| collection_name=config.CHROMA_COLLECTION, |
| embedding_function=embeddings, |
| persist_directory=str(config.VECTORSTORE_DIR), |
| ) |
|
|
|
|
| def ensure_index() -> None: |
| """Build the vector store on first run if it doesn't exist yet. |
| |
| Lets the app bootstrap itself on a fresh deployment (e.g. Hugging Face |
| Spaces). On normal runs where the store already exists, this is a fast |
| no-op. |
| |
| IMPORTANT: this checks the filesystem instead of opening a Chroma client. |
| Probing with a client would create an empty database and keep it open -- |
| and because chromadb caches clients per path, the subsequent rebuild |
| (delete + recreate the directory) would leave that cached client pointing |
| at deleted files, crashing with "unable to open database file". |
| """ |
| if not (config.VECTORSTORE_DIR / "chroma.sqlite3").exists(): |
| from src.ingest import build_index |
| build_index() |
|
|
|
|
| @lru_cache(maxsize=1) |
| def get_llm() -> ChatGroq: |
| if not os.getenv("GROQ_API_KEY"): |
| raise RuntimeError("GROQ_API_KEY is not set. Add it to your .env file.") |
| return ChatGroq( |
| model=config.LLM_MODEL, |
| temperature=config.LLM_TEMPERATURE, |
| max_retries=5, |
| ) |
|
|
|
|
| |
| _STOPWORDS = { |
| "inc", "corp", "corporation", "company", "ltd", "llc", "plc", "the", |
| "and", "group", "holdings", "international", "industries", "products", |
| "resources", "energy", "technologies", "systems", |
| } |
|
|
|
|
| @lru_cache(maxsize=1) |
| def company_aliases() -> dict[str, str]: |
| """Map each recognizable alias (ticker or distinctive name word) -> ticker. |
| |
| This lets FinChat route a question to the right company before retrieving |
| ("knows exactly where to look"). Any alias shared by more than one company |
| is dropped, so we never route to the wrong filing. |
| """ |
| store = get_vectorstore() |
| metadatas = store.get(include=["metadatas"]).get("metadatas", []) |
|
|
| ticker_to_name: dict[str, str] = {} |
| for m in metadatas: |
| ticker = (m.get("ticker") or "").upper() |
| if ticker: |
| ticker_to_name[ticker] = m.get("company") or "" |
|
|
| alias_to_tickers: dict[str, set] = defaultdict(set) |
| for ticker, name in ticker_to_name.items(): |
| alias_to_tickers[ticker.lower()].add(ticker) |
| for word in re.findall(r"[a-z]+", name.lower()): |
| if len(word) >= 4 and word not in _STOPWORDS: |
| alias_to_tickers[word].add(ticker) |
|
|
| |
| return {a: next(iter(ts)) for a, ts in alias_to_tickers.items() if len(ts) == 1} |
|
|
|
|
| @lru_cache(maxsize=1) |
| def available_companies() -> list[tuple[str, str]]: |
| """Return sorted (ticker, company name) pairs present in the vector store.""" |
| store = get_vectorstore() |
| metadatas = store.get(include=["metadatas"]).get("metadatas", []) |
| seen: dict[str, str] = {} |
| for m in metadatas: |
| ticker = (m.get("ticker") or "").upper() |
| if ticker and ticker not in seen: |
| seen[ticker] = m.get("company") or ticker |
| return sorted(seen.items()) |
|
|
|
|
| def detect_ticker(question: str) -> str | None: |
| """Figure out which company the question is about.""" |
| aliases = company_aliases() |
|
|
| |
| for token in re.findall(r"\b[A-Z]{2,6}\b", question): |
| if token.lower() in aliases: |
| return aliases[token.lower()] |
|
|
| |
| for word in re.findall(r"[a-z]+", question.lower()): |
| if len(word) >= 4 and word in aliases: |
| return aliases[word] |
|
|
| return None |
|
|
|
|
| |
| _FINANCIAL_TERMS = ( |
| "revenue", "sales", "income", "earnings", "profit", "margin", "ebitda", |
| "asset", "liabilit", "equity", "cash flow", "cash", "debt", "expense", |
| "eps", "per share", "how much", "dividend", "operating", "gross", "net ", |
| "balance sheet", "capital", "ratio", |
| ) |
|
|
|
|
| def _is_financial_query(question: str) -> bool: |
| q = question.lower() |
| return any(term in q for term in _FINANCIAL_TERMS) |
|
|
|
|
| def retrieve(question: str, ticker: str | None): |
| store = get_vectorstore() |
| search_kwargs: dict = {"k": config.TOP_K} |
| if ticker: |
| |
| search_kwargs["filter"] = {"ticker": ticker} |
| docs = store.as_retriever(search_kwargs=search_kwargs).invoke(question) |
|
|
| |
| |
| |
| |
| if ticker and _is_financial_query(question): |
| fin = store.as_retriever( |
| search_kwargs={ |
| "k": 4, |
| "filter": {"$and": [{"ticker": ticker}, {"type": "financials"}]}, |
| } |
| ).invoke(question) |
| seen = {d.page_content[:80] for d in fin} |
| rest = [d for d in docs if d.page_content[:80] not in seen] |
| docs = (fin + rest)[: config.TOP_K] |
| return docs |
|
|
|
|
| def format_context(docs) -> str: |
| return "\n\n".join( |
| f"[{i}] {d.metadata.get('source', 'source')}\n{d.page_content}" |
| for i, d in enumerate(docs, 1) |
| ) |
|
|
|
|
| def answer(question: str) -> dict: |
| """Route -> retrieve -> generate. Returns answer, routing info, sources.""" |
| ticker = detect_ticker(question) |
| docs = retrieve(question, ticker) |
| context = format_context(docs) if docs else "(no relevant excerpts found)" |
| chain = PROMPT | get_llm() |
| response = chain.invoke({"context": context, "question": question}) |
| return {"answer": response.content, "routed_to": ticker, "sources": docs} |
|
|