Indian-market investing research · a decision brief, never a verdict

Build Folio, one pipeline stage at a time.

A build‑along reference for a personal-use research assistant that reads concalls, annual reports, DRHPs and news, and explains the whats / hows / whys / whens in plain language — backed by numbers that were computed in code, not recalled by the model, and every one of them citing its source. It never says buy or sell. It hands you the bull case, the bear case, what changed, and the flags, so you make the call. Each milestone is a stage in the pipeline below, and each one lands one of the agentic topics you've been studying.

Route Retrieve Rerank Compute Cite Synthesize Audit
12milestones
4optional appendices
Claudedefault provider, OpenAI alt
key-freeoffline --selftest
READ FIRST

What you're building

Folio is a pipeline, and you'll build it one stage at a time, from scratch, in this guide. A query comes in; it leaves as a decision brief with a citation on every claim. Nothing here assumes code you wrote elsewhere — each stage is defined and built on its own:

  • The router — turns a fuzzy query into a typed plan the rest of the graph can dispatch on.
  • Hybrid retrieval — BM25 + FAISS + RRF over the document corpus.
  • Rerank & refine — a cross-encoder for precision, then compression to fit precise, still-attributable context into the window.
  • The numbers layer — the deterministic heart: extract line items, compute every ratio in code.
  • Synthesis — citation-first assembly of the brief, provenance on every claim.
  • The audit — RAGAS faithfulness plus a numbers-audit that fails the run on any uncited figure.

The genuinely hard parts — the deterministic numbers layer, the numbers-audit, the multi-agent analysts — get the most room; the well-trodden parts (a FAISS index, a cross-encoder rerank) are built plainly and quickly so you can move on. Every milestone header lists the lecture topics it turns into code, so the build doubles as the applied half of the course.

M00

Design principles & the pluggable seam

Why the two rules everything else obeys, plus the seam every stage plugs into  ·  Write config, provider seam, receipt base  ·  New guardrails as code, typed receipts
Guardrails & Execution BoundingPrompt Engineering & Structured OutputsLLM Basics & Enterprise API Setup

Two rules shape the whole system. Encode them now, in code, so they can't be quietly violated later:

  1. Numbers are computed in code, never recalled or arithmetic'd by the LLM. The model may read a line item and narrate a figure — it may never calculate one. Extraction and arithmetic are Python.
  2. Every number cites its source, and an audit enforces it. In finance, faithfulness is the product. This is the line between Folio and "take a ticker, print a blurb."

Project layout (modular uv / src)

bash
uv init folio && cd folio
mkdir -p src/folio/{providers,corpus,route,retrieve,compute,synthesize,eval,receipts}
mkdir -p tests data/{corpus,index,cache} scripts
uv add pydantic pydantic-settings numpy python-dateutil
uv add langchain langchain-community langchain-anthropic langchain-openai
uv add anthropic openai                 # only the provider(s) you'll wire
uv add --dev pytest

Convention, standing: LangChain-native only (no from-scratch reimplementations of things LangChain gives you), and every trace/receipt is a typed dataclass with a .render() method so observability is a first-class object, not print statements.

src/folio/config.py

python
from functools import lru_cache
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    llm: Literal["anthropic", "openai", "fake"] = "fake"    # fake => ZERO keys
    embedder: Literal["local", "openai", "fake"] = "local"

    anthropic_chat_model: str = "claude-sonnet-4-5"         # the default brain
    openai_chat_model: str = "gpt-4.1-mini"                 # the alternate
    local_embed_model: str = "sentence-transformers/all-MiniLM-L6-v2"

    anthropic_api_key: str = ""
    openai_api_key: str = ""

    # retrieval knobs
    retrieval_top_k: int = 20      # hybrid returns this many
    rerank_top_k: int = 6          # this many survive the reranker

    # the hard guardrail: staleness horizon (days) before data must be re-fetched
    freshness_days: int = 3

    index_dir: str = "./data/index"
    corpus_dir: str = "./data/corpus"

    model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)


@lru_cache(maxsize=1)
def get_settings() -> "Settings":
    return Settings()

src/folio/providers/base.py — the interfaces

