Skip to content

feat(intake): Phase 8.0 IntakeIntentRouter — Haiku-backed dispatcher with explicit-hint short-circuit#6

Merged
blokzdev merged 1 commit into
mainfrom
claude/petpal-feeding-8.0-intent-router
May 30, 2026
Merged

feat(intake): Phase 8.0 IntakeIntentRouter — Haiku-backed dispatcher with explicit-hint short-circuit#6
blokzdev merged 1 commit into
mainfrom
claude/petpal-feeding-8.0-intent-router

Conversation

@blokzdev
Copy link
Copy Markdown
Owner

First implementation PR of the Phase 8 feeding restructure that
shipped in PR #5. Ships the harness primitive that Phases 8.1, 8.3,
and 8.4 all plug into.

Pure dispatcher. No UI. No WikiRepo changes. No agent_loop
wiring.

What landed

File Status What
lib/harness/intake/intent_router.dart new IntakeIntent flat enum + IntakeIntentRouter class
lib/app/providers.dart edit intakeIntentRouterProvider wired against haikuLlmClientProvider
test/harness/intake/intent_router_test.dart new 20 unit tests covering hint short-circuit + every fallback path
ROADMAP.md edit 8.0 [ ][x]
DECISIONS.md edit row 104 appended (one new agent-category row)

5 files, +504 / −1.

Hybrid resolution contract (DECISIONS rows 98 + 104)

Future<IntakeIntent> resolve({
  required Uint8List imageBytes,
  String? userCaption,
  IntakeIntent? explicitHint,
  String mediaType = 'image/jpeg',
});
  • explicitHint != null → returned immediately. No LLM call,
    no gate check. The caller (Phase 8.4 form toggle) already knew
    the intent.
  • explicitHint == nullVisionGate.check() → Haiku
    classification on the photo (+ optional caption) → JSON parse →
    enum value or generalMemory fallback.

Always-safe contract. Every failure mode degrades to
IntakeIntent.generalMemory: gate block, LLM throw, 8s timeout,
malformed JSON, missing intent field, JSON decoded to a non-object,
unknown drift string ("snack", "logmeal_after"). The capture
flow never has to handle a null and never has to surface an error
toast. generalMemory preserves the user's ability to log a memory
regardless of classifier health.

In-flight design correction (worth flagging)

The approved plan claimed "Haiku as classifier was considered +
deferred — would require transport per-call model override, which
the codebase doesn't have today."
That claim was wrong.

