Wissen QA Agent
Outline of Project
Core Objective
Build a tool-calling reasoning agent that sits on top of our existing Wissen retrieval API and produces analyst-grade answers (plus citations) to equity-research questions (Answers that are typically less than 1 page of output content). The same agent will serve two caller types: (1) humans via chat UI (rich Markdown + inline citations) and (2) upstream AI agents via a strict JSON schema.
Agent Reasoning Loop
1 . Breadth pass – single call with default top_k (5) and minimal filters.
2 . Self-reflection & scoring – LLM rates answer completeness (scale 1-10).
3 . Up to 5 refinement calls – adjusts free-text query and/or any metadata filters.
4 . Early stopwhen score ≥ 8 or five iterations exhausted.
5 . If a call returns 0 results → try an alternative query once, else surface an “insufficient evidence” flag in the agent state.
Output Contract
JSON for AI callers:
json { "answer": "Text with {{C1}}, {{C2}}…", "citations": [ { "id": "C1", "doc_label": "NVDA 10-Q Q3 FY-24", "page_no": 17, "page_uid": "2191bfb-…" } ] }
Markdown for humans:
Sentence-level inline markers (¹, ²…) mapped to a footnote block without page_uid.
Orchestration
Implemented in LangGraph:• Nodes: User ↔ Agent, Search Tool, Self-Reflexion, Formatter.• Persistent checkpointing only per live session (no long-term fact store).
Non-Goals / Out-of-Scope (for now)
• Compliance guard-rails & disclaimers (handled elsewhere).
• Token-cost optimisation & hard latency SLAs.
• Long-term knowledge base of equity facts.
Open Items Needing Final Confirmation
- Exact field names / nesting we'd like in the AI JSON
- Md citation style - superscript numbers are assumed
- Target scale for the self-score threshold
- Evaluations
Key Principles
- Separation of concern: The LLM should reason about what it needs; the schema and tool wrapper should only worry about how to express that to the API.
- This means that the Pydantic models and other validations should only validate things that the retrieval endpoint cannot safely reject or auto-correct
Decisions Made for Stage 2 (excl. Reflection)
Key Decision 1: How to engage with the Wissen Search API
- Wissen Search Space is a multidimensional grid (ticker × fiscal year × quarter × content type × event type). A FilterSet is one tiny cell in that grid. And Wissen Args is the full request the tool receives, it carries the user's natural language query plus one or more FilterSets to explore.
- Filter Set:
- class FilterSet(BaseModel):
tickers: Optional[List[str]]
fiscal_years: Optional[List[int | str]]
fiscal_quarters: Optional[List[int]]
content_types: Optional[List[str]]
event_types: Optional[List[str]]
content_uids: Optional[List[str]] - Scope of one - describes exactly one logical slice
- Light normalisers - e.g. "FY22" -> 2022, quarters validated 1-4
- Enum gates - invalid content_types / event types are rejected locally
- UID override - if content_uids is present, the other fields are ignored
- class FilterSet(BaseModel):
- Wissen Args:
- class WissenArgs(BaseModel):
query: str
filter_sets: List[FilterSet] = [FilterSet()] # default = open search
top_k: int = 5 - query - the semantic search string from the user
- filter_sets - list of FilterSet objects; empty list means no filters
- top_k - max pages per query/ combination
- class WissenArgs(BaseModel):
- Parallel fan out:
- If filter_sets has n entires, the tool wrapper fires n API calls (one per FilterSet), merges the distinct pages, and returns a single JSON blob. That lets the agent:
- Breadth pass - filter_sets=[{}] => 1 broad call
- Refine - append a second or third FilterSet without rewriting the first
- Multi-ticker / multi-year queries - supply several FilterSets at once and dedupe the client-side instead of looping synchronously
- If filter_sets has n entires, the tool wrapper fires n API calls (one per FilterSet), merges the distinct pages, and returns a single JSON blob. That lets the agent:
- Filter Set:
- Why this abstraction was made:
- Composability - filter_sets is just a Python list, the LLM can .append() a new FilterSet for each refinement iteration
- Isolation of validation - All enum and range checks happen inside the Pydantic model before any HTTP request
- Parallel breadth - Need ticker-only + ticker year + ticker event in the same iteration? Pass three FilterSets at once
- Readable tool schema - LangChain exposes the field descriptions including the allowed enum values to GPT to guide its function calls
Key Decision 2: Enum-validated filters
- Content_types and event_types are validated against the allowed lists before the API calls to document the contract and surface mistakes early
- enum lists are exposed in field descriptions sent with the tool schema which gives GPT the exact vocab, reducing hallucinations with filter names
Key Decision 3: Breadth-pass guard rail
- First tool call is pruned to only tickers + fiscal_years. This guarantees a cheap, wide search before refinement to provide some quick context
Key Decision 4: Page-merge logic inside the tool
- All /new_search responses are deduped by page_uid then returned as raw JSON
Decisions Made for Stage 3 (incl. Reflection)
- Back-up relax rule: fiscal_quarters → content_types → event_types → fiscal_years → tickers
- We basically drop the most specific filter in the above sequence in-case we have patchy data or in-case the company just doesn't provide that information in that specific category idiosyncratically
- After relaxing once if zero results are retrieved, the stop-condition that triggers the "insufficient evidence" path is pushed
- After every search call the LLM will pause and self reflect, the output will look like:
- {"score": 6, "plan": [...]}
- Where the score is a number from 1 to 10 saying how complete the current answer would be if we stopped now
- The plan is an instruction for how the agent should improve its next search. The decision has been made for the plan to be a structured output which we put through the FilterSet model and check that every content_types / event_types entry is one of the allowed enums.
- For sourcing we will be using a markdown table for the footnotes in addition to subscript citations in-line in the answer payload. The order of the sourcing is (label . page_no . page_uid) where label = (equity ticker . fiscal period . fiscal year . document type)
- Stopping Criteria:
- We’ll stop when score ≥ 9 or after 5 refinement passes (breadth + ≤5 more tool calls, within the overall 12-turn ceiling).
Project Overview — Stage 3 Actor-Critic LangGraph Agent with Structured Reflection
1 · Core Objective
Deliver an equity-research reasoning agent that can:
- Answer analyst-grade questions in ≤ 6 LLM iterations.
- Automatically decide when to call the Wissen retrieval API, refine its own queries, and cite primary documents.
- Serve two caller types out of the same loop:
- Humans → rich Markdown (inline superscripts + footnote block).
- Up-stream AI agents → strict JSON that passes Pydantic validation.
2 · Stage-3 Enhancements (vs. previous Stage 2)
| Theme | What’s new in the code | Why it matters |
|---|---|---|
| Actor-Critic loop | After every model turn the agent emits a JSON object {answer, score, tool_calls?, citations?}. A downstream “critic” parser (parse_reflection) reads that, builds human Markdown, and decides whether to launch another search. |
Separates generation from evaluation, enabling self-grading and targeted refinement. |
| Structured Reflection | Reflection output is validated by ReflectionOutput (score 1-10, optional tool_calls). |
Guarantees well-formed plans; prevents the LLM from leaking non-JSON text. |
| Hard stop rules | MAX_ITERATIONS = 6 or score ≥ 9 → exit. |
Bakes latency & cost control directly in the graph router. |
| Breadth-guard | First search call is auto-pruned to (ticker, fiscal_year) only. | Ensures a cheap “wide net” before the agent starts adding expensive filters. |
| Azure-only LLM | get_llm() throws if required Azure env vars are missing. |
Deployment environment is explicit; easier ops. |
| Tool-spec exposure | LLM.bind_tools([wissen_equity_search]) sends full enum vocab for filters to GPT. |
Minimises hallucinated field names / invalid enum values. |
3 · Reasoning Loop (Actor side)
- Inject system prompt – Adds a single evergreen
SYSTEM_PROMPTdescribing the JSON contract and scoring rubric. - Inject ticker (placeholder) – Hook point for later ticker extraction heuristics.
- Call LLM – Model sees full conversation + tool schema and emits JSON.
- Parse reflection – Validates JSON;
- If
score < 9and atool_callis present → wrap it in LangChain‐styletool_callsto trigger the Tool node. - Else → format final Markdown answer with
build_markdown.
- If
- Call tool – Executes
wissen_equity_searchonce perFilterSet(with retry and client-side dedupe). - Increment & route –
turns += 1; router decides END vs call_model.
4 · Output Contract
- Markdown (human UI) — superscript numbers¹ mapped to a footnote list generated by
build_markdown().
JSON (AI callers)
{
"answer": "Plain text …",
"score": 1-10,
"tool_calls": [ { "name": "wissen_equity_search", "args": {...} } ]?,
"citations": [ { "id": "C1", "doc_label": "NVDA 10-Q Q3 FY-24", "page_no": 17, "page_uid": "2191bfb-…" } ]?
}
5 · Tool Abstraction — wissen_equity_search
@tool(args_schema=WissenArgs)
def wissen_equity_search(query, filter_sets=[FilterSet()], top_k=5): ...
- FilterSet granularity mirrors a single “slice” of the Wissen grid.
- All enums (
content_types,event_types) are validated before any HTTP call. - Parallel fan-out: one POST per
FilterSet, merged & deduped bypage_uid. - Retry once on failure; surface JSON
{"error": ...}if both attempts fail.
6 · Graph Orchestration (LangGraph)
inject_system → inject_ticker → call_model
↘︎ ↘︎
END ← router ← increment ← call_tool ← parse_reflection
- Entire state lives in a
TypedDict{messages, turns, score}— easy to hydrate / persist. - The compiled graph is exposed via
get_wissen_agent_graph()for external import or CLI testing.
7 · Guard-rails & Heuristics
- Enum gates: invalid
content_types/event_typesrejected locally. - Fiscal normalisation:
"FY22" → 2022; quarter must be 1-4. - Relax-on-zero (back-up plan): if a highly-specific
FilterSetreturns zero pages, the LLM is instructed (in the system prompt) to drop the most restrictive filter next round. - Breadth pruning: On turn 0 only
tickers&fiscal_yearssurvive.
8 · Non-Goals / Out of Scope
- Compliance & disclosure banners (handled by hosting layer).
- Long-term fact cache or learning.
- Token-level cost optimisation beyond fixed iteration cap.
9 · Open Items for Confirmation
- Citation style — current footnote format: “label p.# (UID…)”; any tweaks?
- Score threshold — staying at ≥ 9 feels conservative; adjust?
- Ticker-injection node — keep as no-op or implement regex extraction (
TICKER_REexists). - Relax-on-zero policy — sequence & aggressiveness of dropping filters.
10 · Key Principles (unchanged)
- Separation of concerns — LLM decides what it needs; tool layer decides how to express it.
- Fail fast — Pydantic validation & enum checks catch bad requests before hitting external APIs.
- Deterministic orchestration — all side-effects happen in dedicated nodes, making the graph observable and unit-testable.
TL;DR
Stage 3 wraps the original breadth-then-refine idea in a disciplined actor-critic loop, adds airtight JSON validation, and pushes all orchestration into a LangGraph state machine. The result is a self-grading, tool-calling agent that stops early when confident—or gracefully concedes when the evidence just isn’t there.
Updated Design Snapshot (v 0.2)
Area | Decisions locked-in so far |
|---|---|
Output contract | • Single Markdown format for both humans and upstream agents.• Inline superscript markers (¹, ²…) immediately after each sentence/metric that relies on evidence.• Footnote block at the bottom titled “Sources & Metadata”. Each footnote lists full citation metadata: doc_label · page_no · page_uid (_distance optional). |
Reasoning loop | 1. Breadth pass – one call with top_k=5, minimal filters.2. Self-reflection – same LLM rates completeness 1-10.3. Up to 5 refinement passes – LLM rewrites query ± tightens filters.4. Early-stop as soon as score ≥ 8 or five refinements used.5. Zero-hit fallback – if a search returns 0 results, LLM drafts one alternative query and may relax filters (e.g., drop quarter); if that still yields 0 → surface “insufficient evidence”. |
Retrieval layer | • /new_search endpoint (no auth header).• Each “page” ≈ one A4 page; we’ll keep result windows small (5-10 chunks) → no secondary re-ranker needed. |
Scoring model | Use the same model that answers the question to produce the 1-10 completeness score (for now). |
Persistence | LangGraph in-memory checkpointing scoped to the live chat session; no external store required. |
Deployment / env | Runs in LangStudio, Python 3.x, Azure OpenAI primary (fallback to standard OpenAI already in starter code). No rate-limit or version-flag concerns. |
Open items | • Exact Markdown template for the Sources & Metadata block (e.g., bullet list vs table).• Definition of the “insufficient evidence” message text. |
Let me know if any of the above needs tweaking; otherwise I’ll start refactoring the graph to:
- (a) collapse the JSON/Markdown formatter into one Markdown-only node,*
- (b) wire in the self-reflection scorer,*
- (c) add the zero-hit fallback branch.*