python
from typing import Protocol, runtime_checkable, Any
import numpy as np


@runtime_checkable
class Chatter(Protocol):
    def answer(self, prompt: str, *, system: str = "", tools: list | None = None) -> Any: ...


@runtime_checkable
class Embedder(Protocol):
    dim: int
    def embed(self, texts: list[str]) -> np.ndarray: ...     # (n, dim) float32, unit-norm

Wire the concrete AnthropicChatter / OpenAIChatter / FakeChatter with the same discipline: lazy provider imports (so a provider you don't use never has to be installed), fail loudly if a selected provider's key is empty, and a FakeChatter that echoes the prompt's evidence so you can debug retrieval without a fluent model papering over it.

The receipt base — observability as a typed object

python
# src/folio/receipts/base.py
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone


@dataclass
class Provenance:
    """Where a fact came from. Attached to EVERY number and EVERY claim."""
    doc_id: str                       # "TCS_concall_Q1FY26.pdf"
    locator: str                      # "p.7 · CFO remarks" or a table cell ref
    as_of: datetime                   # when this datum was true / fetched
    kind: Literal["filing", "price", "screener", "news", "computed"] = "filing"  # type: ignore

    def is_stale(self, horizon_days: int) -> bool:
        return (datetime.now(timezone.utc) - self.as_of).days > horizon_days

    def render(self) -> str:
        return f"[{self.doc_id} · {self.locator} · as of {self.as_of:%Y-%m-%d}]"


@dataclass
class Receipt:
    """Base for every stage's trace. Subclasses add stage-specific fields."""
    stage: str
    started: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    notes: list[str] = field(default_factory=list)

    def render(self) -> str:                # each stage overrides for a richer view
        return f"── {self.stage} ──\n" + "\n".join(f"  · {n}" for n in self.notes)
M01

The financial corpus & the freshness rule

Why ~70% of the build is clean, current data  ·  Write loaders, a dated Document, the freshness gate  ·  New date-stamped provenance, India data sources
Intro to LangChain & Advanced Chunking

Getting clean, current Indian-market data is the least glamorous, most load-bearing part of the whole thing. There is no clean official free price API, so you compose unofficial, rate-limited, fragile sources — and the guardrail against that fragility is that every datum is date-stamped, so stale data can never masquerade as current.

The India sources (personal use, free tier)

text
Prices / quotes    nsepython · jugaad-data · yfinance (".NS" suffix)   unofficial, rate-limited
Fundamentals       Screener.in (exportable) · NSE/BSE announcements     retail gold standard
Concalls / AR      company IR pages, exchange sites                      PDFs — your wheelhouse
IPOs               SEBI (DRHP/RHP), exchanges, Chittorgarh               Chittorgarh = retail-friendly
GMP                grey-market aggregators                               UNOFFICIAL — caveat always
Mutual funds       AMFI (official NAV history) · factsheets              deferred to v4

A Document that carries its own freshness

python
# src/folio/corpus/types.py
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class SourceDoc:
    doc_id: str                       # stable id, used as the citation key
    ticker: str                       # "TCS", "INFY" (NSE symbol)
    doc_type: str                     # "concall" | "annual_report" | "drhp" | "news" | "fundamentals"
    text: str
    as_of: datetime                   # publication / fetch date — the freshness anchor
    fiscal_period: str = ""           # "Q1FY26", "FY25" — parsed, not guessed
    url: str = ""
    meta: dict = field(default_factory=dict)

Loaders (one per source) return SourceDocs. PDFs go through an extraction pass — pdfplumber for tables, unstructured or PyMuPDF for prose — and concalls, annual reports and DRHPs are all just PDFs of different shapes. The jobs unique to finance are stamping as_of and parsing fiscal_period from the Indian April–March calendar.

The freshness gate

python
# src/folio/corpus/freshness.py
from datetime import datetime, timezone
from folio.corpus.types import SourceDoc

class StaleDataError(RuntimeError):
    """Raised when a datum that must be current (a price) is older than the horizon."""

def assert_fresh(doc: SourceDoc, horizon_days: int) -> None:
    age = (datetime.now(timezone.utc) - doc.as_of).days
    if doc.doc_type in {"price", "fundamentals"} and age > horizon_days:
        raise StaleDataError(
            f"{doc.ticker} {doc.doc_type} is {age}d old (horizon {horizon_days}d). "
            f"Re-fetch before quoting it as current.")

Filings and DRHPs are meant to be historical, so they never trip the gate — only things that claim to be "current" (prices, live fundamentals) do. This is the freshness rule made executable: the LLM never states a price or figure it didn't get from a tool or retrieval, and stale current-data fails loudly instead of lying quietly.

M02

The router: intent → a typed plan

Why one front door that turns a fuzzy query into a validated plan  ·  Write the router, a Pydantic plan, the graph skeleton  ·  New routing as a state-machine node
State Machines & Execution GraphsMulti-Source RoutingPrompt Engineering & Structured Outputs

Build the front door. Its one job: read the user's query and emit a typed plan — not prose, a Pydantic object the rest of the graph can dispatch on. This is "Multi-Source Routing" and "Structured Outputs" married: the LLM classifies intent, and the type system guarantees the downstream graph gets something it can actually execute.

The plan is a type, so a bad plan can't exist

python
# src/folio/route/plan.py
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field

Intent = Literal["single_stock", "ipo", "portfolio", "mutual_fund", "news", "glossary"]

class ResearchPlan(BaseModel):
    intent: Intent
    tickers: list[str] = Field(default_factory=list)     # ["TCS"] — NSE symbols
    ipo_name: str = ""                                    # for intent="ipo"
    needs: list[Literal["concall", "annual_report", "drhp",
                        "fundamentals", "price", "news"]] = Field(default_factory=list)
    horizon: Literal["latest_quarter", "3yr", "since_purchase"] = "latest_quarter"
    reason: str = ""                                      # why this route — for the receipt

The router node

python
# src/folio/route/switchboard.py
from folio.route.plan import ResearchPlan
from folio.providers.base import Chatter

_SYS = """You are a router for an Indian-market research assistant. Classify the query
into exactly one intent and list the documents needed. You NEVER answer the question,
NEVER give buy/sell opinions — you only produce a plan. Fiscal years are April–March."""

def route(query: str, chatter: Chatter) -> ResearchPlan:
    # LangChain structured output: the model is forced into the ResearchPlan schema
    plan = chatter.answer(query, system=_SYS, tools=None)   # returns a validated ResearchPlan
    return plan   # invalid JSON / bad enum → pydantic raises here, at the front door

Use LangChain's structured-output binding (with_structured_output(ResearchPlan)) so the model is constrained to the schema. A hallucinated intent or a malformed plan fails here, at the front door — the cheapest possible place for it to fail.

M03

Hybrid retrieval over the filings

Why filings are full of exact line-item terms — lexical nails them, semantic catches the rest  ·  Write BM25 + FAISS + RRF wiring  ·  New RRF fusion tuned for finance
Hybrid Search ImplementationsFAISS Index SetupEmbeddings & Vector MathQuery Transformations

Retrieval has two failure modes, and finance triggers both. Semantic (dense/FAISS) search paraphrases away exact terms; lexical (BM25) search misses concepts phrased differently. Financial documents are dense with exact terms — "deferred tax liability", "promoter pledge", "related party transactions", section numbers, line items — so you need both: BM25 nails the exact token, FAISS catches the conceptual rest, and Reciprocal Rank Fusion (RRF) combines their rankings into one list.

python
# src/folio/retrieve/hybrid.py
from rank_bm25 import BM25Okapi        # or LangChain's BM25Retriever
# FAISS store: faiss.IndexFlatIP(dim) over unit-norm embeddings (cosine == inner product);
# Embedder from the M0 provider seam. Both built plainly in src/folio/retrieve/.

def rrf(rankings: list[list[str]], k: int = 60) -> dict[str, float]:
    """Reciprocal Rank Fusion — fuse on RANK POSITION, not raw score.
    A FAISS cosine of 0.71 and a BM25 score of 14.2 aren't comparable,
    but 'ranked 2nd in each list' is."""
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, chunk_id in enumerate(ranking):
            scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank + 1)
    return scores

def hybrid_search(query, bm25, faiss_store, embedder, top_k):
    lexical = bm25.rank(query)                     # list[chunk_id], best first
    dense   = faiss_store.search(embedder.embed([query])[0], top_k)  # list[(chunk, score)]
    fused   = rrf([lexical, [c.chunk_id for c, _ in dense]])
    ranked  = sorted(fused, key=fused.get, reverse=True)[:top_k]
    return ranked
M04

Rerank & refine the context

Why filings are long; the model's window and attention are not  ·  Write rerank + compress, the funnel receipt  ·  New per-chunk provenance survives compression
Re-ranking & Context OptimizationContext Management & Summarization

A 200-page annual report blows any context window, and stuffing 20 fused chunks in dilutes the model's attention with near-misses. This stage does two things: a cross-encoder rerank (precision over the recall-oriented hybrid list) and a compression pass that trims each surviving chunk to only the sentences that matter — without ever dropping the Provenance. This is "Context Optimization" and "Summarization" doing the same job: fit precise, still-attributable context into the window.

The funnel receipt — observability you can read

python
# src/folio/receipts/funnel.py
from dataclasses import dataclass, field
from folio.receipts.base import Receipt

@dataclass
class FunnelReceipt(Receipt):
    hybrid_in: int = 0          # chunks from M3
    reranked: int = 0           # after cross-encoder
    kept: int = 0               # after top-k cut
    chars_before: int = 0
    chars_after: int = 0        # after compression
    per_chunk: list[dict] = field(default_factory=list)  # {id, hybrid_rank, ce_score, kept}

    def render(self) -> str:
        pct = 100 * self.chars_after // max(self.chars_before, 1)
        head = (f"── refine ──  {self.hybrid_in} → rerank → keep {self.kept}   "
                f"context {self.chars_before}→{self.chars_after} chars ({pct}%)")
        rows = "\n".join(
            f"  {'✓' if c['kept'] else '·'} {c['id'][:12]}  "
            f"hy#{c['hybrid_rank']:<2} ce={c['ce_score']:+.2f}" for c in self.per_chunk)
        return head + "\n" + rows