Reading lib/app/providers.dart during implementation surfaced
haikuLlmClientProvider at providers.dart:279 — wired by Phase 6
task 6.8 per DECISIONS row 41 (f) ("Sonnet for the extractor, Haiku
for [lightweight calls]"). The router is exactly the
lightweight-call use case the precedent locks. Implementation uses
Haiku via that existing seam (same pattern as
affectiveObserverProvider at line 287). Same router code; just
a better DI choice.
DECISIONS row 104 captures the corrected
rationale instead of carrying the plan's wrong claim forward.

The plan also speculated a separate intent_router_providers.dart
file. The codebase clusters all providers in lib/app/providers.dart
(single hub pattern); the new provider lives there now, sibling to
photoExtractorProvider.

Design lock (DECISIONS row 104)

  • Flat IntakeIntent enum + fromIdOrFallback factory
    mirrors PhotoSetting / PhotoActivity at
    photo_extractor.dart:159-190. Three cases is below the
    threshold where a sealed hierarchy earns its abstraction cost.
    Future lenses add cases (e.g. logGroomingAfter) without
    restructuring callers.
  • IntakeIntent? explicitHint parameter type (not
    bool? isBefore, not MealPhase?) — symmetry between input
    and output, lens-extensible without growing another optional
    parameter per lens.
  • Non-null Future<IntakeIntent> return + generalMemory
    fallback on every failure path
    — the router is the always-safe
    dispatcher.
  • 8s timeout (half of PhotoExtractor's 15s — runs upstream
    of the extractor in the same form-preview window, must fit
    underneath).
  • Haiku-backed classifier (row 41 (f) precedent reused)
    intakeIntentRouterProvider watches haikuLlmClientProvider,
    same pattern as affectiveObserverProvider. The classifier
    call is a 3-class, short-prompt task — Haiku's profile fits.
  • VisionGate routing even though intake is FREE per row 102
    — keeps the entitlement/BYOK/quota path uniform with the food
    extractor (8.1) and the rest of the photo intake stack.
  • System-prompt bias rules — "when unsure between meal phases
    prefer logMealAfter"; "when unsure between any meal intent and
    non-meal prefer generalMemory". Deliberate "soft cases get
    softer" amortization. Hazard gate (8.3) still fires on either
    meal intent.

Cost trade-off (deliberate accept)

Non-hinted food photos fire 2 vision calls: Haiku classification +
Sonnet extraction. Intake is free per row 102, so the user pays
nothing; the 8.4 form UI defaults to surfacing the before/after
toggle so the expected real-user path ships an explicit hint and
short-circuits the classifier entirely. Worst case: 1 Haiku + 1
Sonnet. Expected case: 1 Sonnet.

Test plan

  • flutter analyze --fatal-infos; echo "exit=$?"exit=0,
    "No issues found! (ran in 3.3s)".
  • flutter test --reporter expanded; echo "exit=$?"exit=0,
    1776 tests pass (baseline 1756 + 20 new router tests).
  • Targeted flutter test test/harness/intake/intent_router_test.dart
    → 20/20 in <1s.

The 20 router tests cover:

  • Explicit hint short-circuit parameterized across all 3 enum
    values (LLM never called when hint present).

  • Soft-case → intent mapping for each of the 3 intents.

  • Prompt + ImageBlock anchors in the LLM call.

  • Caption threading — present caption appears as
    TextBlock('Caption: ...'); whitespace-only caption is NOT
    threaded.

  • Code-fence stripping — model wrapping ```json ... ```
    still parses.

  • Always-safe fallback for every failure path: malformed JSON,
    missing field, drift string, non-object JSON, transport throw,
    timeout, gate block.

  • fromIdOrFallback drift tolerance tested directly (known
    names; null; unknown / drifted strings; empty string).

  • On-device — N/A for this PR. Pure harness primitive, no
    UI. The router gets exercised end-to-end on a device in
    Phase 8.4's on-device gate.

Out of scope (subsequent Phase 8 PRs)

Task What
8.1 Food-mode extractor (Sonnet vision; branches on the resolved intent)
8.2 WikiRepo.writeFoodEntry + structured food frontmatter (MealPhase enum lands here)
8.3 FoodHazardScreener + assets/hazards/food_toxins.yaml + assets/hazards/escalation.yaml
8.4 Capture-flow wiring — Feeding tile, before/after toggle, router invocation from photo_capture_screen.dart, PhotoCapturePrefill.mealPhase
8.5 Phase wrap + on-device verification (mandatory per ROADMAP)

Next session

After merge: planning the smallest follow-on task. Most likely
Phase 8.1 (food-mode extractor) — it's the second harness
primitive that 8.3 (hazard gate) screens the output of, and like
8.0 it's pure unit-testable with a fake LlmClient. Alternative:
8.2 (storage schema) if you'd prefer to land the frontmatter
contract before another LLM-shaped PR.

https://claude.ai/code/session_019ZiQQgMegczk4jZm4Ef3pB


Generated by Claude Code

…with explicit-hint short-circuit

First implementation PR of the Phase 8 feeding restructure. Ships the
harness primitive that 8.1 (food extractor), 8.3 (hazard gate), and
8.4 (capture-flow branching) all plug into. Pure dispatcher; no UI;
no WikiRepo changes; no agent_loop wiring.

## What landed

- `lib/harness/intake/intent_router.dart` — new file. Defines:
  - `IntakeIntent` flat enum (`logMealAfter`, `checkMealBefore`,
    `generalMemory`) with `fromIdOrFallback` drift-tolerant factory,
    mirroring the `PhotoSetting` / `PhotoActivity` precedent at
    photo_extractor.dart:159–190.
  - `IntakeIntentRouter` class. Constructor takes `LlmClient`,
    `VisionGate`, optional 8s timeout. `resolve(...)` signature:
    `imageBytes` + optional `userCaption` + optional `explicitHint`
    + `mediaType`. Returns non-nullable `Future<IntakeIntent>`.
    Locked system prompt + user-instruction const.
- `lib/app/providers.dart` — `intakeIntentRouterProvider` wired
  against `haikuLlmClientProvider` (not `llmClientProvider`) per
  DECISIONS row 41 (f) Sonnet/Haiku split precedent. Imports
  threaded.
- `test/harness/intake/intent_router_test.dart` — 20 unit tests
  using the inline `_MockLlm` + `_BlockingGate` pattern from
  photo_extractor_test.dart. Covers: explicit-hint short-circuit
  (parameterized across all enum values), each soft-case →
  intent mapping, prompt + ImageBlock anchors, caption threading
  (present + whitespace-only), code-fence stripping, every
  always-safe fallback path (malformed JSON, missing field, drift
  string, non-object, transport throw, timeout, gate block), and
  drift tolerance of `fromIdOrFallback` directly.
- `ROADMAP.md` — 8.0 `[ ]` → `[x]`.
- `DECISIONS.md` — row 104 appended.

## Hybrid resolution contract (row 98 + 104)

- `explicitHint != null` → returned immediately; no LLM call,
  no gate check.
- `explicitHint == null` → gate check → Haiku classification on
  the photo (+ optional caption) → JSON parse → enum value or
  `generalMemory` fallback.

The router is the always-safe dispatcher. **Every failure mode
degrades to `IntakeIntent.generalMemory`**: gate block, LLM throw,
timeout, malformed JSON, missing field, JSON decoded to non-object,
unknown drift value. The capture flow never has to handle a null,
never has to surface an error toast. `generalMemory` preserves the
user's ability to log a memory regardless of classifier health.

## In-flight design corrections

The approved plan claimed "Haiku as classifier was considered +
deferred — would require transport per-call model override, which
the codebase doesn't have today." That claim was wrong. Reading
`lib/app/providers.dart` during implementation surfaced
`haikuLlmClientProvider` at line 279 (Phase 6 task 6.8, DECISIONS
row 41 (f) — "Sonnet for the extractor, Haiku for lightweight
calls"). The router is exactly the lightweight-call use case the
precedent locked. Implementation uses Haiku (matching the
`affectiveObserverProvider` wiring at line 287); DECISIONS row 104
captures the corrected rationale. Same router code; different
DI choice.

Plan also speculated a separate `intent_router_providers.dart`
file; reading the codebase showed all providers cluster in
`lib/app/providers.dart` (single hub pattern). Provider lives
there now, sibling to `photoExtractorProvider`.

## Locked design (DECISIONS row 104)

- Flat `IntakeIntent` enum + `fromIdOrFallback` factory.
- `IntakeIntent? explicitHint` parameter type (symmetry between
  input and output; lens-extensible without parameter growth).
- Non-null `Future<IntakeIntent>` return + `generalMemory`
  fallback on every failure path.
- 8s timeout (half of PhotoExtractor's 15s; runs upstream of
  the extractor in the same form-preview window).
- Haiku transport via `haikuLlmClientProvider` (row 41 (f)).
- `VisionGate` routing for entitlement-path uniformity even
  though intake is FREE per row 102.
- System-prompt bias rules ("when unsure prefer logMealAfter";
  "when unsure prefer generalMemory") — deliberate "soft cases
  get softer" amortization. Hazard gate (8.3) still fires on
  either meal intent.

## Cost trade-off (deliberate accept)

Non-hinted food photos fire 2 vision calls: Haiku classification
+ Sonnet extraction. Intake is free per row 102; the 8.4 form UI
defaults to surfacing the before/after toggle, so the expected
real-user path ships an explicit hint and short-circuits the
classifier entirely. Worst case is one Haiku + one Sonnet; expected
case is one Sonnet.

## Verification

- `flutter analyze --fatal-infos; echo "exit=$?"` → `exit=0`,
  "No issues found! (ran in 3.3s)".
- `flutter test --reporter expanded; echo "exit=$?"` → `exit=0`,
  **1776 tests pass** (baseline 1756 + 20 new router tests; matches
  plan's predicted ~1769 within noise).
- Targeted `flutter test test/harness/intake/intent_router_test.dart`
  → 20/20 pass in <1s.
- On-device: N/A — pure harness primitive, no UI. The router is
  exercised end-to-end on a device in Phase 8.4's on-device gate.

## Out of scope (subsequent Phase 8 PRs)

- 8.1 food-mode extractor (separate Sonnet vision call branched
  off the router's resolved intent).
- 8.2 `WikiRepo.writeFoodEntry` + structured frontmatter.
- 8.3 `FoodHazardScreener` + `assets/hazards/food_toxins.yaml` +
  `assets/hazards/escalation.yaml`.
- 8.4 capture-flow wiring (Feeding tile on `_QuickCaptureRow`,
  before/after toggle, router invocation from
  `photo_capture_screen.dart`, `PhotoCapturePrefill.mealPhase`
  extension).
- 8.5 phase wrap + on-device verification.

https://claude.ai/code/session_019ZiQQgMegczk4jZm4Ef3pB
@blokzdev blokzdev merged commit 2b3bd78 into main May 30, 2026
2 checks passed
@blokzdev blokzdev deleted the claude/petpal-feeding-8.0-intent-router branch May 30, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants