What changed, and why

W2's ASK path — propose a category, email the owner, wait — always ran the model, even for a payee whose category the owner had already confirmed enough times for its learned rule to reach trusted. The rule's knowledge was demoted to a one-line prompt hint, and the model re-derived the owner's own repeated answer on every new charge from that payee.

This PR lets a trusted matching rule be the proposal: same category, the same honest rationale the hint used, sources=rule, no model call. The human still decides — the deterministic gate alone grants autonomy, so nothing about write authority moves. As rules accumulate, this removes the proposal call for every repeat payee, the most common model call in the system.

The rule-answered proposal

_proposal_from_rule walks the matching rules and returns a proposal from the first one that qualifies. The bar is deliberately specific: trust must be TRUSTED (consistency-proven, the same threshold bless-eligibility uses), the action must be a single category, and that category must still be a live candidate. Anything weaker returns None and the model path runs unchanged.

Trusted + single-category + still-a-candidate, or None.

src/ynab_agent/agentic/enrich.py · 387 lines
src/ynab_agent/agentic/enrich.py387 lines · Python
⋯ 214 lines hidden (lines 1–214)
1"""The enrichment agent: propose a category for a transaction (SPEC §4.1).
2 
3The agentic half of the ``enrich`` step. Given a transaction's facts and the
4candidate budget categories, a Pydantic AI agent picks the best category, rates
5its confidence, and explains why. The deterministic gate (``policy.gate``) then
6decides auto-apply vs. ask — confidence here is *framing only*, never a gate
7(principle 6). :func:`to_proposal` maps the agent's structured output onto the
8domain :class:`~ynab_agent.domain.proposal.Proposal`.
9 
10The model is injected per run so tests drive a ``TestModel``/``FunctionModel``
11offline; production uses :func:`~ynab_agent.agentic.model.build_model` (Ollama).
12"""
13 
14from __future__ import annotations
15 
16from typing import TYPE_CHECKING
17 
18from pydantic import Field
19from pydantic_ai import Agent
20 
21from ynab_agent.agentic.model import run_structured
22from ynab_agent.domain.allocations import ProposedCategory
23from ynab_agent.domain.base import Frozen
24from ynab_agent.domain.enums import (
25 Confidence,
26 ReviewVerdict,
27 SourceKind,
28 TrustState,
30from ynab_agent.domain.events import AskHuman, AutoApply
31from ynab_agent.domain.ids import CategoryId
32from ynab_agent.domain.proposal import Proposal, ProposalSource
33from ynab_agent.policy.gate import (
34 GateVerdict,
35 build_auto_decision,
36 evaluate_gate,
37 matching_rules,
39 
40if TYPE_CHECKING:
41 import datetime
42 from collections.abc import Iterable
43 
44 from pydantic_ai.models import Model
45 
46 from ynab_agent.domain.events import EnrichmentOutcome
47 from ynab_agent.domain.rule import Rule
48 from ynab_agent.domain.transaction import YnabSnapshot
49 from ynab_agent.policy.floor import AutoActionCounters
50 
51 
52class CandidateCategory(Frozen):
53 """One budget category the agent may choose from."""
54 
55 id: str
56 name: str
57 
58 
59class EnrichmentRequest(Frozen):
60 """The facts the agent reasons over to propose a category."""
61 
62 payee: str
63 amount_display: str
64 candidates: tuple[CandidateCategory, ...] = Field(min_length=1)
65 memo: str | None = None
66 rule_hint: str | None = None
67 
68 
69class EnrichmentSuggestion(Frozen):
70 """The agent's structured proposal (mapped to a domain Proposal)."""
71 
72 category_id: str
73 confidence: Confidence
74 rationale: str
75 alternatives: tuple[str, ...] = ()
76 
77 
78_SYSTEM_PROMPT = """\
79You categorize a single bank transaction for a personal budget. You are given
80the payee, amount, an optional memo, an optional hint from a learned rule, and a
81list of candidate budget categories (each with an id and a name).
82 
83Choose the SINGLE best category for the transaction. Your `category_id` MUST be
84one of the provided candidate ids — never invent one. Also list up to 2
85`alternatives`: the next-most-likely candidate ids (also from the list, never
86the chosen one) the owner might prefer — or leave empty if none fit. Rate your
87confidence (high / medium / low) and give a one-sentence rationale. Confidence
88is framing only; a human or a trusted rule decides whether to auto-apply."""
89 
90_AGENT: Agent[None, EnrichmentSuggestion] = Agent(
91 output_type=EnrichmentSuggestion,
92 system_prompt=_SYSTEM_PROMPT,
94 
95 
96def _format_request(request: EnrichmentRequest) -> str:
97 """Render the request as the agent's user prompt."""
98 lines = [
99 f"Payee: {request.payee}",
100 f"Amount: {request.amount_display}",
101 ]
102 if request.memo:
103 lines.append(f"Memo: {request.memo}")
104 if request.rule_hint:
105 lines.append(f"Rule hint: {request.rule_hint}")
106 lines.append("Candidate categories:")
107 lines.extend(f" - {c.name} (id: {c.id})" for c in request.candidates)
108 return "\n".join(lines)
109 
110 
111async def propose(
112 request: EnrichmentRequest, *, model: Model | None = None
113) -> EnrichmentSuggestion:
114 """Run the enrichment agent for one transaction (SPEC §4.1).
115 
116 Args:
117 request: The transaction facts and candidate categories.
118 model: A model to use; defaults to the configured Ollama/Gemma.
119 
120 Returns:
121 The agent's structured category suggestion.
122 """
123 return await run_structured(
124 _AGENT,
125 _format_request(request),
126 output_type=EnrichmentSuggestion,
127 model=model,
128 )
129 
130 
131def validate_suggestion(
132 suggestion: EnrichmentSuggestion,
133 candidates: tuple[CandidateCategory, ...],
134) -> EnrichmentSuggestion:
135 """Reject a hallucinated category id; drop hallucinated alternatives.
136 
137 The model's ``category_id`` MUST be a candidate id — an invented one would
138 flow into the proposal and surface as a raw id in the email subject (and a
139 write against it would 400). Raising lets the enclosing activity retry,
140 which re-runs the model. Alternatives are framing only, so invalid ones are
141 silently dropped rather than failing the run.
142 """
143 valid = {c.id for c in candidates}
144 if suggestion.category_id not in valid:
145 msg = (
146 f"model proposed unknown category id "
147 f"{suggestion.category_id!r} (not a candidate)"
148 )
149 raise ValueError(msg)
150 kept = tuple(a for a in suggestion.alternatives if a in valid)
151 if kept == suggestion.alternatives:
152 return suggestion
153 return suggestion.model_copy(update={"alternatives": kept})
154 
155 
156def to_proposal(suggestion: EnrichmentSuggestion) -> Proposal:
157 """Map the agent's suggestion onto a domain Proposal (SPEC §4.1).
158 
159 The model's runner-up ids become the proposal's alternatives (the chosen
160 one filtered out defensively), surfaced in the email so the owner can pick
161 one at a glance.
162 """
163 alternatives = tuple(
164 CategoryId(alt)
165 for alt in suggestion.alternatives
166 if alt and alt != suggestion.category_id
167 )
168 return Proposal(
169 allocation=ProposedCategory(
170 category=CategoryId(suggestion.category_id)
171 ),
172 confidence=suggestion.confidence,
173 rationale=suggestion.rationale,
174 sources=(ProposalSource(kind=SourceKind.MODEL),),
175 alternatives=alternatives,
176 )
177 
178 
179def _blessed_category_id(rule: Rule) -> str | None:
180 """The single category a rule auto-applies, or ``None`` for a split.
181 
182 The safety review compares a single independent category against the rule's;
183 a split action has no single category to judge, so the review is skipped for
184 it (learned-eligible rules are always single-category, so this is rare).
185 """
186 allocation = rule.action.allocation
187 if isinstance(allocation, ProposedCategory):
188 return str(allocation.category)
189 return None
190 
191 
192def _rule_hint(
193 snapshot: YnabSnapshot,
194 rules: tuple[Rule, ...],
195 candidates: tuple[CandidateCategory, ...],
196) -> str | None:
197 """A one-line hint from the first matching rule, for the ASK proposal.
198 
199 A matching-but-not-blessed rule (learned, or eligible-awaiting-blessing)
200 still encodes how this payee was handled before — exactly the context the
201 proposing model should weigh. Only the ASK-path prompt gets it; the §0.6
202 clean-context review must stay blind to the rule's choice.
203 """
204 names = {c.id: c.name for c in candidates}
205 for rule in matching_rules(rules, snapshot):
206 category_id = _blessed_category_id(rule)
207 if category_id is not None and category_id in names:
208 return (
209 f"past transactions from this payee were filed under "
210 f"'{names[category_id]}'"
211 )
212 return None
213 
214 
215def _proposal_from_rule(
216 snapshot: YnabSnapshot,
217 rules: tuple[Rule, ...],
218 candidates: tuple[CandidateCategory, ...],
219) -> Proposal | None:
220 """A deterministic ASK proposal from a trusted matching rule, or None.
221 
222 A rule that reached ``trusted`` by consistency already encodes the
223 owner's repeated answer for this payee; re-deriving it with a model call
224 adds latency, not information — and the owner still decides, since the
225 deterministic gate alone grants autonomy (SPEC §14). Only a
226 single-category rule whose category is still a live candidate
227 qualifies; splits, lower trust, and stale category ids fall through to
228 the model. Confidence is framing only, and a consistency-proven rule is
229 exactly the high-confidence case.
230 """
231 names = {c.id: c.name for c in candidates}
232 for rule in matching_rules(rules, snapshot):
233 if rule.trust is not TrustState.TRUSTED:
234 continue
235 category_id = _blessed_category_id(rule)
236 if category_id is None or category_id not in names:
237 continue
238 return Proposal(
239 allocation=ProposedCategory(category=CategoryId(category_id)),
240 confidence=Confidence.HIGH,
241 rationale=(
242 f"past transactions from this payee were filed under "
243 f"'{names[category_id]}'"
244 ),
245 sources=(ProposalSource(kind=SourceKind.RULE),),
246 alternatives=(),
247 )
248 return None
249 
250 
⋯ 137 lines hidden (lines 251–387)
251def review_auto_apply(
252 blessed_category_id: str, suggestion: EnrichmentSuggestion
253) -> ReviewVerdict:
254 """The agent-powered safety review's one-way ratchet (SPEC §0.6 Layer 2).
255 
256 An independent, *clean-context* model categorization (run with no knowledge
257 of the rule's choice, to stay unbiased) judges the impending auto-apply: it
258 ``PROCEED``s only if it considers the blessed category plausible — the
259 category it chose or listed as an alternative — and otherwise
260 ``ESCALATE_TO_HUMAN``. It can only hold an auto-apply back, never grant one,
261 so the deterministic gate still authorizes (principle 6).
262 """
263 plausible = {suggestion.category_id, *suggestion.alternatives}
264 if blessed_category_id in plausible:
265 return ReviewVerdict.PROCEED
266 return ReviewVerdict.ESCALATE_TO_HUMAN
267 
268 
269def _escalation_proposal(
270 suggestion: EnrichmentSuggestion, blessed_category_id: str
271) -> Proposal:
272 """The proposal emailed when the review holds an auto-apply back.
273 
274 Leads with the model's independent pick and offers the usual auto category
275 as an alternative, so the owner sees both views and the disagreement that
276 triggered the question.
277 """
278 base = to_proposal(suggestion)
279 blessed = CategoryId(blessed_category_id)
280 # to_proposal always builds a single-category allocation; narrow for mypy.
281 allocation = base.allocation
282 top = (
283 allocation.category
284 if isinstance(allocation, ProposedCategory)
285 else None
286 )
287 alternatives = base.alternatives
288 if blessed != top and blessed not in alternatives:
289 alternatives = (blessed, *alternatives)
290 return base.model_copy(
291 update={
292 "alternatives": alternatives,
293 "rationale": (
294 "I'd usually auto-file this payee, but this charge looked "
295 "different from the usual — confirming before I apply it."
296 ),
297 }
298 )
299 
300 
301async def decide_enrichment(
302 snapshot: YnabSnapshot,
303 candidates: tuple[CandidateCategory, ...],
304 rules: Iterable[Rule],
305 counters: AutoActionCounters,
306 *,
307 now: datetime.datetime,
308 model: Model | None = None,
309) -> EnrichmentOutcome:
310 """Compose the enrich step: gate, then a safety review (SPEC §4.1, §0.6).
311 
312 The deterministic gate decides autonomy from the rules alone — a single
313 blessed rule may auto-apply *its* action (the model never authorizes a
314 write, principle 6). Before an auto-apply lands, an independent
315 *clean-context* model review judges it (:func:`review_auto_apply`); a
316 disagreement holds it back to ASK (a one-way ratchet — it can only veto). A
317 gated ASK runs the same model to produce the best-guess proposal the email
318 shows.
319 
320 Args:
321 snapshot: The transaction being enriched.
322 candidates: The budget categories the agent may choose from.
323 rules: The rules in scope for the gate.
324 counters: The auto-action counters the hard floor reads.
325 now: The decision timestamp (the activity supplies it).
326 model: A model override for tests; defaults to Ollama/Gemma.
327 
328 Returns:
329 ``AutoApply`` when a blessed rule gates it *and* the review proceeds,
330 else ``AskHuman``.
331 """
332 rules = tuple(rules)
333 gate = evaluate_gate(snapshot, rules, counters)
334 if gate.verdict is GateVerdict.AUTO and gate.rule_id is not None:
335 rule = next((r for r in rules if r.id == gate.rule_id), None)
336 if rule is not None:
337 decision = build_auto_decision(rule, snapshot, now)
338 blessed_category_id = _blessed_category_id(rule)
339 if blessed_category_id is None:
340 return AutoApply(decision=decision)
341 # Clean context: the independent judge never sees the rule's
342 # choice — the memo is a transaction fact, so it rides along.
343 suggestion = validate_suggestion(
344 await propose(
345 EnrichmentRequest(
346 payee=snapshot.payee,
347 amount_display=str(snapshot.amount),
348 candidates=candidates,
349 memo=snapshot.memo,
350 ),
351 model=model,
352 ),
353 candidates,
354 )
355 if (
356 review_auto_apply(blessed_category_id, suggestion)
357 is ReviewVerdict.PROCEED
358 ):
359 return AutoApply(decision=decision)
360 return AskHuman(
361 proposal=_escalation_proposal(suggestion, blessed_category_id)
362 )
363 
364 # A consistency-proven rule answers the ASK without a model call: the
365 # owner has repeatedly confirmed this payee's category, so the model
366 # would only re-derive what the rule already knows (SPEC §14 keeps the
367 # human deciding either way). Anything weaker falls through.
368 rule_proposal = _proposal_from_rule(snapshot, rules, candidates)
369 if rule_proposal is not None:
370 return AskHuman(proposal=rule_proposal)
371 
372 # The model ASK-path proposal gets every fact available: the memo and any
373 # matching rule's history (the clean-context review above never does).
374 suggestion = validate_suggestion(
375 await propose(
376 EnrichmentRequest(
377 payee=snapshot.payee,
378 amount_display=str(snapshot.amount),
379 candidates=candidates,
380 memo=snapshot.memo,
381 rule_hint=_rule_hint(snapshot, rules, candidates),
382 ),
383 model=model,
384 ),
385 candidates,
386 )
387 return AskHuman(proposal=to_proposal(suggestion))

Wiring: one early return on the ASK path

decide_enrichment is untouched until after the AUTO branch. The clean-context review that judges an auto-apply (SPEC §0.6 Layer 2) still runs the model on purpose — that call is a safety layer, not overhead. Only then, where the ASK proposal used to be built, the rule gets first claim; the model call below it is byte-for-byte the old behavior, hint and all.

Rule first; the model ASK (with the rule hint) is the unchanged fallback.

src/ynab_agent/agentic/enrich.py · 387 lines
src/ynab_agent/agentic/enrich.py387 lines · Python
⋯ 363 lines hidden (lines 1–363)
1"""The enrichment agent: propose a category for a transaction (SPEC §4.1).
2 
3The agentic half of the ``enrich`` step. Given a transaction's facts and the
4candidate budget categories, a Pydantic AI agent picks the best category, rates
5its confidence, and explains why. The deterministic gate (``policy.gate``) then
6decides auto-apply vs. ask — confidence here is *framing only*, never a gate
7(principle 6). :func:`to_proposal` maps the agent's structured output onto the
8domain :class:`~ynab_agent.domain.proposal.Proposal`.
9 
10The model is injected per run so tests drive a ``TestModel``/``FunctionModel``
11offline; production uses :func:`~ynab_agent.agentic.model.build_model` (Ollama).
12"""
13 
14from __future__ import annotations
15 
16from typing import TYPE_CHECKING
17 
18from pydantic import Field
19from pydantic_ai import Agent
20 
21from ynab_agent.agentic.model import run_structured
22from ynab_agent.domain.allocations import ProposedCategory
23from ynab_agent.domain.base import Frozen
24from ynab_agent.domain.enums import (
25 Confidence,
26 ReviewVerdict,
27 SourceKind,
28 TrustState,
30from ynab_agent.domain.events import AskHuman, AutoApply
31from ynab_agent.domain.ids import CategoryId
32from ynab_agent.domain.proposal import Proposal, ProposalSource
33from ynab_agent.policy.gate import (
34 GateVerdict,
35 build_auto_decision,
36 evaluate_gate,
37 matching_rules,
39 
40if TYPE_CHECKING:
41 import datetime
42 from collections.abc import Iterable
43 
44 from pydantic_ai.models import Model
45 
46 from ynab_agent.domain.events import EnrichmentOutcome
47 from ynab_agent.domain.rule import Rule
48 from ynab_agent.domain.transaction import YnabSnapshot
49 from ynab_agent.policy.floor import AutoActionCounters
50 
51 
52class CandidateCategory(Frozen):
53 """One budget category the agent may choose from."""
54 
55 id: str
56 name: str
57 
58 
59class EnrichmentRequest(Frozen):
60 """The facts the agent reasons over to propose a category."""
61 
62 payee: str
63 amount_display: str
64 candidates: tuple[CandidateCategory, ...] = Field(min_length=1)
65 memo: str | None = None
66 rule_hint: str | None = None
67 
68 
69class EnrichmentSuggestion(Frozen):
70 """The agent's structured proposal (mapped to a domain Proposal)."""
71 
72 category_id: str
73 confidence: Confidence
74 rationale: str
75 alternatives: tuple[str, ...] = ()
76 
77 
78_SYSTEM_PROMPT = """\
79You categorize a single bank transaction for a personal budget. You are given
80the payee, amount, an optional memo, an optional hint from a learned rule, and a
81list of candidate budget categories (each with an id and a name).
82 
83Choose the SINGLE best category for the transaction. Your `category_id` MUST be
84one of the provided candidate ids — never invent one. Also list up to 2
85`alternatives`: the next-most-likely candidate ids (also from the list, never
86the chosen one) the owner might prefer — or leave empty if none fit. Rate your
87confidence (high / medium / low) and give a one-sentence rationale. Confidence
88is framing only; a human or a trusted rule decides whether to auto-apply."""
89 
90_AGENT: Agent[None, EnrichmentSuggestion] = Agent(
91 output_type=EnrichmentSuggestion,
92 system_prompt=_SYSTEM_PROMPT,
94 
95 
96def _format_request(request: EnrichmentRequest) -> str:
97 """Render the request as the agent's user prompt."""
98 lines = [
99 f"Payee: {request.payee}",
100 f"Amount: {request.amount_display}",
101 ]
102 if request.memo:
103 lines.append(f"Memo: {request.memo}")
104 if request.rule_hint:
105 lines.append(f"Rule hint: {request.rule_hint}")
106 lines.append("Candidate categories:")
107 lines.extend(f" - {c.name} (id: {c.id})" for c in request.candidates)
108 return "\n".join(lines)
109 
110 
111async def propose(
112 request: EnrichmentRequest, *, model: Model | None = None
113) -> EnrichmentSuggestion:
114 """Run the enrichment agent for one transaction (SPEC §4.1).
115 
116 Args:
117 request: The transaction facts and candidate categories.
118 model: A model to use; defaults to the configured Ollama/Gemma.
119 
120 Returns:
121 The agent's structured category suggestion.
122 """
123 return await run_structured(
124 _AGENT,
125 _format_request(request),
126 output_type=EnrichmentSuggestion,
127 model=model,
128 )
129 
130 
131def validate_suggestion(
132 suggestion: EnrichmentSuggestion,
133 candidates: tuple[CandidateCategory, ...],
134) -> EnrichmentSuggestion:
135 """Reject a hallucinated category id; drop hallucinated alternatives.
136 
137 The model's ``category_id`` MUST be a candidate id — an invented one would
138 flow into the proposal and surface as a raw id in the email subject (and a
139 write against it would 400). Raising lets the enclosing activity retry,
140 which re-runs the model. Alternatives are framing only, so invalid ones are
141 silently dropped rather than failing the run.
142 """
143 valid = {c.id for c in candidates}
144 if suggestion.category_id not in valid:
145 msg = (
146 f"model proposed unknown category id "
147 f"{suggestion.category_id!r} (not a candidate)"
148 )
149 raise ValueError(msg)
150 kept = tuple(a for a in suggestion.alternatives if a in valid)
151 if kept == suggestion.alternatives:
152 return suggestion
153 return suggestion.model_copy(update={"alternatives": kept})
154 
155 
156def to_proposal(suggestion: EnrichmentSuggestion) -> Proposal:
157 """Map the agent's suggestion onto a domain Proposal (SPEC §4.1).
158 
159 The model's runner-up ids become the proposal's alternatives (the chosen
160 one filtered out defensively), surfaced in the email so the owner can pick
161 one at a glance.
162 """
163 alternatives = tuple(
164 CategoryId(alt)
165 for alt in suggestion.alternatives
166 if alt and alt != suggestion.category_id
167 )
168 return Proposal(
169 allocation=ProposedCategory(
170 category=CategoryId(suggestion.category_id)
171 ),
172 confidence=suggestion.confidence,
173 rationale=suggestion.rationale,
174 sources=(ProposalSource(kind=SourceKind.MODEL),),
175 alternatives=alternatives,
176 )
177 
178 
179def _blessed_category_id(rule: Rule) -> str | None:
180 """The single category a rule auto-applies, or ``None`` for a split.
181 
182 The safety review compares a single independent category against the rule's;
183 a split action has no single category to judge, so the review is skipped for
184 it (learned-eligible rules are always single-category, so this is rare).
185 """
186 allocation = rule.action.allocation
187 if isinstance(allocation, ProposedCategory):
188 return str(allocation.category)
189 return None
190 
191 
192def _rule_hint(
193 snapshot: YnabSnapshot,
194 rules: tuple[Rule, ...],
195 candidates: tuple[CandidateCategory, ...],
196) -> str | None:
197 """A one-line hint from the first matching rule, for the ASK proposal.
198 
199 A matching-but-not-blessed rule (learned, or eligible-awaiting-blessing)
200 still encodes how this payee was handled before — exactly the context the
201 proposing model should weigh. Only the ASK-path prompt gets it; the §0.6
202 clean-context review must stay blind to the rule's choice.
203 """
204 names = {c.id: c.name for c in candidates}
205 for rule in matching_rules(rules, snapshot):
206 category_id = _blessed_category_id(rule)
207 if category_id is not None and category_id in names:
208 return (
209 f"past transactions from this payee were filed under "
210 f"'{names[category_id]}'"
211 )
212 return None
213 
214 
215def _proposal_from_rule(
216 snapshot: YnabSnapshot,
217 rules: tuple[Rule, ...],
218 candidates: tuple[CandidateCategory, ...],
219) -> Proposal | None:
220 """A deterministic ASK proposal from a trusted matching rule, or None.
221 
222 A rule that reached ``trusted`` by consistency already encodes the
223 owner's repeated answer for this payee; re-deriving it with a model call
224 adds latency, not information — and the owner still decides, since the
225 deterministic gate alone grants autonomy (SPEC §14). Only a
226 single-category rule whose category is still a live candidate
227 qualifies; splits, lower trust, and stale category ids fall through to
228 the model. Confidence is framing only, and a consistency-proven rule is
229 exactly the high-confidence case.
230 """
231 names = {c.id: c.name for c in candidates}
232 for rule in matching_rules(rules, snapshot):
233 if rule.trust is not TrustState.TRUSTED:
234 continue
235 category_id = _blessed_category_id(rule)
236 if category_id is None or category_id not in names:
237 continue
238 return Proposal(
239 allocation=ProposedCategory(category=CategoryId(category_id)),
240 confidence=Confidence.HIGH,
241 rationale=(
242 f"past transactions from this payee were filed under "
243 f"'{names[category_id]}'"
244 ),
245 sources=(ProposalSource(kind=SourceKind.RULE),),
246 alternatives=(),
247 )
248 return None
249 
250 
251def review_auto_apply(
252 blessed_category_id: str, suggestion: EnrichmentSuggestion
253) -> ReviewVerdict:
254 """The agent-powered safety review's one-way ratchet (SPEC §0.6 Layer 2).
255 
256 An independent, *clean-context* model categorization (run with no knowledge
257 of the rule's choice, to stay unbiased) judges the impending auto-apply: it
258 ``PROCEED``s only if it considers the blessed category plausible — the
259 category it chose or listed as an alternative — and otherwise
260 ``ESCALATE_TO_HUMAN``. It can only hold an auto-apply back, never grant one,
261 so the deterministic gate still authorizes (principle 6).
262 """
263 plausible = {suggestion.category_id, *suggestion.alternatives}
264 if blessed_category_id in plausible:
265 return ReviewVerdict.PROCEED
266 return ReviewVerdict.ESCALATE_TO_HUMAN
267 
268 
269def _escalation_proposal(
270 suggestion: EnrichmentSuggestion, blessed_category_id: str
271) -> Proposal:
272 """The proposal emailed when the review holds an auto-apply back.
273 
274 Leads with the model's independent pick and offers the usual auto category
275 as an alternative, so the owner sees both views and the disagreement that
276 triggered the question.
277 """
278 base = to_proposal(suggestion)
279 blessed = CategoryId(blessed_category_id)
280 # to_proposal always builds a single-category allocation; narrow for mypy.
281 allocation = base.allocation
282 top = (
283 allocation.category
284 if isinstance(allocation, ProposedCategory)
285 else None
286 )
287 alternatives = base.alternatives
288 if blessed != top and blessed not in alternatives:
289 alternatives = (blessed, *alternatives)
290 return base.model_copy(
291 update={
292 "alternatives": alternatives,
293 "rationale": (
294 "I'd usually auto-file this payee, but this charge looked "
295 "different from the usual — confirming before I apply it."
296 ),
297 }
298 )
299 
300 
301async def decide_enrichment(
302 snapshot: YnabSnapshot,
303 candidates: tuple[CandidateCategory, ...],
304 rules: Iterable[Rule],
305 counters: AutoActionCounters,
306 *,
307 now: datetime.datetime,
308 model: Model | None = None,
309) -> EnrichmentOutcome:
310 """Compose the enrich step: gate, then a safety review (SPEC §4.1, §0.6).
311 
312 The deterministic gate decides autonomy from the rules alone — a single
313 blessed rule may auto-apply *its* action (the model never authorizes a
314 write, principle 6). Before an auto-apply lands, an independent
315 *clean-context* model review judges it (:func:`review_auto_apply`); a
316 disagreement holds it back to ASK (a one-way ratchet — it can only veto). A
317 gated ASK runs the same model to produce the best-guess proposal the email
318 shows.
319 
320 Args:
321 snapshot: The transaction being enriched.
322 candidates: The budget categories the agent may choose from.
323 rules: The rules in scope for the gate.
324 counters: The auto-action counters the hard floor reads.
325 now: The decision timestamp (the activity supplies it).
326 model: A model override for tests; defaults to Ollama/Gemma.
327 
328 Returns:
329 ``AutoApply`` when a blessed rule gates it *and* the review proceeds,
330 else ``AskHuman``.
331 """
332 rules = tuple(rules)
333 gate = evaluate_gate(snapshot, rules, counters)
334 if gate.verdict is GateVerdict.AUTO and gate.rule_id is not None:
335 rule = next((r for r in rules if r.id == gate.rule_id), None)
336 if rule is not None:
337 decision = build_auto_decision(rule, snapshot, now)
338 blessed_category_id = _blessed_category_id(rule)
339 if blessed_category_id is None:
340 return AutoApply(decision=decision)
341 # Clean context: the independent judge never sees the rule's
342 # choice — the memo is a transaction fact, so it rides along.
343 suggestion = validate_suggestion(
344 await propose(
345 EnrichmentRequest(
346 payee=snapshot.payee,
347 amount_display=str(snapshot.amount),
348 candidates=candidates,
349 memo=snapshot.memo,
350 ),
351 model=model,
352 ),
353 candidates,
354 )
355 if (
356 review_auto_apply(blessed_category_id, suggestion)
357 is ReviewVerdict.PROCEED
358 ):
359 return AutoApply(decision=decision)
360 return AskHuman(
361 proposal=_escalation_proposal(suggestion, blessed_category_id)
362 )
363 
364 # A consistency-proven rule answers the ASK without a model call: the
365 # owner has repeatedly confirmed this payee's category, so the model
366 # would only re-derive what the rule already knows (SPEC §14 keeps the
367 # human deciding either way). Anything weaker falls through.
368 rule_proposal = _proposal_from_rule(snapshot, rules, candidates)
369 if rule_proposal is not None:
370 return AskHuman(proposal=rule_proposal)
371 
372 # The model ASK-path proposal gets every fact available: the memo and any
373 # matching rule's history (the clean-context review above never does).
374 suggestion = validate_suggestion(
375 await propose(
376 EnrichmentRequest(
377 payee=snapshot.payee,
378 amount_display=str(snapshot.amount),
379 candidates=candidates,
380 memo=snapshot.memo,
381 rule_hint=_rule_hint(snapshot, rules, candidates),
382 ),
383 model=model,
384 ),
385 candidates,
386 )
⋯ 1 line hidden (lines 387–387)
387 return AskHuman(proposal=to_proposal(suggestion))

The proof: an explosive model

The discriminating test replaces propose with a stub that raises if reached, so the no-model contract is enforced, not assumed. Two fallback tests pin the other side: a stale category id and a merely-confirmed rule both still consult the model.

A trusted-but-unblessed rule, and the stub that fails the test if the model is consulted.

tests/agentic/test_enrich.py · 469 lines
tests/agentic/test_enrich.py469 lines · Python
⋯ 367 lines hidden (lines 1–367)
1"""Tests for the enrichment agent (SPEC §4.1, §0.5).
2 
3Offline tests drive a ``TestModel`` (no network, deterministic) to exercise the
4agent wiring and the domain mapping. One opt-in live test runs the real Ollama
5Gemma model — SPEC §0.5 spike #2 — and is skipped unless
6``YNAB_AGENT_LIVE_OLLAMA`` is set, so the gate stays fast and offline.
7"""
8 
9from __future__ import annotations
10 
11import datetime
12import os
13 
14import pytest
15from pydantic_ai.models import Model
16from pydantic_ai.models.test import TestModel
17 
18from ynab_agent.agentic.enrich import (
19 CandidateCategory,
20 EnrichmentRequest,
21 EnrichmentSuggestion,
22 decide_enrichment,
23 propose,
24 review_auto_apply,
25 to_proposal,
26 validate_suggestion,
28from ynab_agent.agentic.model import build_model
29from ynab_agent.domain.allocations import ProposedCategory
30from ynab_agent.domain.enums import (
31 Confidence,
32 ReviewVerdict,
33 RuleSource,
34 SourceKind,
35 TrustState,
37from ynab_agent.domain.events import AskHuman, AutoApply
38from ynab_agent.domain.ids import (
39 AccountId,
40 CategoryId,
41 RuleId,
42 YnabTransactionId,
44from ynab_agent.domain.money import Money
45from ynab_agent.domain.rule import Rule, RuleAction, RuleMatch
46from ynab_agent.domain.transaction import YnabSnapshot
47from ynab_agent.policy.floor import AutoActionCounters
48 
49_REQUEST = EnrichmentRequest(
50 payee="Blue Bottle Coffee",
51 amount_display="-$4.50",
52 candidates=(
53 CandidateCategory(id="dining", name="Dining Out"),
54 CandidateCategory(id="groceries", name="Groceries"),
55 ),
57 
58 
59async def test_propose_returns_the_models_structured_suggestion() -> None:
60 model = TestModel(
61 custom_output_args={
62 "category_id": "dining",
63 "confidence": "high",
64 "rationale": "a coffee shop",
65 }
66 )
67 out = await propose(_REQUEST, model=model)
68 assert isinstance(out, EnrichmentSuggestion)
69 assert out.category_id == "dining"
70 assert out.confidence is Confidence.HIGH
71 assert out.rationale == "a coffee shop"
72 
73 
74async def test_propose_wiring_smoke_with_default_testmodel() -> None:
75 # No custom output → TestModel autofills a valid suggestion; this just
76 # proves the agent/output-schema wiring round-trips.
77 out = await propose(_REQUEST, model=TestModel())
78 assert isinstance(out, EnrichmentSuggestion)
79 
80 
81def test_to_proposal_maps_onto_the_domain_proposal() -> None:
82 suggestion = EnrichmentSuggestion(
83 category_id="dining",
84 confidence=Confidence.MEDIUM,
85 rationale="coffee",
86 )
87 proposal = to_proposal(suggestion)
88 assert isinstance(proposal.allocation, ProposedCategory)
89 assert proposal.allocation.category == "dining"
90 assert proposal.confidence is Confidence.MEDIUM
91 assert proposal.sources[0].kind is SourceKind.MODEL
92 
93 
94def test_to_proposal_carries_alternatives_filtering_the_chosen() -> None:
95 suggestion = EnrichmentSuggestion(
96 category_id="dining",
97 confidence=Confidence.LOW,
98 rationale="maybe",
99 # the chosen id and an empty entry are filtered out defensively
100 alternatives=("coffee", "dining", "", "groceries"),
101 )
102 proposal = to_proposal(suggestion)
103 assert [str(a) for a in proposal.alternatives] == ["coffee", "groceries"]
104 
105 
106def test_build_model_constructs_an_ollama_model() -> None:
107 assert isinstance(build_model(model_name="gemma4:e4b"), Model)
108 
109 
110def test_validate_suggestion_rejects_a_hallucinated_id() -> None:
111 # An invented id would surface as a raw id in the email subject and 400 on
112 # write — raising lets the activity retry with a fresh model run.
113 bogus = EnrichmentSuggestion(
114 category_id="10683d916894", confidence=Confidence.HIGH, rationale="x"
115 )
116 with pytest.raises(ValueError, match="unknown category id"):
117 validate_suggestion(bogus, _REQUEST.candidates)
118 
119 
120def test_validate_suggestion_drops_hallucinated_alternatives() -> None:
121 # Alternatives are framing only — invalid ones are dropped, not fatal.
122 mixed = EnrichmentSuggestion(
123 category_id="dining",
124 confidence=Confidence.HIGH,
125 rationale="x",
126 alternatives=("groceries", "10683d916894"),
127 )
128 assert validate_suggestion(mixed, _REQUEST.candidates).alternatives == (
129 "groceries",
130 )
131 
132 
133def test_review_auto_apply_proceeds_when_blessed_is_plausible() -> None:
134 top = EnrichmentSuggestion(
135 category_id="dining", confidence=Confidence.HIGH, rationale="x"
136 )
137 assert review_auto_apply("dining", top) is ReviewVerdict.PROCEED
138 alt = EnrichmentSuggestion(
139 category_id="groceries",
140 confidence=Confidence.HIGH,
141 rationale="x",
142 alternatives=("dining",),
143 )
144 assert review_auto_apply("dining", alt) is ReviewVerdict.PROCEED
145 
146 
147def test_review_auto_apply_escalates_when_blessed_is_implausible() -> None:
148 other = EnrichmentSuggestion(
149 category_id="groceries",
150 confidence=Confidence.HIGH,
151 rationale="x",
152 alternatives=("software",),
153 )
154 assert review_auto_apply("dining", other) is ReviewVerdict.ESCALATE_TO_HUMAN
155 
156 
157_NOW = datetime.datetime(2026, 5, 31, 12, 0, tzinfo=datetime.UTC)
158 
159 
160def _snapshot() -> YnabSnapshot:
161 return YnabSnapshot(
162 ynab_id=YnabTransactionId("t1"),
163 account=AccountId("a1"),
164 payee="Blue Bottle Coffee",
165 amount=Money.from_currency("-4.50"),
166 txn_date=datetime.date(2026, 5, 28),
167 )
168 
169 
170def _blessed_rule() -> Rule:
171 return Rule(
172 id=RuleId("r1"),
173 match=RuleMatch(payee_pattern="Blue Bottle"),
174 action=RuleAction(
175 allocation=ProposedCategory(category=CategoryId("dining"))
176 ),
177 trust=TrustState.TRUSTED,
178 source=RuleSource.HUMAN_EXPLICIT,
179 )
180 
181 
182def _judge(category_id: str, alternatives: tuple[str, ...] = ()) -> TestModel:
183 """A TestModel whose suggestion stands in for the review's judgment."""
184 return TestModel(
185 custom_output_args={
186 "category_id": category_id,
187 "confidence": "high",
188 "rationale": "independent read",
189 "alternatives": list(alternatives),
190 }
191 )
192 
193 
194async def test_decide_enrichment_auto_applies_when_the_review_agrees() -> None:
195 # The independent review picks the same category the blessed rule would →
196 # the auto-apply proceeds (SPEC §0.6 Layer 2).
197 outcome = await decide_enrichment(
198 _snapshot(),
199 _REQUEST.candidates,
200 [_blessed_rule()],
201 AutoActionCounters(),
202 now=_NOW,
203 model=_judge("dining"),
204 )
205 assert isinstance(outcome, AutoApply)
206 assert outcome.decision.rule_id == "r1"
207 
208 
209async def test_decide_enrichment_review_proceeds_if_blessed_is_an_alt() -> None:
210 # The review's top pick differs but it still considers the blessed category
211 # plausible (an alternative) → proceed.
212 outcome = await decide_enrichment(
213 _snapshot(),
214 _REQUEST.candidates,
215 [_blessed_rule()],
216 AutoActionCounters(),
217 now=_NOW,
218 model=_judge("groceries", alternatives=("dining",)),
219 )
220 assert isinstance(outcome, AutoApply)
221 
222 
223async def test_decide_enrichment_review_escalates_on_disagreement() -> None:
224 # The independent review does not find the blessed category plausible →
225 # the one-way ratchet holds the auto-apply back to ASK.
226 outcome = await decide_enrichment(
227 _snapshot(),
228 _REQUEST.candidates,
229 [_blessed_rule()],
230 AutoActionCounters(),
231 now=_NOW,
232 model=_judge("groceries"),
233 )
234 assert isinstance(outcome, AskHuman)
235 assert isinstance(outcome.proposal.allocation, ProposedCategory)
236 # Leads with the model's independent pick; offers the usual auto category.
237 assert outcome.proposal.allocation.category == "groceries"
238 assert CategoryId("dining") in outcome.proposal.alternatives
239 
240 
241def _learned_rule() -> Rule:
242 # Matches the snapshot's payee but is NOT blessed — it cannot gate an
243 # auto-apply, only inform the ASK proposal.
244 return Rule(
245 id=RuleId("r2"),
246 match=RuleMatch(payee_pattern="Blue Bottle"),
247 action=RuleAction(
248 allocation=ProposedCategory(category=CategoryId("dining"))
249 ),
250 trust=TrustState.CONFIRMED,
251 source=RuleSource.LEARNED,
252 )
253 
254 
255def _capturing_model(prompts: list[str]) -> Model:
256 """A FunctionModel that records the user prompt and answers 'dining'."""
257 from pydantic_ai.messages import (
258 ModelMessage,
259 ModelResponse,
260 ToolCallPart,
261 UserPromptPart,
262 )
263 from pydantic_ai.models.function import AgentInfo, FunctionModel
264 
265 def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
266 for message in messages:
267 for part in message.parts:
268 if isinstance(part, UserPromptPart):
269 prompts.append(str(part.content))
270 assert info.output_tools
271 return ModelResponse(
272 parts=[
273 ToolCallPart(
274 tool_name=info.output_tools[0].name,
275 args={
276 "category_id": "dining",
277 "confidence": "high",
278 "rationale": "x",
279 },
280 )
281 ]
282 )
283 
284 return FunctionModel(call)
285 
286 
287async def test_ask_prompt_carries_the_memo_and_the_rule_hint() -> None:
288 # The model finally *sees* the memo and the payee's prior handling — the
289 # context that turns "10683d916894?" guesses into informed proposals.
290 prompts: list[str] = []
291 snapshot = _snapshot().model_copy(update={"memo": "oat latte"})
292 outcome = await decide_enrichment(
293 snapshot,
294 _REQUEST.candidates,
295 [_learned_rule()],
296 AutoActionCounters(),
297 now=_NOW,
298 model=_capturing_model(prompts),
299 )
300 assert isinstance(outcome, AskHuman)
301 (prompt,) = prompts
302 assert "Memo: oat latte" in prompt
303 assert "Rule hint:" in prompt
304 assert "Dining Out" in prompt
305 
306 
307async def test_clean_context_review_never_sees_the_rule_hint() -> None:
308 # §0.6: the independent judge must stay blind to the rule's choice — the
309 # memo (a transaction fact) rides along, the hint never does.
310 prompts: list[str] = []
311 snapshot = _snapshot().model_copy(update={"memo": "oat latte"})
312 outcome = await decide_enrichment(
313 snapshot,
314 _REQUEST.candidates,
315 [_blessed_rule()],
316 AutoActionCounters(),
317 now=_NOW,
318 model=_capturing_model(prompts),
319 )
320 assert isinstance(outcome, AutoApply)
321 (prompt,) = prompts
322 assert "Memo: oat latte" in prompt
323 assert "Rule hint:" not in prompt
324 
325 
326async def test_decide_enrichment_raises_on_a_hallucinated_id() -> None:
327 # The raw-id-in-subject bug at its source: a hallucinated id never becomes
328 # a proposal — the activity fails and retries instead.
329 model = TestModel(
330 custom_output_args={
331 "category_id": "10683d916894",
332 "confidence": "high",
333 "rationale": "??",
334 }
335 )
336 with pytest.raises(ValueError, match="unknown category id"):
337 await decide_enrichment(
338 _snapshot(),
339 _REQUEST.candidates,
340 [],
341 AutoActionCounters(),
342 now=_NOW,
343 model=model,
344 )
345 
346 
347async def test_decide_enrichment_asks_when_no_trusted_rule() -> None:
348 model = TestModel(
349 custom_output_args={
350 "category_id": "dining",
351 "confidence": "medium",
352 "rationale": "a coffee shop",
353 }
354 )
355 outcome = await decide_enrichment(
356 _snapshot(),
357 _REQUEST.candidates,
358 [],
359 AutoActionCounters(),
360 now=_NOW,
361 model=model,
362 )
363 assert isinstance(outcome, AskHuman)
364 assert isinstance(outcome.proposal.allocation, ProposedCategory)
365 assert outcome.proposal.allocation.category == "dining"
366 
367 
368def _trusted_rule() -> Rule:
369 # Consistency-proven but NOT blessed (source=learned): the gate still
370 # routes it to ASK; the proposal itself may now come from the rule.
371 return Rule(
372 id=RuleId("r3"),
373 match=RuleMatch(payee_pattern="Blue Bottle"),
374 action=RuleAction(
375 allocation=ProposedCategory(category=CategoryId("dining"))
376 ),
377 trust=TrustState.TRUSTED,
378 source=RuleSource.LEARNED,
379 )
380 
381 
382def _no_model(monkeypatch: pytest.MonkeyPatch) -> None:
383 """A trusted-rule ASK must never reach the model — make it explosive."""
384 from ynab_agent.agentic import enrich as enrich_mod
385 
386 async def _boom(request: object, *, model: object = None) -> object:
387 msg = "the model must not be called for a trusted-rule ASK"
388 raise AssertionError(msg)
389 
390 monkeypatch.setattr(enrich_mod, "propose", _boom)
391 
392 
393async def test_trusted_rule_answers_the_ask_without_a_model_call(
394 monkeypatch: pytest.MonkeyPatch,
395) -> None:
396 # The fewer-calls contract: a trusted matching rule already encodes the
397 # owner's repeated answer, so the ASK proposal is built from it
398 # deterministically — the model (explosive here) is never consulted.
399 _no_model(monkeypatch)
400 outcome = await decide_enrichment(
401 _snapshot(),
402 _REQUEST.candidates,
403 [_trusted_rule()],
404 AutoActionCounters(),
405 now=_NOW,
406 )
407 assert isinstance(outcome, AskHuman)
408 assert isinstance(outcome.proposal.allocation, ProposedCategory)
409 assert outcome.proposal.allocation.category == "dining"
410 assert outcome.proposal.sources[0].kind is SourceKind.RULE
411 assert "Dining Out" in outcome.proposal.rationale
412 
⋯ 57 lines hidden (lines 413–469)
413 
414async def test_trusted_rule_with_a_stale_category_falls_back() -> None:
415 # The category was deleted from the budget since the rule learned it —
416 # proposing it would render a raw id and 400 on write. Fall back to the
417 # model instead of trusting the stale answer.
418 stale = _trusted_rule().model_copy(
419 update={
420 "action": RuleAction(
421 allocation=ProposedCategory(category=CategoryId("deleted"))
422 )
423 }
424 )
425 model = TestModel(
426 custom_output_args={
427 "category_id": "dining",
428 "confidence": "medium",
429 "rationale": "fallback",
430 }
431 )
432 outcome = await decide_enrichment(
433 _snapshot(),
434 _REQUEST.candidates,
435 [stale],
436 AutoActionCounters(),
437 now=_NOW,
438 model=model,
439 )
440 assert isinstance(outcome, AskHuman)
441 assert outcome.proposal.sources[0].kind is SourceKind.MODEL
442 
443 
444async def test_confirmed_rule_still_consults_the_model() -> None:
445 # Below trusted, the rule is a hypothesis, not an answer: it biases the
446 # model as a hint (the existing behavior) rather than replacing it.
447 prompts: list[str] = []
448 outcome = await decide_enrichment(
449 _snapshot(),
450 _REQUEST.candidates,
451 [_learned_rule()],
452 AutoActionCounters(),
453 now=_NOW,
454 model=_capturing_model(prompts),
455 )
456 assert isinstance(outcome, AskHuman)
457 assert len(prompts) == 1
458 assert outcome.proposal.sources[0].kind is SourceKind.MODEL
459 
460 
461@pytest.mark.skipif(
462 not os.environ.get("YNAB_AGENT_LIVE_OLLAMA"),
463 reason="set YNAB_AGENT_LIVE_OLLAMA=1 to run the live Gemma smoke",
465async def test_live_gemma_categorizes_a_coffee_shop() -> None:
466 # SPEC §0.5 spike #2: real Gemma over Ollama returns usable content.
467 out = await propose(_REQUEST)
468 assert out.category_id in {"dining", "groceries"}
469 assert out.rationale

Fallbacks: stale category id → model; confirmed-only rule → model with hint.

tests/agentic/test_enrich.py · 469 lines
tests/agentic/test_enrich.py469 lines · Python
⋯ 413 lines hidden (lines 1–413)
1"""Tests for the enrichment agent (SPEC §4.1, §0.5).
2 
3Offline tests drive a ``TestModel`` (no network, deterministic) to exercise the
4agent wiring and the domain mapping. One opt-in live test runs the real Ollama
5Gemma model — SPEC §0.5 spike #2 — and is skipped unless
6``YNAB_AGENT_LIVE_OLLAMA`` is set, so the gate stays fast and offline.
7"""
8 
9from __future__ import annotations
10 
11import datetime
12import os
13 
14import pytest
15from pydantic_ai.models import Model
16from pydantic_ai.models.test import TestModel
17 
18from ynab_agent.agentic.enrich import (
19 CandidateCategory,
20 EnrichmentRequest,
21 EnrichmentSuggestion,
22 decide_enrichment,
23 propose,
24 review_auto_apply,
25 to_proposal,
26 validate_suggestion,
28from ynab_agent.agentic.model import build_model
29from ynab_agent.domain.allocations import ProposedCategory
30from ynab_agent.domain.enums import (
31 Confidence,
32 ReviewVerdict,
33 RuleSource,
34 SourceKind,
35 TrustState,
37from ynab_agent.domain.events import AskHuman, AutoApply
38from ynab_agent.domain.ids import (
39 AccountId,
40 CategoryId,
41 RuleId,
42 YnabTransactionId,
44from ynab_agent.domain.money import Money
45from ynab_agent.domain.rule import Rule, RuleAction, RuleMatch
46from ynab_agent.domain.transaction import YnabSnapshot
47from ynab_agent.policy.floor import AutoActionCounters
48 
49_REQUEST = EnrichmentRequest(
50 payee="Blue Bottle Coffee",
51 amount_display="-$4.50",
52 candidates=(
53 CandidateCategory(id="dining", name="Dining Out"),
54 CandidateCategory(id="groceries", name="Groceries"),
55 ),
57 
58 
59async def test_propose_returns_the_models_structured_suggestion() -> None:
60 model = TestModel(
61 custom_output_args={
62 "category_id": "dining",
63 "confidence": "high",
64 "rationale": "a coffee shop",
65 }
66 )
67 out = await propose(_REQUEST, model=model)
68 assert isinstance(out, EnrichmentSuggestion)
69 assert out.category_id == "dining"
70 assert out.confidence is Confidence.HIGH
71 assert out.rationale == "a coffee shop"
72 
73 
74async def test_propose_wiring_smoke_with_default_testmodel() -> None:
75 # No custom output → TestModel autofills a valid suggestion; this just
76 # proves the agent/output-schema wiring round-trips.
77 out = await propose(_REQUEST, model=TestModel())
78 assert isinstance(out, EnrichmentSuggestion)
79 
80 
81def test_to_proposal_maps_onto_the_domain_proposal() -> None:
82 suggestion = EnrichmentSuggestion(
83 category_id="dining",
84 confidence=Confidence.MEDIUM,
85 rationale="coffee",
86 )
87 proposal = to_proposal(suggestion)
88 assert isinstance(proposal.allocation, ProposedCategory)
89 assert proposal.allocation.category == "dining"
90 assert proposal.confidence is Confidence.MEDIUM
91 assert proposal.sources[0].kind is SourceKind.MODEL
92 
93 
94def test_to_proposal_carries_alternatives_filtering_the_chosen() -> None:
95 suggestion = EnrichmentSuggestion(
96 category_id="dining",
97 confidence=Confidence.LOW,
98 rationale="maybe",
99 # the chosen id and an empty entry are filtered out defensively
100 alternatives=("coffee", "dining", "", "groceries"),
101 )
102 proposal = to_proposal(suggestion)
103 assert [str(a) for a in proposal.alternatives] == ["coffee", "groceries"]
104 
105 
106def test_build_model_constructs_an_ollama_model() -> None:
107 assert isinstance(build_model(model_name="gemma4:e4b"), Model)
108 
109 
110def test_validate_suggestion_rejects_a_hallucinated_id() -> None:
111 # An invented id would surface as a raw id in the email subject and 400 on
112 # write — raising lets the activity retry with a fresh model run.
113 bogus = EnrichmentSuggestion(
114 category_id="10683d916894", confidence=Confidence.HIGH, rationale="x"
115 )
116 with pytest.raises(ValueError, match="unknown category id"):
117 validate_suggestion(bogus, _REQUEST.candidates)
118 
119 
120def test_validate_suggestion_drops_hallucinated_alternatives() -> None:
121 # Alternatives are framing only — invalid ones are dropped, not fatal.
122 mixed = EnrichmentSuggestion(
123 category_id="dining",
124 confidence=Confidence.HIGH,
125 rationale="x",
126 alternatives=("groceries", "10683d916894"),
127 )
128 assert validate_suggestion(mixed, _REQUEST.candidates).alternatives == (
129 "groceries",
130 )
131 
132 
133def test_review_auto_apply_proceeds_when_blessed_is_plausible() -> None:
134 top = EnrichmentSuggestion(
135 category_id="dining", confidence=Confidence.HIGH, rationale="x"
136 )
137 assert review_auto_apply("dining", top) is ReviewVerdict.PROCEED
138 alt = EnrichmentSuggestion(
139 category_id="groceries",
140 confidence=Confidence.HIGH,
141 rationale="x",
142 alternatives=("dining",),
143 )
144 assert review_auto_apply("dining", alt) is ReviewVerdict.PROCEED
145 
146 
147def test_review_auto_apply_escalates_when_blessed_is_implausible() -> None:
148 other = EnrichmentSuggestion(
149 category_id="groceries",
150 confidence=Confidence.HIGH,
151 rationale="x",
152 alternatives=("software",),
153 )
154 assert review_auto_apply("dining", other) is ReviewVerdict.ESCALATE_TO_HUMAN
155 
156 
157_NOW = datetime.datetime(2026, 5, 31, 12, 0, tzinfo=datetime.UTC)
158 
159 
160def _snapshot() -> YnabSnapshot:
161 return YnabSnapshot(
162 ynab_id=YnabTransactionId("t1"),
163 account=AccountId("a1"),
164 payee="Blue Bottle Coffee",
165 amount=Money.from_currency("-4.50"),
166 txn_date=datetime.date(2026, 5, 28),
167 )
168 
169 
170def _blessed_rule() -> Rule:
171 return Rule(
172 id=RuleId("r1"),
173 match=RuleMatch(payee_pattern="Blue Bottle"),
174 action=RuleAction(
175 allocation=ProposedCategory(category=CategoryId("dining"))
176 ),
177 trust=TrustState.TRUSTED,
178 source=RuleSource.HUMAN_EXPLICIT,
179 )
180 
181 
182def _judge(category_id: str, alternatives: tuple[str, ...] = ()) -> TestModel:
183 """A TestModel whose suggestion stands in for the review's judgment."""
184 return TestModel(
185 custom_output_args={
186 "category_id": category_id,
187 "confidence": "high",
188 "rationale": "independent read",
189 "alternatives": list(alternatives),
190 }
191 )
192 
193 
194async def test_decide_enrichment_auto_applies_when_the_review_agrees() -> None:
195 # The independent review picks the same category the blessed rule would →
196 # the auto-apply proceeds (SPEC §0.6 Layer 2).
197 outcome = await decide_enrichment(
198 _snapshot(),
199 _REQUEST.candidates,
200 [_blessed_rule()],
201 AutoActionCounters(),
202 now=_NOW,
203 model=_judge("dining"),
204 )
205 assert isinstance(outcome, AutoApply)
206 assert outcome.decision.rule_id == "r1"
207 
208 
209async def test_decide_enrichment_review_proceeds_if_blessed_is_an_alt() -> None:
210 # The review's top pick differs but it still considers the blessed category
211 # plausible (an alternative) → proceed.
212 outcome = await decide_enrichment(
213 _snapshot(),
214 _REQUEST.candidates,
215 [_blessed_rule()],
216 AutoActionCounters(),
217 now=_NOW,
218 model=_judge("groceries", alternatives=("dining",)),
219 )
220 assert isinstance(outcome, AutoApply)
221 
222 
223async def test_decide_enrichment_review_escalates_on_disagreement() -> None:
224 # The independent review does not find the blessed category plausible →
225 # the one-way ratchet holds the auto-apply back to ASK.
226 outcome = await decide_enrichment(
227 _snapshot(),
228 _REQUEST.candidates,
229 [_blessed_rule()],
230 AutoActionCounters(),
231 now=_NOW,
232 model=_judge("groceries"),
233 )
234 assert isinstance(outcome, AskHuman)
235 assert isinstance(outcome.proposal.allocation, ProposedCategory)
236 # Leads with the model's independent pick; offers the usual auto category.
237 assert outcome.proposal.allocation.category == "groceries"
238 assert CategoryId("dining") in outcome.proposal.alternatives
239 
240 
241def _learned_rule() -> Rule:
242 # Matches the snapshot's payee but is NOT blessed — it cannot gate an
243 # auto-apply, only inform the ASK proposal.
244 return Rule(
245 id=RuleId("r2"),
246 match=RuleMatch(payee_pattern="Blue Bottle"),
247 action=RuleAction(
248 allocation=ProposedCategory(category=CategoryId("dining"))
249 ),
250 trust=TrustState.CONFIRMED,
251 source=RuleSource.LEARNED,
252 )
253 
254 
255def _capturing_model(prompts: list[str]) -> Model:
256 """A FunctionModel that records the user prompt and answers 'dining'."""
257 from pydantic_ai.messages import (
258 ModelMessage,
259 ModelResponse,
260 ToolCallPart,
261 UserPromptPart,
262 )
263 from pydantic_ai.models.function import AgentInfo, FunctionModel
264 
265 def call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
266 for message in messages:
267 for part in message.parts:
268 if isinstance(part, UserPromptPart):
269 prompts.append(str(part.content))
270 assert info.output_tools
271 return ModelResponse(
272 parts=[
273 ToolCallPart(
274 tool_name=info.output_tools[0].name,
275 args={
276 "category_id": "dining",
277 "confidence": "high",
278 "rationale": "x",
279 },
280 )
281 ]
282 )
283 
284 return FunctionModel(call)
285 
286 
287async def test_ask_prompt_carries_the_memo_and_the_rule_hint() -> None:
288 # The model finally *sees* the memo and the payee's prior handling — the
289 # context that turns "10683d916894?" guesses into informed proposals.
290 prompts: list[str] = []
291 snapshot = _snapshot().model_copy(update={"memo": "oat latte"})
292 outcome = await decide_enrichment(
293 snapshot,
294 _REQUEST.candidates,
295 [_learned_rule()],
296 AutoActionCounters(),
297 now=_NOW,
298 model=_capturing_model(prompts),
299 )
300 assert isinstance(outcome, AskHuman)
301 (prompt,) = prompts
302 assert "Memo: oat latte" in prompt
303 assert "Rule hint:" in prompt
304 assert "Dining Out" in prompt
305 
306 
307async def test_clean_context_review_never_sees_the_rule_hint() -> None:
308 # §0.6: the independent judge must stay blind to the rule's choice — the
309 # memo (a transaction fact) rides along, the hint never does.
310 prompts: list[str] = []
311 snapshot = _snapshot().model_copy(update={"memo": "oat latte"})
312 outcome = await decide_enrichment(
313 snapshot,
314 _REQUEST.candidates,
315 [_blessed_rule()],
316 AutoActionCounters(),
317 now=_NOW,
318 model=_capturing_model(prompts),
319 )
320 assert isinstance(outcome, AutoApply)
321 (prompt,) = prompts
322 assert "Memo: oat latte" in prompt
323 assert "Rule hint:" not in prompt
324 
325 
326async def test_decide_enrichment_raises_on_a_hallucinated_id() -> None:
327 # The raw-id-in-subject bug at its source: a hallucinated id never becomes
328 # a proposal — the activity fails and retries instead.
329 model = TestModel(
330 custom_output_args={
331 "category_id": "10683d916894",
332 "confidence": "high",
333 "rationale": "??",
334 }
335 )
336 with pytest.raises(ValueError, match="unknown category id"):
337 await decide_enrichment(
338 _snapshot(),
339 _REQUEST.candidates,
340 [],
341 AutoActionCounters(),
342 now=_NOW,
343 model=model,
344 )
345 
346 
347async def test_decide_enrichment_asks_when_no_trusted_rule() -> None:
348 model = TestModel(
349 custom_output_args={
350 "category_id": "dining",
351 "confidence": "medium",
352 "rationale": "a coffee shop",
353 }
354 )
355 outcome = await decide_enrichment(
356 _snapshot(),
357 _REQUEST.candidates,
358 [],
359 AutoActionCounters(),
360 now=_NOW,
361 model=model,
362 )
363 assert isinstance(outcome, AskHuman)
364 assert isinstance(outcome.proposal.allocation, ProposedCategory)
365 assert outcome.proposal.allocation.category == "dining"
366 
367 
368def _trusted_rule() -> Rule:
369 # Consistency-proven but NOT blessed (source=learned): the gate still
370 # routes it to ASK; the proposal itself may now come from the rule.
371 return Rule(
372 id=RuleId("r3"),
373 match=RuleMatch(payee_pattern="Blue Bottle"),
374 action=RuleAction(
375 allocation=ProposedCategory(category=CategoryId("dining"))
376 ),
377 trust=TrustState.TRUSTED,
378 source=RuleSource.LEARNED,
379 )
380 
381 
382def _no_model(monkeypatch: pytest.MonkeyPatch) -> None:
383 """A trusted-rule ASK must never reach the model — make it explosive."""
384 from ynab_agent.agentic import enrich as enrich_mod
385 
386 async def _boom(request: object, *, model: object = None) -> object:
387 msg = "the model must not be called for a trusted-rule ASK"
388 raise AssertionError(msg)
389 
390 monkeypatch.setattr(enrich_mod, "propose", _boom)
391 
392 
393async def test_trusted_rule_answers_the_ask_without_a_model_call(
394 monkeypatch: pytest.MonkeyPatch,
395) -> None:
396 # The fewer-calls contract: a trusted matching rule already encodes the
397 # owner's repeated answer, so the ASK proposal is built from it
398 # deterministically — the model (explosive here) is never consulted.
399 _no_model(monkeypatch)
400 outcome = await decide_enrichment(
401 _snapshot(),
402 _REQUEST.candidates,
403 [_trusted_rule()],
404 AutoActionCounters(),
405 now=_NOW,
406 )
407 assert isinstance(outcome, AskHuman)
408 assert isinstance(outcome.proposal.allocation, ProposedCategory)
409 assert outcome.proposal.allocation.category == "dining"
410 assert outcome.proposal.sources[0].kind is SourceKind.RULE
411 assert "Dining Out" in outcome.proposal.rationale
412 
413 
414async def test_trusted_rule_with_a_stale_category_falls_back() -> None:
415 # The category was deleted from the budget since the rule learned it —
416 # proposing it would render a raw id and 400 on write. Fall back to the
417 # model instead of trusting the stale answer.
418 stale = _trusted_rule().model_copy(
419 update={
420 "action": RuleAction(
421 allocation=ProposedCategory(category=CategoryId("deleted"))
422 )
423 }
424 )
425 model = TestModel(
426 custom_output_args={
427 "category_id": "dining",
428 "confidence": "medium",
429 "rationale": "fallback",
430 }
431 )
432 outcome = await decide_enrichment(
433 _snapshot(),
434 _REQUEST.candidates,
435 [stale],
436 AutoActionCounters(),
437 now=_NOW,
438 model=model,
439 )
440 assert isinstance(outcome, AskHuman)
441 assert outcome.proposal.sources[0].kind is SourceKind.MODEL
442 
443 
444async def test_confirmed_rule_still_consults_the_model() -> None:
445 # Below trusted, the rule is a hypothesis, not an answer: it biases the
446 # model as a hint (the existing behavior) rather than replacing it.
447 prompts: list[str] = []
448 outcome = await decide_enrichment(
449 _snapshot(),
450 _REQUEST.candidates,
451 [_learned_rule()],
452 AutoActionCounters(),
453 now=_NOW,
454 model=_capturing_model(prompts),
455 )
456 assert isinstance(outcome, AskHuman)
⋯ 13 lines hidden (lines 457–469)
457 assert len(prompts) == 1
458 assert outcome.proposal.sources[0].kind is SourceKind.MODEL
459 
460 
461@pytest.mark.skipif(
462 not os.environ.get("YNAB_AGENT_LIVE_OLLAMA"),
463 reason="set YNAB_AGENT_LIVE_OLLAMA=1 to run the live Gemma smoke",
465async def test_live_gemma_categorizes_a_coffee_shop() -> None:
466 # SPEC §0.5 spike #2: real Gemma over Ollama returns usable content.
467 out = await propose(_REQUEST)
468 assert out.category_id in {"dining", "groceries"}
469 assert out.rationale

Gate: 745 passed, 7 skipped; ruff, mypy, and the workflow-determinism check clean.