Being able to see which chunk was ranked where, scored what by the cross-encoder, and whether it survived — that's the difference between debugging retrieval and guessing. Print FunnelReceipt.render() on every run during development.

M05

The numbers layer — the heart of the app

Why faithfulness IS the product; the LLM must not do arithmetic  ·  Write line-item extraction, deterministic ratios, computed provenance  ·  New tools the model calls but doesn't compute
Tool Binding & ExecutionSystem API Integration (Write Access)

This is the milestone that makes Folio trustworthy, and the one with no shortcuts. The split is absolute:

  • The LLM extracts line items from a filing into a typed structure (that's reading, which it's good at).
  • Python computes every ratio from those line items (that's arithmetic, which the LLM must never do).
  • The LLM later narrates the computed figures — but the figures themselves are code output, each carrying kind="computed" provenance that points back to the line items it was built from.

Extraction: LLM reads line items into a type

python
# src/folio/compute/lineitems.py
from pydantic import BaseModel
from folio.receipts.base import Provenance

class LineItem(BaseModel):
    name: str            # "revenue", "net_profit", "total_debt", "shareholder_equity"
    value_inr: float     # RAW rupees — never crore/lakh internally
    period: str          # "Q1FY26", "FY25"
    prov: Provenance     # exactly where in the filing this number was read from

# The LLM's ONLY numeric job: extract, with the page/table locator, into LineItem[].
# It is explicitly instructed NOT to compute, sum, or infer any value.

Computation: deterministic, in code, provenance-tracked

python
# src/folio/compute/ratios.py
from dataclasses import dataclass
from datetime import datetime, timezone
from folio.compute.lineitems import LineItem
from folio.receipts.base import Provenance

@dataclass
class ComputedFigure:
    name: str
    value: float
    unit: str                 # "%", "x", "₹ cr"
    inputs: list[Provenance]  # the line items this was computed FROM
    formula: str              # "net_profit / revenue * 100" — human-auditable

    def prov(self) -> Provenance:
        return Provenance(doc_id="computed", locator=self.formula,
                          as_of=datetime.now(timezone.utc), kind="computed")

def net_margin(rev: LineItem, pat: LineItem) -> ComputedFigure:
    return ComputedFigure("net_margin", round(pat.value_inr / rev.value_inr * 100, 2),
                          "%", [rev.prov, pat.prov], "net_profit / revenue * 100")

def debt_to_equity(debt: LineItem, equity: LineItem) -> ComputedFigure:
    return ComputedFigure("debt_to_equity", round(debt.value_inr / equity.value_inr, 2),
                          "x", [debt.prov, equity.prov], "total_debt / shareholder_equity")

# P/E, ROCE, 3-yr revenue/PAT CAGR, margin trend — all the same shape:
# pure functions of LineItems, each returning a ComputedFigure with formula + input provenance.
M06

Synthesis: the decision brief

Why understanding, not a verdict — a schema with no buy/sell field  ·  Write the brief schema, citation-first synthesis  ·  New provenance on every claim, enforced by type
Prompt Engineering & Structured Outputs

Now assemble the brief: citation-first synthesis where every claim carries provenance. The load-bearing part is the output schema, and its most important property is what it cannot represent. There is no recommendation: buy|hold|sell field — because the design principle ("research assistant, not decision oracle") is enforced by the type, not by a polite instruction the model might drift from.

The brief — a fixed schema of decision factors, each evidence-backed

python
# src/folio/synthesize/brief.py
from pydantic import BaseModel
from folio.receipts.base import Provenance

class Claim(BaseModel):
    text: str                 # a single, plain-language statement
    prov: list[Provenance]    # ≥1 source. A Claim with no provenance is INVALID by construction.

class NumberedClaim(Claim):
    figure: float
    unit: str                 # this claim asserts a COMPUTED number → must trace to a ComputedFigure

class DecisionBrief(BaseModel):
    ticker: str
    as_of: str
    business: Claim                       # how they actually make money, plainly
    financials: list[NumberedClaim]       # 3-yr revenue / PAT / margin — computed, not recalled
    valuation: list[NumberedClaim]        # P/E, ROCE, debt vs own history & sector
    bull_case: list[Claim]
    bear_case: list[Claim]
    what_changed: list[Claim]             # delta vs last quarter
    flags: list[Claim]                    # margin compression, promoter pledge, mgmt change, RPTs, thesis-breaking news
    # NOTE: there is deliberately NO recommendation field. You make the call.
M07

The ReAct research agent

Why a brief needs iterative evidence-gathering, not one shot  ·  Write the reason–act loop, tool set, run thread  ·  New ReAct over your own tools, persisted
The ReAct Architecture ImplementationTool Binding & ExecutionPersistence & Threading

So far the pipeline is a fixed graph. But real research is iterative: read the concall → notice a margin dip → go compute the margin trend → that raises a debt question → fetch the debt line items. That's the ReAct loopreason, act (call a tool), observe, repeat — and it's the right shape for the synthesis stage of a single-stock or IPO brief.

python
# src/folio/synthesize/agent.py  (LangGraph ReAct — sketch)
TOOLS = [
    retrieve_chunks,      # M3+M4: hybrid + crucible over the corpus
    extract_line_items,   # M5: LLM reads a filing section into LineItem[]
    compute_ratio,        # M5: deterministic ratio functions (the model CANNOT do the math)
    get_price,            # M1/M5: read-only, freshness-gated
    search_news,          # cross-cutting: recency-filtered, source-quality-gated
]

# ReAct: the model proposes a tool call → runtime executes → observation returns →
# repeat until it has enough EVIDENCE to fill the DecisionBrief schema. It stops when
# every required field has ≥1 sourced Claim, not when it "feels done".
M08

Eval harness: RAGAS faithfulness + numbers-audit

Why in finance, an unverified brief is worse than none  ·  Write RAGAS wiring + the numbers-audit gate  ·  New a domain audit that fails the run
Evaluation Pipelines (Ragas)Guardrails & Execution Bounding

Two evals, doing two different jobs. RAGAS scores general faithfulness (does the prose stay grounded in the retrieved context?). The numbers-audit is the domain-specific gate that makes this a finance tool: extract every quantitative claim from the brief → verify each appears in a source doc or was produced by a ComputedFigure → fail the run on any orphan.

python
# src/folio/eval/numbers_audit.py
from dataclasses import dataclass
from folio.synthesize.brief import DecisionBrief, NumberedClaim

@dataclass
class AuditResult:
    total_numbers: int
    orphans: list[str]          # numbers with no source / no computed lineage
    stale: list[str]            # numbers whose provenance is past the freshness horizon
    @property
    def passed(self) -> bool:
        return not self.orphans and not self.stale

def audit(brief: DecisionBrief, horizon_days: int) -> AuditResult:
    orphans, stale, n = [], [], 0
    for claim in _all_numbered_claims(brief):        # walk financials + valuation
        n += 1
        if not claim.prov:                            # should be impossible — schema forbids — but verify
            orphans.append(claim.text); continue
        if all(p.is_stale(horizon_days) and p.kind in {"price", "fundamentals"} for p in claim.prov):
            stale.append(claim.text)
    return AuditResult(n, orphans, stale)
M09

v2 — the IPO explainer from the DRHP

Why highest-value feature; retail almost never reads the DRHP  ·  Write a DRHP route + brief variant  ·  New reuse the whole pipeline for a new doc type

The DRHP (Draft Red Herring Prospectus) is the single most information-dense document retail investors ignore. The beauty of the pipeline you've built: an IPO explainer is mostly reuse. Same route (intent="ipo"), same hybrid retrieval, same rerank & refine, same numbers layer, same citation-first synthesis, same audit. What changes is the loader (a DRHP is a specific PDF shape) and the output schema.

The IPO brief schema

python
class IPOBrief(BaseModel):
    company: str
    as_of: str
    business: Claim                       # how the business makes money
    financials: list[NumberedClaim]       # 3-yr revenue / PAT trend — computed
    use_of_proceeds: list[Claim]          # what they'll do with the money
    key_risks: list[Claim]                # distilled from the legally-required risk-factors section
    shareholding: list[Claim]             # promoter / shareholding pattern
    valuation_vs_peers: list[NumberedClaim]  # P/E vs listed comparables
    red_flags: list[Claim]                # RPTs, litigation, declining margins
    gmp: Claim | None = None              # ONLY with the hard caveat; never a thesis basis
M10

v3 — portfolio decision brief & the thesis journal

Why "hold/sell advice" reshaped into sourced decision factors  ·  Write broker-CSV ingest, thesis journal, per-holding fan-out  ·  New persistent state that makes the tool compound
Persistence & Threading

The reshape is the whole point: this was "hold/sell advice", and it becomes a portfolio decision brief — no verdict, a fixed schema of decision factors each filled with sourced evidence. Ingest holdings from a broker CSV (Zerodha Console / Groww export), then fan out the single-stock pipeline over each holding.

Per-holding schema

python
class HoldingBrief(BaseModel):
    ticker: str
    cost_basis: float
    current_price: NumberedClaim          # freshness-gated; caveated if stale
    since_you_bought: list[Claim]         # results, news, events since purchase date
    valuation_snapshot: list[NumberedClaim]  # vs its own history and sector
    thesis_check: Claim                   # is the ORIGINAL reason you bought it still intact?
    flags: list[Claim]                    # fixed set: results miss, pledge increase, mgmt change, sector headwind
    # again: no verdict.
M11

The supervisor of analysts (multi-agent)

Why bull, bear and flags are genuinely different jobs  ·  Write a supervisor orchestrator + specialist agents + shared scratchpad  ·  New topology, orchestration, consensus
Multi-Agent TopologiesThe Supervisor OrchestratorShared Scratchpads & Consensus

A single ReAct agent writing bull and bear cases tends to hedge — the same context that supports the bull argument softens the bear one. The reshape: make them separate specialist agents with opposed mandates, coordinated by a supervisor orchestrator. This is the applied form of three lectures at once.

text
                         ┌─────────────────┐
        query ──────────▶│   SUPERVISOR    │  plans, dispatches, assembles the brief
                         └───────┬─────────┘
              ┌──────────────────┼──────────────────┐
              ▼                  ▼                  ▼
        ┌───────────┐    ┌───────────┐    ┌───────────┐
        │ BULL      │    │ BEAR      │    │ FLAGS     │   each: own ReAct loop + tools
        │ analyst   │    │ analyst   │    │ analyst   │        mandate: make the STRONGEST
        └─────┬─────┘    └─────┬─────┘    └─────┬─────┘        case for its side, sourced
              └────────────────┼────────────────┘
                               ▼
                     ┌───────────────────┐
                     │ SHARED SCRATCHPAD │  evidence pool: computed figures + cited chunks,
                     └───────────────────┘  written once, read by all — no duplicate retrieval
A

Appendix — GraphRAG for related-party transactions

Why RPTs and promoter networks are relationships, not passages  ·  Optional a knowledge graph over entities in filings
Knowledge Graph PrimitivesLLM Entity ExtractionQuerying & Traversing GraphRAG

Some of the most important red flags aren't in any single passage — they're in the relationships: promoter A also controls vendor B, to whom the company pays inflated fees; director C sits on the board of the counterparty in a "related party transaction." Vector retrieval finds the RPT note; a knowledge graph lets you traverse the web behind it.

  • LLM entity extraction: pull entities (promoters, directors, subsidiaries, vendors) and typed edges (controls, transacts_with, director_of) from the annual report — each edge keeping the page provenance, so a graph-derived flag still cites its source.
  • Graph primitives + traversal: build the graph, then answer questions retrieval can't — "is any counterparty connected to a promoter within 2 hops?" That query is the red-flag detector.
B

Appendix — Text-to-SQL over structured fundamentals

Why Screener-style fundamentals are tabular; SQL beats RAG for them  ·  Optional a text-to-SQL route
Structured Data Orchestration (Text-to-SQL)Multi-Source Routing

Not every question wants a document. "Show me NIFTY 50 names with ROCE > 20% and debt-to-equity < 0.5" is a query over a table, not a passage to retrieve. Load Screener-exported fundamentals into SQLite and add a text-to-SQL path — and this is where the router's multi-source routing (M2) pays off: it sends structured/comparative questions to SQL and narrative ones to RAG.

C

Appendix — DSPy-tuned briefs

Why stop hand-tuning prompts; optimize them against a metric  ·  Optional declarative optimization with the audit as the metric
Declarative Optimization (DSPy)Automated Prompt TuningMulti-Hop Reasoning Compilation

Once the numbers-audit (M8) is solid, you have something rare: a hard, automatic metric for brief quality (faithfulness score + zero audit orphans). That's the exact input DSPy wants. Instead of hand-tweaking the synthesis prompt, express the brief step as a DSPy module and let it compile the prompt against your metric over an eval set of briefs.

  • Automated prompt tuning: DSPy optimizes the instructions/few-shots to maximize faithfulness — measurably better than your hand-written prompt, and you'll know by how much.
  • Multi-hop reasoning compilation: the extract → compute → cite → synthesize chain is a multi-hop program; DSPy can optimize the whole pipeline end-to-end, not just one prompt in isolation.
D

Appendix — human-in-the-loop & time travel

Why you want to steer a research run, and replay it  ·  Optional interrupts, breakpoints, checkpoint replay
Interruptions & BreakpointsTime Travel & State Forgery

The whole design principle is "stay in the loop." So make the loop literal. On top of the M7 persisted threads:

  • Interruptions & breakpoints: set a breakpoint before the agent finalizes a brief, inspect the evidence it gathered, and inject a correction ("you missed the pledge disclosure on p.44") before it synthesizes. Human-in-the-loop as a first-class graph feature, not a restart.
  • Time travel & state forgery: because every run is checkpointed, you can rewind to any step, edit the state (swap in a corrected line item, force a different retrieval), and replay forward — to see how a single changed input reshapes the brief. Invaluable for debugging why a brief said what it said.