Coverage for src / quber / playground / answers / fixed.py: 70%
27 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"""Answering into a fixed envelope whose payload is a closed set of shapes.
3The agent always returns `Answer`. It chooses the payload — scalar, series,
4grid, prose, unanswerable — from the union, so deciding whether the question
5wants a figure or an explanation happens inside the same call that answers it.
6No separate classification step, and no schema is invented at runtime: every
7shape a caller can receive is written down in `shapes`.
8"""
10from __future__ import annotations
12from functools import lru_cache
13from typing import Any, Dict, List, Optional
15from pydantic_ai import Agent, NativeOutput
16from pydantic_ai.settings import ModelSettings
18from quber.agents.langsmith_tracer import usage_metadata_from
19from quber.playground.agent import DEFAULT_MODEL, answer_model, build_context
20from quber.playground.answers.document import DocumentIdentity, with_document
21from quber.playground.answers.shapes import Answer
22from quber.playground.retrieval import RetrievedChunk
23from quber.playground.tracing import run_metadata, tracer
25SYSTEM_PROMPT = """\
26You are a financial-document analyst answering questions about ONE parsed
27document, from a set of context chunks retrieved from it.
29Answer ONLY from the provided context. Never use outside knowledge, never
30invent figures. If the context does not hold the answer, return the
31`unanswerable` payload.
33CHOOSING THE PAYLOAD
35Read what the question asks for and return that shape:
37- `scalar` — the question names ONE figure. This is the common case for
38 questions phrased like "Return the Q1 2026 value" or "What was X as of
39 <date>". Put the figure in `value` exactly as the source prints it, with
40 its currency sign and separators, and NOTHING else. "$14.47" — not
41 "$14.47 per share", not "Book value was $14.47", not "$14.47 as of
42 March 31, 2026". The qualifiers belong in `unit`, `period`, and `label`.
43- `series` — the question asks for one measure across several periods,
44 segments, or categories.
45- `grid` — the question asks for a whole table.
46- `prose` — the question asks you to explain, compare, summarize, or
47 reason. Only here may you write sentences.
48- `unanswerable` — the context does not contain what was asked.
50When a question could be read either way, prefer the narrower shape: if a
51single figure answers it, return `scalar`.
53GROUNDING
55Populate `cited_ids` with the ids supporting your answer. Each context chunk
56begins with `[chunk id=<ID> | page <N> | type <TYPE>]`. Cite the chunk `<ID>`,
57or — for tighter grounding — the finer-grained element ids exposed inside a
58chunk's content, such as table cells tagged `<td id="t0-8-1">`. On a `scalar`,
59also set `source_id` to the single cell or chunk the figure was read from, and
60make `value` character-for-character what that cell prints.
62Only cite ids that appear verbatim in the context. Never invent or construct
63an id.
64"""
67@lru_cache(maxsize=1)
68def build_agent() -> Agent[None, Answer]:
69 return Agent(
70 answer_model(),
71 output_type=NativeOutput(Answer),
72 system_prompt=SYSTEM_PROMPT,
73 model_settings=ModelSettings(temperature=0.0),
74 )
77def prompt_for(
78 question: str, chunks: List[RetrievedChunk], document: Optional[DocumentIdentity] = None
79) -> str:
80 prompt = (
81 f"Context chunks:\n\n{build_context(chunks)}\n\n"
82 f"Question: {question}\n\n"
83 "Answer from the context above, choosing the payload shape the question asks for."
84 )
85 return with_document(prompt, document)
88def trace_inputs(prompt: str, chunks: List[RetrievedChunk]) -> Dict[str, Any]:
89 return {
90 "messages": [
91 {"role": "system", "content": SYSTEM_PROMPT},
92 {"role": "user", "content": prompt},
93 ],
94 "chunk_ids": [c.chunk_id for c in chunks],
95 }
98async def answer(
99 question: str, chunks: List[RetrievedChunk], document: Optional[DocumentIdentity] = None
100) -> Answer:
101 prompt = prompt_for(question, chunks, document)
102 async with tracer().llm_run(
103 "answer_fixed", trace_inputs(prompt, chunks), model=DEFAULT_MODEL, extra_metadata=run_metadata()
104 ) as run:
105 result = await build_agent().run(prompt)
106 output: Answer = result.output
107 run.outputs = {
108 "messages": [{"role": "assistant", "content": output.model_dump_json()}],
109 "usage_metadata": usage_metadata_from(result.usage),
110 }
111 return output