Coverage for src / quber / playground / answers / planned.py: 82%
44 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 schema built for the question that was asked.
3Two calls. A planner reads the question alone — never the context, never the
4answer — and declares the fields the answer should have. Those declarations
5are compiled into a Pydantic model, and the answering agent is constrained to
6it through the same call `declared` uses. The only difference between the two
7approaches is who authored the model.
9The planner emits field declarations rather than raw JSON Schema. A model
10writing free-form JSON Schema produces dialects the validator then rejects,
11and that surfaces as a retry loop rather than an answer; a closed set of field
12types cannot fail that way.
13"""
15from __future__ import annotations
17from functools import lru_cache
18from typing import Any, Dict, List, Literal, Optional, Tuple, Type
20from pydantic import BaseModel, Field, create_model
21from pydantic_ai import Agent, NativeOutput
22from pydantic_ai.settings import ModelSettings
24from quber.agents.langsmith_tracer import usage_metadata_from
25from quber.playground.agent import anthropic_model
26from quber.playground.tracing import run_metadata, tracer
28PLANNER_MODEL = "claude-haiku-4-5"
30FieldType = Literal["string", "number", "string_list", "number_list"]
32_PY_TYPES: Dict[str, Any] = {
33 "string": str,
34 "number": float,
35 "string_list": List[str],
36 "number_list": List[float],
37}
39PLANNER_PROMPT = """\
40You design the return shape for a financial-document question. You are given
41the QUESTION only — never the document, never the answer. Do not attempt to
42answer it.
44Decide first whether the question asks for data or for explanation.
46- If it asks you to explain, compare, summarize, or reason, set `wants_value`
47 false and return no fields. It will be answered in prose.
48- Otherwise set `wants_value` true and declare the fields that hold the
49 answer, and only those.
51Rules for fields:
52- Declare the narrowest set that answers the question. A question naming one
53 figure gets ONE field holding that figure, plus at most the qualifiers the
54 question itself names.
55- Name fields in snake_case after what they hold.
56- Each field's description must tell the answering agent to report the figure
57 exactly as printed in the source, with no surrounding words.
58- Never declare a field for citations or sources; those are added for you.
59- Never declare a field whose value would be a sentence.
60"""
63class FieldSpec(BaseModel):
64 name: str = Field(description="snake_case field name.")
65 type: FieldType = Field(description="The field's type.")
66 description: str = Field(description="What the answering agent must put here.")
67 required: bool = True
70class Plan(BaseModel):
71 wants_value: bool = Field(
72 description="True when the question asks for data; false when it asks for explanation."
73 )
74 fields: List[FieldSpec] = Field(
75 default_factory=list, description="The answer's fields. Empty when wants_value is false."
76 )
79@lru_cache(maxsize=1)
80def _planner() -> Agent[None, Plan]:
81 return Agent(
82 anthropic_model(PLANNER_MODEL),
83 output_type=NativeOutput(Plan),
84 system_prompt=PLANNER_PROMPT,
85 model_settings=ModelSettings(temperature=0.0),
86 )
89async def plan_for(question: str) -> Plan:
90 prompt = f"QUESTION: {question}"
91 inputs = {
92 "messages": [
93 {"role": "system", "content": PLANNER_PROMPT},
94 {"role": "user", "content": prompt},
95 ]
96 }
97 async with tracer().llm_run(
98 "plan_answer", inputs, model=PLANNER_MODEL, extra_metadata=run_metadata()
99 ) as run:
100 result = await _planner().run(prompt)
101 output: Plan = result.output
102 run.outputs = {
103 "messages": [{"role": "assistant", "content": output.model_dump_json()}],
104 "usage_metadata": usage_metadata_from(result.usage),
105 }
106 return output
109def compile_model(plan: Plan) -> Type[BaseModel]:
110 """Turn a plan's field declarations into a model the answer agent fills.
112 `cited_ids` and `not_found` are appended to every compiled model, so
113 grounding and the not-present case survive whatever the planner declared.
114 A plan that declared a field by either of those names loses it to the
115 appended one, which is the intended precedence.
116 """
117 fields: Dict[str, Tuple[Any, Any]] = {}
118 for spec in plan.fields:
119 if spec.name in ("cited_ids", "not_found"):
120 continue
121 py = _PY_TYPES[spec.type]
122 if spec.required:
123 fields[spec.name] = (py, Field(description=spec.description))
124 else:
125 fields[spec.name] = (Optional[py], Field(default=None, description=spec.description))
126 fields["cited_ids"] = (
127 List[str],
128 Field(default_factory=list, description="Chunk and/or cell ids supporting the answer."),
129 )
130 fields["not_found"] = (
131 Optional[str],
132 Field(default=None, description="Set only when the context lacks the answer; say what."),
133 )
134 return create_model("PlannedAnswer", **fields) # type: ignore[call-overload]