From 55af56c8bc526a9d550ba697a87ab1fcf85617ce Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 18 Jul 2026 23:51:15 +0800 Subject: [PATCH] docs: plan dynamic birth time rectification --- ...dynamic-choice-birth-time-rectification.md | 1255 +++++++++++++++++ ...-guided-birth-time-rectification-design.md | 658 +++++---- 2 files changed, 1608 insertions(+), 305 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md diff --git a/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md b/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md new file mode 100644 index 00000000..13466393 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md @@ -0,0 +1,1255 @@ +# Dynamic-Choice Birth-Time Rectification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the fixed five-domain, fixed-round, text-draft birth-time flow with a model-generated one-question-at-a-time choice flow whose candidate scoring, stopping decisions, persistence, and application permissions remain deterministic and server-owned. + +**Architecture:** The Python Jyotish engine computes minute candidates, date-window opportunities, candidate partitions, information gain, and versioned scores. A constrained Mastra Agent may select one server-issued opportunity and write neutral Simplified Chinese question/option labels, while a TypeScript validator binds those labels to server-issued partition IDs. `BirthTimeJourney` persists the complete internal question, accepts only `questionId + optionId` from the client, drives scoring and stop policy, and makes terminal states irreversible within the same case. + +**Tech Stack:** Python 3.11+, TypeScript 5, Zod 3, Next.js 16.2 Route Handlers, React 19, Mastra 1.50, Supabase/PostgreSQL, Node test runner, pytest, Playwright visual QA. + +## Global Constraints + +- Preserve the dirty worktree. Never reset, restore, overwrite, or stage unrelated user changes. +- New assessments use protocol `dynamic-choice-v2`; existing fixed-question fields remain read-only legacy audit data. +- The UI never displays a fixed total question count or an adaptive round number. +- The deterministic engine may use a finite registry of scoreable experience dimensions, but v2 has no “ask every domain” checklist: opportunity gain may skip a dimension, revisit a different partition in one dimension, or stop before any nominal coverage target. +- Each generated question has 2–4 primary choices plus server-added `不确定 / 不记得` and `都不符合` choices. +- A primary choice submits immediately; it never creates a date draft, precision selector, or second confirmation screen. +- `都不符合` may collect at most 240 characters of optional context. That text is never scored directly. +- The model cannot create candidate minutes, partitions, weights, scores, confidence, progress, permissions, or application commands. +- The browser submits only `caseId`, `actionId`, `turnVersion`, `questionId`, and `optionId`; it never receives or submits a `partitionId`. +- Stop on high confidence, no useful opportunity, two consecutive effective plateaus, repeated question/partition fingerprints, explicit user finish, unrecoverable generation fallback, or 10 effective answers. +- `present_low_result`, `present_medium_result`, and `ready` are terminal for their existing `caseId`; resume cannot generate another question. +- Low and medium confidence can save a candidate range only. Only an explicitly confirmed high-confidence candidate may update `active_birth_time`. +- `reported_birth_time` is immutable. +- Keep all scoring thresholds in a versioned deterministic module; prompts and client parameters cannot override them. +- Read `frontend/node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md` and `05-server-and-client-components.md` before changing Route Handlers or server/client component boundaries. +- Add no runtime dependency. +- Every task uses red → green TDD and ends with a focused commit containing only that task's files. + +## File Responsibility Map + +New focused files: + +- `scripts/dynamic_rectification.py`: candidate-window opportunity generation and deterministic choice scoring. +- `frontend/src/lib/birth-time-dynamic-choice.ts`: browser-safe public question, option, and range schemas. +- `frontend/src/lib/birth-time-dynamic-choice-internal.ts`: server-only opportunities, private partition mappings, answers, evidence, and control state. +- `frontend/src/lib/birth-time-dynamic-stop-policy.ts`: pure stop/continue decision and plateau calculation. +- `frontend/src/lib/birth-time-dynamic-question-validator.ts`: bind model labels to server opportunities and add special options. +- `frontend/src/lib/birth-time-dynamic-transitions.ts`: pure v2 Journey transitions. +- `frontend/src/lib/birth-time-dynamic-actions.ts`: authenticated/idempotent v2 mutations. +- `frontend/src/lib/birth-time-dynamic-scoring-service.ts`: claim, execute, and complete v2 score jobs. +- `frontend/src/components/birth-time-choice-question.tsx`: click-first question and optional unmatched note UI. + +Existing files retain these roles: + +- `scripts/active_rectification_questions.py` and `active_rectification_scoring.py`: legacy fixed-question audit behavior only. +- `frontend/src/lib/birth-time-guide-agent.ts`: constrained question-generation request/output contract. +- `frontend/src/lib/birth-time-guide-service.ts`: generate and persist a v2 question; it does not score. +- `frontend/src/lib/birth-time-journey-service.ts`: protocol routing and public journey response orchestration. +- `frontend/src/lib/birth-time-journey-turn-protocol.ts`: public `NextAction` and progress protocol. +- `frontend/src/lib/birth-time-journey-turn-persistence.ts`: load public case state plus service-role-only v2 private state and save both atomically through RPCs. +- `frontend/src/hooks/use-birth-time-guided-journey.ts`: browser coordination only; no scoring or stop decisions. + +--- + +### Task 1: Dynamic Choice Contracts and Stop Policy + +**Files:** +- Create: `frontend/src/lib/birth-time-dynamic-choice.ts` +- Create: `frontend/src/lib/birth-time-dynamic-choice-internal.ts` +- Create: `frontend/src/lib/birth-time-dynamic-stop-policy.ts` +- Modify: `frontend/src/lib/birth-time-journey-turn-protocol.ts` +- Modify: `frontend/src/lib/birth-time-journey-turn.ts` +- Test: `frontend/tests/birth-time-dynamic-choice.test.ts` +- Test: `frontend/tests/birth-time-dynamic-stop-policy.test.ts` + +**Interfaces:** +- Produces `CandidateDifferencePacket`, `QuestionOpportunity`, `PersistedDynamicChoiceQuestion`, `PublicDynamicChoiceQuestion`, `StoredChoiceAnswer`, and `DynamicControlState`. +- Produces `decideDynamicStop(input: DynamicStopInput): DynamicStopDecision`. +- Replaces fixed `ask_baseline_evidence` / `ask_adaptive_evidence` in v2 with `generate_dynamic_question`, `ask_dynamic_choice`, and `clarify_unmatched_answer`. + +- [ ] **Step 1: Write failing schema tests** + +```ts +test("public questions never expose partition ids", () => { + const parsed = publicDynamicChoiceQuestionSchema.parse({ + questionId: "11111111-1111-4111-8111-111111111111", + prompt: "哪一个时间段更接近这次工作变化?", + options: [ + { optionId: "22222222-2222-4222-8222-222222222222", label: "2018—2020 年", kind: "primary" }, + { optionId: "33333333-3333-4333-8333-333333333333", label: "2021—2023 年", kind: "primary" }, + { optionId: "44444444-4444-4444-8444-444444444444", label: "不确定 / 不记得", kind: "unknown" }, + { optionId: "55555555-5555-4555-8555-555555555555", label: "都不符合", kind: "unmatched" }, + ], + }); + assert.equal("partitionId" in parsed.options[0], false); + assert.equal(publicDynamicChoiceQuestionSchema.safeParse({ + ...parsed, + options: [{ ...parsed.options[0], partitionId: "private" }, ...parsed.options.slice(1)], + }).success, false); +}); + +test("internal primary choices require a server partition", () => { + assert.equal(persistedDynamicChoiceQuestionSchema.safeParse(internalQuestion).success, true); + assert.equal(persistedDynamicChoiceQuestionSchema.safeParse({ + ...internalQuestion, + options: internalQuestion.options.map((option) => option.kind === "primary" + ? { optionId: option.optionId, label: option.label, kind: option.kind, partitionId: null } + : option), + }).success, false); +}); +``` + +- [ ] **Step 2: Run the contracts test and verify RED** + +Run: `cd frontend && node --test tests/birth-time-dynamic-choice.test.ts` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `birth-time-dynamic-choice.ts`. + +- [ ] **Step 3: Add strict public/internal schemas** + +Add the browser-safe shapes to `birth-time-dynamic-choice.ts` and the partition-bearing shapes to `birth-time-dynamic-choice-internal.ts`. The internal module starts with `import "server-only"` and is never imported by a component, hook, or client transport. + +```ts +export type PublicChoiceKind = "primary" | "unknown" | "unmatched"; + +export type TimeRange = { readonly startTime: string; readonly endTime: string }; + +export type PublicDynamicChoiceQuestion = { + readonly questionId: string; + readonly prompt: string; + readonly options: readonly { + readonly optionId: string; + readonly label: string; + readonly kind: PublicChoiceKind; + }[]; +}; +``` + +Use these exact server-only shapes: + +```ts +import type { CandidateResult } from "./birth-time-evidence.ts"; +import type { PublicChoiceKind, PublicDynamicChoiceQuestion, TimeRange } from "./birth-time-dynamic-choice.ts"; + +export type EvidencePartition = { + readonly partitionId: string; + readonly descriptor: string; + readonly fallbackLabel: string; +}; + +export type ScoredEvidencePartition = EvidencePartition & { + readonly candidateScores: Readonly>; +}; + +export type QuestionOpportunity = { + readonly opportunityId: string; + readonly dimensionCode: string; + readonly neutralContext: string; + readonly estimatedInformationGain: number; + readonly candidatePartitionFingerprint: string; + readonly fallbackPrompt: string; + readonly partitions: readonly EvidencePartition[]; +}; + +export type CandidateDifferencePacket = { + readonly caseId: string; + readonly scoringVersion: "birth-time-choice-scoring-v2"; + readonly currentRange: TimeRange; + readonly opportunities: readonly QuestionOpportunity[]; + readonly askedQuestionFingerprints: readonly string[]; + readonly candidatePartitionFingerprints: readonly string[]; + readonly recentRangeHistory: readonly TimeRange[]; +}; + +export type CandidateDifferenceBuild = { + readonly packet: CandidateDifferencePacket; + readonly candidateModel: Readonly>; + readonly scoringPartitions: Readonly>; +}; + +export type PersistedDynamicChoiceQuestion = PublicDynamicChoiceQuestion & { + readonly opportunityId: string; + readonly dimensionCode: string; + readonly estimatedInformationGain: number; + readonly scoringVersion: string; + readonly source: "agent" | "fallback"; + readonly questionFingerprint: string; + readonly candidatePartitionFingerprint: string; + readonly options: readonly { + readonly optionId: string; + readonly label: string; + readonly kind: PublicChoiceKind; + readonly partitionId: string | null; + readonly candidateScores: Readonly> | null; + }[]; +}; + +export type StoredChoiceAnswer = { + readonly questionId: string; + readonly optionId: string; + readonly kind: PublicChoiceKind; + readonly opportunityId: string; + readonly answeredAt: string; +}; + +export type ServerChoiceEvidence = { + readonly questionId: string; + readonly opportunityId: string; + readonly partitionId: string; + readonly dimensionCode: string; + readonly candidateScores: Readonly>; + readonly informationGain: number; +}; + +export type DynamicChoiceScoringResult = { + readonly candidate: CandidateResult; + readonly evidenceMode: "dynamic_choice"; + readonly effectiveAnswerCount: number; + readonly dimensionCount: number; +}; + +export type PausedDynamicAction = + | { readonly kind: "generate_dynamic_question" } + | { readonly kind: "ask_dynamic_choice"; readonly questionId: string } + | { readonly kind: "clarify_unmatched_answer"; readonly questionId: string } + | { readonly kind: "retry_question_generation" } + | { readonly kind: "score_pending"; readonly jobId: string } + | { readonly kind: "retry_scoring"; readonly jobId: string }; + +export type DynamicControlState = { + readonly asOfDate: string; + readonly answeredCount: number; + readonly effectiveAnswerCount: number; + readonly plateauCount: number; + readonly questionFingerprints: readonly string[]; + readonly partitionFingerprints: readonly string[]; + readonly dismissedOpportunityIds: readonly string[]; + readonly recentRanges: readonly TimeRange[]; + readonly pausedAction: PausedDynamicAction | null; +}; +``` + +Use `.strict().readonly()` Zod objects. Enforce exactly 2–4 `primary`, exactly one `unknown`, exactly one `unmatched`, unique `optionId`, and nonempty labels up to 80 characters. Primary choices require a nonempty `partitionId` and finite `candidateScores`; both special choices require `partitionId === null` and `candidateScores === null`. + +- [ ] **Step 4: Write failing stop-policy tests** + +```ts +test("two effective unchanged scores stop without starting another question", () => { + const decision = decideDynamicStop({ + result: mediumCandidate, + effectiveAnswer: true, + previousResult: mediumCandidate, + priorPlateauCount: 1, + usefulOpportunityCount: 3, + repeatedOnly: false, + effectiveAnswerCount: 6, + }); + assert.deepEqual(decision, { kind: "finish", reason: "plateau", plateauCount: 2 }); +}); + +test("unknown answers do not advance plateau or the effective safety count", () => { + const decision = decideDynamicStop({ + result: lowCandidate, + effectiveAnswer: false, + previousResult: lowCandidate, + priorPlateauCount: 1, + usefulOpportunityCount: 2, + repeatedOnly: false, + effectiveAnswerCount: 4, + }); + assert.deepEqual(decision, { kind: "continue", plateauCount: 1 }); +}); + +test("terminal conditions are deterministic", () => { + assert.equal(decisionFor({ confidence: "high" }).reason, "high_confidence"); + assert.equal(decisionFor({ usefulOpportunityCount: 0 }).reason, "no_information_gain"); + assert.equal(decisionFor({ repeatedOnly: true }).reason, "repeated_partition"); + assert.equal(decisionFor({ effectiveAnswerCount: 10 }).reason, "safety_cap"); +}); +``` + +- [ ] **Step 5: Run the stop-policy test and verify RED** + +Run: `cd frontend && node --test tests/birth-time-dynamic-stop-policy.test.ts` + +Expected: FAIL because `decideDynamicStop` does not exist. + +- [ ] **Step 6: Implement deterministic stop ordering** + +Use this decision order so a high-confidence result cannot be hidden by a secondary reason: + +```ts +export function decideDynamicStop(input: DynamicStopInput): DynamicStopDecision { + const plateauCount = input.effectiveAnswer + ? materiallyChanged(input.previousResult, input.result) ? 0 : input.priorPlateauCount + 1 + : input.priorPlateauCount; + if (input.result.confidence === "high") return { kind: "finish", reason: "high_confidence", plateauCount }; + if (input.effectiveAnswerCount >= 10) return { kind: "finish", reason: "safety_cap", plateauCount }; + if (plateauCount >= 2) return { kind: "finish", reason: "plateau", plateauCount }; + if (input.usefulOpportunityCount === 0) return { kind: "finish", reason: "no_information_gain", plateauCount }; + if (input.repeatedOnly) return { kind: "finish", reason: "repeated_partition", plateauCount }; + return { kind: "continue", plateauCount }; +} +``` + +`materiallyChanged()` returns true when the winning range start/end changes, the winning representative changes, or the margin changes by at least 2 percentage points. + +- [ ] **Step 7: Replace the public v2 progress/action shapes** + +Add these variants without deleting the legacy parser path yet: + +```ts +type DynamicNextAction = + | { readonly kind: "generate_dynamic_question" } + | { readonly kind: "ask_dynamic_choice"; readonly question: PublicDynamicChoiceQuestion } + | { readonly kind: "clarify_unmatched_answer"; readonly questionId: string } + | { readonly kind: "retry_question_generation" } + | { readonly kind: "score_pending"; readonly jobId: string } + | { readonly kind: "retry_scoring"; readonly jobId: string } + | { readonly kind: "present_low_result"; readonly resultId: string | null } + | { readonly kind: "present_medium_result"; readonly resultId: string } + | { readonly kind: "request_candidate_confirmation"; readonly resultId: string } + | { readonly kind: "ready"; readonly activeTime: string } + | { readonly kind: "paused" }; + +type DynamicJourneyProgress = { + readonly phase: "question" | "clarification" | "scoring" | "result" | "ready" | "paused"; + readonly answeredCount: number; + readonly effectiveAnswerCount: number; + readonly currentRange: TimeRange; + readonly previousRange: TimeRange | null; + readonly plateauCount: number; +}; +``` + +Do not expose the hidden safety count or a maximum question count in either schema. + +- [ ] **Step 8: Run focused tests and commit** + +Run: `cd frontend && node --test tests/birth-time-dynamic-choice.test.ts tests/birth-time-dynamic-stop-policy.test.ts tests/birth-time-journey-turn.test.ts` + +Expected: all selected tests pass. + +```bash +git add frontend/src/lib/birth-time-dynamic-choice.ts frontend/src/lib/birth-time-dynamic-choice-internal.ts frontend/src/lib/birth-time-dynamic-stop-policy.ts frontend/src/lib/birth-time-journey-turn-protocol.ts frontend/src/lib/birth-time-journey-turn.ts frontend/tests/birth-time-dynamic-choice.test.ts frontend/tests/birth-time-dynamic-stop-policy.test.ts +git commit -m "feat: define dynamic birth time choice protocol" +``` + +--- + +### Task 2: Deterministic Candidate Opportunities and Choice Scoring + +**Files:** +- Create: `scripts/dynamic_rectification.py` +- Modify: `scripts/jyotish_api_server.py:1280-1325,1735-1755,6766-6890,7645-7660,7770-7790` +- Test: `tests/test_dynamic_rectification.py` +- Modify: `tests/test_active_rectification_api.py` + +**Interfaces:** +- Produces `build_difference_packet(request) -> dict` and `score_choice_evidence(request) -> dict`. +- Adds `POST /api/dynamic_rectification_opportunities` and `POST /api/dynamic_rectification_score`. +- Keeps `/api/active_rectification_questions`, `/api/active_rectification_score`, and `/api/active_rectification_events` unchanged for legacy cases. + +- [ ] **Step 1: Write failing opportunity tests** + +```python +def test_packet_contains_only_candidate_backed_high_gain_opportunities(monkeypatch): + monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", fake_rows) + packet = dynamic_rectification.build_difference_packet(base_request()) + assert packet["scoring_version"] == "birth-time-choice-scoring-v2" + assert packet["current_range"] == {"start_time": "05:30", "end_time": "06:00"} + assert len(packet["opportunities"]) >= 1 + for opportunity in packet["opportunities"]: + assert opportunity["estimated_information_gain"] >= 0.15 + assert 2 <= len(opportunity["partitions"]) <= 4 + assert len({item["partition_id"] for item in opportunity["partitions"]}) == len(opportunity["partitions"]) + +def test_packet_excludes_used_opportunity_and_partition_fingerprints(monkeypatch): + monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", fake_rows) + first = dynamic_rectification.build_difference_packet(base_request()) + used = first["opportunities"][0] + request = base_request() + request["dismissed_opportunity_ids"] = [used["opportunity_id"]] + request["partition_fingerprints"] = [used["candidate_partition_fingerprint"]] + second = dynamic_rectification.build_difference_packet(request) + assert all(item["opportunity_id"] != used["opportunity_id"] for item in second["opportunities"]) + assert all(item["candidate_partition_fingerprint"] != used["candidate_partition_fingerprint"] for item in second["opportunities"]) + +def test_packet_reuses_the_persisted_candidate_model(monkeypatch): + calls = [] + monkeypatch.setattr(dynamic_rectification, "_compute_candidate_model", lambda request: calls.append(request) or fake_model()) + first = dynamic_rectification.build_difference_packet(base_request()) + second = dynamic_rectification.build_difference_packet({ + **base_request(), "candidate_model": first["candidate_model"], + }) + assert len(calls) == 1 + assert second["candidate_model"] == first["candidate_model"] +``` + +- [ ] **Step 2: Run opportunity tests and verify RED** + +Run: `.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py -k packet` + +Expected: FAIL with `ImportError: cannot import name 'dynamic_rectification'`. + +- [ ] **Step 3: Generate candidate-backed date-window opportunities** + +Use minute candidates from the submitted range, the existing local chart engine, D4/D9/D10/D24/D30, Vimshottari, and Narayana Dasha. For each supported experience dimension, evaluate bounded calendar windows from age 12 through the persisted `as_of_date`. Compute each candidate chart once, then reuse it across every dimension/window. Return a compact versioned `candidate_model` containing only candidate activation numbers needed for later opportunity ranking; a subsequent request must validate and reuse that model instead of recalculating charts. A candidate joins the partition for the window with its strongest domain activation; discard opportunities with fewer than two populated partitions or normalized entropy below `0.15`. + +The exact opportunity contract is: + +```python +class EvidencePartition(TypedDict): + partition_id: str + descriptor: str + fallback_label: str + candidate_scores: dict[str, float] + +class QuestionOpportunity(TypedDict): + opportunity_id: str + dimension_code: str + neutral_context: str + estimated_information_gain: float + candidate_partition_fingerprint: str + fallback_prompt: str + partitions: list[EvidencePartition] +``` + +`candidate_scores` keys are `HH:MM` candidates inside the current range. IDs and fingerprints are SHA-256 hashes of canonical JSON containing scoring version, dimension, window boundaries, and sorted candidate memberships. Never use prose in a fingerprint. + +- [ ] **Step 4: Write failing deterministic scoring tests** + +```python +def test_primary_choice_changes_rankings_and_returns_a_real_range(): + result = dynamic_rectification.score_choice_evidence({ + **score_request(), + "choice_evidence": [{ + "question_id": str(uuid4()), + "opportunity_id": "career-window", + "partition_id": "career-2020-2022", + "dimension_code": "career", + "candidate_scores": {"05:30": 0.0, "05:31": 1.0, "05:32": 1.0, "05:33": 0.0}, + "information_gain": 0.5, + }], + }) + assert result["effective_answer_count"] == 1 + assert result["winning_segment"] == { + "start_time": "05:31", "end_time": "05:32", "representative_time": "05:31", "width_minutes": 2, + } + assert result["can_apply"] is False + +def test_unknown_and_unmatched_are_never_choice_evidence(): + with pytest.raises(ValueError, match="partition evidence"): + dynamic_rectification.score_choice_evidence({ + **score_request(), + "choice_evidence": [{"kind": "unknown"}], + }) + +def test_high_confidence_requires_versioned_hard_gates(): + result = dynamic_rectification.adjudicate_choice_rows( + decisive_rows(), effective_answer_count=4, dimension_count=3, missing_layers=[] + ) + assert result["confidence"] == "high" + assert result["can_apply"] is True + assert result["winning_segment"]["width_minutes"] <= 5 + assert result["margin_percent"] >= 20 +``` + +- [ ] **Step 5: Run scoring tests and verify RED** + +Run: `.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py -k 'primary_choice or unknown or high_confidence'` + +Expected: FAIL because choice scoring functions are absent. + +- [ ] **Step 6: Add versioned scoring gates** + +Set `ALGORITHM_VERSION = "birth-time-choice-scoring-v2"`. Sum only server-resolved primary evidence. Keep `answered_count` separate from `effective_answer_count`; the Python scorer receives only effective evidence. Return existing candidate-result compatibility fields, with `event_count = effective_answer_count`, `domain_count = dimension_count`, and an empty public `evidence` array because private choice evidence remains in the service-only table. Also return: + +```python +{ + "evidence_mode": "dynamic_choice", + "effective_answer_count": effective_answer_count, + "dimension_count": dimension_count, + "algorithm_version": ALGORITHM_VERSION, +} +``` + +High confidence requires one winning segment, at least 4 effective answers across 3 dimensions, width at most 5 minutes, margin at least 20%, and no missing mandatory layers. Medium requires one segment, at least 3 effective answers across 2 dimensions, width at most 15 minutes, and margin at least 10%. Every other result is low and `can_apply` is false. + +- [ ] **Step 7: Add strict API validation and endpoints** + +For opportunities accept only birth date, a persisted ISO `as_of_date`, start/end time, location, an optional server-owned `candidate_model`, existing choice evidence summary, dismissed opportunity IDs, and fingerprint arrays. For scoring accept only birth/location/range and server-resolved `choice_evidence`. Reject candidate models whose version, range, candidate times, or numeric activation shape do not match the request; also reject candidate times outside the submitted range, duplicate question IDs, more than 10 evidence rows, non-finite scores, unsupported dimensions, and any client-style `option_id` field. Window generation uses `as_of_date`, never the Python process clock, so an existing case remains reproducible across days. + +- [ ] **Step 8: Run Python suites and commit** + +Run: `.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py tests/test_active_rectification_api.py tests/test_active_rectification_questions.py tests/test_active_rectification_events.py` + +Expected: all selected tests pass. + +```bash +git add scripts/dynamic_rectification.py scripts/jyotish_api_server.py tests/test_dynamic_rectification.py tests/test_active_rectification_api.py +git commit -m "feat: score dynamic birth time choices" +``` + +--- + +### Task 3: TypeScript Engine Adapter and Trust Boundary + +**Files:** +- Modify: `frontend/src/lib/birth-time-journey-service.ts:1-120` +- Modify: `frontend/src/lib/birth-time-journey-engine.ts` +- Modify: `frontend/src/lib/birth-time-journey-adapters.ts` +- Modify: `frontend/src/lib/birth-time-journey-engine-model.ts` +- Modify: `frontend/src/lib/birth-time-evidence.ts:86-150` +- Test: `frontend/tests/birth-time-journey-engine.test.ts` +- Test: `frontend/tests/birth-time-journey-adapters.test.ts` + +**Interfaces:** +- Adds `buildDifferencePacket(input: DifferencePacketInput): Promise`. +- Adds `scoreChoices(input: DynamicChoiceScoreInput): Promise`. +- Preserves `scan`, `score`, and `scoreEvents` for legacy protocol cases. + +- [ ] **Step 1: Write failing adapter tests** + +```ts +test("difference packets keep candidate scores on the server-only internal shape", () => { + const build = parseCandidateDifferenceBuild(apiPacket); + assert.equal(build.scoringPartitions["career-window"][0].candidateScores["05:31"], 1); + assert.equal(build.packet.opportunities[0].estimatedInformationGain, 0.5); + assert.deepEqual(build.candidateModel, apiPacket.candidate_model); +}); + +test("choice score parser rejects model-controlled confidence fields", () => { + assert.throws(() => parseDynamicChoiceScoring({ + ...apiScore, + confidence: "high", + effective_answer_count: 1, + can_apply: true, + })); +}); + +test("choice scores adapt into the existing guarded candidate shape", () => { + const parsed = parseDynamicChoiceScoring(apiScore); + assert.equal(parsed.candidate.eventCount, parsed.effectiveAnswerCount); + assert.equal(parsed.candidate.domainCount, parsed.dimensionCount); + assert.deepEqual(parsed.candidate.evidence, []); + assert.equal(parsed.candidate.algorithmVersion, "birth-time-choice-scoring-v2"); +}); +``` + +- [ ] **Step 2: Run and verify RED** + +Run: `cd frontend && node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts` + +Expected: FAIL because both parsers and engine methods are missing. + +- [ ] **Step 3: Add exact engine inputs** + +```ts +export type DifferencePacketInput = { + readonly caseId: string; + readonly asOfDate: string; + readonly birthDate: string; + readonly startTime: string; + readonly endTime: string; + readonly lat: number; + readonly lon: number; + readonly tz: number; + readonly evidence: readonly ServerChoiceEvidence[]; + readonly dismissedOpportunityIds: readonly string[]; + readonly questionFingerprints: readonly string[]; + readonly partitionFingerprints: readonly string[]; + readonly recentRanges: readonly TimeRange[]; + readonly candidateModel: Readonly> | null; +}; + +export type DynamicChoiceScoreInput = Pick; +``` + +Extend `BirthTimeJourneyEngine` with the two methods. Do not add partition data to any client response schema. + +Raise the compatibility `candidateResultSchema.eventCount` maximum from 6 to 10 and change its high-gate message from “events” to “effective evidence items.” The dated-event request schema remains capped at 6, so legacy API behavior does not broaden; the shared candidate result can now represent the v2 safety cap. + +- [ ] **Step 4: Post to the new Python endpoints** + +`buildDifferencePacket()` posts snake-case payloads to `/api/dynamic_rectification_opportunities` and separates the response into `{ packet, candidateModel, scoringPartitions }`. Only `packet` may enter the Agent prompt; `candidateModel` and `scoringPartitions` stay server-only. `bindDynamicQuestion()` copies the selected partition's score vector into the private persisted question, and the model cannot supply or alter that vector. `scoreChoices()` posts to `/api/dynamic_rectification_score`. Both use the existing 45-second abort timeout and strict adapter parsing. + +`parseDynamicChoiceScoring()` must require `event_count === effective_answer_count`, `domain_count === dimension_count`, `evidence_mode === "dynamic_choice"`, an empty public evidence array, and the v2 algorithm version before constructing `DynamicChoiceScoringResult`. This prevents a malformed engine payload from satisfying the high-confidence gate with inconsistent counts. + +- [ ] **Step 5: Verify endpoint payload ownership** + +Add a source-level test asserting that `candidate_scores` appears only in server modules and never in `birth-time-journey-client.ts`, `birth-time-journey-request.ts`, or a component/hook. + +- [ ] **Step 6: Run focused tests and commit** + +Run: `cd frontend && node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts` + +Expected: all selected tests pass. + +```bash +git add frontend/src/lib/birth-time-journey-service.ts frontend/src/lib/birth-time-journey-engine.ts frontend/src/lib/birth-time-journey-adapters.ts frontend/src/lib/birth-time-journey-engine-model.ts frontend/src/lib/birth-time-evidence.ts frontend/tests/birth-time-journey-engine.test.ts frontend/tests/birth-time-journey-adapters.test.ts +git commit -m "feat: connect dynamic rectification engine" +``` + +--- + +### Task 4: Constrained Agent Question Generation and Fallback + +**Files:** +- Create: `frontend/src/lib/birth-time-dynamic-question-validator.ts` +- Modify: `frontend/src/lib/birth-time-guide-agent.ts` +- Modify: `frontend/src/lib/birth-time-guide-service.ts` +- Modify: `frontend/src/mastra/index.ts:179-220` +- Test: `frontend/tests/birth-time-guide-agent.test.ts` +- Test: `frontend/tests/birth-time-guide-route.test.ts` + +**Interfaces:** +- Produces `generateDynamicQuestionPrompt(packet, note)` and `parseDynamicQuestionOutput(value, packet)`. +- Produces `bindDynamicQuestion(output, build, ids): PersistedDynamicChoiceQuestion`; `build.packet` supplies model-safe IDs/copy and `build.scoringPartitions` supplies the private score vector. +- Model output is either `{ kind: "question", opportunityId, prompt, options }` or `{ kind: "no_useful_question" }`. + +- [ ] **Step 1: Replace variant tests with failing dynamic-output tests** + +```ts +test("agent output may only reference one server opportunity and its partitions", () => { + const parsed = parseDynamicQuestionOutput({ + kind: "question", + opportunityId: "career-window", + prompt: "哪一个时间段更接近一次明显的工作变化?", + options: [ + { partitionId: "window-a", label: "2018—2020 年" }, + { partitionId: "window-b", label: "2021—2023 年" }, + ], + }, packet); + assert.equal(parsed.kind, "question"); + assert.throws(() => parseDynamicQuestionOutput({ + ...parsed, + options: [{ partitionId: "invented", label: "某个时间" }], + }, packet), BirthTimeGuideOutputError); +}); + +test("server adds special options and keeps partitions private", () => { + const internal = bindDynamicQuestion(validOutput, differenceBuild, deterministicIds); + const publicQuestion = toPublicDynamicChoiceQuestion(internal); + assert.deepEqual(publicQuestion.options.slice(-2).map((item) => item.label), ["不确定 / 不记得", "都不符合"]); + assert.equal(publicQuestion.options.some((item) => "partitionId" in item), false); +}); +``` + +- [ ] **Step 2: Run and verify RED** + +Run: `cd frontend && node --test tests/birth-time-guide-agent.test.ts` + +Expected: FAIL because dynamic generation functions do not exist. + +- [ ] **Step 3: Define the model prompt boundary** + +Send only opportunity ID, dimension code, neutral context, partition ID, descriptor, fallback label, prior public question summaries, and the optional unmatched note. Do not send candidate times, candidate scores, partition memberships, confidence thresholds, or support directions. + +The Mastra instruction must require valid JSON only, one question, 2–4 options, neutral Simplified Chinese, no birth-minute claim, no methodology exposure, and exact server IDs. It must state that `no_useful_question` is advisory and the server makes the stop decision. + +- [ ] **Step 4: Bind, fingerprint, and validate server-side** + +`bindDynamicQuestion()` must: + +1. verify the opportunity exists; +2. verify each partition belongs to it and appears once; +3. require 2–4 primary labels; +4. reject prompts over 120 characters and labels over 80; +5. reject time-of-birth strings matching `HH:MM`, confidence language, candidate-support language, and control claims; +6. create UUIDs server-side for question/options; +7. add the two special options with null partitions; +8. hash normalized public semantics for `questionFingerprint`; +9. reject existing question or partition fingerprints. + +- [ ] **Step 5: Add one retry and deterministic fallback tests** + +```ts +test("invalid model output retries once then persists the top opportunity fallback", async () => { + const calls: string[] = []; + const result = await serviceWithGenerator(async () => { + calls.push("generate"); + return { text: "{}" }; + }).generateQuestion("owner-1", generationCommand); + assert.equal(calls.length, 2); + assert.equal(result.nextAction.kind, "ask_dynamic_choice"); + assert.equal(result.nextAction.question.prompt, packet.opportunities[0].fallbackPrompt); + assert.equal(result.nextAction.question.options.length, packet.opportunities[0].partitions.length + 2); +}); + +test("no opportunity ends safely instead of regenerating the first question", async () => { + const result = await serviceWithPacket({ ...packet, opportunities: [] }) + .generateQuestion("owner-1", generationCommand); + assert.equal(result.nextAction.kind, "present_low_result"); +}); + +test("model no_useful_question cannot stop while the engine has an opportunity", async () => { + const result = await serviceWithGenerator(async () => ({ + text: JSON.stringify({ kind: "no_useful_question" }), + })).generateQuestion("owner-1", generationCommand); + assert.equal(result.nextAction.kind, "ask_dynamic_choice"); + assert.equal(result.nextAction.question.prompt, packet.opportunities[0].fallbackPrompt); +}); +``` + +- [ ] **Step 6: Run focused tests and commit** + +Run: `cd frontend && node --test tests/birth-time-guide-agent.test.ts tests/birth-time-guide-route.test.ts` + +Expected: all selected tests pass. + +```bash +git add frontend/src/lib/birth-time-dynamic-question-validator.ts frontend/src/lib/birth-time-guide-agent.ts frontend/src/lib/birth-time-guide-service.ts frontend/src/mastra/index.ts frontend/tests/birth-time-guide-agent.test.ts frontend/tests/birth-time-guide-route.test.ts +git commit -m "feat: generate constrained dynamic choice questions" +``` + +--- + +### Task 5: Durable v2 Persistence and Legacy Isolation + +**Files:** +- Create: `frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql` +- Modify: `frontend/src/lib/birth-time-journey-turn-persistence.ts` +- Modify: `frontend/src/lib/birth-time-journey-store.ts` +- Modify: `frontend/src/lib/birth-time-journey-service.ts` +- Modify: `tests/test_birth_time_journey_contract.py` +- Test: `frontend/tests/birth-time-dynamic-persistence.test.ts` + +**Interfaces:** +- Persists `journey_protocol` on the existing public case row. +- Persists the candidate model, internal current question, choice answers, server choice evidence, dynamic control state, and optional Agent context in `birth_time_rectification_dynamic_state`, which authenticated clients cannot select. +- Adds `saveDynamicTurn(value, expectedVersion, actionId)` and `upgradeLegacyActiveCase(value)`. +- Existing terminal cases remain terminal and are never upgraded into a question state. + +- [ ] **Step 1: Write failing migration contract tests** + +```python +def test_dynamic_choice_migration_keeps_private_mapping_and_agent_context_server_side(): + sql = DYNAMIC_CHOICE_MIGRATION.read_text() + assert "journey_protocol text not null default 'legacy-guided-v1'" in sql + assert "create table if not exists public.birth_time_rectification_dynamic_state" in sql + assert "candidate_model jsonb" in sql + assert "current_choice_question jsonb" in sql + assert "choice_answers jsonb not null default '[]'::jsonb" in sql + assert "choice_evidence jsonb not null default '[]'::jsonb" in sql + assert "dynamic_control jsonb" in sql + assert "agent_context jsonb not null default '[]'::jsonb" in sql + assert "revoke all on table public.birth_time_rectification_dynamic_state from anon, authenticated" in sql + assert "grant all on table public.birth_time_rectification_dynamic_state to service_role" in sql + assert "save_birth_time_dynamic_turn" in sql + assert "complete_birth_time_dynamic_scoring_job" in sql + assert "fail_birth_time_dynamic_scoring_job" in sql +``` + +- [ ] **Step 2: Run and verify RED** + +Run: `.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py -k dynamic_choice` + +Expected: FAIL because the migration is absent. + +- [ ] **Step 3: Add a private dynamic-state table and transactional RPC** + +Add only `journey_protocol` to `birth_time_rectification_cases`, allowing `legacy-guided-v1` or `dynamic-choice-v2`. Create `birth_time_rectification_dynamic_state` with `case_id` primary/foreign key, `user_id`, `candidate_model`, the other five private JSON fields, and timestamps. Add JSON type checks, cap the audit-only `choice_answers` array at 50 rows, cap effective `choice_evidence` at 10 rows, and cap Agent context at 10 notes of at most 240 characters. Enable RLS, revoke every privilege from `anon` and `authenticated`, and grant all only to `service_role`. + +Create `save_birth_time_dynamic_turn(p_user_id, p_case_id, p_expected_version, p_action_id, p_public_turn_state, p_snapshot, p_candidate_result, p_private_state)`. The function must be `security definer`, set `search_path = ''`, require the matching owner and `dynamic-choice-v2`, perform the optimistic version/action-receipt update, and upsert the private row in the same database transaction. Return the new version; return the existing version for a replayed action; raise `stale_birth_time_dynamic_turn` otherwise. Revoke function execution from `public`, `anon`, and `authenticated`; grant it only to `service_role`. + +Create matching service-role-only `complete_birth_time_dynamic_scoring_job(...)` and `fail_birth_time_dynamic_scoring_job(...)` RPCs. Each verifies the owner, case, job ID, expected turn version, evidence fingerprint, algorithm version, and current job state before atomically updating the job, public turn/result, and private dynamic state. A replay returns the already completed/failed turn; a mismatch raises a stale-job exception. + +- [ ] **Step 4: Write failing store tests** + +```ts +test("v2 load restores the exact internal question after refresh", async () => { + const loaded = await loadStoredRectificationCase(fakeSupabase(v2CaseRow, v2PrivateRow), "owner", caseId); + assert.deepEqual(loaded?.currentChoiceQuestion, persistedQuestion); + assert.deepEqual(loaded?.candidateModel, persistedCandidateModel); + assert.deepEqual(loaded?.dynamicControl.questionFingerprints, [persistedQuestion.questionFingerprint]); +}); + +test("save uses optimistic version and action receipt once", async () => { + const first = await store.saveDynamicTurn(updated, 7, actionId); + const replay = await store.saveDynamicTurn(updated, 7, actionId); + assert.equal(first.turnVersion, 8); + assert.equal(replay.turnVersion, 8); + assert.equal(replay.processedActionIds.filter((value) => value === actionId).length, 1); +}); +``` + +- [ ] **Step 5: Extend stored case parsing and persistence** + +Discriminate by `journey_protocol`. `saveAssessment()` explicitly creates a `dynamic-choice-v2` case, initializes `asOfDate`, and inserts its empty private state before returning the case ID. For v2 resume, load the owner-scoped public case row and the service-role-only private row, then parse private JSON with Task 1 schemas; a missing private row is a store error, not an excuse to regenerate from scratch. `saveDynamicTurn()` calls the transactional RPC and never writes `active_birth_time`. Only `toPublicDynamicChoiceQuestion(currentChoiceQuestion)` is stored in public `turn_state` and projected into `nextAction`; candidate scores, partition IDs, and Agent notes never enter the case row. + +- [ ] **Step 6: Define legacy upgrade rules** + +`upgradeLegacyActiveCase()` is allowed only when the old case is nonterminal. It preserves `answers`, `life_events`, questionnaire, candidate result, reported range, and audit timestamps; sets protocol v2; initializes dynamic counters from confirmed legacy evidence; excludes legacy question fingerprints; and sets `generate_dynamic_question`. Old `present_low_result`, `present_medium_result`, confirmation, and ready states return unchanged. + +- [ ] **Step 7: Run persistence tests and commit** + +Run: `.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py && cd frontend && node --test tests/birth-time-dynamic-persistence.test.ts tests/birth-time-journey-turn-persistence.test.mjs` + +Expected: all selected tests pass. + +```bash +git add frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql frontend/src/lib/birth-time-journey-turn-persistence.ts frontend/src/lib/birth-time-journey-store.ts frontend/src/lib/birth-time-journey-service.ts tests/test_birth_time_journey_contract.py frontend/tests/birth-time-dynamic-persistence.test.ts +git commit -m "feat: persist dynamic rectification turns" +``` + +--- + +### Task 6: Journey Actions, Scoring Jobs, and Anti-Loop Transitions + +**Files:** +- Create: `frontend/src/lib/birth-time-dynamic-transitions.ts` +- Create: `frontend/src/lib/birth-time-dynamic-actions.ts` +- Create: `frontend/src/lib/birth-time-dynamic-scoring-service.ts` +- Modify: `frontend/src/lib/birth-time-journey-service.ts` +- Modify: `frontend/src/lib/birth-time-scoring-job.ts` +- Modify: `frontend/src/lib/birth-time-scoring-job-store.ts` +- Test: `frontend/tests/birth-time-dynamic-actions.test.ts` +- Test: `frontend/tests/birth-time-dynamic-scoring.test.ts` +- Test: `frontend/tests/birth-time-dynamic-terminal.test.ts` + +**Interfaces:** +- Produces `answerDynamicChoice`, `submitUnmatchedContext`, `generateDynamicQuestion`, `pauseDynamic`, `resumeDynamic`, and `finishDynamic` service actions. +- Primary choices resolve a stored partition and create one idempotent `birth-time-choice-scoring-v2` job. +- Unknown and unmatched answers never create `ServerChoiceEvidence`. + +- [ ] **Step 1: Write failing primary-answer tests** + +```ts +test("a primary click resolves its private partition and enters score_pending", async () => { + const result = await flow.answerDynamicChoice("owner", { + caseId, actionId, turnVersion: 4, questionId, optionId: primaryOptionId, + }); + assert.equal(result.nextAction.kind, "score_pending"); + assert.equal(flow.saved.choiceAnswers.length, 1); + assert.equal(flow.saved.choiceEvidence[0].partitionId, "window-a"); + assert.equal(flow.saved.dynamicControl.effectiveAnswerCount, 1); +}); + +test("a forged or stale option cannot affect evidence", async () => { + await assert.rejects(() => flow.answerDynamicChoice("owner", { + caseId, actionId, turnVersion: 3, questionId, optionId: forgedOptionId, + }), StaleJourneyTurnError); + assert.deepEqual(flow.saved.choiceEvidence, []); +}); +``` + +- [ ] **Step 2: Run action tests and verify RED** + +Run: `cd frontend && node --test tests/birth-time-dynamic-actions.test.ts` + +Expected: FAIL because dynamic actions do not exist. + +- [ ] **Step 3: Implement special-choice transitions** + +- Primary: persist answer and private evidence, increment both counts, clear current question, create score job. +- Unknown: persist a non-effective answer, increment only `answeredCount`, dismiss the opportunity/fingerprints, clear current question, enter `generate_dynamic_question`. +- Unmatched: persist a non-effective answer, increment only `answeredCount`, retain the question, enter `clarify_unmatched_answer`. +- Unmatched context: validate at most 240 characters, persist separate Agent context, dismiss the old opportunity/fingerprints, clear the question, enter `generate_dynamic_question` without scoring. +- Finish: preserve current result/range and enter a terminal low or medium result. + +- [ ] **Step 4: Write failing score-completion tests** + +```ts +test("score completion continues only when the stop policy allows it", async () => { + const result = await scoring.complete(lowChangedScore, packetWithUsefulOpportunity); + assert.equal(result.nextAction.kind, "generate_dynamic_question"); +}); + +test("the second plateau is terminal and resume stays terminal", async () => { + const terminal = await scoring.complete(mediumUnchangedScore, packetWithUsefulOpportunity); + assert.equal(terminal.nextAction.kind, "present_medium_result"); + const resumed = await flow.resumeDynamic("owner", caseId); + assert.deepEqual(resumed.nextAction, terminal.nextAction); +}); + +test("high confidence still requires explicit confirmation", async () => { + const result = await scoring.complete(highScore, packetWithUsefulOpportunity); + assert.equal(result.nextAction.kind, "request_candidate_confirmation"); + assert.equal(result.snapshot.activeTime, null); + assert.equal(result.permissions.canConfirmCandidate, true); +}); +``` + +- [ ] **Step 5: Run scoring tests and verify RED** + +Run: `cd frontend && node --test tests/birth-time-dynamic-scoring.test.ts tests/birth-time-dynamic-terminal.test.ts` + +Expected: FAIL because v2 completion and terminal guards are absent. + +- [ ] **Step 6: Add scoring claim/completion flow** + +Fingerprint canonical server choice evidence, not public labels or Agent notes. Claim jobs by case, evidence fingerprint, and algorithm version. Validate returned effective count, dimension count, algorithm version, candidate range, and confidence gates before persisting. Apply `decideDynamicStop()` in the same saved turn as the candidate result; never expose an intermediate low result that `resume()` could reinterpret as a new cycle. + +- [ ] **Step 7: Make terminal transitions one-way** + +Every answer, generation, reframe, retry, and scoring action must reject a terminal `nextAction`. `resumeDynamic()` returns the stored terminal state byte-for-byte. `pauseDynamic()` stores the exact non-paused action in `dynamicControl.pausedAction`; resume restores only that action and clears the saved pause action. + +- [ ] **Step 8: Run focused tests and commit** + +Run: `cd frontend && node --test tests/birth-time-dynamic-actions.test.ts tests/birth-time-dynamic-scoring.test.ts tests/birth-time-dynamic-terminal.test.ts tests/birth-time-scoring-job.test.ts` + +Expected: all selected tests pass. + +```bash +git add frontend/src/lib/birth-time-dynamic-transitions.ts frontend/src/lib/birth-time-dynamic-actions.ts frontend/src/lib/birth-time-dynamic-scoring-service.ts frontend/src/lib/birth-time-journey-service.ts frontend/src/lib/birth-time-scoring-job.ts frontend/src/lib/birth-time-scoring-job-store.ts frontend/tests/birth-time-dynamic-actions.test.ts frontend/tests/birth-time-dynamic-scoring.test.ts frontend/tests/birth-time-dynamic-terminal.test.ts +git commit -m "feat: orchestrate dynamic rectification turns" +``` + +--- + +### Task 7: Authenticated API, Client Commands, and Automatic Browser Coordination + +**Files:** +- Modify: `frontend/src/lib/birth-time-journey-request.ts` +- Modify: `frontend/src/lib/birth-time-journey-response-schema.ts` +- Modify: `frontend/src/lib/birth-time-journey-client.ts` +- Modify: `frontend/src/app/api/birth-time-journey/route.ts` +- Modify: `frontend/src/app/api/birth-time-guide/route.ts` +- Modify: `frontend/src/hooks/use-birth-time-guided-journey.ts` +- Test: `frontend/tests/birth-time-dynamic-api.test.ts` +- Modify: `frontend/tests/birth-time-guide-client.test.ts` +- Modify: `frontend/tests/birth-time-guided-polling.test.ts` + +**Interfaces:** +- Journey command: `{ type: "answer_dynamic_choice", caseId, actionId, turnVersion, questionId, optionId }`. +- Guide commands: `generate_dynamic_question` and `reframe_unmatched`. +- Controller exposes `selectOption(optionId)`, `submitUnmatchedContext(note)`, `finish()`, `pause()`, and existing candidate actions. + +- [ ] **Step 1: Write failing request-boundary tests** + +```ts +test("choice commands accept only public ids", () => { + const valid = { type: "answer_dynamic_choice", caseId, actionId, turnVersion: 4, questionId, optionId }; + assert.equal(birthTimeJourneyRequestSchema.safeParse(valid).success, true); + for (const field of ["partitionId", "candidateScores", "confidence", "time"] as const) { + assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, [field]: "forged" }).success, false); + } +}); + +test("unmatched context is optional, trimmed, and bounded", () => { + assert.equal(birthTimeGuideRequestSchema.safeParse({ + type: "reframe_unmatched", caseId, actionId, turnVersion: 5, questionId, note: " 更像是 2017 年 ", + }).success, true); + assert.equal(birthTimeGuideRequestSchema.safeParse({ + type: "reframe_unmatched", caseId, actionId, turnVersion: 5, questionId, note: "字".repeat(241), + }).success, false); +}); +``` + +- [ ] **Step 2: Run API tests and verify RED** + +Run: `cd frontend && node --test tests/birth-time-dynamic-api.test.ts tests/birth-time-guide-client.test.ts` + +Expected: FAIL because v2 commands are absent. + +- [ ] **Step 3: Add strict route dispatch** + +Authenticate before body parsing. Route each v2 command to only its scoped service method. Map stale/terminal/forged actions to 409, missing cases to 404, invalid model output to the deterministic fallback path, and engine/store outages to 503 while preserving the current question. Record metrics after persisted transitions only. + +- [ ] **Step 4: Add automatic generation and scoring coordination** + +In the hook: + +- on `generate_dynamic_question`, call the guide route once per `caseId:turnVersion` identity; +- on `score_pending`, poll the existing idempotent job identity; +- on network failure, keep the same action and show retry; do not optimistically create another question; +- on `ask_dynamic_choice`, render the persisted public question directly, without a second render-question request; +- on primary click, disable all options until the mutation resolves; +- on terminal result, stop all generation and polling effects. + +- [ ] **Step 5: Add race/replay tests** + +```ts +test("duplicate option clicks publish one advanced turn", async () => { + const requests = coordinateDuplicateClicks(); + await Promise.all([requests.select(primaryOptionId), requests.select(primaryOptionId)]); + assert.equal(requests.sent.length, 1); + assert.equal(requests.published.at(-1)?.nextAction.kind, "score_pending"); +}); + +test("a stale generated question cannot replace a newer turn", async () => { + const result = await resolveGenerationAfterTurnAdvanced(); + assert.equal(result.current.turnVersion, 8); + assert.notEqual(result.current.nextAction.kind, "ask_dynamic_choice"); +}); +``` + +- [ ] **Step 6: Run focused tests and commit** + +Run: `cd frontend && node --test tests/birth-time-dynamic-api.test.ts tests/birth-time-guide-client.test.ts tests/birth-time-guided-polling.test.ts tests/birth-time-guided-review-fixes.test.ts` + +Expected: all selected tests pass. + +```bash +git add frontend/src/lib/birth-time-journey-request.ts frontend/src/lib/birth-time-journey-response-schema.ts frontend/src/lib/birth-time-journey-client.ts frontend/src/app/api/birth-time-journey/route.ts frontend/src/app/api/birth-time-guide/route.ts frontend/src/hooks/use-birth-time-guided-journey.ts frontend/tests/birth-time-dynamic-api.test.ts frontend/tests/birth-time-guide-client.test.ts frontend/tests/birth-time-guided-polling.test.ts +git commit -m "feat: expose dynamic rectification actions" +``` + +--- + +### Task 8: Click-First Question UI and Simplified Progress + +**Files:** +- Create: `frontend/src/components/birth-time-choice-question.tsx` +- Modify: `frontend/src/components/birth-time-rectification.tsx` +- Modify: `frontend/src/components/birth-time-candidate-result.tsx` +- Modify: `frontend/src/app/globals.css:360-415,650-670` +- Modify: `frontend/src/hooks/use-birth-time-guided-journey.ts` +- Test: `frontend/tests/birth-time-choice-question.test.ts` +- Modify: `frontend/tests/birth-time-guide-flow.test.ts` +- Modify: `frontend/tests/birth-time-rectification-contract.test.ts` + +**Interfaces:** +- Consumes only `PublicDynamicChoiceQuestion`, `DynamicJourneyProgress`, and controller callbacks. +- Removes v2 imports/usages of `BirthTimeGuideTurn` and `BirthTimeEvidenceDraftCard` from the active rectification path. + +- [ ] **Step 1: Write failing UI contract tests** + +```ts +test("the v2 question surface is click-first", () => { + assert.match(choiceSource, /question\.options\.map/); + assert.match(choiceSource, /onSelect\(option\.optionId\)/); + assert.doesNotMatch(choiceSource, /整理为经历草稿|记得的精度|发生时间|第.*\/.*轮/); + assert.doesNotMatch(choiceSource, /