Coverage for src / quber / playground / agent.py: 57%
63 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""Grounded RAG agent for the ADE playground.
3Runs the best available Claude model through Pydantic AI,
4authenticated with the Claude subscription OAuth token via the project's
5`_oauth_gate` helper (which rewrites the system prompt into the 2-block
6array format the subscription gate requires).
8The agent answers strictly from retrieved ADE chunks and returns the ids it
9grounded on. Because table chunk markdown carries per-cell ids
10(`<td id="0-8">...`), the model can cite individual cells, which the app
11resolves to page + bounding-box overlays — the "visual reference for the
12answer" behaviour from the Landing.ai playground.
13"""
15from __future__ import annotations
17from functools import lru_cache
18from typing import Any, Dict, List, Optional
20from pydantic import BaseModel, Field
21from pydantic_ai import Agent
22from pydantic_ai.models.anthropic import AnthropicModel
23from pydantic_ai.providers.anthropic import AnthropicProvider
24from pydantic_ai.settings import ModelSettings
26from quber.agents._oauth_gate import make_oauth_anthropic_model
27from quber.agents.langsmith_tracer import usage_metadata_from
28from quber.playground.answers.document import DocumentIdentity, with_document
29from quber.playground.retrieval import RetrievedChunk
30from quber.playground.tracing import run_metadata, tracer
31from quber.settings import get_settings
33# Best available Claude model for the chat/answer step.
34DEFAULT_MODEL = "claude-opus-5"
37def anthropic_model(model_id: str):
38 """An Anthropic model over whichever credential the host presents.
40 The subscription OAuth token wins when it is set, routed through the OAuth
41 gate as before. Without one the API key is used through the stock model,
42 which is how the hosted playground runs: the token never leaves the
43 developer host, and the task is given only the key. Neither set is a
44 configuration error, reported as such.
45 """
46 llm = get_settings().llm
47 if llm.anthropic_auth_token:
48 return make_oauth_anthropic_model(model_id, llm.anthropic_auth_token)
49 if llm.anthropic_api_key:
50 return AnthropicModel(model_id, provider=AnthropicProvider(api_key=llm.anthropic_api_key))
51 raise RuntimeError(
52 "Neither ANTHROPIC_AUTH_TOKEN nor ANTHROPIC_API_KEY is set; the playground needs one of them."
53 )
56def answer_model():
57 """The playground's answering model.
59 One place decides which model answers; the three answer paths all build
60 from here rather than each pinning its own copy of the name.
61 """
62 return anthropic_model(DEFAULT_MODEL)
65SYSTEM_PROMPT = """\
66You are a financial-document analyst answering questions about ONE parsed
67document. You are given a set of context chunks retrieved from that document.
69Rules:
70- Answer ONLY from the provided context chunks. Never use outside knowledge
71 and never invent figures. If the context does not contain the answer, say
72 so plainly.
73- Be precise and quantitative. Report exact values, units, and periods as
74 they appear in the source.
75- Populate `cited_ids` with the ids that support your answer. Each context
76 chunk begins with a header line: `[chunk id=<ID> | page <N> | type <TYPE>]`.
77 * By default, cite the `<ID>` from the header of each chunk you used.
78 * If a chunk's content exposes finer-grained element ids — e.g. table cells
79 tagged like `<td id="0-8">` — you MAY cite those specific cell ids instead,
80 for tighter grounding.
81 * Only cite ids that literally appear in the provided context (a header
82 `<ID>` or an element id inside a chunk's content). NEVER invent, guess, or
83 construct an id that is not present verbatim.
84"""
87class GroundedAnswer(BaseModel):
88 answer: str = Field(description="The answer, drawn only from the context chunks.")
89 cited_ids: List[str] = Field(
90 default_factory=list,
91 description="Chunk ids and/or table cell ids that support the answer.",
92 )
95@lru_cache(maxsize=1)
96def _agent() -> Agent[None, GroundedAnswer]:
97 return Agent(
98 answer_model(),
99 output_type=GroundedAnswer,
100 system_prompt=SYSTEM_PROMPT,
101 model_settings=ModelSettings(temperature=0.0),
102 )
105def build_context(chunks: List[RetrievedChunk]) -> str:
106 blocks = []
107 for c in chunks:
108 # ADE pages are 0-based; show 1-based to match the PDF viewer.
109 header = f"[chunk id={c.chunk_id} | page {c.page + 1} | type {c.chunk_type}]"
110 blocks.append(f"{header}\n{c.content}")
111 return "\n\n".join(blocks)
114def _prompt(question: str, chunks: List[RetrievedChunk], document: Optional[DocumentIdentity] = None) -> str:
115 context = build_context(chunks)
116 prompt = (
117 f"Context chunks:\n\n{context}\n\n"
118 f"Question: {question}\n\n"
119 "Answer from the context above and cite the ids you used."
120 )
121 return with_document(prompt, document)
124def _trace_inputs(prompt: str, chunks: List[RetrievedChunk]) -> Dict[str, Any]:
125 return {
126 "messages": [
127 {"role": "system", "content": SYSTEM_PROMPT},
128 {"role": "user", "content": prompt},
129 ],
130 "chunk_ids": [c.chunk_id for c in chunks],
131 }
134async def answer(
135 question: str, chunks: List[RetrievedChunk], document: Optional[DocumentIdentity] = None
136) -> GroundedAnswer:
137 prompt = _prompt(question, chunks, document)
138 async with tracer().llm_run(
139 "answer_grounded", _trace_inputs(prompt, chunks), model=DEFAULT_MODEL, extra_metadata=run_metadata()
140 ) as run:
141 result = await _agent().run(prompt)
142 output: GroundedAnswer = result.output
143 run.outputs = {
144 "messages": [{"role": "assistant", "content": output.model_dump_json()}],
145 "usage_metadata": usage_metadata_from(result.usage),
146 }
147 return output
150async def answer_stream(
151 question: str, chunks: List[RetrievedChunk], document: Optional[DocumentIdentity] = None
152):
153 """Yield ("delta", <answer so far>) as the model generates, then
154 ("final", GroundedAnswer) once the full output validates."""
155 prompt = _prompt(question, chunks, document)
156 async with tracer().llm_run(
157 "answer_grounded", _trace_inputs(prompt, chunks), model=DEFAULT_MODEL, extra_metadata=run_metadata()
158 ) as run:
159 async with _agent().run_stream(prompt) as result:
160 last = ""
161 async for partial in result.stream_output(debounce_by=0.05):
162 text = getattr(partial, "answer", "") or ""
163 if text and text != last:
164 last = text
165 yield "delta", text
166 final: GroundedAnswer = await result.get_output()
167 run.outputs = {
168 "messages": [{"role": "assistant", "content": final.model_dump_json()}],
169 "usage_metadata": usage_metadata_from(result.usage),
170 }
171 yield "final", final