From 814c924e4aa1911954d1805428def561d86697a4 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 7 Sep 2026 09:10:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(rectification):=20exhaustion=20exit,=20expl?= =?UTF-8?q?ain=20layer,=20range=20reading,=20unknown-time=20scan=20(BUG-56?= =?UTF-8?q?5=E2=80=93568)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep askable cards after exhaustion, explain each probe, read the adopted credible range in reports and chat, and compare declared periods before the minute grid when the clock is unknown. Co-authored-by: Cursor --- CHANGELOG.md | 18 +- docs/BLOCKED.md | 21 + docs/BUG_HISTORY.md | 65 +++ ...ification-convergence-exit-fix-20260906.md | 45 ++ ...SS-rectification-explain-layer-20260906.md | 33 ++ ...SS-rectification-range-reading-20260906.md | 36 ++ ...ESS-rectification-unknown-time-20260906.md | 39 ++ docs/tasks/README.md | 8 +- ...rectification-convergence-exit-20260906.md | 13 +- .../rectification-explain-layer-20260906.md | 30 ++ .../rectification-range-reading-20260906.md | 37 ++ .../rectification-unknown-time-20260906.md | 44 ++ frontend/DESIGN.md | 4 +- frontend/docs/VOICE.md | 7 +- frontend/src/app/api/consult/route.ts | 21 +- .../src/app/api/rectification/agent/route.ts | 2 + .../cases/[caseId]/candidates/accept/route.ts | 25 +- .../api/rectification/cases/[caseId]/route.ts | 8 +- frontend/src/app/globals.css | 40 +- frontend/src/components/birth-time-intake.tsx | 7 +- .../components/rectification-agentic-chat.tsx | 50 +- .../components/rectification-choice-card.tsx | 19 +- frontend/src/lib/consultation-agent-events.ts | 2 + .../src/lib/consultation-birth-time-mode.ts | 10 + .../src/lib/consultation-route-service.ts | 27 + .../core/rectification-decision.ts | 55 ++ .../lib/rectification-agentic/user-copy.ts | 29 ++ .../v9/adopt-narration.ts | 52 ++ .../lib/rectification-agentic/v9/agent-run.ts | 43 +- .../rectification-agentic/v9/answer-choice.ts | 238 ++++++++- .../v9/block-scan-answer.ts | 124 +++++ .../rectification-agentic/v9/block-scan.ts | 245 +++++++++ .../rectification-agentic/v9/case-service.ts | 80 ++- .../rectification-agentic/v9/choice-action.ts | 16 +- .../rectification-agentic/v9/choice-card.ts | 64 ++- .../v9/confirmation-gate.ts | 7 + .../v9/decision-from-dossier.ts | 43 +- .../rectification-agentic/v9/engine-client.ts | 91 ++++ .../v9/interview-state.ts | 49 +- .../v9/method-followup.ts | 76 ++- .../rectification-agentic/v9/probe-explain.ts | 238 +++++++++ .../rectification-agentic/v9/server-focus.ts | 2 + .../rectification-agentic/v9/step-state.ts | 118 +++++ .../rectification-agentic/v9/tool-service.ts | 48 ++ .../lib/rectification-agentic/v9/turn-exit.ts | 21 +- .../src/lib/rectification-candidate-result.ts | 7 + .../src/lib/rectification-surface-state.ts | 1 + frontend/src/lib/report-candidate-range.ts | 37 ++ frontend/src/mastra/consultation-tools.ts | 39 +- frontend/src/mastra/consultation-workflow.ts | 37 +- frontend/src/mastra/rectification-v9-tools.ts | 289 +++++++++-- .../20260906020000_adopted_credible_range.sql | 469 ++++++++++++++++++ ...6030000_rectification_block_scan_stage.sql | 453 +++++++++++++++++ .../adopted-credible-range-migration.test.ts | 37 ++ .../birth-time-consultation-consent.test.ts | 3 +- frontend/tests/birth-time-intake.test.ts | 4 +- .../tests/consultation-route-service.test.ts | 31 +- .../tests/database-local-business.test.ts | 2 +- .../database-rectification-block-scan.test.ts | 193 +++++++ .../database-report-candidate-range.test.ts | 18 + ...ification-adopt-narration-20260904.test.ts | 69 +++ .../tests/rectification-answer-choice.test.ts | 37 ++ .../rectification-block-scan-20260906.test.ts | 394 +++++++++++++++ ...rectification-block-scan-migration.test.ts | 23 + .../tests/rectification-choice-card.test.ts | 39 ++ .../rectification-decision-authority.test.ts | 2 +- ...ification-exhaustion-exit-20260906.test.ts | 313 +++++++++++- .../rectification-question-ownership.test.ts | 5 +- ...ctification-range-reading-20260906.test.ts | 158 ++++++ .../tests/rectification-spoken-prompt.test.ts | 2 + .../rectification-step-state-20260906.test.ts | 72 +++ .../rectification-v9-case-service.test.ts | 8 +- .../tests/rectification-v9-contracts.test.ts | 7 + .../tests/rectification-v9-database.test.ts | 34 ++ .../tests/rectification-v9-test-support.ts | 7 +- .../rectification-varga-style-copy.test.ts | 7 +- frontend/tests/report-candidate-range.test.ts | 17 + scripts/active_rectification_event_engine.py | 8 +- scripts/active_rectification_events.py | 1 + scripts/api_heavy_compute_gate.py | 2 + scripts/flexible_birth_time_profile.py | 6 +- scripts/jyotish_api_server.py | 44 ++ scripts/jyotish_engine.py | 47 +- scripts/rectification/api_service.py | 181 ++++++- scripts/rectification/contracts.py | 9 +- scripts/rectification/decision_policy.py | 1 + scripts/rectification/refinement_packet.py | 21 + scripts/rectification/scoring_service.py | 4 +- tests/test_flexible_birth_time_engine.py | 68 +++ tests/test_flexible_birth_time_profile.py | 10 +- tests/test_rectification_v5_services.py | 120 +++++ 91 files changed, 5362 insertions(+), 224 deletions(-) create mode 100644 docs/BLOCKED.md create mode 100644 docs/tasks/PROGRESS-rectification-convergence-exit-fix-20260906.md create mode 100644 docs/tasks/PROGRESS-rectification-explain-layer-20260906.md create mode 100644 docs/tasks/PROGRESS-rectification-range-reading-20260906.md create mode 100644 docs/tasks/PROGRESS-rectification-unknown-time-20260906.md create mode 100644 docs/testing/rectification-explain-layer-20260906.md create mode 100644 docs/testing/rectification-range-reading-20260906.md create mode 100644 docs/testing/rectification-unknown-time-20260906.md create mode 100644 frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts create mode 100644 frontend/src/lib/rectification-agentic/v9/block-scan.ts create mode 100644 frontend/src/lib/rectification-agentic/v9/probe-explain.ts create mode 100644 frontend/src/lib/rectification-agentic/v9/step-state.ts create mode 100644 frontend/supabase/migrations/20260906020000_adopted_credible_range.sql create mode 100644 frontend/supabase/migrations/20260906030000_rectification_block_scan_stage.sql create mode 100644 frontend/tests/adopted-credible-range-migration.test.ts create mode 100644 frontend/tests/database-rectification-block-scan.test.ts create mode 100644 frontend/tests/rectification-block-scan-20260906.test.ts create mode 100644 frontend/tests/rectification-block-scan-migration.test.ts create mode 100644 frontend/tests/rectification-range-reading-20260906.test.ts create mode 100644 frontend/tests/rectification-step-state-20260906.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0960c068..367c40af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,24 @@ # 印度占星 Skill 更新日志 +## 2026-09-06 — 完全不知道出生时间时先比时段 + +选「完全不清楚」后可以直接开始生时校正:先用记得的经历比出清晨、上午、下午、傍晚或夜里哪一段更像,选定后再按分钟收窄。不会再劝退成「以后再做」,也不会在全日窗口上给出分钟或采用卡。家人记得的时段只作旁白建议,改不了搜索窗口。Skill 版本仍是 10.0.14。 + +## 2026-09-06 — 采用后的报告和聊天按可信区间读盘 + +生时校正点采用之后,报告和之后的对话读的是当时那一段可信区间,不再误用开工时的搜索窗口。采用旁白会多两句:这段里哪些主题判断稳定、哪些会随分钟变。没有校正、只声明了出生时段的咨询,也会按同样分层来看,并标明「这只是粗看」。Skill 版本仍是 10.0.14。 + +## 2026-09-06 — 选择题能展开为什么问,答完能看见范围有没有变 + +生时校正的选择题下面可以展开「为什么问这题」,点选项时会看到哪一段会领先或落后。答完后旁白会说哪段升了、哪段降了,以及范围是收窄了还是没变。输入框上方有当前是第几步、为什么、下一步做什么。Skill 版本仍是 10.0.14。 + +## 2026-09-06 — 问完后的说明只出现一次,还能问的题不会被换成说明 + +生时校正把能问的经历问完、又还不能选定时间时,那句「范围已经收到、还差哪类经历」只出现一次,不会连说两三遍。如果还有能分开候选的选择题或核对题,会继续问,不会直接换成那句说明。口述采集时输入框上方仍有「先这样,先看当前范围」;范围那一行小字只是状态,不再能点。Skill 版本仍是 10.0.14。 + ## 2026-09-06 — 生时校正问完会给结果,不再卡在「再说一件事」 -七个带年份的领域和职业都问过之后,会按现有材料给候选卡,或说明还差哪类经历;不会再问「也可以再说一件你记得大概时间的事」。口述采集时输入框上方有「先这样,先看当前范围」,范围小字也可以点,同一动作。Skill 版本仍是 10.0.14。 +七个带年份的领域和职业都问过之后,会按现有材料给候选卡,或说明还差哪类经历;不会再问「也可以再说一件你记得大概时间的事」。口述采集时输入框上方有「先这样,先看当前范围」。Skill 版本仍是 10.0.14。 ## 2026-09-06 — 同一年同一领域的「有没有发生」只问一次,没锚点的性格题不出 diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md new file mode 100644 index 00000000..95764ec5 --- /dev/null +++ b/docs/BLOCKED.md @@ -0,0 +1,21 @@ +# Blocked work + +本文件记录当前无法在本仓内闭环、也不得标成通过的验证。新条目追加在表后。不写姓名、出生资料或完整请求体。 + +## 条目 + +### BLK-001 · 长对话本地收窄后 VedAstro 调用窗口与断言不一致 + +- 状态:blocked(基线即失败,非 BUG-565~567 引入) +- 首次记录:2026-09-06 +- 测试:`tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow` +- 复现: + +```bash +.venv/bin/python -m pytest -q tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow --tb=line +``` + +- 失败断言:`result["winning_segment"]` 期望 `start_time=05:07`、`end_time=05:08`、`representative_time=05:07`、`width_minutes=2`;实测 `04:16` / `04:16` / `04:16` / `width_minutes=1`。 +- 已知同样失败的 SHA:`e2f4b55c`(父任务书验收段已复跑);本工作树 `43a26a3c`(文档头,Python 与 `origin/staging` 一致)同样失败。测试首次出现于 `3ca30ed7`。未做完整二分:父任务已证明早于 BUG-558~560。 +- 本单范围:只记录。不改引擎、不改门槛、不改断言来让它变绿。 +- 相关:BUG-560 根因升级(分钟级原始分区分力≈随机);`docs/tasks/TASK-rectification-convergence-exit-20260906.md` 验收段。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 37091511..181a5c78 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8670,6 +8670,7 @@ - 用户现象:多件带年月经历之后,公开候选相对支持度仍是 7–9,范围几乎不收;之后只靠点选卡 ±2 推分,问尽仍收不窄。 - 触发条件:公开最多 12 个簇代表按原始分正比例把 100 分掉;原始分底座远大于事件极差。 - 根因:`_relative_support` 按原始分正比例归一。`TASK-rectification-provisional-adopt-20260901.md` §2 已看到 lead 停在约 5,当时只改门、四个常数不动,没有改 prior 尺度。 +- 根因升级(2026-09-06):校准门「覆盖 ≥ 旧方案 − 1 且中位宽度更窄」设计有缺陷。旧方案 proportional 在 20 例公开 holdout 上中位宽度 21 分钟 = 整个候选窗口(半径 10),覆盖 17/20 只是因为不收窄。先验一旦按引擎原始分拉尖(offset),真实分钟落在领先集合里只有 12/20(中位宽 10),与随机取 10 分钟(≈48%)相当。引擎原始分在分钟级几乎没有区分力;现有八法计分不足以在 20 分钟窗口内可靠收窄。「收敛」只能来自用户答题和更强的引擎证据,不能靠换尺度。本条状态保持 blocked,未启用新尺度。 - 修复:实现 offset(分−全网格最低分)与 softmax,由校准脚本在 20 例公开 holdout 上比覆盖与中位宽度。合入门:覆盖 ≥ 旧方案 − 1 且中位宽度更窄。实测无方案过门(offset 覆盖 12/20 < 16;softmax T=2 中位宽度 21 不小于旧值 21)。默认仍 `proportional`,`POLICY_VERSION` / `ALGORITHM_VERSION` 不 bump,旧 Case 不因此自动重算。 - 验证:`scripts/rectification_prior_calibration.py` holdout 表见进度记录;`tests/test_rectification_relative_support.py` 锁定默认 proportional,并断言显式 offset 在拉开的格子上领先不差于 proportional。 - 防复发:不得在校准门未过时启用 offset/softmax 或 bump 政策版本。不得改 `MIN_SEPARATION_LEAD=8` 或采用/确认/熔断常数来「先让分看起来够」。 @@ -8741,4 +8742,68 @@ - 复发自:BUG-561(`cfcd369d` 的 heading-ensure 只覆盖空包缺席,未覆盖成功路径漏标题) - 修复版本:`e4d16b75` +## BUG-565 | 穷尽门槛句每个回合写成两条相同助手消息 + +- 状态:resolved +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:`persistExhaustionCollect`、`finalizeSuccessfulTurnExit`、`runV9AgentTurn` opening/evidence、`applyRectificationChoice` 点选路径、`/api/rectification/agent` +- 用户现象:方法覆盖完成、无剩余采集、引擎采用门关闭时,同一段「范围已经收到…还差带月份的经历」连出两三遍。 +- 触发条件:A4 用例 2 形状上 `finalizeSuccessfulTurnExit({ action: "message" })`、`action: "evidence"` 经 agent-run,或点选最后一张关闭天花板的卡。 +- 根因:门槛翻译句被当成独立消息。`persistExhaustionCollect` 自己 `persistV9DeterministicTurn`(`requestId: randomUUID()`)写第 1 条;`inspectNonTerminalTurnExit.satisfied` 不认门槛句为载体,再写第 2 条。opening/evidence 在 finalize 前还会先 idle 一次。点选路径一条独立门槛消息,又把同一句拼进「已记录你的选择」。 +- 修复:门槛句是回合正文,写入方只能有一个。`persistExhaustionCollect` 只返回 `{ hostNarration, terminalNote: true, persisted: false }`,不再写 turn。idle 返回 `terminalNote` 且本回合尚未写过门槛时,由 `finalizeSuccessfulTurnExit` / agent-run / route 用幂等 `requestId` 写一次并跳过非终止修复。点选路径只把门槛句并入该回合正文。 +- 验证:`frontend/tests/rectification-exhaustion-exit-20260906.test.ts`:message finalize、evidence agent-run、最后一张卡各计 1 次含门槛句的助手 append;A4 用例 2 后再调 `ensureNonTerminalTurnExit`,append 不增加。非 DB 校正套件 910/910。 +- 防复发:`persistExhaustionCollect` 不得再调用 `persistV9DeterministicTurn`。门槛 `requestId` 必须可幂等。`inspectNonTerminalTurnExit` 必须把穷尽态视为已交付。 +- 相关记录:BUG-558、BUG-566 +- 复发自:BUG-558(穷尽交付把门槛句做成独立消息,出口修复未锁单一写入方) +- 修复版本:待合入 `origin/staging`(`codex/rectification-convergence-exit-fix-20260906`) + +## BUG-566 | 穷尽分支排在问题持久化之前,吞掉仍可问的区分卡和 holdout + +- 状态:resolved +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:`persistNextInterviewIfIdle`、`isExhaustedGateState`、决策层 `ask_candidate_discriminator` / `ask_holdout_validation` +- 用户现象:引擎采用门关闭(例如 `low_date_quality`)后,聊天只剩门槛句,不再出现还能分开候选的选择题或盘外核对。 +- 触发条件:无剩余采集、方法覆盖完成、`canAdopt=false`,但决策层仍给出可渲染区分卡或 holdout 题。 +- 根因:idle 穷尽分支只看「无剩余采集且不可采用」,不看 `decision.nextAction`,排在 `persistNextInterviewAfterChoice` 之前。 +- 修复:抽 `isExhaustedGateState`。idle 穷尽分支额外要求 `nextAction ∈ {offer_provisional_range, complete_with_range, ask_fact_collection}`。区分卡 / holdout 照常持久化。`inspectNonTerminalTurnExit` 与 idle 共用穷尽态判定。 +- 验证:同测试文件:关闭天花板但留一条可渲染探针 → 持久化区分卡焦点,无门槛句;holdout 仍开 → 持久化 holdout,无门槛句。 +- 防复发:不得把穷尽门槛排在 `ask_candidate_discriminator` / `ask_holdout_validation` 之前。不得重新引入 `USER_COLLECT_QUESTION.other` 兜底。 +- 相关记录:BUG-558、BUG-565 +- 复发自:BUG-558(穷尽交付分支未白名单 nextAction) +- 修复版本:待合入 `origin/staging`(`codex/rectification-convergence-exit-fix-20260906`) + +## BUG-567 | 范围小字伪装成停止按钮,文案仍是状态句 + +- 状态:resolved +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:`RectificationReadonlyRange`、口述采集停止按钮、`frontend/DESIGN.md` +- 用户现象:范围一行写着「目前范围 …–…,还在收窄」,看起来不可点,点了却等于「先这样」。同一屏下方已有明确停止按钮。 +- 触发条件:口述采集或只读范围可见时。 +- 根因:BUG-558 A3 把只读范围改成 ` +

可以直接开始生时校正:先从你记得的经历比出大致时段。

)} diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index b46cd5ae..06d9c6c3 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -37,8 +37,10 @@ import { isRecommendedRectificationCandidate, natalRecastMeaning, parseRectificationCandidateResult, + parseRectificationStepState, workingRectificationHouseTable, type RectificationCandidateResult, + type RectificationStepState, } from "@/lib/rectification-candidate-result"; import { diffRectificationBoard, @@ -167,26 +169,13 @@ function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | n function RectificationReadonlyRange({ range, - onStop, }: Readonly<{ range: readonly [string, string]; - onStop?: () => void; }>) { - if (!onStop) { - return ( -

- 目前范围 {range[0]}–{range[1]},还在收窄 -

- ); - } return ( - +

); } @@ -446,6 +435,7 @@ type CaseSnapshotState = Readonly<{ question: CurrentQuestionModel | null; questionSource: "focus" | "unavailable" | null; choice: ChoiceCardModel | null; + stepState: RectificationStepState | null; caseStatus: RectificationCaseStatus | null; savedTime: string | null; savedStatus: "accepted" | "confirmed" | null; @@ -467,6 +457,7 @@ function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): Ca question: currentQuestionFromSnapshot(payload.current_question), questionSource: questionSourceFromSnapshot(payload.question_source), choice: parseRectificationChoiceCard(payload.choice_card), + stepState: parseRectificationStepState(payload.step_state), caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null, savedTime: confirmedTime ?? acceptedTime, savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null, @@ -508,6 +499,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null); const [candidateResult, setCandidateResult] = useState(() => caseSnapshotState(initialSnapshot)?.candidate ?? null); const [choiceCard, setChoiceCard] = useState(() => caseSnapshotState(initialSnapshot)?.choice ?? null); + const [stepState, setStepState] = useState(() => caseSnapshotState(initialSnapshot)?.stepState ?? null); const [currentQuestion, setCurrentQuestion] = useState(() => caseSnapshotState(initialSnapshot)?.question ?? null); const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null); const [caseStatus, setCaseStatus] = useState(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null); @@ -654,6 +646,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { latest_result?: unknown; current_question?: unknown; choice_card?: unknown; + step_state?: unknown; turns?: unknown; question_source?: unknown; next_user_action?: { id?: unknown }; @@ -679,6 +672,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setCurrentQuestion(nextQuestion); setQuestionSource(questionSourceFromSnapshot(payload.question_source)); setChoiceCard(nextChoice); + setStepState(parseRectificationStepState(payload.step_state)); setCaseStatus(nextCaseStatus); setNextUserActionId(nextActionId || null); setCaseSnapshotLoaded(true); @@ -1718,7 +1712,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { {showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && ( )} @@ -1779,15 +1772,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { 之后新建对话即按此时间排盘。 )} - {currentQuestion?.kind === "collect_spoken" && !readonly && !busy && ( - - )} +
+ {stepState ? ( +

+ {stepState.headline} — {stepState.reason} — 下一步:{stepState.next} +

+ ) : null} + {currentQuestion?.kind === "collect_spoken" && !readonly && !busy && ( + + )} +
(""); + const [hoveredKey, setHoveredKey] = useState(""); const selectedKey = props.selectedKey || localSelected; const answered = Boolean(selectedKey); + const impactKey = (selectedKey === "A" || selectedKey === "B" || selectedKey === "C" || selectedKey === "D" + ? selectedKey + : hoveredKey) as ChoiceKey | ""; + const impactLine = impactKey ? props.card.answer_impact?.[impactKey] ?? "" : ""; function select(key: ChoiceKey) { if (props.pending || props.disabled || selectedKey) return; @@ -60,7 +65,12 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) { {hidePrompt ? {props.card.prompt} : {props.card.prompt}} - {!hidePrompt && props.card.why ?

{props.card.why}

: null} + {!hidePrompt && props.card.why_user ? ( +
+ 为什么问这题 +

{props.card.why_user}

+
+ ) : null}
{props.card.options.map((option) => ( ) : null}
+ {impactLine ? ( +

{impactLine}

+ ) : null} ); diff --git a/frontend/src/lib/consultation-agent-events.ts b/frontend/src/lib/consultation-agent-events.ts index 0fb0500c..6d500e3d 100644 --- a/frontend/src/lib/consultation-agent-events.ts +++ b/frontend/src/lib/consultation-agent-events.ts @@ -19,6 +19,7 @@ export type WorkflowReceipt = Readonly<{ // Requested but not calculated, because the run's wall clock could not pay // for them. Present so a partial plan cannot be read as a complete one. omittedDomains?: readonly ConsultationDomain[]; + minuteSensitiveThemes?: readonly string[]; }>; export const workflowReceiptSchema: z.ZodType = z.object({ @@ -28,6 +29,7 @@ export const workflowReceiptSchema: z.ZodType = z.object({ missingLayers: z.array(z.string().max(120)).max(30), domains: z.array(consultationDomainSchema).min(1).max(6).optional(), omittedDomains: z.array(consultationDomainSchema).min(1).max(6).optional(), + minuteSensitiveThemes: z.array(z.string().max(40)).max(24).optional(), }).strict(); export const techniqueAuditStatusSchema = z.enum(["executed", "blocked", "not_applicable"]); diff --git a/frontend/src/lib/consultation-birth-time-mode.ts b/frontend/src/lib/consultation-birth-time-mode.ts index 0440a134..f93edd11 100644 --- a/frontend/src/lib/consultation-birth-time-mode.ts +++ b/frontend/src/lib/consultation-birth-time-mode.ts @@ -20,6 +20,7 @@ export type NatalMinuteConsultationMode = Extract< export const UNVERIFIED_BIRTH_TIME_NOTICE = "使用未校正填报时间;分钟敏感结论的置信度已降低。"; export const HOSPITAL_REPORTED_BIRTH_TIME_NOTICE = "使用你填报的出生时间(医院记录)排盘"; export const FAMILY_EXACT_REPORTED_BIRTH_TIME_NOTICE = "使用你填报的出生时间排盘;分钟敏感结论的置信度已降低。"; +export const ACCEPTED_RANGE_READING_INSTRUCTION = "当前采用的是一段可信出生区间,不是已确认的唯一出生分钟。区间里稳定的主题可按代表分钟读;会随分钟变的主题按范围读,不得写成单一分钟结论。"; export function unverifiedBirthTimeNotice(source: string | null | undefined): string { if (source === "hospital_record") return HOSPITAL_REPORTED_BIRTH_TIME_NOTICE; @@ -76,12 +77,21 @@ export function applyBirthTimeModeToWorkflowContext< export function createBirthTimeModeOutputGuard( mode: ConsultationBirthTimeMode, canAnswerPreciseTiming: boolean, + options?: { + currentTheme?: string | null; + minuteSensitiveThemes?: readonly string[] | null; + }, ): (text: string) => string { return (text) => { if (mode === "general_no_birth_time") return guardGeneralNoBirthTimeOutput(text); if (mode === "declared_birth_window" || !canAnswerPreciseTiming) { return guardPreciseTimingOutput(text); } + const theme = options?.currentTheme ?? ""; + const sensitive = options?.minuteSensitiveThemes ?? []; + if (theme && (theme === "timing" || sensitive.includes(theme))) { + return guardPreciseTimingOutput(text); + } return text; }; } diff --git a/frontend/src/lib/consultation-route-service.ts b/frontend/src/lib/consultation-route-service.ts index 2133f36f..7ac08e37 100644 --- a/frontend/src/lib/consultation-route-service.ts +++ b/frontend/src/lib/consultation-route-service.ts @@ -46,6 +46,8 @@ type ServerChartToolInput = Readonly<{ ayanamsa: AyanamsaName; declared_accuracy: DeclaredBirthAccuracy["declaredAccuracy"]; time_source: string; + birth_time_accuracy?: "provisional"; + candidate_range?: Readonly<{ start_time: string; end_time: string }>; }>; export type ServerChartConsultation = Readonly<{ @@ -125,6 +127,7 @@ type PrepareConsultationRouteInput = Readonly<{ userId: string; mode: ConsultationBirthTimeMode; loadProfile: (userId: string) => Promise; + loadCandidateRange?: (userId: string) => Promise<{ startTime: string; endTime: string } | null>; resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise; beforeReserve?: (context: ConsultationPreReserveContext) => unknown | Promise; reserve: () => Promise; @@ -523,6 +526,30 @@ export async function prepareConsultationRoute( throw new ConsultationProfileTruthError("profile_unavailable"); } serverChart = serverChartFromProfile(profile, consultationMode); + if ( + consultationMode === "verified_chart" + && serverChart.truth.birthTimeStatus === "accepted" + && input.loadCandidateRange + ) { + try { + const range = await input.loadCandidateRange(input.userId); + if (range) { + serverChart = Object.freeze({ + ...serverChart, + toolInput: Object.freeze({ + ...serverChart.toolInput, + birth_time_accuracy: "provisional" as const, + candidate_range: Object.freeze({ + start_time: range.startTime, + end_time: range.endTime, + }), + }), + }); + } + } catch { + // Fail closed: accepted charts still consult the representative minute. + } + } } else if (consultationMode === "declared_birth_window") { const range = declaredClockRangeFromProfile(record(profile) ?? {}); try { diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 4307f7f6..048ae29d 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -30,6 +30,7 @@ export { REPRESENTATIVE_MINUTE_DISCLAIMER, nonConvergingRangeNarration }; export type RectificationNextActionType = | "ask_fact_collection" + | "ask_block_choice" | "ask_candidate_discriminator" | "ask_holdout_validation" | "offer_provisional_range" @@ -108,6 +109,7 @@ export const MIN_STANDALONE_DATED_DOMAINS = 2; export type DecisionSessionOutcome = | "collect_evidence" + | "compare_blocks" | "discriminate_candidates" | "validate_holdout" | "provisional_range" @@ -189,6 +191,8 @@ export type DecideRectificationInput = Readonly<{ datedEventCount?: number; datedDomainCount?: number; userUncertaintyHigh?: boolean; + caseStage?: "minute" | "block_scan"; + blockScanDeclined?: boolean; }>; function classifyStop( @@ -242,6 +246,9 @@ function deliveryCapability(input: { } export function decideRectification(input: DecideRectificationInput): RectificationDecision { + if (input.caseStage === "block_scan") { + return decideBlockScan(input); + } const separation = evaluateCandidateSeparation(input.candidateScores); const probe = input.discriminatorProbe ?? null; const holdout = input.holdoutValidation ?? "unavailable"; @@ -404,6 +411,53 @@ function discriminateOrExhaust( return discriminate(separation, holdout, range, probe, capability, stopReason); } +function decideBlockScan(input: DecideRectificationInput): RectificationDecision { + const closed: DeliveryCapability = { + canAdopt: false, + selectionAllowed: false, + proposeAllowed: false, + canConfirmExactMinute: false, + }; + const separation = evaluateCandidateSeparation([]); + const range = input.inferenceCredibleRange ?? (["00:00", "23:59"] as const); + if (input.blockScanDeclined === true) { + return collect(separation, "unavailable", range, null, closed, "insufficient_dated_events"); + } + if (input.trainingGateOpen === false) { + const stopReason: EvidenceStopReason = (input.datedDomainCount ?? 0) < MIN_STANDALONE_DATED_DOMAINS + && (input.datedEventCount ?? 0) >= MIN_STANDALONE_DATED_EVENTS + ? "insufficient_domains" + : "insufficient_dated_events"; + return collect(separation, "unavailable", range, null, closed, stopReason); + } + return askBlockChoice(separation, range, closed); +} + +function askBlockChoice( + separation: CandidateSeparation, + range: readonly [string, string] | null, + capability: DeliveryCapability, +): RectificationDecision { + return { + phase: "event_collection", + nextAction: "ask_block_choice", + sessionOutcome: "compare_blocks", + resultStatus: "insufficient_evidence", + canOfferRange: false, + ...capability, + precisionStage: "collect_events", + activeFocusPolicy: "keep", + completionStatus: null, + validated: false, + credibleRange: range, + representativeTime: null, + separation, + probe: null, + holdoutValidation: "unavailable", + droppedProbes: [], + }; +} + function collect( separation: CandidateSeparation, holdout: HoldoutValidationStatus, @@ -614,6 +668,7 @@ export function sessionKindFromNextAction( type: RectificationNextActionType, ): DecisionSessionOutcome { if (type === "ask_fact_collection") return "collect_evidence"; + if (type === "ask_block_choice") return "compare_blocks"; if (type === "ask_candidate_discriminator") return "discriminate_candidates"; if (type === "ask_holdout_validation") return "validate_holdout"; if (type === "offer_provisional_range") return "provisional_range"; diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 8afdcfc3..715f0f93 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -6,6 +6,9 @@ * no unique-minute claim. */ +import { PROBE_EXPLAIN_COPY } from "./v9/probe-explain.ts"; +import { STEP_STATE_COPY } from "./v9/step-state.ts"; + export const REPRESENTATIVE_MINUTE_DISCLAIMER = "这只是代表性候选,不是已确认的唯一出生分钟。"; /** Phrases that keep the unique-minute boundary in delivery/adopt turns. */ @@ -282,6 +285,32 @@ export function listUserVisibleCopy(): string[] { RECTIFICATION_USER_COPY.lowDateQualityGate, RECTIFICATION_USER_COPY.noCandidatesGate, RECTIFICATION_USER_COPY.postAdoptVerifyDone, + PROBE_EXPLAIN_COPY.unsureImpact, + PROBE_EXPLAIN_COPY.splitGroups, + `${PROBE_EXPLAIN_COPY.vargaWhyPrefix} D9 ${PROBE_EXPLAIN_COPY.vargaWhySuffix}`, + STEP_STATE_COPY.collect.headline, + STEP_STATE_COPY.collect.reason, + STEP_STATE_COPY.collect.next, + STEP_STATE_COPY.discriminate.headline, + STEP_STATE_COPY.discriminate.reason, + STEP_STATE_COPY.discriminate.next, + STEP_STATE_COPY.deliver.headline, + STEP_STATE_COPY.deliver.reason, + STEP_STATE_COPY.deliver.next, + STEP_STATE_COPY.deliverExhausted.reason, + STEP_STATE_COPY.deliverUncertain.reason, + STEP_STATE_COPY.postAdopt.headline, + STEP_STATE_COPY.postAdopt.reason, + STEP_STATE_COPY.postAdopt.next, + "这不是已确认的唯一出生分钟。", + "这只是粗看。", + "的判断是稳定的", + "会随分钟变,看盘时按范围读。", + "没有整段都稳定的主题", + "没有会随分钟变的主题。", + "事业方向", + "婚恋(D9)", + "性格底色", ...Object.values(USER_COLLECT_QUESTION), ...Object.values(USER_COLLECT_QUESTION_RETRY), ]; diff --git a/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts b/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts index 818de19b..351bf34f 100644 --- a/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts +++ b/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts @@ -309,6 +309,58 @@ export function appendAdoptCue(text: string): string { return `${trimmed} ${RECTIFICATION_USER_COPY.adoptCue}`; } +export const RANGE_READING_THEME_LABELS: Readonly> = { + career: "事业方向", + marriage: "婚恋(D9)", + wealth: "财富", + health: "身体", + timing: "应期", + general: "性格底色", +}; + +export const RANGE_READING_COPY = { + boundary: "这不是已确认的唯一出生分钟。", + coarseLook: "这只是粗看。", + stableSuffix: "的判断是稳定的", + sensitiveSuffix: "会随分钟变,看盘时按范围读。", + emptyStable: "没有整段都稳定的主题", + emptySensitive: "没有会随分钟变的主题。", +} as const; + +export type RangeReadingExplainInput = Readonly<{ + widthMinutes?: number | null; + stableThemes?: readonly string[] | null; + sensitiveThemes?: readonly string[] | null; + coarseLook?: boolean; +}>; + +function themeLabel(theme: string): string { + return RANGE_READING_THEME_LABELS[theme] ?? ""; +} + +export function templateRangeReadingExplain( + input: RangeReadingExplainInput | null | undefined, +): string | null { + if (!input) return null; + const stable = [...new Set((input.stableThemes ?? []).map(themeLabel).filter(Boolean))]; + const sensitive = [...new Set((input.sensitiveThemes ?? []).map(themeLabel).filter(Boolean))]; + if (stable.length === 0 && sensitive.length === 0) return null; + const prefix = input.coarseLook ? RANGE_READING_COPY.coarseLook : ""; + const width = input.widthMinutes; + const window = typeof width === "number" && Number.isInteger(width) && width > 0 + ? `这 ${width} 分钟里,` + : "这段时间里,"; + const stableClause = stable.length + ? `${stable.join("、")}${RANGE_READING_COPY.stableSuffix}` + : RANGE_READING_COPY.emptyStable; + const sensitiveClause = sensitive.length + ? `${sensitive.join("、")}${RANGE_READING_COPY.sensitiveSuffix}` + : RANGE_READING_COPY.emptySensitive; + return `${prefix}${window}${stableClause};${sensitiveClause}${RANGE_READING_COPY.boundary}` + .replace(/\s+/g, " ") + .trim(); +} + export type AdoptNarrationWriter = ( facts: AdoptDeliveryFacts, fallback: string, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 703c29ec..765ef18b 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -19,6 +19,7 @@ import { insertV9SkillRunReceipt, loadV9CaseDossier, loadV9CaseSkillIdentity, + loadV9CaseCompute, RectificationToolServiceError, type RectificationRpcClient, type V9CaseDossier, @@ -28,7 +29,7 @@ import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt"; import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "../../agent-generation-settings.ts"; import { toAgentModelFinishReason } from "../../agent-observability.ts"; import { decideFromDossier } from "./decision-from-dossier"; -import { persistNextInterviewIfIdle } from "./answer-choice"; +import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice"; import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card"; import { resolveExactSkillPackage, @@ -213,7 +214,7 @@ function shouldAutoRetry(errorCode: string, signal?: AbortSignal): boolean { return !signal?.aborted && isRetryableError(errorCode); } -function openingBrief(dossier: V9CaseDossier): string { +function openingBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): string { const confirmed = dossier.evidence.filter((item) => item.status === "confirmed"); const pending = dossier.evidence.filter((item) => item.status === "draft" || item.status === "pending_confirmation"); const domains = [...new Set(confirmed.map((item) => item.domain))].slice(0, 6); @@ -221,11 +222,17 @@ function openingBrief(dossier: V9CaseDossier): string { const uncertaintyType = range && typeof range === "object" ? "用户的出生时间存在一个服务端保存的不确定范围" : "用户的出生时间精度仍需通过经历证据核对"; + const clue = typeof birthTimeClue === "string" && birthTimeClue.trim() + ? birthTimeClue.trim() + : ""; return [ "【服务端 opening brief】", `Case 状态:${dossier.case.status}。`, `出生时间不确定类型:${uncertaintyType}。`, `已有证据摘要:已确认 ${confirmed.length} 条,待澄清或待确认 ${pending.length} 条${domains.length ? `;已覆盖 ${domains.join("、")}` : ""}。`, + ...(clue + ? [`家人或本人关于出生时段的线索(仅旁白建议,不得改搜索窗口):${clue}`] + : []), "正文只打招呼,说明可以慢慢说、记得大概年份即可,不要要求一次说完。不要提问,不要举大学、工作、搬家的例子。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题。", ].join("\n"); } @@ -258,6 +265,16 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise abortController.abort(); @@ -976,6 +1008,7 @@ function buildAgentMessages( attempt: number, dossier: V9CaseDossier, skillInstructions: string, + birthTimeClue: string | null = null, ): unknown[] { const timeContext = options.timeContext ?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`; @@ -992,7 +1025,7 @@ function buildAgentMessages( if (options.action === "opening") { return [bootstrap, { role: "user", - content: [timeContext, caseContext, openingBrief(dossier)].join("\n"), + content: [timeContext, caseContext, openingBrief(dossier, birthTimeClue)].join("\n"), }]; } return [bootstrap, { diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 11452e7d..244df842 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -21,6 +21,7 @@ import { acceptanceGateNarration, deliveryAdoptNarration, openingRangeFromCandidateRange, + rangeWidthMinutes, RECTIFICATION_USER_COPY, } from "../user-copy.ts"; import { @@ -62,12 +63,15 @@ import { type V9CaseDossier, } from "./tool-service"; import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, type ChoiceKey } from "./choice-card"; +import { clusterScoreDeltas } from "./probe-explain.ts"; import { adoptDeliveryFacts, templatePostAdoptExplain, + templateRangeReadingExplain, templateStopExplain, type AdoptNarrationWriter, } from "./adopt-narration.ts"; +import { runV9RangeReading } from "./engine-client.ts"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion, linkFocusAskedTurn, followupHasPersistableDomain } from "./server-focus"; import { collectionProgressFromReceipt, trainingScoreableGate } from "./evidence-model"; import { composeCollectSpokenAssistantText } from "./collect-prompt"; @@ -84,10 +88,48 @@ import { type MethodFollowup, type MethodFollowupPlan, } from "./method-followup"; +import { followupCaseArgs, isBlockChoiceSchema } from "./block-scan.ts"; +import { mutateCaseForBlockChoice } from "./block-scan-answer.ts"; import type { SessionOutcomeKind } from "./confirmation-gate"; import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet"; import { projectCurrentQuestion } from "./turn-decision"; +const EXHAUSTION_DELIVERY_ACTIONS = new Set([ + "offer_provisional_range", + "complete_with_range", + "ask_fact_collection", +]); + +export function isExhaustedGateState(input: { + remainingCollect: unknown; + methods: readonly MethodCoverage[]; + canAdopt: boolean; + accepted?: boolean; +}): boolean { + return !input.remainingCollect + && blockingMethodsCovered(input.methods) + && !input.canAdopt + && !input.accepted; +} + +export function exhaustionGateRequestId(askedTurnId: string | null | undefined, caseId: string): string { + return `${askedTurnId ?? caseId}:gate`; +} + +export async function persistExhaustionGateTurn(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + askedTurnId?: string | null; + hostNarration: string; +}): Promise { + await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, { + requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId), + userMessage: null, + assistantMessage: input.hostNarration, + }); +} + function withProspectiveWindows( base: string, receipt: Readonly> | null | undefined, @@ -98,6 +140,43 @@ function withProspectiveWindows( return extra ? `${base} ${extra}` : base; } +async function withRangeReadingNarration( + base: string, + input: { + accounting: AccountingClient; + userId: string; + caseId: string; + credibleRange?: readonly [string, string] | null; + representativeTime?: string | null; + coarseLook?: boolean; + }, +): Promise { + const range = input.credibleRange; + if (!range?.[0] || !range[1]) return base; + try { + const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId); + const reading = await runV9RangeReading({ + baselineBirthSnapshot: compute.baselineBirthSnapshot, + candidateRange: { + start_time: range[0], + end_time: range[1], + representative_time: input.representativeTime ?? range[0], + }, + representativeTime: input.representativeTime, + }); + const span = rangeWidthMinutes(range[0], range[1]); + const extra = templateRangeReadingExplain({ + widthMinutes: span == null ? null : span + 1, + stableThemes: reading?.stableThemes, + sensitiveThemes: reading?.sensitiveThemes, + coarseLook: input.coarseLook, + }); + return extra ? `${base} ${extra}`.replace(/\s+/g, " ").trim() : base; + } catch { + return base; + } +} + function openingRangeFromDossier(dossier: { case?: { acceptedTime?: string | null; @@ -142,6 +221,7 @@ function shouldSkipFollowupPersist(input: { input.nextAction === "ask_fact_collection" || input.nextAction === "ask_candidate_discriminator" || input.nextAction === "ask_holdout_validation" + || input.nextAction === "ask_block_choice" ) { return false; } @@ -225,6 +305,7 @@ export type AppliedChoiceReceipt = Readonly<{ nextInterviewPersisted: boolean; nextChoiceReady: boolean; turnId: string | null; + snapshotCurrent?: boolean; }>; function asText(value: unknown): string | null { @@ -327,6 +408,47 @@ export async function applyRectificationChoice( throw new RectificationToolServiceError("agentic_rectification_invalid_choice_schema"); } + if (isBlockChoiceSchema(schema)) { + const mutated = await mutateCaseForBlockChoice({ + accounting, + userId: command.userId, + caseId: command.caseId, + dossier, + schema, + optionId, + }); + const declined = optionId === "D" || optionId === "stop" || optionId === "skip_probe"; + return persistApplied(accounting, command, { + focusId: focus.id, + questionId, + focusStatus: declined ? "skipped" : "resolved", + probeId: schemaProbeId ?? command.probeId ?? null, + optionId, + scoring: false, + appliedInference: false, + answerClass: declined && (optionId === "stop" || optionId === "skip_probe") ? null : answerClass, + sourceQuote: optionId === "A" || optionId === "B" || optionId === "C" || optionId === "D" + ? optionQuoteFromSchema(schema, optionId) + : null, + year: null, + expectedRevision: previous?.revision ?? command.expectedRevision, + inference: null, + narration: declined + ? "已记下。再说一件带年份的经历后,会再比一次时段。" + : "已选定出生时段,接下来只在这一段里按分钟比较。", + userDisplay: command.userDisplay + ?? (optionId === "skip_probe" + ? CHOICE_SKIP_QUESTION_LABEL + : optionId === "stop" + ? "先这样,先看当前范围" + : userDisplayFromSchema(schema, optionId)), + decisionState: null, + userStopped: false, + dossier: mutated.dossier, + snapshotCurrent: mutated.snapshotCurrent, + }); + } + if (optionId === "skip_probe" || command.action === SKIP_PROBE_ACTION) { const skippedState = previous ? markVerifyProbeSkipped(previous, schema, schemaProbeId ?? command.probeId ?? null) : null; const probeId = schemaProbeId ?? command.probeId ?? asText(schema.semantic_key) ?? "skip_probe"; @@ -461,11 +583,17 @@ export async function applyRectificationChoice( ?? nextProbe(previous) : null; const sourceQuote = optionQuoteFromSchema(schema, optionId); + const posteriorBefore = posteriorMap(previous.candidates); + const posteriorAfter = posteriorMap(applied.state.candidates); + const appliedScoreDeltas = scoreDeltas(posteriorBefore, posteriorAfter); const narration = composeChoiceNarration({ optionId, scoring, appliedInference: persistable && scoring, answerClass, + deltasByCluster: clusterScoreDeltas(previous.candidates, appliedScoreDeltas), + rangeBefore: [previous.range_start, previous.range_end], + rangeAfter: [applied.state.range_start, applied.state.range_end], }); const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint ?? evidenceLedgerFingerprint(dossier.evidence); @@ -481,12 +609,9 @@ export async function applyRectificationChoice( answerClass: applied.answerClass, rawAnswer: optionId, inferenceState: applied.state as unknown as Record, - posteriorBefore: posteriorMap(previous.candidates), - posteriorAfter: posteriorMap(applied.state.candidates), - scoreDeltas: scoreDeltas( - posteriorMap(previous.candidates), - posteriorMap(applied.state.candidates), - ), + posteriorBefore, + posteriorAfter, + scoreDeltas: appliedScoreDeltas, decisionStateFingerprint: inferenceFingerprintForState( command.caseId, evidenceFp, @@ -578,6 +703,10 @@ export async function persistNextInterviewAfterChoice(input: { accepted: Boolean(input.dossier.case.acceptedTime), candidatesSeparated: input.nextAction.type !== "ask_candidate_discriminator" && input.nextAction.type !== "ask_holdout_validation", + ...followupCaseArgs({ + stage: input.dossier.case.stage, + blockScan: input.dossier.case.blockScan, + }), }); const followup = interviewToPersist(plan); if (shouldSkipFollowupPersist({ @@ -593,10 +722,17 @@ export async function persistNextInterviewAfterChoice(input: { decision, receipt: liveDossier.latestResult?.decisionReceipt, }); + const narrated = input.narrateAdopt + ? await input.narrateAdopt(facts, fallback) + : fallback; return { - hostNarration: input.narrateAdopt - ? await input.narrateAdopt(facts, fallback) - : fallback, + hostNarration: await withRangeReadingNarration(narrated, { + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + credibleRange: decision.credibleRange, + representativeTime: decision.representativeTime, + }), choiceReady: false, persisted: false, followup: null, @@ -897,7 +1033,7 @@ export async function persistNextInterviewIfIdle(input: { caseId: string; askedTurnId?: string | null; narrateAdopt?: AdoptNarrationWriter; -}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> { +}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> { let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); const staleFocus = dossier.conversationSummary.activeFocus; const staleFocusId = staleFocus?.id; @@ -953,6 +1089,11 @@ export async function persistNextInterviewIfIdle(input: { ...catalog, birthDate, accepted: Boolean(dossier.case.acceptedTime), + holdoutValidation: decision.holdoutValidation, + ...followupCaseArgs({ + stage: dossier.case.stage, + blockScan: dossier.case.blockScan, + }), }); const followup = interviewToPersist(plan); if (shouldSkipFollowupPersist({ @@ -968,12 +1109,19 @@ export async function persistNextInterviewIfIdle(input: { decision, receipt: dossier.latestResult?.decisionReceipt, }); + const narrated = input.narrateAdopt + ? await input.narrateAdopt(facts, fallback) + : fallback; return { persisted: false, choiceReady: false, - hostNarration: input.narrateAdopt - ? await input.narrateAdopt(facts, fallback) - : fallback, + hostNarration: await withRangeReadingNarration(narrated, { + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + credibleRange: decision.credibleRange, + representativeTime: decision.representativeTime, + }), }; } const remainingCollect = exhaustionSpokenCollectFollowup({ @@ -983,9 +1131,13 @@ export async function persistNextInterviewIfIdle(input: { eventProbes: catalog.eventProbes, }); if ( - !remainingCollect - && !dossier.case.acceptedTime - && !decision.canAdopt + isExhaustedGateState({ + remainingCollect, + methods: plan.methods, + canAdopt: decision.canAdopt, + accepted: Boolean(dossier.case.acceptedTime), + }) + && EXHAUSTION_DELIVERY_ACTIONS.has(decision.nextAction) ) { return persistExhaustionCollect({ accounting: input.accounting, @@ -1011,7 +1163,11 @@ export async function persistNextInterviewIfIdle(input: { askedTurnId: input.askedTurnId ?? null, }); } - if (!followup) { + if ( + !followup + && decision.nextAction !== "ask_candidate_discriminator" + && decision.nextAction !== "ask_holdout_validation" + ) { if (dossier.case.acceptedTime) { return { persisted: false, @@ -1146,10 +1302,16 @@ async function persistExhaustionCollect(input: { return { persisted: false, choiceReady: false, - hostNarration: adoptHostNarration({ + hostNarration: await withRangeReadingNarration(adoptHostNarration({ dossier, decision: adopted, receipt, + }), { + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + credibleRange: adopted.credibleRange, + representativeTime: adopted.representativeTime, }), focus: null, terminalNote: true, @@ -1176,15 +1338,6 @@ async function persistExhaustionCollect(input: { [range, gate].filter(Boolean).join(""), receipt, ); - try { - await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, { - requestId: globalThis.crypto.randomUUID(), - userMessage: null, - assistantMessage: hostNarration, - }); - } catch { - // Fake RPCs and already-narrated turns must not block the gate carrier. - } return { persisted: false, choiceReady: false, @@ -1215,6 +1368,7 @@ async function persistApplied( decisionState?: InferenceState | null; userStopped?: boolean; dossier: Parameters[0]["dossier"]; + snapshotCurrent?: boolean; }, ): Promise { const persisted = await persistV9ChoiceAction(accounting, command.userId, command.caseId, { @@ -1266,6 +1420,7 @@ async function persistApplied( shouldContinueAfterStructuredChoice(nextAction) || skipThisProbe || accepted + || EXHAUSTION_DELIVERY_ACTIONS.has(nextAction.type) ) ) { const nextInterview = await persistNextInterviewAfterChoice({ @@ -1410,6 +1565,7 @@ ${nonConvergingRangeNarration({ nextInterviewPersisted, nextChoiceReady, turnId, + snapshotCurrent: input.snapshotCurrent, }; } @@ -1427,12 +1583,40 @@ async function inspectNonTerminalTurnExit(input: { birthDate = null; } const decision = decideFromDossier(dossier, { birthDate }); + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); + const remainingCollect = exhaustionSpokenCollectFollowup({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + answeredProbes: catalog.answeredProbes, + eventProbes: catalog.eventProbes, + }); + const plan = buildMethodFollowupPlan({ + evidence: dossier.evidence, + activeFocus: null, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: decision.sessionOutcome, + ...catalog, + birthDate, + accepted: Boolean(dossier.case.acceptedTime), + ...followupCaseArgs({ + stage: dossier.case.stage, + blockScan: dossier.case.blockScan, + }), + }); + const exhausted = isExhaustedGateState({ + remainingCollect, + methods: plan.methods, + canAdopt: decision.canAdopt, + accepted: Boolean(dossier.case.acceptedTime), + }); const satisfied = Boolean( projectCurrentQuestion(dossier.conversationSummary.activeFocus) || dossier.case.acceptedTime || dossier.case.confirmedTime || decision.completionStatus === "provisional_range_user_stopped" || publicCanAdopt(decision) + || exhausted ); return { dossier, decision, satisfied }; } diff --git a/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts b/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts new file mode 100644 index 00000000..677f9b70 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts @@ -0,0 +1,124 @@ +/** + * Unknown-time A/B/C/D: A/B/C advance the declared period; D collects again. + * Window changes never write the evidence ledger or inference state. + */ + +import { askedDiscriminatorKeys } from "./inference-adapter.ts"; +import { + isBlockChoiceSchema, + serializeBlockScanPayload, + windowFromBlockChoice, +} from "./block-scan.ts"; +import { runV9BlockScan, runV9CandidateScore, toEngineEvents } from "./engine-client.ts"; +import { + advanceV9CaseFromBlockScan, + candidateRangeFingerprint, + evidenceLedgerFingerprint, + loadV9CaseCompute, + loadV9CaseDossier, + persistV9Candidate, + scorableEvidence, + writeV9BlockScan, + type AccountingClient, + type V9CaseDossier, +} from "./tool-service.ts"; +import type { ChoiceKey } from "./choice-card.ts"; + +export async function mutateCaseForBlockChoice(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + dossier: V9CaseDossier; + schema: Readonly>; + optionId: ChoiceKey | "stop" | "skip_probe"; +}): Promise<{ dossier: V9CaseDossier; snapshotCurrent: boolean }> { + if (!isBlockChoiceSchema(input.schema)) { + return { dossier: input.dossier, snapshotCurrent: true }; + } + const window = windowFromBlockChoice({ + schema: input.schema, + optionId: input.optionId, + }); + if (window) { + await advanceV9CaseFromBlockScan(input.accounting, input.userId, input.caseId, window); + try { + await rescoreMinuteAfterBlockAdvance(input.accounting, input.userId, input.caseId); + } catch (error) { + console.warn( + `[rectification-v9] block_scan minute rescore deferred case=${input.caseId} reason=${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return { + dossier: await loadV9CaseDossier(input.accounting, input.userId, input.caseId), + snapshotCurrent: false, + }; + } + const current = input.dossier.case.blockScan; + if (current) { + await writeV9BlockScan( + input.accounting, + input.userId, + input.caseId, + { + ...serializeBlockScanPayload(current), + declined_at_fingerprint: evidenceLedgerFingerprint(input.dossier.evidence), + }, + ); + } + return { + dossier: await loadV9CaseDossier(input.accounting, input.userId, input.caseId), + snapshotCurrent: true, + }; +} + +async function rescoreMinuteAfterBlockAdvance( + accounting: AccountingClient, + userId: string, + caseId: string, +): Promise { + const dossier = await loadV9CaseDossier(accounting, userId, caseId); + if (dossier.case.stage !== "minute" || !dossier.case.candidateRange) return; + const scorable = scorableEvidence(dossier.evidence); + if (scorable.length === 0) return; + const compute = await loadV9CaseCompute(accounting, userId, caseId); + const events = toEngineEvents(scorable); + const score = await runV9CandidateScore({ + baselineBirthSnapshot: compute.baselineBirthSnapshot, + candidateRange: dossier.case.candidateRange, + events, + askedProbeKeys: askedDiscriminatorKeys(dossier.latestResult?.decisionReceipt, dossier.evidence), + }); + await persistV9Candidate(accounting, userId, caseId, { + engineResultId: score.engineResultId, + algorithmVersion: score.algorithmVersion, + evidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), + rangeFingerprint: candidateRangeFingerprint( + dossier.case.candidateRange, + compute.baselineProfileFingerprint, + ), + skillVersion: dossier.case.skillVersion, + eventContractVersion: score.eventContractVersion, + policyVersion: score.policyVersion, + candidateRange: dossier.case.candidateRange, + candidates: score.candidates, + decisionReceipt: score.decisionReceipt, + executionLedger: score.executionLedger, + }); +} + +export async function persistBlockScanPayload(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + evidenceFingerprint: string; + scan: Awaited>; +}): Promise { + await writeV9BlockScan(input.accounting, input.userId, input.caseId, { + evidence_ledger_fingerprint: input.evidenceFingerprint, + algorithm_version: input.scan.algorithmVersion, + minute_step: input.scan.minuteStep, + blocks: input.scan.blocks, + }); +} diff --git a/frontend/src/lib/rectification-agentic/v9/block-scan.ts b/frontend/src/lib/rectification-agentic/v9/block-scan.ts new file mode 100644 index 00000000..e7fee331 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/block-scan.ts @@ -0,0 +1,245 @@ +/** + * Unknown-time block scan: compare declared periods before any minute grid. + * + * Choice cards stay TypeScript-internal. They never enter the Python probe + * contract. Picking A/B/C writes a declared period window; D collects again. + */ + +import { + DECLARED_PERIOD_RANGES, + type DeclaredBirthPeriod, +} from "../../declared-birth-window.ts"; +import type { AnswerClass } from "../core/types.ts"; +import { + CHOICE_MODE, + CHOICE_STOP_LABEL, + CHOICE_STOP_MESSAGE, + type ChoiceKey, + type RectificationChoiceFrame, + type RectificationChoiceOption, +} from "./choice-card.ts"; + +export const RECTIFICATION_CASE_STAGES = ["minute", "block_scan"] as const; +export type RectificationCaseStage = (typeof RECTIFICATION_CASE_STAGES)[number]; + +export const BLOCK_CHOICE_KIND = "block_choice" as const; +export const BLOCK_CHOICE_INTENT = "choose_birth_block" as const; + +export const BLOCK_PERIOD_LABELS: Readonly> = { + early_morning: "清晨 04:00—07:59", + morning: "上午 08:00—11:59", + afternoon: "下午 12:00—17:59", + evening: "傍晚到晚上 18:00—22:59", + late_night: "夜里到凌晨 23:00—03:59", +}; + +const BLOCK_ANSWER_CLASSES: readonly AnswerClass[] = ["yes", "weak_yes", "no", "unsure"]; + +export type BlockScanBlock = Readonly<{ + period: DeclaredBirthPeriod; + start_time: string; + end_time: string; + relative_support: number; + top_events?: readonly Readonly>[]; + candidate_count?: number; +}>; + +export type BlockScanPayload = Readonly<{ + evidenceLedgerFingerprint?: string | null; + algorithmVersion?: string | null; + minuteStep?: number | null; + declinedAtFingerprint?: string | null; + blocks: readonly BlockScanBlock[]; +}>; + +export function isRectificationCaseStage(value: unknown): value is RectificationCaseStage { + return value === "minute" || value === "block_scan"; +} + +export function parseRectificationCaseStage(value: unknown): RectificationCaseStage { + return value === "block_scan" ? "block_scan" : "minute"; +} + +function isDeclaredPeriod(value: unknown): value is DeclaredBirthPeriod { + return value === "early_morning" + || value === "morning" + || value === "afternoon" + || value === "evening" + || value === "late_night"; +} + +function clockText(value: unknown): string | null { + return typeof value === "string" && /^([01]\d|2[0-3]):[0-5]\d$/.test(value.slice(0, 5)) + ? value.slice(0, 5) + : null; +} + +export function parseBlockScanBlock(value: unknown): BlockScanBlock | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + if (!isDeclaredPeriod(row.period)) return null; + const expected = DECLARED_PERIOD_RANGES[row.period]; + const start = clockText(row.start_time) ?? expected.startTime; + const end = clockText(row.end_time) ?? expected.endTime; + if (start !== expected.startTime || end !== expected.endTime) return null; + const support = typeof row.relative_support === "number" && Number.isFinite(row.relative_support) + ? Math.max(0, Math.round(row.relative_support)) + : 0; + return { + period: row.period, + start_time: start, + end_time: end, + relative_support: support, + ...(Array.isArray(row.top_events) ? { top_events: row.top_events as BlockScanBlock["top_events"] } : {}), + ...(typeof row.candidate_count === "number" ? { candidate_count: row.candidate_count } : {}), + }; +} + +export function parseBlockScanPayload(value: unknown): BlockScanPayload | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + const rawBlocks = Array.isArray(row.blocks) ? row.blocks : []; + const blocks = rawBlocks.flatMap((item) => { + const parsed = parseBlockScanBlock(item); + return parsed ? [parsed] : []; + }); + if (blocks.length !== 5) return null; + return { + evidenceLedgerFingerprint: typeof row.evidence_ledger_fingerprint === "string" + ? row.evidence_ledger_fingerprint + : null, + algorithmVersion: typeof row.algorithm_version === "string" ? row.algorithm_version : null, + minuteStep: typeof row.minute_step === "number" ? row.minute_step : null, + declinedAtFingerprint: typeof row.declined_at_fingerprint === "string" + ? row.declined_at_fingerprint + : null, + blocks, + }; +} + +export function serializeBlockScanPayload(payload: BlockScanPayload): Record { + return { + evidence_ledger_fingerprint: payload.evidenceLedgerFingerprint ?? null, + algorithm_version: payload.algorithmVersion ?? null, + minute_step: payload.minuteStep ?? null, + declined_at_fingerprint: payload.declinedAtFingerprint ?? null, + blocks: payload.blocks.map((block) => ({ + period: block.period, + start_time: block.start_time, + end_time: block.end_time, + relative_support: block.relative_support, + ...(block.top_events ? { top_events: block.top_events } : {}), + ...(typeof block.candidate_count === "number" ? { candidate_count: block.candidate_count } : {}), + })), + }; +} + +export function blockScanDeclinedForFingerprint( + payload: BlockScanPayload | null | undefined, + fingerprint: string | null | undefined, +): boolean { + return Boolean( + payload?.declinedAtFingerprint + && fingerprint + && payload.declinedAtFingerprint === fingerprint + ); +} + +export function followupCaseArgs(input: { + stage?: string | null; + blockScan?: BlockScanPayload | null; +}): { caseStage: RectificationCaseStage; blockScan: BlockScanPayload | null } { + return { + caseStage: input.stage === "block_scan" ? "block_scan" : "minute", + blockScan: input.blockScan ?? null, + }; +} + +export function rankedBlockScanPeriods( + payload: BlockScanPayload | null | undefined, +): readonly BlockScanBlock[] { + return [...(payload?.blocks ?? [])].sort((left, right) => ( + right.relative_support - left.relative_support + || left.start_time.localeCompare(right.start_time) + )); +} + +export function topBlockScanChoices( + payload: BlockScanPayload | null | undefined, +): readonly [BlockScanBlock, BlockScanBlock, BlockScanBlock] | null { + const ranked = rankedBlockScanPeriods(payload); + if (ranked.length < 3) return null; + return [ranked[0]!, ranked[1]!, ranked[2]!]; +} + +function optionLabel(block: BlockScanBlock): string { + const name = BLOCK_PERIOD_LABELS[block.period]; + return `${name}(相对支持 ${block.relative_support})`; +} + +export function buildBlockChoiceFrame( + payload: BlockScanPayload, +): RectificationChoiceFrame | null { + const top = topBlockScanChoices(payload); + if (!top) return null; + const options: RectificationChoiceOption[] = [ + { key: "A", label: optionLabel(top[0]), answer_class: BLOCK_ANSWER_CLASSES[0]!, role: "primary" }, + { key: "B", label: optionLabel(top[1]), answer_class: BLOCK_ANSWER_CLASSES[1]!, role: "primary" }, + { key: "C", label: optionLabel(top[2]), answer_class: BLOCK_ANSWER_CLASSES[2]!, role: "primary" }, + { key: "D", label: "说不好 / 都不像", answer_class: BLOCK_ANSWER_CLASSES[3]!, role: "primary" }, + ]; + return { + question_id: "block_scan:choose_birth_block:holdout", + method_id: "dasha_events", + period: "", + prompt: "按你说的经历,这三段里哪一段更像出生时段?", + varga: null, + why: "还不知道具体钟点,先用经历比出大概时段,选定后再按分钟收窄。", + option_a_hint: options[0]!.label, + option_b_hint: options[1]!.label, + neither_label: options[2]!.label, + unsure_label: options[3]!.label, + option_a_answer_class: options[0]!.answer_class, + option_b_answer_class: options[1]!.answer_class, + option_c_answer_class: options[2]!.answer_class, + option_d_answer_class: options[3]!.answer_class, + choice_mode: CHOICE_MODE, + stop_label: CHOICE_STOP_LABEL, + stop_message: CHOICE_STOP_MESSAGE, + scoring: false, + why_user: "还不知道具体钟点,先用经历比出大概时段,选定后再按分钟收窄。", + answer_impact: { + A: `选定后只在 ${BLOCK_PERIOD_LABELS[top[0].period]} 里按分钟比较`, + B: `选定后只在 ${BLOCK_PERIOD_LABELS[top[1].period]} 里按分钟比较`, + C: `选定后只在 ${BLOCK_PERIOD_LABELS[top[2].period]} 里按分钟比较`, + D: "不计分,再收一件带年份的经历后重比时段", + }, + choice_kind: BLOCK_CHOICE_KIND, + }; +} + +export function blockPeriodsForChoice( + payload: BlockScanPayload, +): Readonly> | null { + const top = topBlockScanChoices(payload); + if (!top) return null; + return { A: top[0], B: top[1], C: top[2] }; +} + +export function windowFromBlockChoice(input: { + schema: Readonly>; + optionId: ChoiceKey | "stop" | "skip_probe"; +}): { start_time: string; end_time: string } | null { + if (input.optionId === "D" || input.optionId === "stop" || input.optionId === "skip_probe") { + return null; + } + const periods = input.schema.block_periods; + if (!periods || typeof periods !== "object" || Array.isArray(periods)) return null; + const row = (periods as Record)[input.optionId]; + const parsed = parseBlockScanBlock(row); + return parsed ? { start_time: parsed.start_time, end_time: parsed.end_time } : null; +} + +export function isBlockChoiceSchema(schema: Readonly> | null | undefined): boolean { + return schema?.choice_kind === BLOCK_CHOICE_KIND; +} diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts index 368221b5..dfbce5be 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -10,6 +10,10 @@ import { createHash } from "node:crypto"; import type { SupabaseClient } from "@supabase/supabase-js"; import { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts"; import { declaredClockRange } from "../../declared-birth-window.ts"; +import { + parseRectificationCaseStage, + type RectificationCaseStage, +} from "./block-scan.ts"; import { resolveAyanamsa, type AyanamsaName } from "../../ayanamsa.ts"; import { normalizePersistedBirthDate } from "../../birth-time-intake-model.ts"; import { @@ -22,6 +26,8 @@ import { RECTIFICATION_SKILL_VERSION, type RectificationCaseStatus, } from "./case-status.ts"; +export { projectRectificationStepState } from "./step-state.ts"; +export type { RectificationStepState } from "./step-state.ts"; import { RECTIFICATION_USER_COPY } from "../user-copy.ts"; import { openResponse, @@ -41,6 +47,7 @@ export type V9BaselineSnapshot = Readonly<{ timezone_offset: number; birth_time_source: string; birth_time_period: string | null; + birth_time_clue: string | null; declared_window_start: string | null; declared_window_end: string | null; reported_birth_time: string | null; @@ -55,6 +62,7 @@ export type V9RectificationProfile = Readonly<{ baseline: V9BaselineSnapshot; baselineFingerprint: string; candidateRange: { start_time: string; end_time: string }; + stage: RectificationCaseStage; }>; export type RectificationSkillIdentityStatus = "verified" | "legacy_unverifiable"; @@ -76,6 +84,7 @@ export type RectificationCaseView = Readonly<{ skillVersion: string; skillIdentityStatus: RectificationSkillIdentityStatus; requiresSkillAdoption: boolean; + stage: RectificationCaseStage; candidateRange: { start_time: string; end_time: string } | null; acceptedTime: string | null; confirmedTime: string | null; @@ -120,17 +129,20 @@ function shiftedTime(time: string, offsetMinutes: number): string { // the honest server-owned range instead of inventing a baseline minute. const FRESH_CASE_SEARCH_RADIUS_MINUTES = 15; -function deriveCandidateRange(input: { +function deriveRectificationOpenPlan(input: { reportedTime: string | null; source: string; period: string | null; windowStart: string | null; windowEnd: string | null; -}): { start_time: string; end_time: string } { +}): { candidateRange: { start_time: string; end_time: string }; stage: RectificationCaseStage } { if (input.reportedTime) { return { - start_time: shiftedTime(input.reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES), - end_time: shiftedTime(input.reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES), + candidateRange: { + start_time: shiftedTime(input.reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES), + end_time: shiftedTime(input.reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES), + }, + stage: "minute", }; } @@ -141,7 +153,30 @@ function deriveCandidateRange(input: { endTime: input.windowEnd, }); if (!range) throw new RectificationCaseServiceError("profile_incomplete"); - return { start_time: range.startTime, end_time: range.endTime }; + return { + candidateRange: { start_time: range.startTime, end_time: range.endTime }, + stage: input.source === "unknown" ? "block_scan" : "minute", + }; +} + +export function deriveRectificationOpenWindow(input: { + reportedTime: string | null; + source: string; + period: string | null; + windowStart: string | null; + windowEnd: string | null; +}): { candidateRange: { start_time: string; end_time: string }; stage: RectificationCaseStage } { + return deriveRectificationOpenPlan(input); +} + +function deriveCandidateRange(input: { + reportedTime: string | null; + source: string; + period: string | null; + windowStart: string | null; + windowEnd: string | null; +}): { start_time: string; end_time: string } { + return deriveRectificationOpenPlan(input).candidateRange; } function baselineFingerprint(baseline: V9BaselineSnapshot): string { @@ -169,7 +204,7 @@ export async function loadV9RectificationProfile( const { data, error } = await accounting .from("profiles") .select( - "birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa", + "birth_date,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa", ) .eq("id", userId) .single(); @@ -191,6 +226,9 @@ export async function loadV9RectificationProfile( const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : ""; const reportedTime = timeValue(row.reported_birth_time); const period = typeof row.birth_time_period === "string" ? row.birth_time_period : null; + const clue = typeof row.birth_time_clue === "string" && row.birth_time_clue.trim() + ? row.birth_time_clue.trim().slice(0, 240) + : null; const windowStart = timeValue(row.declared_window_start); const windowEnd = timeValue(row.declared_window_end); const uncertaintyBefore = numberOrNull(row.uncertainty_before_minutes); @@ -208,6 +246,7 @@ export async function loadV9RectificationProfile( timezone_offset: timezoneOffset, birth_time_source: source, birth_time_period: period, + birth_time_clue: clue, declared_window_start: windowStart, declared_window_end: windowEnd, reported_birth_time: reportedTime, @@ -218,17 +257,20 @@ export async function loadV9RectificationProfile( ayanamsa: resolveAyanamsa(row), }; + const openPlan = deriveRectificationOpenPlan({ + reportedTime, + source, + period, + windowStart, + windowEnd, + }); + return { userId, baseline, baselineFingerprint: baselineFingerprint(baseline), - candidateRange: deriveCandidateRange({ - reportedTime, - source, - period, - windowStart, - windowEnd, - }), + candidateRange: openPlan.candidateRange, + stage: openPlan.stage, }; } @@ -257,6 +299,9 @@ const KNOWN_RPC_ERROR_CODES = new Map): RectificationCaseView { skillVersion: String(row.skill_version ?? ""), skillIdentityStatus: row.skill_identity_status === "legacy_unverifiable" ? "legacy_unverifiable" : "verified", requiresSkillAdoption: row.requires_skill_adoption === true, + stage: parseRectificationCaseStage(row.stage), candidateRange: range && typeof range.start_time === "string" && typeof range.end_time === "string" ? { start_time: range.start_time, end_time: range.end_time } diff --git a/frontend/src/lib/rectification-agentic/v9/choice-action.ts b/frontend/src/lib/rectification-agentic/v9/choice-action.ts index 946a6361..2fc02cb0 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-action.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-action.ts @@ -17,6 +17,11 @@ import { type ChoiceKey, type RectificationChoiceCard, } from "./choice-card"; +import { + explainRangeChange, + explainScoreMovement, + type ClusterScoreDelta, +} from "./probe-explain.ts"; export const CHOICE_ACTION = "answer_choice" as const; export const STOP_ACTION = "stop_and_review" as const; @@ -96,6 +101,9 @@ export function composeChoiceNarration(input: { scoring: boolean; appliedInference: boolean; answerClass?: AnswerClass | null; + deltasByCluster?: readonly ClusterScoreDelta[]; + rangeBefore?: readonly [string, string] | null; + rangeAfter?: readonly [string, string] | null; }): string { if (input.optionId === "stop") { return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`; @@ -110,6 +118,11 @@ export function composeChoiceNarration(input: { return "已记录你的选择。这是盘外核对,不会改候选分数。"; } if (input.appliedInference) { + const movement = explainScoreMovement(input.deltasByCluster ?? []); + const range = explainRangeChange(input.rangeBefore, input.rangeAfter); + if (movement || range) { + return ["已记录你的选择", movement, range].filter(Boolean).join("。") + "。"; + } return "已记录你的选择,并更新了候选比较。"; } return "已记录你的选择。"; @@ -124,7 +137,8 @@ export function shouldContinueAfterStructuredChoice( const type = (nextAction as { type?: unknown }).type; return type === "ask_fact_collection" || type === "ask_candidate_discriminator" - || type === "ask_holdout_validation"; + || type === "ask_holdout_validation" + || type === "ask_block_choice"; } export function stableChoiceActionKey(focusId: string, optionId: ChoiceOptionId): string { diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index b4895c82..b13b77b0 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -13,6 +13,13 @@ import { engineMeaningToDisplayCopy } from "../user-copy.ts"; import { completeStyleOptions, clippedProbeLabel, canRenderYearlessChoice } from "./probe-question-contract"; import type { DiscriminatingEventProbe, EventProbeChoiceKind, EventProbeStyleOption } from "./refinement-packet"; import type { InternalVargaObservation } from "./varga-observations"; +import { + explainProbeForUser, + type ProbeAnswerImpact, + type ProbeExplainCandidate, +} from "./probe-explain.ts"; + +export type ChoiceCardKind = EventProbeChoiceKind | "block_choice"; export const CHOICE_MODE = "A/B/C/D"; export const CHOICE_STOP_LABEL = "先这样,先看当前范围"; @@ -55,7 +62,9 @@ export type RectificationChoiceFrame = Readonly<{ stop_label: string; stop_message: string; scoring: boolean; - choice_kind?: EventProbeChoiceKind; + why_user: string; + answer_impact: ProbeAnswerImpact; + choice_kind?: ChoiceCardKind; skip_this_probe?: boolean; }>; @@ -86,7 +95,9 @@ export type RectificationChoiceCard = Readonly<{ probe_id: string | null; case_revision: number | null; focus_id: string | null; - choice_kind?: EventProbeChoiceKind; + why_user?: string; + answer_impact?: ProbeAnswerImpact; + choice_kind?: ChoiceCardKind; skip_this_probe?: boolean; }>; @@ -97,7 +108,7 @@ export type ChoiceCardFollowup = Readonly<{ user_prompt_hint: string; intent?: string; source?: string; - choice_kind?: EventProbeChoiceKind; + choice_kind?: ChoiceCardKind; style_options?: readonly EventProbeStyleOption[]; semantic_key?: string; probe_id?: string; @@ -363,6 +374,7 @@ function hypothesisFor( if (!styleOptions.ok) return null; const period = periodFor(evidence, domain, probes, birthDate, followup); const kind = followup.choice_kind ?? probe.choice_kind ?? "existence"; + if (kind === "block_choice") return null; if (kind === "varga_style") { if (!canRenderYearlessChoice({ choiceKind: kind, styleOptions: styleOptions.options })) return null; } else if (!isConcreteChoicePeriod(period)) { @@ -382,6 +394,7 @@ export function buildChoiceFrame( probes?: readonly DiscriminatingEventProbe[]; birthDate?: string | null; scoring?: boolean; + candidates?: readonly ProbeExplainCandidate[]; } = {}, ): RectificationChoiceFrame | null { const scoring = input.scoring !== false; @@ -401,10 +414,20 @@ export function buildChoiceFrame( || followup.source === "oos_blind"; const probeKey = followup.semantic_key?.trim() || followup.probe_id?.trim() || ""; const questionBase = `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`; + const period = periodFor(input.evidence, domain, input.probes, input.birthDate, followup); + const kind = hypothesisKind(followup, input.probes); + const explain = explainProbeForUser({ + probe: pickProbe(input.probes, domain, followup), + period, + choiceKind: kind, + methodId: followup.method_id, + domain, + candidates: input.candidates, + }); return { question_id: probeKey ? `${questionBase}:${probeKey}` : questionBase, method_id: followup.method_id, - period: periodFor(input.evidence, domain, input.probes, input.birthDate, followup), + period, prompt: hypothesis.prompt, varga: hypothesis.varga, why: hypothesis.why, @@ -420,7 +443,9 @@ export function buildChoiceFrame( stop_label: skipQuestion ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL, stop_message: skipQuestion ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE, scoring, - choice_kind: hypothesisKind(followup, input.probes), + why_user: explain.why_user, + answer_impact: explain.answer_impact, + choice_kind: kind, ...(skipThisProbe ? { skip_this_probe: true } : {}), }; } @@ -429,15 +454,31 @@ function hypothesisKind( followup: ChoiceCardFollowup, probes?: readonly DiscriminatingEventProbe[], ): EventProbeChoiceKind { - return followup.choice_kind + const kind = followup.choice_kind ?? pickProbe(probes, followupDomain(followup), followup)?.choice_kind ?? "existence"; + return kind === "block_choice" ? "existence" : kind; } function clippedCopy(value: unknown, min: number, max: number): string | null { return clippedProbeLabel(value, min, max); } +function emptyAnswerImpact(): ProbeAnswerImpact { + return { A: "", B: "", C: "", D: "" }; +} + +function parseAnswerImpact(value: unknown): ProbeAnswerImpact { + if (!value || typeof value !== "object" || Array.isArray(value)) return emptyAnswerImpact(); + const row = value as Record; + return { + A: typeof row.A === "string" ? row.A.trim() : "", + B: typeof row.B === "string" ? row.B.trim() : "", + C: typeof row.C === "string" ? row.C.trim() : "", + D: typeof row.D === "string" ? row.D.trim() : "", + }; +} + function isAnswerClass(value: unknown): value is AnswerClass { return value === "yes" || value === "weak_yes" || value === "no" || value === "unsure"; } @@ -539,6 +580,8 @@ export function mergeChoiceCard( probe_id: meta.probe_id ?? null, case_revision: meta.case_revision ?? null, focus_id: meta.focus_id ?? null, + why_user: frame.why_user, + answer_impact: frame.answer_impact, choice_kind: frame.choice_kind, ...(frame.skip_this_probe ? { skip_this_probe: true } : {}), }; @@ -572,6 +615,8 @@ export function choiceCardFromPersistedVerifyCopy(input: { probe_id: input.probeId, case_revision: input.caseRevision, focus_id: input.focusId, + why_user: "", + answer_impact: emptyAnswerImpact(), choice_kind: "existence", skip_this_probe: true, }; @@ -632,7 +677,12 @@ export function parseRectificationChoiceCard(value: unknown): RectificationChoic ? row.case_revision : null, focus_id: focusId, - ...(row.choice_kind === "existence" || row.choice_kind === "varga_style" || row.choice_kind === "event_quality" + why_user: typeof row.why_user === "string" ? row.why_user.trim() : "", + answer_impact: parseAnswerImpact(row.answer_impact), + ...(row.choice_kind === "existence" + || row.choice_kind === "varga_style" + || row.choice_kind === "event_quality" + || row.choice_kind === "block_choice" ? { choice_kind: row.choice_kind } : {}), ...(row.skip_this_probe === true ? { skip_this_probe: true } : {}), diff --git a/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts b/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts index 1abb8a1f..c4472ad2 100644 --- a/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts +++ b/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts @@ -56,6 +56,7 @@ export type ConfirmationGate = Readonly<{ export type SessionOutcomeKind = | "collect_evidence" + | "compare_blocks" | "discriminate_candidates" | "validate_holdout" | "provisional_range" @@ -126,6 +127,12 @@ export function sessionOutcomeView(kind: SessionOutcomeKind): SessionOutcome { user_meaning: `本次校正已完成,并交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`, }; } + if (kind === "compare_blocks") { + return { + kind, + user_meaning: "还不知道出生钟点。先用经历比出大致时段,不要给出分钟或采用卡。", + }; + } return { kind: "collect_evidence", user_meaning: "还需要能评分的带日期事件,才能给出可采用的代表性时间。", diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 4d8c9f67..634b8475 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -20,6 +20,8 @@ import { decideRectification, engineCapabilityCeilingFromReceipt, publicDecisionFields, + MIN_STANDALONE_DATED_DOMAINS, + MIN_STANDALONE_DATED_EVENTS, type HoldoutValidationStatus, type RectificationDecision, } from "../core/rectification-decision.ts"; @@ -48,6 +50,10 @@ import { windowScanFromDecisionReceipt } from "./varga-observations"; import type { DroppedProbe } from "./probe-question-contract.ts"; import { RECTIFICATION_POLICY } from "../../rectification-policy.ts"; import { evidenceLedgerFingerprint } from "./tool-service"; +import { + blockScanDeclinedForFingerprint, + followupCaseArgs, +} from "./block-scan.ts"; import { candidateSnapshotSource, storedSnapshotIsCurrent, @@ -97,6 +103,8 @@ export type DecisionDossier = Readonly<{ acceptedTime: string | null; status?: string; candidateRange?: { start_time?: string; end_time?: string } | null; + stage?: "minute" | "block_scan"; + blockScan?: import("./block-scan.ts").BlockScanPayload | null; }; turns?: readonly Readonly<{ role: string; text: string | null }>[]; }>; @@ -581,6 +589,10 @@ export function decideFromDossier( sessionOutcome: "collect_evidence", answeredProbes: catalog.answeredProbes, eventProbes: catalog.eventProbes, + ...followupCaseArgs({ + stage: dossier.case.stage, + blockScan: dossier.case.blockScan, + }), }); const remainingCollect = exhaustionSpokenCollectFollowup({ evidence: dossier.evidence, @@ -593,6 +605,10 @@ export function decideFromDossier( const evidenceStops = evidenceStopInputs(dossier.evidence); const userUncertaintyHigh = uncertaintyHighFromAnswers(inference?.answered_probes); const snapshotCurrent = scoreableSnapshotCurrentFromDossier(dossier, options, inference); + const caseStage = dossier.case.stage === "block_scan" ? "block_scan" : "minute"; + const blockScanReady = caseStage === "block_scan" + && evidenceStops.datedEventCount >= MIN_STANDALONE_DATED_EVENTS + && evidenceStops.datedDomainCount >= MIN_STANDALONE_DATED_DOMAINS; const confirmationGate = buildConfirmationGate({ engineConfirmationAllowed: latest?.confirmationAllowed === true, candidates: (latest?.candidates ?? []).map((candidate) => ({ @@ -629,7 +645,7 @@ export function decideFromDossier( return { ...decideRectification({ methodCoverageAll: blockingMethodsCovered(collecting.methods), - trainingGateOpen: trainingGate.open, + trainingGateOpen: caseStage === "block_scan" ? blockScanReady : trainingGate.open, confirmationAllowed: confirmationGate.confirmation_allowed, userStopped: dossier.case.status === "paused", snapshotCurrent, @@ -646,6 +662,11 @@ export function decideFromDossier( ...decisionBudget, ...evidenceStops, userUncertaintyHigh, + caseStage, + blockScanDeclined: blockScanDeclinedForFingerprint( + dossier.case.blockScan, + options?.currentEvidenceFingerprint ?? evidenceLedgerFingerprint(dossier.evidence as never), + ), }), droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped), }; @@ -665,6 +686,10 @@ export function decideAfterInferenceChange(input: { sessionOutcome: "collect_evidence", answeredProbes: catalog.answeredProbes, eventProbes: catalog.eventProbes, + ...followupCaseArgs({ + stage: input.dossier.case.stage, + blockScan: input.dossier.case.blockScan, + }), }); const remainingCollect = exhaustionSpokenCollectFollowup({ evidence: input.dossier.evidence, @@ -674,14 +699,23 @@ export function decideAfterInferenceChange(input: { }); if (!input.state) { const evidenceStops = evidenceStopInputs(input.dossier.evidence); + const caseStage = input.dossier.case.stage === "block_scan" ? "block_scan" : "minute"; return decideRectification({ methodCoverageAll: blockingMethodsCovered(collecting.methods), - trainingGateOpen: trainingScoreableGate(input.dossier.evidence).open, + trainingGateOpen: caseStage === "block_scan" + ? evidenceStops.datedEventCount >= MIN_STANDALONE_DATED_EVENTS + && evidenceStops.datedDomainCount >= MIN_STANDALONE_DATED_DOMAINS + : trainingScoreableGate(input.dossier.evidence).open, candidateScores: [], userStopped: input.userStopped, engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null), ...decisionBudgetFromInference(null), ...evidenceStops, + caseStage: input.dossier.case.stage === "block_scan" ? "block_scan" : "minute", + blockScanDeclined: blockScanDeclinedForFingerprint( + input.dossier.case.blockScan, + evidenceLedgerFingerprint(input.dossier.evidence as never), + ), }); } const training = input.state.events.filter((item) => item.usage === "training"); @@ -743,6 +777,11 @@ export function decideAfterInferenceChange(input: { ...decisionBudgetFromInference(input.state), ...evidenceStops, userUncertaintyHigh, + caseStage: input.dossier.case.stage === "block_scan" ? "block_scan" : "minute", + blockScanDeclined: blockScanDeclinedForFingerprint( + input.dossier.case.blockScan, + evidenceLedgerFingerprint(input.dossier.evidence as never), + ), }), droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped), }; diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index 568bd2a0..57a2ae15 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -24,6 +24,7 @@ import { } from "./varga-observations"; import { questionContractVersionIsCompatible } from "./probe-question-contract"; import { resolveAyanamsa } from "../../ayanamsa.ts"; +import { parseBlockScanPayload, type BlockScanBlock } from "./block-scan.ts"; export class RectificationEngineError extends Error { readonly code: string; @@ -678,3 +679,93 @@ export async function runV9Diagnostics(input: { export const v9EngineVersion = (): string => process.env.RECTIFICATION_ENGINE_VERSION?.trim() || "rectification-v5"; + +export type V9RangeReading = Readonly<{ + stableThemes: readonly string[]; + sensitiveThemes: readonly string[]; + claimBoundary: string | null; + themeSensitivity: Readonly>; + window: Readonly> | null; +}>; + +export async function runV9RangeReading(input: { + baselineBirthSnapshot: Readonly>; + candidateRange: { start_time: string; end_time: string; representative_time?: string }; + representativeTime?: string | null; + birthTimeAccuracy?: "provisional" | "approximate"; +}): Promise { + try { + const snapshot = input.baselineBirthSnapshot; + const birthDate = String(snapshot.birth_date ?? ""); + const lat = engineNumber(snapshot.latitude); + const lon = engineNumber(snapshot.longitude); + const tz = engineNumber(snapshot.timezone_offset); + if (!birthDate || lat === null || lon === null || tz === null) return null; + const representative = input.representativeTime + ?? input.candidateRange.representative_time + ?? input.candidateRange.start_time; + const data = await postEngine("/api/rectification/v5/range_reading", { + birth_date: birthDate, + lat, + lon, + tz, + ayanamsa: resolveAyanamsa(snapshot), + node_mode: "mean", + birth_time_accuracy: input.birthTimeAccuracy ?? "provisional", + representative_time: representative, + candidate_range: { + start_time: input.candidateRange.start_time, + end_time: input.candidateRange.end_time, + representative_time: representative, + }, + }, 8_000); + const stable = Array.isArray(data.stable_themes) + ? data.stable_themes.filter((item): item is string => typeof item === "string") + : []; + const sensitive = Array.isArray(data.sensitive_themes) + ? data.sensitive_themes.filter((item): item is string => typeof item === "string") + : []; + return { + stableThemes: stable, + sensitiveThemes: sensitive, + claimBoundary: typeof data.claim_boundary === "string" ? data.claim_boundary : null, + themeSensitivity: record(data.theme_sensitivity) ?? {}, + window: record(data.window), + }; + } catch { + return null; + } +} + +export type V9BlockScanResult = Readonly<{ + resultId: string | null; + algorithmVersion: string; + minuteStep: number; + blocks: readonly BlockScanBlock[]; +}>; + +export async function runV9BlockScan(input: { + baselineBirthSnapshot: Readonly>; + candidateRange: { start_time: string; end_time: string }; + events: readonly V9EngineEvent[]; +}): Promise { + const data = await postEngine("/api/rectification/v5/block_scan", { + ...engineRequestBody(input), + minute_step: 10, + }); + const parsed = parseBlockScanPayload({ + blocks: data.blocks, + algorithm_version: data.algorithm_version, + minute_step: data.minute_step, + evidence_ledger_fingerprint: data.evidence_ledger_fingerprint, + }); + if (!parsed) { + throw new RectificationEngineError("engine_invalid_response", "block_scan payload is invalid"); + } + return { + resultId: typeof data.result_id === "string" ? data.result_id : null, + algorithmVersion: parsed.algorithmVersion ?? String(data.algorithm_version ?? ""), + minuteStep: parsed.minuteStep ?? 10, + blocks: parsed.blocks, + }; +} diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts index 718df38d..b2c13936 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -13,12 +13,14 @@ import { rectificationFollowupCatalog, } from "./decision-from-dossier"; import { evidenceLedgerFingerprint } from "./tool-service"; -import { projectRectificationChoiceCard, buildMethodFollowupPlan, buildNextUserAction } from "./method-followup"; +import { projectRectificationChoiceCard, buildMethodFollowupPlan, buildNextUserAction, blockingMethodsCovered } from "./method-followup"; +import { projectRectificationStepState, type RectificationStepState } from "./step-state.ts"; import { internalObservationsFromWindowScan, windowScanFromDecisionReceipt, } from "./varga-observations"; import type { RectificationChoiceCard } from "./choice-card"; +import { followupCaseArgs } from "./block-scan.ts"; export function choiceCardFromCaseDossier(dossier: { evidence: readonly Readonly<{ @@ -57,7 +59,11 @@ export function choiceCardFromCaseDossier(dossier: { } | null; case: { acceptedTime: string | null; + confirmedTime?: string | null; status?: string; + stage?: "minute" | "block_scan"; + blockScan?: import("./block-scan.ts").BlockScanPayload | null; + candidateRange?: { start_time?: string; end_time?: string } | null; }; turns?: readonly Readonly<{ role: string; text: string | null }>[]; }): RectificationChoiceCard | null { @@ -99,6 +105,43 @@ export function choiceCardFromCaseDossier(dossier: { latestAssistantText, candidatesSeparated: decision.separation.sufficient, holdoutValidation: decision.holdoutValidation, + candidateRanges: inference?.candidates.map((item) => ({ + id: item.id, + time: item.time, + cluster_range: item.cluster_range, + })), + ...followupCaseArgs({ + stage: dossier.case.stage, + blockScan: dossier.case.blockScan, + }), + }); +} + +export function stepStateFromCaseDossier(dossier: Parameters[0]): RectificationStepState { + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); + const decision = decideFromDossier(dossier, { + currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never), + }); + const plan = buildMethodFollowupPlan({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: decision.sessionOutcome, + ...catalog, + accepted: Boolean(dossier.case.acceptedTime), + candidatesSeparated: decision.separation.sufficient, + holdoutValidation: decision.holdoutValidation, + ...followupCaseArgs({ + stage: dossier.case.stage, + blockScan: dossier.case.blockScan, + }), + }); + return projectRectificationStepState({ + accepted: Boolean(dossier.case.acceptedTime), + confirmed: Boolean(dossier.case.confirmedTime), + nextAction: decision.nextAction, + methodsCovered: blockingMethodsCovered(plan.methods), + stopReason: decision.stopReason ?? null, }); } @@ -122,6 +165,10 @@ export function nextUserActionFromDossier(dossier: Parameters>; }>; export type MethodFollowupPlan = Readonly<{ @@ -999,6 +1010,7 @@ function followupOwnedProbe( if (!item.style_options?.length) return null; if (!item.domain || !EVENT_PROBE_DOMAINS.includes(item.domain as EventProbeDomain)) return null; const kind = item.choice_kind ?? "existence"; + if (kind === "block_choice") return null; const styleOptions: EventProbeStyleOption[] = []; for (const row of item.style_options) { const answer = row.answer_class; @@ -1348,6 +1360,7 @@ export type NextUserActionId = | "ask_method_followup" | "ask_candidate_discriminator" | "ask_holdout_validation" + | "ask_block_choice" | "offer_provisional_range" | "explain_current_window" | "verify_adopted_time" @@ -1357,7 +1370,7 @@ export type NextUserAction = Readonly<{ id: NextUserActionId; user_meaning: string; on_user_stop: { - id: Exclude; + id: Exclude; user_meaning: string; }; }>; @@ -1396,6 +1409,7 @@ export function isOfferBlockingFollowup( function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDiscriminatorProbe | null { if (!followup) return null; + if (followup.choice_kind === "block_choice") return null; if (followup.intent === "clarify_event") return null; if (followup.choice_kind === "event_quality" && followup.intent !== "distinguish_candidates") return null; const realProbe = followup.source === "event_probe" @@ -1569,6 +1583,16 @@ export function buildNextUserAction(input: { ), }; } + if (input.sessionOutcome === "compare_blocks" && input.nextFollowup) { + return { + id: "ask_block_choice", + user_meaning: input.nextFollowup.user_prompt_hint, + on_user_stop: action( + "record_stated_events", + "说不好就再收一件带年份的经历。不要给出分钟或采用卡。", + ), + }; + } const scoreNow = action( "score_now", "已有可评分事件但还没有候选结果。本轮必须比较候选,不要只口头确认事件。", @@ -1646,6 +1670,27 @@ export function holdoutFollowupFor( return holdoutAskFields(prompt, reserved); } +function blockChoiceFollowup(payload: BlockScanPayload | null | undefined): MethodFollowup | null { + if (!payload) return null; + const frame = buildBlockChoiceFrame(payload); + const periods = blockPeriodsForChoice(payload); + if (!frame || !periods) return null; + return { + method_id: "dasha_events", + intent: BLOCK_CHOICE_INTENT, + ask_theme: "birth_block", + domain: null, + kind_hint: null, + user_prompt_hint: frame.prompt, + must_not_label: false, + choice_frame: frame, + source: "block_scan", + choice_kind: BLOCK_CHOICE_KIND, + semantic_key: "block_scan.choose_birth_block", + block_periods: periods, + }; +} + export function buildMethodFollowupPlan(input: { evidence: readonly MethodFollowupEvidence[]; activeFocus?: MethodFollowupFocus | null; @@ -1668,6 +1713,9 @@ export function buildMethodFollowupPlan(input: { topCandidateTimes?: readonly string[]; holdoutValidation?: HoldoutValidationStatus; holdoutEvents?: readonly Readonly<{ domain: string; year: number | null }>[]; + candidateRanges?: readonly ProbeExplainCandidate[]; + caseStage?: RectificationCaseStage; + blockScan?: BlockScanPayload | null; }): MethodFollowupPlan { const makeFollowup = ( item: Omit, @@ -1704,6 +1752,7 @@ export function buildMethodFollowupPlan(input: { ], birthDate: input.birthDate, scoring, + candidates: input.candidateRanges, }) : null, }; @@ -1766,7 +1815,9 @@ export function buildMethodFollowupPlan(input: { const askedKeys = new Set([ ...(input.askedProbeKeys ?? []), ]); - const rankedCatalog = dashaCovered && meetsAcceptanceEventQuality(input.evidence) + const rankedCatalog = input.caseStage === "block_scan" + ? { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[], dropped: [] as DroppedProbe[] } + : dashaCovered && meetsAcceptanceEventQuality(input.evidence) ? rankRenderableDiscriminators({ eventProbes: remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys, input.birthDate), contrastProbes, @@ -1791,14 +1842,18 @@ export function buildMethodFollowupPlan(input: { || (focus.targetDomain === "career" && (careerCovered || declined.has("career"))) || (focus.targetDomain === "family" && (familyCovered || declined.has("family"))) || (focus.targetDomain === "horary" && horaryStatus !== "uncovered") + || (input.caseStage === "block_scan" && sessionOutcome === "compare_blocks") ), ); const staleDiscriminatorFocus = Boolean( focus - && focus.intent === "distinguish_candidates" - && catalogWinnerKey - && persistedFocusProbeKey(focus) - && persistedFocusProbeKey(focus) !== catalogWinnerKey + && ( + (focus.intent === "distinguish_candidates" + && catalogWinnerKey + && persistedFocusProbeKey(focus) + && persistedFocusProbeKey(focus) !== catalogWinnerKey) + || (focus.intent === BLOCK_CHOICE_INTENT && sessionOutcome !== "compare_blocks") + ) ); if ( focus @@ -2471,6 +2526,9 @@ export function buildMethodFollowupPlan(input: { } next = null; } + if (input.caseStage === "block_scan" && sessionOutcome === "compare_blocks") { + next = blockChoiceFollowup(input.blockScan); + } const deferAdoption = sessionOutcome === "adopt_representative" || sessionOutcome === "validated_range" || sessionOutcome === "exact_minute_confirmed" diff --git a/frontend/src/lib/rectification-agentic/v9/probe-explain.ts b/frontend/src/lib/rectification-agentic/v9/probe-explain.ts new file mode 100644 index 00000000..283340e7 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/probe-explain.ts @@ -0,0 +1,238 @@ +/** + * Server-owned user copy for discriminator cards and post-answer narration. + * The model writes the stem; this module writes "why" and "what answering does". + */ + +import type { AnswerClass } from "../core/types.ts"; +import type { DiscriminatingEventProbe, ProbeExpectedOutcome } from "./refinement-packet.ts"; + +export type ChoiceKey = "A" | "B" | "C" | "D"; + +export type ProbeExplainCandidate = Readonly<{ + id?: string; + time: string; + cluster_range?: readonly [string, string]; +}>; + +export type ClusterScoreDelta = Readonly<{ + range: readonly [string, string]; + delta: number; +}>; + +export type ProbeAnswerImpact = Readonly<{ + A: string; + B: string; + C: string; + D: string; +}>; + +export type ProbeUserExplain = Readonly<{ + why_user: string; + answer_impact: ProbeAnswerImpact; +}>; + +const TRACK_LABEL: Readonly> = { + vimshottari: "毗湿奴多利", + narayana: "那罗延", +}; + +const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/; + +export const PROBE_EXPLAIN_COPY = { + unsureImpact: "不计分,换一题", + splitGroups: "分成两组", + vargaWhyPrefix: "这题对照", + vargaWhySuffix: "的类型差异", +} as const; + +export function formatClusterRange(range: readonly [string, string]): string { + const start = range[0]?.slice(0, 5) ?? ""; + const end = range[1]?.slice(0, 5) ?? ""; + if (!CLOCK.test(start)) return ""; + if (!CLOCK.test(end) || start === end) return `${start} 这段`; + return `${start}–${end} 这段`; +} + +export function formatClusterRangeList(ranges: readonly (readonly [string, string])[]): string { + const unique: string[] = []; + const seen = new Set(); + for (const range of ranges) { + const label = formatClusterRange(range); + if (!label || seen.has(label)) continue; + seen.add(label); + unique.push(label); + } + return unique.join("、"); +} + +function clockTime(value: string | null | undefined): string | null { + const time = value?.slice(0, 5) ?? ""; + return CLOCK.test(time) ? time : null; +} + +function rangeForTime( + time: string, + candidates: readonly ProbeExplainCandidate[] | undefined, +): readonly [string, string] { + const clock = clockTime(time) ?? time; + const match = candidates?.find((item) => ( + item.time === clock || item.id === time || item.id === clock + )); + const start = clockTime(match?.cluster_range?.[0]) ?? clock; + const end = clockTime(match?.cluster_range?.[1]) ?? clock; + return [start, end]; +} + +function rangesForTimes( + times: readonly string[], + candidates: readonly ProbeExplainCandidate[] | undefined, +): readonly (readonly [string, string])[] { + return times.map((time) => rangeForTime(time, candidates)); +} + +function trackLabels(tracks: readonly string[] | undefined): string { + const labels = [...new Set((tracks ?? []).map((track) => TRACK_LABEL[track]).filter(Boolean))]; + return labels.join("、"); +} + +function vargaChartName(input: { + probe: DiscriminatingEventProbe | null; + methodId?: string | null; + domain?: string | null; +}): string | null { + const blob = `${input.methodId ?? ""} ${input.probe?.semantic_key ?? ""}`.toLowerCase(); + if (/\bd24\b/.test(blob)) return "D24"; + if (/\bd9\b/.test(blob) || input.domain === "relationship") return "D9"; + if (/\bd10\b/.test(blob) || input.domain === "career" || input.domain === "occupation") return "D10"; + return null; +} + +function outcomeFor( + outcomes: readonly ProbeExpectedOutcome[] | undefined, + answerClass: AnswerClass, +): ProbeExpectedOutcome | null { + return outcomes?.find((item) => item.answer_class === answerClass) ?? null; +} + +function impactForOutcome( + outcome: ProbeExpectedOutcome | null, + candidates: readonly ProbeExplainCandidate[] | undefined, +): string { + if (!outcome) return ""; + const supports = formatClusterRangeList(rangesForTimes(outcome.supports, candidates)); + const conflicts = formatClusterRangeList(rangesForTimes(outcome.conflicts, candidates)); + if (supports && conflicts) return `会让 ${supports}领先、${conflicts}落后`; + if (supports) return `会让 ${supports}领先`; + if (conflicts) return `会让 ${conflicts}落后`; + return ""; +} + +function clusterCount( + probe: DiscriminatingEventProbe, + candidates: readonly ProbeExplainCandidate[] | undefined, +): number { + const fromCandidates = new Set( + (probe.candidate_ids ?? []).map((id) => formatClusterRange(rangeForTime(id, candidates))), + ); + fromCandidates.delete(""); + if (fromCandidates.size >= 2) return fromCandidates.size; + const fromOutcomes = new Set(); + for (const outcome of probe.expected_outcomes ?? []) { + for (const time of [...outcome.supports, ...outcome.conflicts]) { + const label = formatClusterRange(rangeForTime(time, candidates)); + if (label) fromOutcomes.add(label); + } + } + if (fromOutcomes.size >= 2) return fromOutcomes.size; + return Math.max(fromCandidates.size, 2); +} + +export function explainProbeForUser(input: { + probe: DiscriminatingEventProbe | null; + period: string; + choiceKind?: DiscriminatingEventProbe["choice_kind"]; + methodId?: string | null; + domain?: string | null; + candidates?: readonly ProbeExplainCandidate[]; +}): ProbeUserExplain { + const empty: ProbeUserExplain = { + why_user: "", + answer_impact: { A: "", B: "", C: "", D: PROBE_EXPLAIN_COPY.unsureImpact }, + }; + const probe = input.probe; + if (!probe) return empty; + const kind = input.choiceKind ?? probe.choice_kind ?? "existence"; + const varga = vargaChartName({ probe, methodId: input.methodId, domain: input.domain }); + const whyUser = kind === "varga_style" && varga + ? `${PROBE_EXPLAIN_COPY.vargaWhyPrefix} ${varga} ${PROBE_EXPLAIN_COPY.vargaWhySuffix}` + : explainExistenceWhy(probe, input.period, input.candidates); + const yes = impactForOutcome(outcomeFor(probe.expected_outcomes, "yes"), input.candidates); + const weak = impactForOutcome(outcomeFor(probe.expected_outcomes, "weak_yes"), input.candidates) || yes; + const no = impactForOutcome(outcomeFor(probe.expected_outcomes, "no"), input.candidates); + return { + why_user: whyUser, + answer_impact: { + A: yes, + B: weak, + C: no, + D: PROBE_EXPLAIN_COPY.unsureImpact, + }, + }; +} + +function explainExistenceWhy( + probe: DiscriminatingEventProbe, + period: string, + candidates: readonly ProbeExplainCandidate[] | undefined, +): string { + const when = period.trim() || probe.year_label.trim(); + if (!when) return ""; + const count = clusterCount(probe, candidates); + const tracks = trackLabels(probe.tracks); + const groups = `${PROBE_EXPLAIN_COPY.splitGroups}${tracks ? `(${tracks})` : ""}`; + return `${when} 这段经历能把当前 ${count} 段候选${groups}`; +} + +export function clusterScoreDeltas( + candidates: readonly ProbeExplainCandidate[], + deltas: Readonly>, +): readonly ClusterScoreDelta[] { + const summed = new Map(); + for (const [id, delta] of Object.entries(deltas)) { + if (!Number.isFinite(delta) || delta === 0) continue; + const match = candidates.find((item) => item.id === id || item.time === id); + const matchedTime = clockTime(match?.time); + const range = match?.cluster_range + ?? (matchedTime ? [matchedTime, matchedTime] as const : clockTime(id) ? [id.slice(0, 5), id.slice(0, 5)] as const : null); + if (!range) continue; + const key = `${range[0]}|${range[1]}`; + const current = summed.get(key); + summed.set(key, { range, delta: (current?.delta ?? 0) + delta }); + } + return [...summed.values()].filter((item) => item.delta !== 0); +} + +export function explainScoreMovement(deltas: readonly ClusterScoreDelta[]): string { + if (deltas.length === 0) return ""; + const rising = [...deltas].filter((item) => item.delta > 0).sort((a, b) => b.delta - a.delta)[0] ?? null; + const falling = [...deltas].filter((item) => item.delta < 0).sort((a, b) => a.delta - b.delta)[0] ?? null; + const up = rising ? formatClusterRange(rising.range) : ""; + const down = falling ? formatClusterRange(falling.range) : ""; + if (up && down) return `${up}领先,${down}落后`; + if (up) return `${up}领先`; + if (down) return `${down}落后`; + return ""; +} + +export function explainRangeChange( + before: readonly [string, string] | null | undefined, + after: readonly [string, string] | null | undefined, +): string { + const startBefore = clockTime(before?.[0]); + const endBefore = clockTime(before?.[1]); + const startAfter = clockTime(after?.[0]); + const endAfter = clockTime(after?.[1]); + if (!startBefore || !endBefore || !startAfter || !endAfter) return ""; + if (startBefore === startAfter && endBefore === endAfter) return "范围没变"; + return `范围从 ${startBefore}–${endBefore} 收到 ${startAfter}–${endAfter}`; +} diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 77765aec..5c9029cf 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -95,6 +95,8 @@ export function expectedAnswerSchemaFor( semantic_key: followup.semantic_key ?? null, candidate_split_hash: followup.candidate_split_hash ?? null, choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence", + scoring: frame.scoring, + ...(followup.block_periods ? { block_periods: followup.block_periods } : {}), }; const receipt = decisionReceipt ?? null; const state = withNakshatraBoundaryProbe( diff --git a/frontend/src/lib/rectification-agentic/v9/step-state.ts b/frontend/src/lib/rectification-agentic/v9/step-state.ts new file mode 100644 index 00000000..39e41e74 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/step-state.ts @@ -0,0 +1,118 @@ +/** + * Server-owned interview step strip: which stage, why, and what to do next. + */ + +import type { RectificationNextActionType } from "../core/rectification-decision.ts"; + +export type RectificationStepStage = "collect" | "discriminate" | "deliver" | "post_adopt"; + +export type RectificationStepState = Readonly<{ + stage: RectificationStepStage; + index: 1 | 2 | 3 | 4; + headline: string; + reason: string; + next: string; +}>; + +export const STEP_STATE_COPY = { + collect: { + headline: "第 1 步·收集经历", + reason: "还需要带年月的经历来分开候选", + next: "继续回答当前这题,或再说一件带年份的经历", + }, + discriminate: { + headline: "第 2 步·区分候选", + reason: "带年月的经历已经够了,现在用几道选择题分开相邻的候选", + next: "答完当前这题,或者按「先这样」看结果", + }, + deliver: { + headline: "第 3 步·给出结果", + reason: "按现有材料给出当前范围", + next: "看当前范围,或补一件带月份的经历", + }, + deliverExhausted: { + headline: "第 3 步·给出结果", + reason: "能分开候选的问题已经问完", + next: "看当前范围,或补一件带月份的经历", + }, + deliverUncertain: { + headline: "第 3 步·给出结果", + reason: "前面几道题多半说不好,再问也分不开", + next: "看当前范围,或补一件带月份的经历", + }, + compareBlocks: { + headline: "第 1 步·比较时段", + reason: "还不知道具体钟点,先用经历比出大概时段", + next: "选出更像的出生时段,或者说一件带年份的经历", + }, + postAdopt: { + headline: "第 4 步·已采用", + reason: "已经选定一个代表时间,之后新建对话按这个时间排盘", + next: "之后新建对话即按已采用时间排盘", + }, +} as const; + +const DELIVER_ACTIONS = new Set([ + "offer_provisional_range", + "complete_with_range", + "ready_to_adopt", +]); + +const DISCRIMINATE_ACTIONS = new Set([ + "ask_candidate_discriminator", + "ask_holdout_validation", +]); + +export function projectRectificationStepState(input: { + accepted?: boolean; + confirmed?: boolean; + nextAction?: RectificationNextActionType | string | null; + methodsCovered?: boolean; + stopReason?: string | null; +}): RectificationStepState { + if (input.accepted === true || input.confirmed === true) { + return { stage: "post_adopt", index: 4, ...STEP_STATE_COPY.postAdopt }; + } + const action = input.nextAction ?? ""; + const stop = input.stopReason ?? ""; + if (action === "ask_block_choice") { + return { stage: "collect", index: 1, ...STEP_STATE_COPY.compareBlocks }; + } + if (stop === "user_uncertainty_too_high") { + return { stage: "deliver", index: 3, ...STEP_STATE_COPY.deliverUncertain }; + } + if (stop === "probe_pool_exhausted" || DELIVER_ACTIONS.has(action)) { + return { + stage: "deliver", + index: 3, + ...(stop === "probe_pool_exhausted" ? STEP_STATE_COPY.deliverExhausted : STEP_STATE_COPY.deliver), + }; + } + if (input.methodsCovered === true && DISCRIMINATE_ACTIONS.has(action)) { + return { stage: "discriminate", index: 2, ...STEP_STATE_COPY.discriminate }; + } + return { stage: "collect", index: 1, ...STEP_STATE_COPY.collect }; +} + +export function parseRectificationStepState(value: unknown): RectificationStepState | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + const stage = row.stage; + const index = row.index; + if ( + (stage !== "collect" && stage !== "discriminate" && stage !== "deliver" && stage !== "post_adopt") + || (index !== 1 && index !== 2 && index !== 3 && index !== 4) + || typeof row.headline !== "string" || !row.headline.trim() + || typeof row.reason !== "string" || !row.reason.trim() + || typeof row.next !== "string" || !row.next.trim() + ) { + return null; + } + return { + stage, + index, + headline: row.headline.trim(), + reason: row.reason.trim(), + next: row.next.trim(), + }; +} diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 049a7ad0..da343045 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -10,6 +10,7 @@ * event arrays, scores or permission decisions. */ import { createHash } from "node:crypto"; +import type { AdoptedCredibleRangePayload } from "../../report-candidate-range.ts"; import { EVIDENCE_KINDS, type EvidenceKind, @@ -23,6 +24,12 @@ import { type PublicRectificationTool, } from "./public-receipt"; import { RECTIFICATION_SKILL_VERSION } from "./case-status"; +import { + parseBlockScanPayload, + parseRectificationCaseStage, + type BlockScanPayload, + type RectificationCaseStage, +} from "./block-scan.ts"; import { asInferenceState, type InferenceTransitionSnapshot, @@ -117,6 +124,8 @@ export type V9CaseDossier = Readonly<{ skillName: string; skillVersion: string; candidateRange: { start_time: string; end_time: string } | null; + stage: RectificationCaseStage; + blockScan: BlockScanPayload | null; acceptedTime: string | null; confirmedTime: string | null; completedAt: string | null; @@ -436,6 +445,8 @@ export function parseV9CaseDossier(value: unknown): V9CaseDossier | null { skillName: String(caseRow.skill_name ?? ""), skillVersion: String(caseRow.skill_version ?? ""), candidateRange, + stage: parseRectificationCaseStage(caseRow.stage), + blockScan: parseBlockScanPayload(caseRow.block_scan), acceptedTime: timeValue(caseRow.accepted_time), confirmedTime: timeValue(caseRow.confirmed_time), completedAt: rowText(caseRow.completed_at), @@ -1468,6 +1479,41 @@ export async function persistV9Candidate( }; } +export async function writeV9BlockScan( + accounting: AccountingClient, + userId: string, + caseId: string, + payload: Readonly>, +): Promise { + await rpc(accounting, "write_agentic_rectification_block_scan", { + p_user_id: userId, + p_case_id: caseId, + p_block_scan: payload, + }); +} + +export async function advanceV9CaseFromBlockScan( + accounting: AccountingClient, + userId: string, + caseId: string, + window: { start_time: string; end_time: string }, +): Promise<{ start_time: string; end_time: string }> { + const row = await rpc>( + accounting, + "advance_agentic_rectification_case_from_block_scan", + { + p_user_id: userId, + p_case_id: caseId, + p_start_time: window.start_time, + p_end_time: window.end_time, + }, + ); + const range = rowObject(row.candidate_range); + const start = typeof range?.start_time === "string" ? range.start_time : window.start_time; + const end = typeof range?.end_time === "string" ? range.end_time : window.end_time; + return { start_time: start, end_time: end }; +} + function parseTransitionSnapshot(value: unknown): InferenceTransitionSnapshot | null { const row = rowObject(value); const inference = asInferenceState(row?.inference_state); @@ -1717,6 +1763,7 @@ export async function acceptV9Candidate( resultId: string, candidateId: string, requestId: string, + credibleRange?: AdoptedCredibleRangePayload | null, ): Promise { if (!uuidPattern.test(candidateId) || !uuidPattern.test(requestId)) { throw new RectificationToolServiceError("invalid_candidate_ref"); @@ -1730,6 +1777,7 @@ export async function acceptV9Candidate( p_result_id: resultId, p_candidate_id: candidateId, p_request_id: requestId, + ...(credibleRange ? { p_credible_range: credibleRange } : {}), }, ); const savedTime = timeValue(row.saved_time); diff --git a/frontend/src/lib/rectification-agentic/v9/turn-exit.ts b/frontend/src/lib/rectification-agentic/v9/turn-exit.ts index 14beabd6..6973622f 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-exit.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-exit.ts @@ -1,5 +1,6 @@ import { ensureNonTerminalTurnExit, + persistExhaustionGateTurn, persistNextInterviewIfIdle, } from "./answer-choice.ts"; import type { RectificationRpcClient } from "./tool-service.ts"; @@ -40,12 +41,30 @@ export async function finalizeSuccessfulTurnExit(input: { return; } try { - await persistNextInterviewIfIdle({ + const idle = await persistNextInterviewIfIdle({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, askedTurnId: input.askedTurnId ?? null, }); + if (idle.terminalNote) { + if (!input.askedTurnId && idle.hostNarration) { + try { + await persistExhaustionGateTurn({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + askedTurnId: input.askedTurnId ?? null, + hostNarration: idle.hostNarration, + }); + } catch (error) { + console.warn( + `[rectification-v9] persist exhaustion gate after turn failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, + ); + } + } + return; + } } catch (error) { console.warn( `[rectification-v9] persist next interview after turn failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index 97a9757a..870e8d9e 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -24,6 +24,13 @@ import { sessionOutcomeAllowsAdopt, type DecisionSessionOutcome, } from "./rectification-agentic/core/rectification-decision"; +import { + parseRectificationStepState, + type RectificationStepState, +} from "./rectification-agentic/v9/step-state.ts"; + +export { parseRectificationStepState }; +export type { RectificationStepState }; export type RectificationCandidate = Readonly<{ candidateId: string; diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index 3a9fcaed..330de7d5 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -58,6 +58,7 @@ export type RectificationCaseSnapshotPayload = Readonly<{ latest_result?: unknown; current_question?: unknown; choice_card?: unknown; + step_state?: unknown; question_source?: unknown; next_user_action?: Readonly<{ id?: unknown }>; case?: Readonly<{ diff --git a/frontend/src/lib/report-candidate-range.ts b/frontend/src/lib/report-candidate-range.ts index 16d0a472..eea9af49 100644 --- a/frontend/src/lib/report-candidate-range.ts +++ b/frontend/src/lib/report-candidate-range.ts @@ -1,10 +1,19 @@ export const READ_REPORT_CANDIDATE_RANGE_RPC = "read_report_candidate_range"; +export const ADOPTED_CREDIBLE_RANGE_SOURCE = "inference_credible_range"; export type ReportCandidateClockRange = Readonly<{ startTime: string; endTime: string; }>; +export type AdoptedCredibleRangePayload = Readonly<{ + start_time: string; + end_time: string; + representative_time: string; + width_minutes: number; + source: typeof ADOPTED_CREDIBLE_RANGE_SOURCE; +}>; + export type ReportCandidateRangeRpcClient = Readonly<{ rpc: ( fn: string, @@ -39,6 +48,28 @@ export function parseReportCandidateRange(value: unknown): ReportCandidateClockR return { startTime, endTime }; } +export function adoptedCredibleRangePayload(input: Readonly<{ + startTime?: string | null; + endTime?: string | null; + representativeTime?: string | null; +}>): AdoptedCredibleRangePayload | null { + const startTime = matchClock(input.startTime); + const endTime = matchClock(input.endTime); + if (!startTime || !endTime || startTime > endTime) return null; + const representativeTime = matchClock(input.representativeTime) ?? startTime; + const from = clockMinutes(startTime); + const to = clockMinutes(endTime); + if (from == null || to == null) return null; + const span = to >= from ? to - from : to + 24 * 60 - from; + return { + start_time: startTime, + end_time: endTime, + representative_time: representativeTime, + width_minutes: span + 1, + source: ADOPTED_CREDIBLE_RANGE_SOURCE, + }; +} + export async function loadReportCandidateRange( client: ReportCandidateRangeRpcClient, input: Readonly<{ userId: string; rectificationCaseId?: string | null }>, @@ -71,6 +102,12 @@ function matchClock(value: unknown): string | null { return value.trim().match(candidateClockPattern)?.[1] ?? null; } +function clockMinutes(value: string): number | null { + const match = /^(\d{2}):(\d{2})$/.exec(value); + if (!match) return null; + return Number(match[1]) * 60 + Number(match[2]); +} + function defaultWarn(payload: Readonly<{ event: string; reason: string }>): void { console.warn(JSON.stringify(payload)); } diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index c17b2176..b27f3f7c 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -10,6 +10,7 @@ import { applyBirthTimeModeToWorkflowContext, type ConsultationBirthTimeMode } f import { consultationMethodologyForDomains } from "../lib/consultation-methodology.ts"; import type { DeclaredBirthWindowConsultation, ServerChartConsultation } from "../lib/consultation-route-service.ts"; import { fetchDeclaredWindowChart } from "../lib/declared-window-chart.ts"; +import { runV9RangeReading } from "../lib/rectification-agentic/v9/engine-client.ts"; import { createConsultationPlan, type ConsultationPlan } from "../lib/consultation-plan.ts"; import type { TechniqueAuditRow, WorkflowReceipt } from "../lib/consultation-agent-events.ts"; import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit.ts"; @@ -270,6 +271,7 @@ export type WindowConsultationAgentContext = Readonly<{ state: ConsultationRuntimeState; now?: () => number; fetchWindowChart?: typeof fetchDeclaredWindowChart; + runRangeReading?: typeof runV9RangeReading; }>; export function createConsultationAgentContext(context: ConsultationAgentContext) { @@ -368,6 +370,9 @@ function aggregateWorkflowReceipt( : execution.receipt.missingLayers.split(",").map((item) => item.trim()).filter(Boolean) ))); const statuses = executions.map((execution) => execution.receipt.status); + const minuteSensitiveThemes = unionStringList(executions.map((execution) => ( + execution.receipt.minuteSensitiveThemes ?? [] + ))); return { route: executions.length === 1 && omittedDomains.length === 0 ? executions[0].receipt.route : "multi-domain", // A plan the clock could not finish is by definition not the full answer, @@ -377,6 +382,7 @@ function aggregateWorkflowReceipt( missingLayers, domains, ...(omittedDomains.length > 0 ? { omittedDomains: [...omittedDomains] } : {}), + ...(minuteSensitiveThemes.length > 0 ? { minuteSensitiveThemes } : {}), }; } @@ -711,6 +717,26 @@ export function createWindowConsultationTools(ctx: WindowConsultationAgentContex window: ctx.declaredWindow, signal: context.abortSignal ?? ctx.abortSignal, }); + let minuteSensitiveThemes: string[] = []; + try { + const reading = await (ctx.runRangeReading ?? runV9RangeReading)({ + baselineBirthSnapshot: { + birth_date: ctx.declaredWindow.truth.birthDate, + latitude: ctx.declaredWindow.truth.latitude, + longitude: ctx.declaredWindow.truth.longitude, + timezone_offset: ctx.declaredWindow.truth.timezoneOffset, + ayanamsa: ctx.declaredWindow.toolInput.ayanamsa, + }, + candidateRange: { + start_time: ctx.declaredWindow.toolInput.rangeStart, + end_time: ctx.declaredWindow.toolInput.rangeEnd, + }, + birthTimeAccuracy: "approximate", + }); + minuteSensitiveThemes = [...(reading?.sensitiveThemes ?? [])]; + } catch { + minuteSensitiveThemes = []; + } const varyingLagna = Array.isArray(packet.varying_layers.ascendant_signs) && packet.varying_layers.ascendant_signs.length > 1; ctx.state.workflowReceipt = { @@ -721,6 +747,7 @@ export function createWindowConsultationTools(ctx: WindowConsultationAgentContex ...packet.blocked_layers, ...(varyingLagna ? ["single-lagna"] : []), ], + ...(minuteSensitiveThemes.length > 0 ? { minuteSensitiveThemes } : {}), }; ctx.state.techniqueTruth = "declared-window"; ctx.state.consultationToolDurationMs = now() - startedAt; @@ -745,12 +772,18 @@ export function createWindowConsultationTools(ctx: WindowConsultationAgentContex stable_layers: packet.stable_layers, varying_layers: packet.varying_layers, blocked_layers: packet.blocked_layers, - answer_policy: packet.answer_policy, + answer_policy: { + ...packet.answer_policy, + minute_sensitive_themes: minuteSensitiveThemes, + }, status: ctx.state.workflowReceipt.status, evidence_contract: { - answer_policy: packet.answer_policy, + answer_policy: { + ...packet.answer_policy, + minute_sensitive_themes: minuteSensitiveThemes, + }, hard_blockers: packet.blocked_layers, - user_facing_limitation: "这是声明出生窗口内的稳定结构,不是单一出生分钟的本命盘。", + user_facing_limitation: "这是声明出生窗口内的稳定结构,不是单一出生分钟的本命盘。这只是粗看。", }, rectification: { boundary: "not_auto_rectified" }, }; diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index af81b82c..463df743 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -170,11 +170,45 @@ export async function runConsultationWorkflow( "Jyotish API returned an incomplete consultation contract", ); } - return parsed.data; + return attachMinuteSensitiveThemes(parsed.data); +} + +export function minuteSensitiveThemesFromBirthTimeSensitivity(value: unknown): string[] { + const sensitivity = record(value); + const themes = record(sensitivity.theme_sensitivity); + const sensitive: string[] = []; + for (const [key, row] of Object.entries(themes)) { + if (record(row).status === "sensitive" && !sensitive.includes(key)) { + sensitive.push(key); + } + } + return sensitive.slice(0, 24); +} + +function attachMinuteSensitiveThemes(data: T): T { + const consumer = record(data.consumer_context); + const policy = record(consumer.answer_policy); + return { + ...data, + consumer_context: { + ...consumer, + answer_policy: { + ...policy, + minute_sensitive_themes: minuteSensitiveThemesFromBirthTimeSensitivity(data.birth_time_sensitivity), + }, + }, + }; } export function consultationWorkflowReceipt(data: JsonRecord) { const consumerContext = workflowConsumerContextSchema.parse(data.consumer_context); + const policy = record(consumerContext.answer_policy); + const minuteSensitive = Array.isArray(policy.minute_sensitive_themes) + ? policy.minute_sensitive_themes.filter((item): item is string => typeof item === "string") + : minuteSensitiveThemesFromBirthTimeSensitivity(data.birth_time_sensitivity); return { route: consumerContext.route, status: consumerContext.core_status, @@ -182,6 +216,7 @@ export function consultationWorkflowReceipt(data: JsonRecord) { missingLayers: consumerContext.missing_route_layers.join(",") || "none", techniqueTruth: String(record(consumerContext.technique_truth).status || "unknown"), evidenceStatus: record(consumerContext.commercial_evidence_status), + ...(minuteSensitive.length > 0 ? { minuteSensitiveThemes: minuteSensitive } : {}), }; } diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index bd3d0f45..32692dfe 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -55,12 +55,15 @@ import { isHoldoutVerificationQuote, } from "@/lib/rectification-agentic/v9/choice-card"; import { indistinguishableWidthMinutes } from "@/lib/rectification-agentic/v9/candidate-plateau"; +import { followupCaseArgs } from "@/lib/rectification-agentic/v9/block-scan"; +import { persistBlockScanPayload } from "@/lib/rectification-agentic/v9/block-scan-answer"; import { buildConfirmationGate, readVedastroMinuteSensitiveStatus, sessionOutcomeView, } from "@/lib/rectification-agentic/v9/confirmation-gate"; import { publicChartForMinute } from "@/lib/rectification-candidate-result"; +import { adoptedCredibleRangePayload } from "@/lib/report-candidate-range"; import { buildMethodFollowupPlan, buildNextUserAction, @@ -144,6 +147,8 @@ import { toEngineEvents, v9EngineVersion, executedMethodsFromLedger, + runV9RangeReading, + runV9BlockScan, type V9EngineScoreResult, } from "@/lib/rectification-agentic/v9/engine-client"; @@ -259,8 +264,14 @@ export function safeCaseProjection( accepted, candidatesSeparated: decision.separation.sufficient, holdoutValidation: decision.holdoutValidation, + ...followupCaseArgs({ + stage: parsed.case.stage, + blockScan: parsed.case.blockScan, + }), }); - const latestProjection = latest ? latestResultToolProjection(latest, decision) : null; + const latestProjection = latest && parsed.case.stage !== "block_scan" + ? latestResultToolProjection(latest, decision) + : null; const birthContext = safeBirthContext(compute); const nextUserAction = buildNextUserAction({ scorableCount: parsed.scorable.length, @@ -294,6 +305,7 @@ export function safeCaseProjection( skill_name: caseRow.skillName, skill_version: caseRow.skillVersion, candidate_range: caseRow.candidateRange, + stage: caseRow.stage, accepted_time: caseRow.acceptedTime, confirmed_time: caseRow.confirmedTime, completed_at: caseRow.completedAt, @@ -326,6 +338,8 @@ type DossierForTools = { skillName: string; skillVersion: string; candidateRange: { start_time: string; end_time: string } | null; + stage: import("@/lib/rectification-agentic/v9/block-scan").RectificationCaseStage; + blockScan: import("@/lib/rectification-agentic/v9/block-scan").BlockScanPayload | null; acceptedTime: string | null; confirmedTime: string | null; completedAt: string | null; @@ -389,6 +403,26 @@ export function latestResultToolProjection( latest: NonNullable, decision: RectificationDecision, ): Record { + if (decision.nextAction === "ask_block_choice" || decision.sessionOutcome === "compare_blocks") { + const overlaid = overlayPublicDecision({ + ...latest, + candidates: [], + selectionAllowed: false, + confirmationAllowed: false, + representativeTime: null, + }, decision); + return { + ...overlaid, + result_id: latest.resultId || null, + candidates: [], + confirmation_allowed: false, + representative_time: null, + selected_time: null, + selection_kind: null, + unique_minute_claim: false, + candidate_range_not_birth_time_truth: true, + }; + } const width = indistinguishableWidthMinutes(latest.candidates); const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt ?? null); const confirmationGate = buildConfirmationGate({ @@ -555,11 +589,11 @@ function agentVisibleLatestProjection( function followupPlanForParsed( parsed: DossierForTools, - latest: NonNullable, + latest: DossierForTools["latestResult"], decision: RectificationDecision, birthDate?: string | null, ) { - const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt ?? null); + const windowScan = windowScanFromDecisionReceipt(latest?.decisionReceipt ?? null); const observations = internalObservationsFromWindowScan(windowScan); const catalog = rectificationFollowupCatalog(latest, parsed.evidence); return { @@ -575,6 +609,10 @@ function followupPlanForParsed( accepted: Boolean(parsed.case.acceptedTime), candidatesSeparated: decision.separation.sufficient, holdoutValidation: decision.holdoutValidation, + ...followupCaseArgs({ + stage: parsed.case.stage, + blockScan: parsed.case.blockScan, + }), }), contrastPacket: catalog.contrastPacket, sessionOutcome: decision.sessionOutcome, @@ -584,7 +622,7 @@ function followupPlanForParsed( function sessionAwareFollowupForParsed( parsed: DossierForTools, - latest: NonNullable, + latest: DossierForTools["latestResult"], options?: { birthDate?: string | null; snapshotCurrent?: boolean }, ) { const decision = decideFromDossier({ @@ -664,6 +702,8 @@ function parseDossierForTools(dossier: V9CaseDossier): DossierForTools { skillName: dossier.case.skillName, skillVersion: dossier.case.skillVersion, candidateRange: dossier.case.candidateRange, + stage: dossier.case.stage, + blockScan: dossier.case.blockScan, acceptedTime: dossier.case.acceptedTime, confirmedTime: dossier.case.confirmedTime, completedAt: dossier.case.completedAt, @@ -834,6 +874,120 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const candidateRange = parsed.case.candidateRange; const compute = await loadV9CaseCompute(accounting, userId, targetCaseId); const evidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence); + if (parsed.case.stage === "block_scan") { + const cachedPayload = parsed.case.blockScan; + const cached = Boolean( + cachedPayload + && cachedPayload.evidenceLedgerFingerprint === evidenceFingerprint, + ); + if (!cached) { + const events = toEngineEvents(scorableEvidence(dossier.evidence)); + const scoreStarted = Date.now(); + const scan = await runV9BlockScan({ + baselineBirthSnapshot: compute.baselineBirthSnapshot, + candidateRange, + events, + }); + const engineCompareMs = Date.now() - scoreStarted; + const persistStarted = Date.now(); + await persistBlockScanPayload({ + accounting, + userId, + caseId: targetCaseId, + evidenceFingerprint, + scan, + }); + const reloaded = parseDossierForTools(await loadV9CaseDossier(accounting, userId, targetCaseId)); + return { + persisted: { + resultId: scan.resultId ?? "", + cached: false, + candidates: [], + overallConfidence: "low" as const, + selectionAllowed: false, + confirmationAllowed: false, + representativeTime: null, + algorithmVersion: scan.algorithmVersion, + eventContractVersion: "", + policyVersion: "", + decisionReceipt: {}, + executionLedger: [], + }, + score: { + engineResultId: scan.resultId ?? "", + algorithmVersion: scan.algorithmVersion, + eventContractVersion: "", + policyVersion: "", + candidateRange, + candidates: [], + overallConfidence: "low" as const, + marginPercent: null, + acceptanceAllowed: false, + selectionAllowed: false, + proposeAllowed: false, + confirmationAllowed: false, + representativeCandidateId: null, + representativeTime: null, + decisionReceipt: {}, + executionLedger: [], + executedMethods: [], + windowScan: null, + }, + parsed: reloaded, + windowScan: null, + decisionReceipt: {}, + timings: { + engine_compare_ms: engineCompareMs, + vedastro_validate_ms: 0, + persist_ms: Date.now() - persistStarted, + }, + }; + } + return { + persisted: { + resultId: "", + cached: true, + candidates: [], + overallConfidence: "low" as const, + selectionAllowed: false, + confirmationAllowed: false, + representativeTime: null, + algorithmVersion: cachedPayload?.algorithmVersion ?? "", + eventContractVersion: "", + policyVersion: "", + decisionReceipt: {}, + executionLedger: [], + }, + score: { + engineResultId: "", + algorithmVersion: cachedPayload?.algorithmVersion ?? "", + eventContractVersion: "", + policyVersion: "", + candidateRange, + candidates: [], + overallConfidence: "low" as const, + marginPercent: null, + acceptanceAllowed: false, + selectionAllowed: false, + proposeAllowed: false, + confirmationAllowed: false, + representativeCandidateId: null, + representativeTime: null, + decisionReceipt: {}, + executionLedger: [], + executedMethods: [], + windowScan: null, + }, + parsed, + windowScan: null, + decisionReceipt: {}, + timings: { + engine_compare_ms: 0, + vedastro_validate_ms: 0, + persist_ms: 0, + }, + }; + } const rangeFingerprint = candidateRangeFingerprint( candidateRange, compute.baselineProfileFingerprint, @@ -1035,7 +1189,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const persistPlanFocus = async ( parsed: DossierForTools, - latest: NonNullable, + latest: DossierForTools["latestResult"], ) => { let birthDate: string | null = null; try { @@ -1051,7 +1205,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { userId, caseId, activeFocus: parsed.conversationSummary.activeFocus, - decisionReceipt: latest.decisionReceipt, + decisionReceipt: latest?.decisionReceipt, followup, askedTurnId: turnId, }); @@ -1066,7 +1220,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { userId, caseId, activeFocus: parsed.conversationSummary.activeFocus, - decisionReceipt: latest.decisionReceipt, + decisionReceipt: latest?.decisionReceipt, followup, askedTurnId: turnId, }); @@ -1103,7 +1257,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null }; } const fingerprint = evidenceLedgerFingerprint(dossier.evidence); - if (dossier.latestResult?.evidenceLedgerFingerprint === fingerprint) { + if (parsed.case.stage !== "block_scan" && dossier.latestResult?.evidenceLedgerFingerprint === fingerprint) { const persisted = parsed.latestResult ? await persistPlanFocus(parsed, parsed.latestResult) : null; @@ -1118,17 +1272,19 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }; } const scored = await scoreAndPersistCurrentEvidence(targetCaseId); - const latest = { - resultId: scored.persisted.resultId, - candidates: scored.persisted.candidates, - selectionAllowed: scored.persisted.selectionAllowed, - confirmationAllowed: scored.persisted.confirmationAllowed, - representativeTime: scored.persisted.representativeTime, - selectedTime: null, - selectionKind: null, - algorithmVersion: scored.persisted.algorithmVersion, - decisionReceipt: scored.decisionReceipt, - }; + const latest = scored.parsed.case.stage === "block_scan" + ? null + : { + resultId: scored.persisted.resultId, + candidates: scored.persisted.candidates, + selectionAllowed: scored.persisted.selectionAllowed, + confirmationAllowed: scored.persisted.confirmationAllowed, + representativeTime: scored.persisted.representativeTime, + selectedTime: null, + selectionKind: null, + algorithmVersion: scored.persisted.algorithmVersion, + decisionReceipt: scored.decisionReceipt, + }; const persisted = await persistPlanFocus(scored.parsed, latest); return { status: "completed" as const, @@ -1239,10 +1395,8 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { try { const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); const parsed = parseDossierForTools(dossier); - if (parsed.latestResult) { - nextFollowup = sessionAwareFollowupForParsed(parsed, parsed.latestResult).plan.next_followup; - decisionReceipt = parsed.latestResult.decisionReceipt ?? null; - } + nextFollowup = sessionAwareFollowupForParsed(parsed, parsed.latestResult).plan.next_followup; + decisionReceipt = parsed.latestResult?.decisionReceipt ?? null; } catch { nextFollowup = null; } @@ -1840,19 +1994,31 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { await receipt("rectification-compare-candidates", "candidates.comparing", "started", { inputFingerprint, engineVersion }); try { const scored = await scoreAndPersistCurrentEvidence(input.caseId); - const latest = { - resultId: scored.persisted.resultId, - candidates: scored.persisted.candidates, - selectionAllowed: scored.persisted.selectionAllowed, - confirmationAllowed: scored.persisted.confirmationAllowed, - representativeTime: scored.persisted.representativeTime, - selectedTime: null, - selectionKind: null, - algorithmVersion: scored.persisted.algorithmVersion, - decisionReceipt: scored.decisionReceipt, - }; + const latest = scored.parsed.case.stage === "block_scan" + ? null + : { + resultId: scored.persisted.resultId, + candidates: scored.persisted.candidates, + selectionAllowed: scored.persisted.selectionAllowed, + confirmationAllowed: scored.persisted.confirmationAllowed, + representativeTime: scored.persisted.representativeTime, + selectedTime: null, + selectionKind: null, + algorithmVersion: scored.persisted.algorithmVersion, + decisionReceipt: scored.decisionReceipt, + }; const { collectingPlan, persistedFocus, decision } = await persistPlanFocus(scored.parsed, latest); - const latestProjection = latestResultToolProjection(latest, decision); + const latestProjection = latest + ? latestResultToolProjection(latest, decision) + : { + candidates: [], + representative_time: null, + selection_allowed: false, + confirmation_allowed: false, + can_adopt: false, + session_outcome: decision.sessionOutcome, + next_action: decision.nextAction, + }; const projection = { ...agentVisibleLatestProjection(latestProjection, { openQuestion: openQuestionFromPersistedFocus(persistedFocus), @@ -1987,11 +2153,41 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { computed_from: currentSnapshot, }, }; + let rangeReading: Readonly<{ + stable_themes: readonly string[]; + sensitive_themes: readonly string[]; + claim_boundary: string | null; + }> | null = null; + try { + const range = decision.credibleRange; + if (range?.[0] && range[1]) { + const compute = await loadV9CaseCompute(accounting, userId, input.caseId); + const reading = await runV9RangeReading({ + baselineBirthSnapshot: compute.baselineBirthSnapshot, + candidateRange: { + start_time: range[0], + end_time: range[1], + representative_time: decision.representativeTime ?? range[0], + }, + representativeTime: decision.representativeTime, + }); + if (reading) { + rangeReading = { + stable_themes: reading.stableThemes, + sensitive_themes: reading.sensitiveThemes, + claim_boundary: reading.claimBoundary, + }; + } + } + } catch { + rangeReading = null; + } + const payload = rangeReading ? { ...offered, range_reading: rangeReading } : offered; await receipt("rectification-offer-candidates", "candidates.updated", "completed", { inputFingerprint, - resultFingerprint: hashResult(offered), + resultFingerprint: hashResult(payload), }); - return offered; + return payload; } catch (error) { await receipt("rectification-offer-candidates", "candidates.updated", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) }); throw error; @@ -2014,7 +2210,24 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const inputFingerprint = canonicalToolInputFingerprint("rectification-accept-candidate", input); await receipt("rectification-accept-candidate", "candidate.accepted", "started", { inputFingerprint }); try { - const result = await acceptV9Candidate(accounting, userId, input.caseId, resultId, input.candidateId, turnId); + const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); + const decision = decideFromDossier(dossier, { + currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), + }); + const credibleRange = adoptedCredibleRangePayload({ + startTime: decision.credibleRange?.[0], + endTime: decision.credibleRange?.[1], + representativeTime: decision.representativeTime, + }); + const result = await acceptV9Candidate( + accounting, + userId, + input.caseId, + resultId, + input.candidateId, + turnId, + credibleRange, + ); const projection = { saved_time: result.savedTime, status: result.status, diff --git a/frontend/supabase/migrations/20260906020000_adopted_credible_range.sql b/frontend/supabase/migrations/20260906020000_adopted_credible_range.sql new file mode 100644 index 00000000..62f49558 --- /dev/null +++ b/frontend/supabase/migrations/20260906020000_adopted_credible_range.sql @@ -0,0 +1,469 @@ +-- Adopted credible range is the interview window written at accept. +-- Opening candidate_range stays the search baseline and is never rewritten here. +-- read_report_candidate_range prefers adopted_credible_range, then candidate_range. + +begin; + +do $migration$ +begin + if current_user <> 'schema_owner' then + raise exception 'adopted_credible_range_requires_schema_owner' + using errcode = '42501'; + end if; +end +$migration$; + +alter table public.agentic_rectification_cases + add column if not exists adopted_credible_range jsonb; + +comment on column public.agentic_rectification_cases.adopted_credible_range is + 'Interview credible range written at accept; never overwrites candidate_range.'; + +drop function if exists public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid); + +create or replace function public.accept_agentic_rectification_candidate_for_case_v2( + p_user_id uuid, + p_case_id uuid, + p_result_id uuid, + p_candidate_id uuid, + p_request_id uuid, + p_credible_range jsonb default null +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_candidate public.agentic_rectification_candidates%rowtype; + v_profile public.profiles%rowtype; + v_snapshot jsonb; + v_inference jsonb; + v_expected_candidate_set_id text; + v_persisted_candidate_count integer; + v_top_active_time text; + v_active_range_start text; + v_active_range_end text; + v_latest_transition public.agentic_rectification_inference_transitions%rowtype; + v_existing_decision public.agentic_rectification_candidate_decisions%rowtype; + v_response jsonb; + v_adopted jsonb; + v_adopted_start text; + v_adopted_end text; + v_adopted_rep text; + v_adopted_width integer; +begin + if p_user_id is null or p_case_id is null or p_result_id is null + or p_candidate_id is null or p_request_id is null then + raise exception 'agentic_rectification_candidate_invalid_input' using errcode = 'P0001'; + end if; + + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':' || p_request_id::text, 0) + ); + + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + select * into v_result + from public.agentic_rectification_results + where id = p_result_id + and user_id = p_user_id + and case_id = p_case_id + for update; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + + select * into v_candidate + from public.agentic_rectification_candidates + where id = p_candidate_id + and result_id = p_result_id + and user_id = p_user_id + and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + + select * into v_existing_decision + from public.agentic_rectification_candidate_decisions + where user_id = p_user_id and request_id = p_request_id + for update; + if found then + if v_existing_decision.decision_kind <> 'accept' + or v_existing_decision.case_id <> p_case_id + or v_existing_decision.result_id <> p_result_id + or v_existing_decision.candidate_id <> p_candidate_id then + raise exception 'agentic_rectification_candidate_request_conflict' using errcode = 'P0001'; + end if; + return jsonb_set(v_existing_decision.response, '{idempotent}', 'true'::jsonb, true); + end if; + + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then + raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001'; + end if; + if not v_result.display_allowed or not v_result.selection_allowed then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + if exists ( + select 1 + from public.agentic_rectification_results newer + where newer.user_id = p_user_id + and newer.case_id = p_case_id + and newer.invalidated_at is null + and newer.created_at > v_result.created_at + ) then + raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001'; + end if; + + select * into v_profile + from public.profiles + where id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + v_snapshot := v_case.baseline_birth_snapshot; + if v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date + or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone + or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source' + or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period' + or v_profile.declared_window_start is distinct from v_snapshot ->> 'declared_window_start' + or v_profile.declared_window_end is distinct from v_snapshot ->> 'declared_window_end' + or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer + or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer + or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision + or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision + or v_profile.timezone_id is distinct from v_snapshot ->> 'timezone_id' + or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + -- Adoption is a trust-boundary write. Structured answers append inference + -- transitions instead of mutating the engine result, so the latest transition + -- is authoritative when present; persisted display candidates remain a cache. + select * into v_latest_transition + from public.agentic_rectification_inference_transitions + where case_id = p_case_id + and result_id = v_result.id + order by revision desc + limit 1; + v_inference := case + when v_latest_transition.id is not null then v_latest_transition.inference_state + else v_result.decision_receipt -> 'inference_state' + end; + if v_inference is null + or jsonb_typeof(v_inference) <> 'object' + or jsonb_typeof(v_inference -> 'candidates') <> 'array' + or jsonb_array_length(v_inference -> 'candidates') = 0 + or jsonb_typeof(v_inference -> 'revision') is distinct from 'number' + or coalesce((v_inference ->> 'revision') !~ '^[0-9]+$', true) + or length(btrim(coalesce(v_inference ->> 'candidate_set_id', ''))) = 0 + or (v_inference ->> 'range_start') is distinct from v_case.candidate_range ->> 'start_time' + or (v_inference ->> 'range_end') is distinct from v_case.candidate_range ->> 'end_time' then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + if v_latest_transition.id is not null + and ( + v_latest_transition.revision is distinct from (v_inference ->> 'revision')::integer + or v_latest_transition.candidate_set_id is distinct from v_inference ->> 'candidate_set_id' + ) then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + select count(*), + (v_case.candidate_range ->> 'start_time') || '-' || + (v_case.candidate_range ->> 'end_time') || ':' || + string_agg(pg_catalog.to_char(candidate_time, 'HH24:MI'), ',' order by candidate_time) + into v_persisted_candidate_count, v_expected_candidate_set_id + from public.agentic_rectification_candidates + where result_id = v_result.id + and user_id = p_user_id + and case_id = p_case_id; + + if v_persisted_candidate_count = 0 + or jsonb_array_length(v_inference -> 'candidates') <> v_persisted_candidate_count + or (v_inference ->> 'candidate_set_id') is distinct from v_expected_candidate_set_id + or exists ( + select 1 + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where jsonb_typeof(item.value) <> 'object' + ) then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + if exists ( + select 1 + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where coalesce((item.value ->> 'time') !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$', true) + or coalesce(item.value ->> 'status', '') not in ('active', 'equivalent', 'winner', 'eliminated') + or jsonb_typeof(item.value -> 'probability') is distinct from 'number' + or jsonb_typeof(item.value -> 'posterior_score') is distinct from 'number' + or jsonb_typeof(item.value -> 'cluster_range') is distinct from 'array' + ) then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + if exists ( + select 1 + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where jsonb_array_length(item.value -> 'cluster_range') is distinct from 2 + or coalesce((item.value -> 'cluster_range' ->> 0) !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$', true) + or coalesce((item.value -> 'cluster_range' ->> 1) !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$', true) + or item.value -> 'cluster_range' ->> 0 > item.value ->> 'time' + or item.value -> 'cluster_range' ->> 1 < item.value ->> 'time' + or not exists ( + select 1 + from public.agentic_rectification_candidates persisted + where persisted.result_id = v_result.id + and pg_catalog.to_char(persisted.candidate_time, 'HH24:MI') = item.value ->> 'time' + ) + ) or ( + select count(distinct item.value ->> 'time') + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + ) <> v_persisted_candidate_count then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + select item.value ->> 'time' + into v_top_active_time + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where item.value ->> 'status' <> 'eliminated' + order by (item.value ->> 'probability')::numeric desc, + (item.value ->> 'posterior_score')::numeric desc, + item.value ->> 'time' + limit 1; + + select min(item.value -> 'cluster_range' ->> 0), + max(item.value -> 'cluster_range' ->> 1) + into v_active_range_start, v_active_range_end + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where item.value ->> 'status' <> 'eliminated'; + + if v_top_active_time is null + or (v_inference ->> 'representative_time') is distinct from v_top_active_time + or jsonb_typeof(v_inference -> 'credible_range') <> 'array' + or jsonb_array_length(v_inference -> 'credible_range') <> 2 + or (v_inference -> 'credible_range' ->> 0) is distinct from v_active_range_start + or (v_inference -> 'credible_range' ->> 1) is distinct from v_active_range_end + or not exists ( + select 1 + from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value) + where item.value ->> 'time' = pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI') + and item.value ->> 'status' <> 'eliminated' + ) then + raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001'; + end if; + + if v_result.selected_candidate_id is not null then + if v_result.selected_candidate_id = p_candidate_id + and v_result.selected_time is not distinct from v_candidate.candidate_time + and v_case.accepted_time is not distinct from v_candidate.candidate_time + and v_result.selection_kind is not distinct from 'user_accepted' + and v_profile.active_birth_time is not distinct from v_candidate.candidate_time + and v_profile.birth_time_status is not distinct from 'accepted' then + v_response := jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), + 'status', 'accepted', + 'result_id', v_result.id, + 'candidate_id', v_candidate.id, + 'case_status', 'candidate_accepted', + 'idempotent', true + ); + insert into public.agentic_rectification_candidate_decisions ( + user_id, case_id, result_id, candidate_id, request_id, decision_kind, response + ) values ( + p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'accept', v_response + ); + return v_response; + end if; + + if v_case.status is distinct from 'candidate_accepted' + or v_result.selection_kind is distinct from 'user_accepted' + or v_profile.active_birth_time is distinct from v_result.selected_time + or v_profile.birth_time_status is distinct from 'accepted' then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + else + if v_result.selected_time is not null then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + end if; + + update public.profiles + set active_birth_time = v_candidate.candidate_time, + birth_time = v_candidate.candidate_time, + birth_time_status = 'accepted', + rectification_confidence = case + when v_result.overall_confidence = 'high' then 100 + when v_result.overall_confidence = 'medium' then 70 + else 40 + end, + updated_at = pg_catalog.now() + where id = p_user_id; + + update public.agentic_rectification_results + set selected_candidate_id = p_candidate_id, + selected_time = v_candidate.candidate_time, + selection_kind = 'user_accepted', + selected_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_result.id; + + update public.agentic_rectification_results + set invalidated_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where user_id = p_user_id + and case_id = p_case_id + and id <> v_result.id + and invalidated_at is null + and selected_time is null; + + v_adopted := null; + if p_credible_range is not null + and jsonb_typeof(p_credible_range) = 'object' then + v_adopted_start := nullif(btrim(coalesce(p_credible_range->>'start_time', '')), ''); + v_adopted_end := nullif(btrim(coalesce(p_credible_range->>'end_time', '')), ''); + v_adopted_rep := nullif(btrim(coalesce(p_credible_range->>'representative_time', '')), ''); + begin + v_adopted_width := (p_credible_range->>'width_minutes')::integer; + exception when others then + v_adopted_width := null; + end; + end if; + if v_adopted_start is null or v_adopted_end is null + or v_adopted_start !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' + or v_adopted_end !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then + v_adopted_start := v_inference -> 'credible_range' ->> 0; + v_adopted_end := v_inference -> 'credible_range' ->> 1; + v_adopted_rep := v_inference ->> 'representative_time'; + v_adopted_width := null; + end if; + if v_adopted_start is not null + and v_adopted_end is not null + and v_adopted_start ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' + and v_adopted_end ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then + v_adopted := jsonb_strip_nulls(jsonb_build_object( + 'start_time', v_adopted_start, + 'end_time', v_adopted_end, + 'representative_time', coalesce(v_adopted_rep, v_top_active_time), + 'width_minutes', v_adopted_width, + 'source', 'inference_credible_range' + )); + end if; + + update public.agentic_rectification_cases + set status = 'candidate_accepted', + accepted_time = v_candidate.candidate_time, + confirmed_time = null, + completed_at = null, + adopted_credible_range = coalesce(v_adopted, adopted_credible_range), + last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_case.id; + + v_response := jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), + 'status', 'accepted', + 'result_id', v_result.id, + 'candidate_id', v_candidate.id, + 'case_status', 'candidate_accepted', + 'idempotent', false + ); + + insert into public.agentic_rectification_candidate_decisions ( + user_id, case_id, result_id, candidate_id, request_id, decision_kind, response + ) values ( + p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'accept', v_response + ); + + return v_response; +end; +$$; + +revoke all on function public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid, jsonb) + from public, anon, authenticated; +grant execute on function public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid, jsonb) + to service_role; + +create or replace function public.read_report_candidate_range( + p_user_id uuid, + p_rectification_case_id uuid default null +) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_start text; + v_end text; + v_range jsonb; +begin + if p_user_id is null then + return null; + end if; + + if p_rectification_case_id is not null then + select to_char(c.candidate_start, 'HH24:MI'), to_char(c.candidate_end, 'HH24:MI') + into v_start, v_end + from public.birth_time_rectification_cases as c + where c.id = p_rectification_case_id + and c.user_id = p_user_id + and c.status in ('confirmed', 'completed') + and c.candidate_start is not null + and c.candidate_end is not null; + if found then + return jsonb_build_object('start_time', v_start, 'end_time', v_end); + end if; + end if; + + select case + when c.adopted_credible_range is not null + and nullif(btrim(coalesce(c.adopted_credible_range->>'start_time', '')), '') is not null + and nullif(btrim(coalesce(c.adopted_credible_range->>'end_time', '')), '') is not null + then c.adopted_credible_range + else c.candidate_range + end + into v_range + from public.agentic_rectification_cases as c + where c.user_id = p_user_id + and c.status = 'candidate_accepted' + order by c.updated_at desc + limit 1; + + if v_range is null then + return null; + end if; + + v_start := nullif(btrim(coalesce(v_range->>'start_time', '')), ''); + v_end := nullif(btrim(coalesce(v_range->>'end_time', '')), ''); + if v_start is null or v_end is null then + return null; + end if; + + return jsonb_build_object('start_time', v_start, 'end_time', v_end); +end; +$$; + +revoke all on function public.read_report_candidate_range(uuid, uuid) + from public, anon, authenticated; +grant execute on function public.read_report_candidate_range(uuid, uuid) + to service_role; + +commit; diff --git a/frontend/supabase/migrations/20260906030000_rectification_block_scan_stage.sql b/frontend/supabase/migrations/20260906030000_rectification_block_scan_stage.sql new file mode 100644 index 00000000..271d6f6b --- /dev/null +++ b/frontend/supabase/migrations/20260906030000_rectification_block_scan_stage.sql @@ -0,0 +1,453 @@ +-- Unknown-time rectification: stage block_scan then minute. +-- Opening candidate_range stays 00:00-23:59 until the user picks a declared period. + +begin; + +do $migration$ +begin + if current_user <> 'schema_owner' then + raise exception 'rectification_block_scan_stage_requires_schema_owner' + using errcode = '42501'; + end if; +end +$migration$; + +alter table public.agentic_rectification_cases + add column if not exists stage text not null default 'minute'; + +alter table public.agentic_rectification_cases + add column if not exists block_scan jsonb; + +alter table public.agentic_rectification_cases + drop constraint if exists agentic_rectification_cases_stage_check; + +alter table public.agentic_rectification_cases + add constraint agentic_rectification_cases_stage_check + check (stage in ('minute', 'block_scan')); + +comment on column public.agentic_rectification_cases.stage is + 'minute is the existing interview. block_scan compares declared periods before any minute grid.'; + +comment on column public.agentic_rectification_cases.block_scan is + 'Latest period-support payload for stage=block_scan. Cleared when the window advances to minute.'; + +update public.agentic_rectification_cases +set stage = 'block_scan' +where stage = 'minute' + and accepted_time is null + and confirmed_time is null + and status not in ('confirmed', 'closed', 'abandoned', 'superseded') + and coalesce(candidate_range->>'start_time', '') = '00:00' + and coalesce(candidate_range->>'end_time', '') = '23:59' + and coalesce(baseline_birth_snapshot->>'birth_time_source', '') = 'unknown'; + +create or replace function public.set_agentic_rectification_case_stage( + p_user_id uuid, + p_case_id uuid, + p_stage text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; +begin + if p_user_id is null or p_case_id is null or p_stage not in ('minute', 'block_scan') then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + if p_stage = 'block_scan' + and ( + coalesce(v_case.candidate_range->>'start_time', '') is distinct from '00:00' + or coalesce(v_case.candidate_range->>'end_time', '') is distinct from '23:59' + ) then + raise exception 'agentic_rectification_invalid_block_scan_window' using errcode = 'P0001'; + end if; + update public.agentic_rectification_cases + set + stage = p_stage, + last_activity_at = pg_catalog.now() + where id = v_case.id; + return jsonb_build_object( + 'case_id', v_case.id, + 'stage', p_stage + ); +end; +$$; + +create or replace function public.write_agentic_rectification_block_scan( + p_user_id uuid, + p_case_id uuid, + p_block_scan jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; +begin + if p_user_id is null or p_case_id is null + or p_block_scan is null or jsonb_typeof(p_block_scan) <> 'object' then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + if v_case.stage is distinct from 'block_scan' then + raise exception 'agentic_rectification_not_block_scan' using errcode = 'P0001'; + end if; + update public.agentic_rectification_cases + set + block_scan = p_block_scan, + last_activity_at = pg_catalog.now() + where id = v_case.id; + return jsonb_build_object( + 'case_id', v_case.id, + 'stage', v_case.stage, + 'block_scan', p_block_scan + ); +end; +$$; + +create or replace function public.advance_agentic_rectification_case_from_block_scan( + p_user_id uuid, + p_case_id uuid, + p_start_time text, + p_end_time text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_range jsonb; +begin + if p_user_id is null or p_case_id is null + or p_start_time is null or p_end_time is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + if not ( + (p_start_time, p_end_time) in ( + ('04:00', '07:59'), + ('08:00', '11:59'), + ('12:00', '17:59'), + ('18:00', '22:59'), + ('23:00', '03:59') + ) + ) then + raise exception 'agentic_rectification_invalid_block_period' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + if v_case.stage is distinct from 'block_scan' then + raise exception 'agentic_rectification_not_block_scan' using errcode = 'P0001'; + end if; + v_range := jsonb_build_object('start_time', p_start_time, 'end_time', p_end_time); + update public.agentic_rectification_results + set invalidated_at = pg_catalog.now() + where case_id = v_case.id + and invalidated_at is null; + update public.agentic_rectification_cases + set + candidate_range = v_range, + stage = 'minute', + block_scan = null, + last_activity_at = pg_catalog.now() + where id = v_case.id; + return jsonb_build_object( + 'case_id', v_case.id, + 'stage', 'minute', + 'candidate_range', v_range + ); +end; +$$; + +revoke all on function public.set_agentic_rectification_case_stage(uuid, uuid, text) + from public, anon, authenticated; +grant execute on function public.set_agentic_rectification_case_stage(uuid, uuid, text) + to service_role; + +revoke all on function public.write_agentic_rectification_block_scan(uuid, uuid, jsonb) + from public, anon, authenticated; +grant execute on function public.write_agentic_rectification_block_scan(uuid, uuid, jsonb) + to service_role; + +revoke all on function public.advance_agentic_rectification_case_from_block_scan(uuid, uuid, text, text) + from public, anon, authenticated; +grant execute on function public.advance_agentic_rectification_case_from_block_scan(uuid, uuid, text, text) + to service_role; + +create or replace function public.get_agentic_rectification_case( + p_user_id uuid, + p_case_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_evidence_count bigint; + v_turn_count bigint; +begin + if p_user_id is null or p_case_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + select count(*) into v_evidence_count + from public.agentic_rectification_evidence + where case_id = v_case.id; + + select count(*) into v_turn_count + from public.agentic_rectification_turns + where case_id = v_case.id; + + select * into v_result + from public.agentic_rectification_results + where case_id = v_case.id + and invalidated_at is null + order by created_at desc + limit 1; + + return jsonb_build_object( + 'case_id', v_case.id, + 'session_id', v_case.session_id, + 'status', v_case.status, + 'skill_name', v_case.skill_name, + 'skill_version', v_case.skill_version, + 'candidate_range', v_case.candidate_range, + 'stage', coalesce(v_case.stage, 'minute'), + 'block_scan', v_case.block_scan, + 'accepted_time', v_case.accepted_time, + 'confirmed_time', v_case.confirmed_time, + 'created_at', v_case.created_at, + 'last_activity_at', v_case.last_activity_at, + 'completed_at', v_case.completed_at, + 'closed_reason', v_case.closed_reason, + 'evidence_count', v_evidence_count, + 'turn_count', v_turn_count, + 'latest_result', case + when v_result.id is null then null + else jsonb_build_object( + 'result_id', v_result.id, + 'candidates', v_result.candidates, + 'overall_confidence', v_result.overall_confidence, + 'display_allowed', v_result.display_allowed, + 'selection_allowed', v_result.selection_allowed, + 'confirmation_allowed', v_result.confirmation_allowed, + 'representative_time', v_result.representative_time, + 'selected_candidate_id', v_result.selected_candidate_id, + 'selected_time', v_result.selected_time, + 'selection_kind', v_result.selection_kind, + 'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint, + 'candidate_range_fingerprint', v_result.candidate_range_fingerprint, + 'skill_version', v_result.skill_version, + 'algorithm_version', v_result.algorithm_version, + 'event_contract_version', v_result.event_contract_version, + 'decision_policy_version', v_result.decision_policy_version, + 'decision_receipt', public.compose_agentic_rectification_decision_receipt(v_case.id, v_result.id, v_result.decision_receipt), + 'execution_ledger', v_result.execution_ledger, + 'created_at', v_result.created_at, + 'invalidated_at', v_result.invalidated_at + ) + end + ); +end; +$$; + +revoke all on function public.get_agentic_rectification_case(uuid, uuid) + from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_case(uuid, uuid) + to service_role; + +create or replace function public.get_agentic_rectification_case_dossier( + p_user_id uuid, + p_case_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_turns jsonb; + v_evidence jsonb; + v_summary public.agentic_rectification_case_conversation_summaries%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_evidence_count bigint; + v_turn_count bigint; +begin + if p_user_id is null or p_case_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + select coalesce(jsonb_agg(jsonb_build_object( + 'id', recent.id, + 'role', message.role, + 'text', message.text, + 'status', recent.status, + 'created_at', recent.created_at, + 'completed_at', recent.completed_at + ) order by recent.created_at, recent.id, message.ordinal), '[]'::jsonb) + into v_turns + from ( + select t.* + from public.agentic_rectification_turns t + where t.case_id = v_case.id + order by t.created_at desc, t.id desc + limit 50 + ) recent + cross join lateral ( + values + (1, 'user'::text, recent.user_message), + (2, 'assistant'::text, recent.assistant_message) + ) as message(ordinal, role, text) + where message.text is not null; + + select coalesce(jsonb_agg(jsonb_build_object( + 'id', e.id, + 'source_turn_id', e.source_turn_id, + 'subject', e.subject, + 'event_kind', e.event_kind, + 'domain', e.domain, + 'occurred_from', e.occurred_from, + 'occurred_to', e.occurred_to, + 'date_precision', e.date_precision, + 'summary', e.summary, + 'status', e.status, + 'supersedes_evidence_id', e.supersedes_evidence_id, + 'created_at', e.created_at + ) order by e.created_at, e.id), '[]'::jsonb) + into v_evidence + from public.agentic_rectification_evidence e + where e.case_id = v_case.id; + + select count(*) into v_evidence_count + from public.agentic_rectification_evidence where case_id = v_case.id; + select count(*) into v_turn_count + from public.agentic_rectification_turns where case_id = v_case.id; + + select * into v_summary + from public.agentic_rectification_case_conversation_summaries + where case_id = v_case.id; + if not found then + perform public.refresh_agentic_rectification_case_conversation_summary(v_case.id); + select * into v_summary + from public.agentic_rectification_case_conversation_summaries + where case_id = v_case.id; + end if; + + select * into v_result + from public.agentic_rectification_results + where case_id = v_case.id and invalidated_at is null + order by created_at desc, id desc + limit 1; + + return jsonb_build_object( + 'case', jsonb_build_object( + 'case_id', v_case.id, + 'session_id', v_case.session_id, + 'status', v_case.status, + 'skill_name', v_case.skill_name, + 'skill_version', v_case.skill_version, + 'candidate_range', v_case.candidate_range, + 'stage', coalesce(v_case.stage, 'minute'), + 'block_scan', v_case.block_scan, + 'accepted_time', v_case.accepted_time, + 'confirmed_time', v_case.confirmed_time, + 'completed_at', v_case.completed_at, + 'closed_reason', v_case.closed_reason, + 'last_activity_at', v_case.last_activity_at, + 'evidence_count', v_evidence_count, + 'turn_count', v_turn_count + ), + 'turns', v_turns, + 'evidence', v_evidence, + 'conversation_summary', jsonb_build_object( + 'confirmed_evidence_summary', v_summary.confirmed_evidence_summary, + 'pending_revisions', v_summary.pending_revisions, + 'active_focus', v_summary.active_focus, + 'declined_skipped_topics', v_summary.declined_skipped_topics, + 'candidate_divergence_summary', v_summary.candidate_divergence_summary, + 'missing_evidence_categories', v_summary.missing_evidence_categories, + 'last_result_policy', v_summary.last_result_policy, + 'summary_version', v_summary.summary_version, + 'updated_at', v_summary.updated_at + ), + 'latest_result', case when v_result.id is null then null else jsonb_build_object( + 'result_id', v_result.id, + 'candidates', v_result.candidates, + 'overall_confidence', v_result.overall_confidence, + 'display_allowed', v_result.display_allowed, + 'selection_allowed', v_result.selection_allowed, + 'confirmation_allowed', v_result.confirmation_allowed, + 'representative_time', v_result.representative_time, + 'selected_candidate_id', v_result.selected_candidate_id, + 'selected_time', v_result.selected_time, + 'selection_kind', v_result.selection_kind, + 'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint, + 'candidate_range_fingerprint', v_result.candidate_range_fingerprint, + 'skill_version', v_result.skill_version, + 'algorithm_version', v_result.algorithm_version, + 'event_contract_version', v_result.event_contract_version, + 'decision_policy_version', v_result.decision_policy_version, + 'decision_receipt', public.compose_agentic_rectification_decision_receipt(v_case.id, v_result.id, v_result.decision_receipt), + 'execution_ledger', v_result.execution_ledger, + 'created_at', v_result.created_at, + 'invalidated_at', v_result.invalidated_at + ) end + ); +end; +$$; + +revoke all on function public.get_agentic_rectification_case_dossier(uuid, uuid) + from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_case_dossier(uuid, uuid) + to service_role; + +commit; diff --git a/frontend/tests/adopted-credible-range-migration.test.ts b/frontend/tests/adopted-credible-range-migration.test.ts new file mode 100644 index 00000000..db3dd31e --- /dev/null +++ b/frontend/tests/adopted-credible-range-migration.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const migration = readFileSync( + new URL("../supabase/migrations/20260906020000_adopted_credible_range.sql", import.meta.url), + "utf8", +); + +test("accept RPC gains an optional credible-range argument and never rewrites the opening window", () => { + assert.match(migration, /add column if not exists adopted_credible_range jsonb/); + assert.match( + migration, + /drop function if exists public\.accept_agentic_rectification_candidate_for_case_v2\(uuid, uuid, uuid, uuid, uuid\);/, + ); + assert.match( + migration, + /create or replace function public\.accept_agentic_rectification_candidate_for_case_v2\(\s*p_user_id uuid,\s*p_case_id uuid,\s*p_result_id uuid,\s*p_candidate_id uuid,\s*p_request_id uuid,\s*p_credible_range jsonb default null/, + ); + assert.match(migration, /adopted_credible_range = coalesce\(v_adopted, adopted_credible_range\)/); + assert.doesNotMatch(migration, /candidate_range\s*=\s*v_adopted/); + assert.match( + migration, + /grant execute on function public\.accept_agentic_rectification_candidate_for_case_v2\(uuid, uuid, uuid, uuid, uuid, jsonb\)\s+to service_role/, + ); +}); + +test("read_report_candidate_range prefers adopted_credible_range then the opening window", () => { + assert.match(migration, /when c\.adopted_credible_range is not null/); + assert.match(migration, /then c\.adopted_credible_range/); + assert.match(migration, /else c\.candidate_range/); + assert.match(migration, /jsonb_build_object\('start_time', v_start, 'end_time', v_end\)/); + assert.match( + migration, + /grant execute on function public\.read_report_candidate_range\(uuid, uuid\)\s+to service_role/, + ); +}); diff --git a/frontend/tests/birth-time-consultation-consent.test.ts b/frontend/tests/birth-time-consultation-consent.test.ts index 42178d4e..9c15d39c 100644 --- a/frontend/tests/birth-time-consultation-consent.test.ts +++ b/frontend/tests/birth-time-consultation-consent.test.ts @@ -222,7 +222,8 @@ test("birth time intake starts with exact or uncertain choices and keeps rectifi assert.doesNotMatch(intake, /source === "family_exact" \|\| source === "approximate"/); assert.match(intake, /选择你记得的开始和结束时间/); assert.match(intake, /完全不清楚,跳过出生时间/); - assert.match(intake, /生时校正以后需要时再做/); + assert.match(intake, /可以直接开始生时校正:先从你记得的经历比出大致时段/); + assert.doesNotMatch(intake, /生时校正以后需要时再做/); assert.doesNotMatch(intake, /请选择最接近的时间范围|补充描述/); }); diff --git a/frontend/tests/birth-time-intake.test.ts b/frontend/tests/birth-time-intake.test.ts index 51c18372..c8815fc4 100644 --- a/frontend/tests/birth-time-intake.test.ts +++ b/frontend/tests/birth-time-intake.test.ts @@ -389,7 +389,9 @@ test("fresh intake preserves exact, approximate-period, and unknown-time paths w assert.match(source, /birthTimeSource: "unknown"/); assert.match(source, /选择你记得的开始和结束时间/); assert.match(source, /完全不清楚,跳过出生时间/); - assert.match(source, /我可以选一段时间范围/); + assert.match(source, /可以直接开始生时校正:先从你记得的经历比出大致时段/); + assert.doesNotMatch(source, /我可以选一段时间范围/); + assert.doesNotMatch(source, /生时校正以后需要时再做/); assert.doesNotMatch(source, /补充描述|请选择最接近的时间范围|我可以描述一个时间范围/); assert.match(source, /birthTimeStatus: "reported"/); assert.match(source, /birthTimeConsultationOptionsCopy\(value\)/); diff --git a/frontend/tests/consultation-route-service.test.ts b/frontend/tests/consultation-route-service.test.ts index ef85f2c9..3a9447b9 100644 --- a/frontend/tests/consultation-route-service.test.ts +++ b/frontend/tests/consultation-route-service.test.ts @@ -384,6 +384,7 @@ test("consult route constructs workflow input from the route service rather than assert.match(route, new RegExp(select)); assert.match(route, /prepareConsultationRoute/); + assert.match(route, /loadCandidateRange/); assert.match(route, /timezone_id,timezone_source,ayanamsa/); assert.match(route, /\.\.\.prepared\.serverChart\.toolInput/); const toolInput = route.slice(route.indexOf("const toolInput = consultationInputSchema.parse")); @@ -392,5 +393,33 @@ test("consult route constructs workflow input from the route service rather than test("consult route rejects retired rectification handoff payloads", () => { const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); - assert.doesNotMatch(route, /rectificationHandoff|candidateRange/); + assert.doesNotMatch(route, /rectificationHandoff/); + assert.doesNotMatch(route, /parsed\.data\.candidateRange/); +}); + +test("accepted rectification time attaches the adopted range and confirmed does not", async () => { + const accepted = await prepareConsultationRoute({ + userId: "user-1", + mode: "verified_chart", + loadProfile: async () => ({ ...profile, birth_time_status: "accepted" }), + loadCandidateRange: async () => ({ startTime: "05:00", endTime: "05:26" }), + reserve: async () => "reserved", + }); + assert.equal(accepted.serverChart?.toolInput.birth_time_accuracy, "provisional"); + assert.deepEqual(accepted.serverChart?.toolInput.candidate_range, { + start_time: "05:00", + end_time: "05:26", + }); + assert.equal(accepted.serverChart?.truth.birthTimeStatus, "accepted"); + + const confirmed = await prepareConsultationRoute({ + userId: "user-1", + mode: "verified_chart", + loadProfile: async () => ({ ...profile, birth_time_status: "confirmed" }), + loadCandidateRange: async () => ({ startTime: "05:00", endTime: "05:26" }), + reserve: async () => "reserved", + }); + assert.equal("birth_time_accuracy" in (confirmed.serverChart?.toolInput ?? {}), false); + assert.equal("candidate_range" in (confirmed.serverChart?.toolInput ?? {}), false); + assert.equal(confirmed.serverChart?.truth.birthTimeStatus, "confirmed"); }); diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index ebf0487c..0e7d5043 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -832,7 +832,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic fixture.psql(` select not has_function_privilege('service_role', 'public.accept_agentic_rectification_candidate(uuid,uuid,uuid,time without time zone)', 'EXECUTE') - and has_function_privilege('service_role', 'public.accept_agentic_rectification_candidate_for_case_v2(uuid,uuid,uuid,uuid,uuid)', 'EXECUTE') + and has_function_privilege('service_role', 'public.accept_agentic_rectification_candidate_for_case_v2(uuid,uuid,uuid,uuid,uuid,jsonb)', 'EXECUTE') `), "t", ); diff --git a/frontend/tests/database-rectification-block-scan.test.ts b/frontend/tests/database-rectification-block-scan.test.ts new file mode 100644 index 00000000..53d2771d --- /dev/null +++ b/frontend/tests/database-rectification-block-scan.test.ts @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); + +function dockerAvailable(): boolean { + const probe = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { + encoding: "utf8", + stdio: "ignore", + }); + return probe.status === 0; +} + +const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host"; + +function rpcError(error: unknown): string { + if (!error || typeof error !== "object") return ""; + const value = error as { message?: unknown }; + return typeof value.message === "string" ? value.message : ""; +} + +const snapshot = { + birth_date: "1998-03-15", + birth_place_label: "北京市", + latitude: 39.9042, + longitude: 116.4074, + timezone_id: "Asia/Shanghai", + timezone_offset: 8, + birth_time_source: "unknown", + reported_birth_time: null, + active_birth_time: null, + birth_time_period: null, + uncertainty_before_minutes: null, + uncertainty_after_minutes: null, +}; +const fingerprint = "b".repeat(64); +const fullDay = { start_time: "00:00", end_time: "23:59" }; +const skillName = "jyotish-birth-time-rectification"; +const skillVersion = "9.0.0"; +const blocks = { + blocks: [ + { period: "early_morning", start_time: "04:00", end_time: "07:59", relative_support: 12 }, + { period: "morning", start_time: "08:00", end_time: "11:59", relative_support: 41 }, + { period: "afternoon", start_time: "12:00", end_time: "17:59", relative_support: 28 }, + { period: "evening", start_time: "18:00", end_time: "22:59", relative_support: 11 }, + { period: "late_night", start_time: "23:00", end_time: "03:59", relative_support: 8 }, + ], +}; + +test("block_scan RPCs are service_role-only and advance a declared period", { skip: skipWithoutDocker }, async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + assert.equal(migration.status, 0, migration.stderr); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.users (name, email, email_verified, email_verified_at) + values ('Unknown', 'unknown-time@example.com', true, now()); + `, + ); + const userId = fixture.psql( + "select id from identity.users where email = 'unknown-time@example.com'", + ); + fixture.psql(` + update public.profiles + set birth_date = '1998-03-15', + reported_birth_time = null, + birth_time_source = 'unknown', + latitude = 39.9042, longitude = 116.4074, timezone_offset = 8, + birth_time_status = 'reported' + where id = '${userId}'; + `); + fixture.psql(` + with provider as ( + insert into public.model_providers (code, name, provider_type, encrypted_api_key, enabled) + values ('block-scan-test', 'Block Scan Test', 'openai', 'test-ciphertext', true) + returning id + ), config as ( + insert into public.model_configs (model_id) + values ('block-scan-default-model') + returning id + ) + insert into public.model_config_versions ( + config_id, version, provider_id, label, provider_model, enabled, is_default, status, published_at + ) + select config.id, 1, provider.id, 'Default', 'gpt-test', true, true, 'published', now() + from config cross join provider; + `); + + const service = createLocalPostgresDataClient( + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), + null, + "service_role", + ); + const opened = await service.rpc("open_agentic_rectification_case", { + p_user_id: userId, + p_request_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + p_intent: "homepage", + p_session_id: null, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: fingerprint, + p_baseline_birth_snapshot: snapshot, + p_candidate_range: fullDay, + }); + assert.equal(opened.error, null, rpcError(opened.error)); + const caseId = String((opened.data as Record).case_id); + + const staged = await service.rpc("set_agentic_rectification_case_stage", { + p_user_id: userId, + p_case_id: caseId, + p_stage: "block_scan", + }); + assert.equal(staged.error, null, rpcError(staged.error)); + + const written = await service.rpc("write_agentic_rectification_block_scan", { + p_user_id: userId, + p_case_id: caseId, + p_block_scan: blocks, + }); + assert.equal(written.error, null, rpcError(written.error)); + + const dossier = await service.rpc("get_agentic_rectification_case_dossier", { + p_user_id: userId, + p_case_id: caseId, + }); + assert.equal(dossier.error, null, rpcError(dossier.error)); + const caseRow = (dossier.data as { case?: { stage?: string; block_scan?: unknown } }).case; + assert.equal(caseRow?.stage, "block_scan"); + assert.ok(caseRow?.block_scan); + + const advanced = await service.rpc("advance_agentic_rectification_case_from_block_scan", { + p_user_id: userId, + p_case_id: caseId, + p_start_time: "08:00", + p_end_time: "11:59", + }); + assert.equal(advanced.error, null, rpcError(advanced.error)); + assert.equal((advanced.data as { stage?: string }).stage, "minute"); + assert.deepEqual((advanced.data as { candidate_range?: unknown }).candidate_range, { + start_time: "08:00", + end_time: "11:59", + }); + assert.equal( + fixture.psql(`select stage from public.agentic_rectification_cases where id = '${caseId}'`), + "minute", + ); + assert.equal( + fixture.psql(`select block_scan is null from public.agentic_rectification_cases where id = '${caseId}'`), + "t", + ); + + const invalidPeriod = await service.rpc("advance_agentic_rectification_case_from_block_scan", { + p_user_id: userId, + p_case_id: caseId, + p_start_time: "04:50", + p_end_time: "05:10", + }); + assert.match(rpcError(invalidPeriod.error), /agentic_rectification_not_block_scan|agentic_rectification_invalid_block_period/); + + const privileges = fixture.psql(` + select concat_ws(':', + has_function_privilege('service_role', 'public.set_agentic_rectification_case_stage(uuid,uuid,text)', 'EXECUTE'), + has_function_privilege('anon', 'public.set_agentic_rectification_case_stage(uuid,uuid,text)', 'EXECUTE'), + has_function_privilege('authenticated', 'public.write_agentic_rectification_block_scan(uuid,uuid,jsonb)', 'EXECUTE'), + has_function_privilege('service_role', 'public.advance_agentic_rectification_case_from_block_scan(uuid,uuid,text,text)', 'EXECUTE') + ) + `); + assert.equal(privileges, "t:f:f:t"); + } finally { + try { + await closeLocalPostgresDataPool( + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), + ); + } finally { + fixture.stop(); + } + } +}); diff --git a/frontend/tests/database-report-candidate-range.test.ts b/frontend/tests/database-report-candidate-range.test.ts index 58cc79f9..f785c35c 100644 --- a/frontend/tests/database-report-candidate-range.test.ts +++ b/frontend/tests/database-report-candidate-range.test.ts @@ -159,6 +159,24 @@ test("read_report_candidate_range is service_role-only, returns only the window, "f", ); + fixture.psql(` + update public.agentic_rectification_cases + set adopted_credible_range = '{"start_time":"05:00","end_time":"05:26","representative_time":"05:12","width_minutes":27,"source":"inference_credible_range"}'::jsonb + where id = ${sqlLiteral(ids.caseId)}; + `); + assert.equal( + serviceSql( + `select public.read_report_candidate_range(${sqlLiteral(ids.user)}::uuid) = '{"start_time":"05:00","end_time":"05:26"}'::jsonb`, + ), + "t", + ); + const opening = fixture.psql(` + select (candidate_range->>'start_time') || '-' || (candidate_range->>'end_time') + from public.agentic_rectification_cases + where id = ${sqlLiteral(ids.caseId)}; + `); + assert.match(opening, /10:00-10:04/); + try { fixture.psql(` insert into public.birth_time_rectification_cases ( diff --git a/frontend/tests/rectification-adopt-narration-20260904.test.ts b/frontend/tests/rectification-adopt-narration-20260904.test.ts index 4aa053c1..0f9ab578 100644 --- a/frontend/tests/rectification-adopt-narration-20260904.test.ts +++ b/frontend/tests/rectification-adopt-narration-20260904.test.ts @@ -8,7 +8,9 @@ import type { ConflictProbe, InferenceState } from "../src/lib/rectification-age import { adoptDeliveryFacts, templatePostAdoptExplain, + templateRangeReadingExplain, validateAdoptNarration, + RANGE_READING_COPY, type AdoptDeliveryFacts, } from "../src/lib/rectification-agentic/v9/adopt-narration.ts"; import { @@ -1218,3 +1220,70 @@ test("family collect declined vs extra distinguish declined leaves the same adop assert.equal(narratedLeft.hostNarration, USER_COLLECT_QUESTION.finance); assert.equal(narratedLeft.persisted, true); }); + +test("range-reading explain uses theme_sensitivity labels and the unique-minute boundary", () => { + const text = templateRangeReadingExplain({ + widthMinutes: 27, + stableThemes: ["career", "general"], + sensitiveThemes: ["marriage"], + }); + assert.match(text ?? "", /这 27 分钟里/); + assert.match(text ?? "", /事业方向/); + assert.match(text ?? "", /性格底色/); + assert.match(text ?? "", /稳定/); + assert.match(text ?? "", /婚恋(D9)/); + assert.match(text ?? "", /随分钟变/); + assert.match(text ?? "", /按范围读/); + assert.ok(text?.includes(RANGE_READING_COPY.boundary)); + assert.doesNotMatch(text ?? "", /这只是粗看/); +}); + +test("adopt skip-followup appends range-reading sentences when the engine answers", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/rectification/v5/range_reading")) { + return new Response(JSON.stringify({ + stable_themes: ["career", "general"], + sensitive_themes: ["marriage"], + claim_boundary: RANGE_READING_COPY.boundary, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + try { + const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); + const persisted = await persistNextInterviewIfIdle({ + accounting: adoptAccounting(dossier).client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.match(persisted.hostNarration ?? "", /事业方向/); + assert.match(persisted.hostNarration ?? "", /性格底色/); + assert.match(persisted.hostNarration ?? "", /婚恋(D9)/); + assert.match(persisted.hostNarration ?? "", /随分钟变/); + assert.ok((persisted.hostNarration ?? "").includes(RANGE_READING_COPY.boundary)); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("adopt skip-followup omits range-reading sentences when the engine is down", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("engine down"); + }) as typeof fetch; + try { + const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS }); + const persisted = await persistNextInterviewIfIdle({ + accounting: adoptAccounting(dossier).client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.ok(persisted.hostNarration); + assert.doesNotMatch(persisted.hostNarration ?? "", /随分钟变/); + assert.doesNotMatch(persisted.hostNarration ?? "", /这只是粗看/); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 5d8ad190..c9e222c3 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -1198,6 +1198,41 @@ test("an unsure choice keeps applied inference and does not claim scores changed assert.doesNotMatch(narration, /更新了候选比较/); }); +test("a scoring choice narrates cluster movement and range change", () => { + const narrowed = composeChoiceNarration({ + optionId: "A", + scoring: true, + appliedInference: true, + deltasByCluster: [ + { range: ["04:31", "04:39"], delta: 2 }, + { range: ["05:00", "05:07"], delta: -2 }, + ], + rangeBefore: ["04:31", "05:07"], + rangeAfter: ["04:31", "04:39"], + }); + assert.match(narrowed, /领先/); + assert.match(narrowed, /落后/); + assert.match(narrowed, /范围从 04:31–05:07 收到 04:31–04:39/); + const unchanged = composeChoiceNarration({ + optionId: "A", + scoring: true, + appliedInference: true, + deltasByCluster: [{ range: ["04:31", "04:39"], delta: 1 }], + rangeBefore: ["04:31", "04:39"], + rangeAfter: ["04:31", "04:39"], + }); + assert.match(unchanged, /范围没变/); + const unsure = composeChoiceNarration({ + optionId: "D", + scoring: true, + appliedInference: true, + answerClass: "unsure", + rangeBefore: ["04:31", "04:39"], + rangeAfter: ["04:31", "04:39"], + }); + assert.equal(unsure, "已记录。这题先不计分,换一件事问。"); +}); + test("the public agent route treats structured choice as a non-model command", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const start = route.indexOf("if (isStructuredChoice)"); @@ -1336,6 +1371,8 @@ test("choice followup narration uses the server-owned prompt", () => { stop_label: "先这样,先看当前范围", stop_message: "先这样", scoring: true, + why_user: "", + answer_impact: { A: "", B: "", C: "", D: "" }, }, source: "event_probe", }; diff --git a/frontend/tests/rectification-block-scan-20260906.test.ts b/frontend/tests/rectification-block-scan-20260906.test.ts new file mode 100644 index 00000000..86212cdc --- /dev/null +++ b/frontend/tests/rectification-block-scan-20260906.test.ts @@ -0,0 +1,394 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { deriveRectificationOpenWindow } from "../src/lib/rectification-agentic/v9/case-service.ts"; +import { decideAfterInferenceChange, decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts"; +import { projectRectificationStepState } from "../src/lib/rectification-agentic/v9/step-state.ts"; +import { + BLOCK_CHOICE_INTENT, + BLOCK_PERIOD_LABELS, + buildBlockChoiceFrame, + parseBlockScanPayload, + serializeBlockScanPayload, + windowFromBlockChoice, +} from "../src/lib/rectification-agentic/v9/block-scan.ts"; +import { + CASE_ID, + FOCUS_ID, + SESSION_ID, + TURN_ID, + USER_ID, + activeFocusFixture, + computeFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const BLOCKS = [ + { period: "early_morning", start_time: "04:00", end_time: "07:59", relative_support: 12 }, + { period: "morning", start_time: "08:00", end_time: "11:59", relative_support: 41 }, + { period: "afternoon", start_time: "12:00", end_time: "17:59", relative_support: 28 }, + { period: "evening", start_time: "18:00", end_time: "22:59", relative_support: 11 }, + { period: "late_night", start_time: "23:00", end_time: "03:59", relative_support: 8 }, +] as const; + +const EVIDENCE = [ + { + id: "e-edu", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "education_start", + domain: "education", + occurred_from: "2016-09-01", + occurred_to: "2016-09-30", + date_precision: "month", + summary: "education start", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-06T00:00:00.000Z", + }, + { + id: "e-rel", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "relationship_end", + domain: "relationship", + occurred_from: "2024-08-08", + occurred_to: "2024-08-08", + date_precision: "day", + summary: "relationship end", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-06T00:00:01.000Z", + }, + { + id: "e-move", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "relocation", + domain: "relocation", + occurred_from: "2023-07-01", + occurred_to: "2023-07-31", + date_precision: "month", + summary: "relocation", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-06T00:00:02.000Z", + }, +]; + +function blockPayload(extra: Record = {}) { + return { + evidence_ledger_fingerprint: "f".repeat(64), + algorithm_version: "test", + minute_step: 10, + blocks: [...BLOCKS], + ...extra, + }; +} + +function blockSchema() { + const payload = parseBlockScanPayload(blockPayload()); + assert.ok(payload); + const frame = buildBlockChoiceFrame(payload); + assert.ok(frame); + return { + choice: { + prompt: frame.prompt, + option_a: frame.option_a_hint, + option_b: frame.option_b_hint, + option_c: frame.neither_label, + option_d: frame.unsure_label, + options: [ + { key: "A", label: frame.option_a_hint, answer_class: frame.option_a_answer_class, role: "primary" }, + { key: "B", label: frame.option_b_hint, answer_class: frame.option_b_answer_class, role: "primary" }, + { key: "C", label: frame.neither_label, answer_class: frame.option_c_answer_class, role: "primary" }, + { key: "D", label: frame.unsure_label, answer_class: frame.option_d_answer_class, role: "primary" }, + ], + }, + choice_kind: "block_choice", + scoring: false, + block_periods: { + A: BLOCKS[1], + B: BLOCKS[2], + C: BLOCKS[0], + }, + }; +} + +function blockDossier(extra: { + declined?: boolean; + stage?: string; + range?: { start_time: string; end_time: string }; + blockScan?: unknown; +} = {}) { + const schema = blockSchema(); + return dossierFixture({ + candidateRange: extra.range ?? { start_time: "00:00", end_time: "23:59" }, + stage: extra.stage ?? "block_scan", + blockScan: extra.blockScan ?? (extra.declined + ? blockPayload({ declined_at_fingerprint: "f".repeat(64), evidence_ledger_fingerprint: "f".repeat(64) }) + : blockPayload()), + evidence: EVIDENCE, + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: extra.stage === "minute" + ? null + : activeFocusFixture({ + intent: BLOCK_CHOICE_INTENT, + targetDomain: null, + targetKind: null, + questionId: "block_scan:choose_birth_block:holdout", + expectedAnswerSchema: schema, + }), + declined_skipped_topics: [], + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-09-06T00:00:00.000Z", + }, + }); +} + +test("unknown source opens a full-day block_scan window", () => { + assert.deepEqual( + deriveRectificationOpenWindow({ + reportedTime: null, + source: "unknown", + period: null, + windowStart: null, + windowEnd: null, + }), + { + candidateRange: { start_time: "00:00", end_time: "23:59" }, + stage: "block_scan", + }, + ); + assert.deepEqual( + deriveRectificationOpenWindow({ + reportedTime: "05:00", + source: "unknown", + period: null, + windowStart: null, + windowEnd: null, + }).stage, + "minute", + ); + assert.equal( + deriveRectificationOpenWindow({ + reportedTime: "05:00", + source: "family_exact", + period: null, + windowStart: null, + windowEnd: null, + }).stage, + "minute", + ); +}); + +test("three dated events in two domains ask a block choice and never adopt", () => { + const parsed = parseV9CaseDossier(blockDossier()); + assert.ok(parsed); + const decision = decideFromDossier(parsed); + assert.equal(decision.nextAction, "ask_block_choice"); + assert.equal(decision.canAdopt, false); + assert.equal(decision.selectionAllowed, false); + assert.equal(decision.representativeTime, null); + assert.equal(decision.separation.ranked.length, 0); + const step = projectRectificationStepState({ nextAction: decision.nextAction }); + assert.equal(step.index, 1); + assert.equal(step.stage, "collect"); + assert.match(step.headline, /比较时段/); +}); + +test("block choice copy names periods, not a winning minute", () => { + const payload = parseBlockScanPayload(blockPayload()); + assert.ok(payload); + const frame = buildBlockChoiceFrame(payload); + assert.ok(frame); + assert.match(frame.prompt, /哪一段更像出生时段/); + assert.doesNotMatch(frame.prompt, /更像\s*\d/); + assert.match(frame.option_a_hint ?? "", new RegExp(BLOCK_PERIOD_LABELS.morning)); + assert.equal(frame.scoring, false); + assert.equal(frame.choice_kind, "block_choice"); + const schema = blockSchema(); + assert.deepEqual(windowFromBlockChoice({ schema, optionId: "B" }), { + start_time: "12:00", + end_time: "17:59", + }); + assert.equal(windowFromBlockChoice({ schema, optionId: "D" }), null); +}); + +test("declined block_scan fingerprint returns collect instead of another card", () => { + const parsed = parseV9CaseDossier(blockDossier()); + assert.ok(parsed); + const fingerprint = evidenceLedgerFingerprint(parsed.evidence); + const declined = parseV9CaseDossier(blockDossier({ + blockScan: { + ...blockPayload(), + declined_at_fingerprint: fingerprint, + }, + })); + assert.ok(declined); + assert.equal(declined.case.blockScan?.declinedAtFingerprint, fingerprint); + const decision = decideAfterInferenceChange({ + dossier: declined, + state: null, + userStopped: false, + }); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.canAdopt, false); +}); + +test("choosing B advances the declared period and does not keep the 24h snapshot current", async () => { + let current = blockDossier(); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => current, + get_agentic_rectification_case_compute: () => computeFixture({ + baselineBirthSnapshot: { + birth_date: "1998-03-15", + latitude: 39.9042, + longitude: 116.4074, + timezone_id: "Asia/Shanghai", + timezone_offset: 8, + birth_time_source: "unknown", + }, + }), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), + apply_agentic_rectification_choice_action: (_fn, args) => ({ + action_id: args.p_action_id, + status: "applied", + idempotent: false, + question_id: args.p_question_id, + option_id: args.p_option_id, + revision: 1, + narration: args.p_narration, + focus_status: args.p_focus_status, + }), + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-06T00:00:00.000Z", + resolved_at: null, + }, + idempotent: false, + }), + advance_agentic_rectification_case_from_block_scan: (_fn, args) => { + current = blockDossier({ + stage: "minute", + range: { start_time: String(args.p_start_time), end_time: String(args.p_end_time) }, + }); + return { + case_id: CASE_ID, + stage: "minute", + candidate_range: { start_time: args.p_start_time, end_time: args.p_end_time }, + }; + }, + persist_agentic_rectification_candidate_v2: () => { + throw new Error("must not persist the 24h minute snapshot"); + }, + }); + const receipt = await applyRectificationChoice(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + action: CHOICE_ACTION, + focusId: FOCUS_ID, + optionId: "B", + expectedRevision: 0, + }); + assert.equal(receipt.snapshotCurrent, false); + assert.equal(receipt.nextAction.can_adopt, false); + const parsed = parseV9CaseDossier(current); + assert.equal(parsed?.case.stage, "minute"); + assert.deepEqual(parsed?.case.candidateRange, { start_time: "12:00", end_time: "17:59" }); + assert.ok(accounting.calls.some((item) => item.fn === "advance_agentic_rectification_case_from_block_scan")); +}); + +test("choosing D stays in block_scan and collects again", async () => { + let current = blockDossier(); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => current, + get_agentic_rectification_case_compute: () => computeFixture(), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), + apply_agentic_rectification_choice_action: (_fn, args) => ({ + action_id: args.p_action_id, + status: "applied", + idempotent: false, + question_id: args.p_question_id, + option_id: args.p_option_id, + revision: 1, + narration: args.p_narration, + focus_status: args.p_focus_status, + }), + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-06T00:00:00.000Z", + resolved_at: null, + }, + idempotent: false, + }), + write_agentic_rectification_block_scan: (_fn, args) => { + current = blockDossier({ blockScan: args.p_block_scan }); + return { case_id: CASE_ID, stage: "block_scan", block_scan: args.p_block_scan }; + }, + advance_agentic_rectification_case_from_block_scan: () => { + throw new Error("D must not advance the window"); + }, + }); + const receipt = await applyRectificationChoice(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", + action: CHOICE_ACTION, + focusId: FOCUS_ID, + optionId: "D", + expectedRevision: 0, + }); + const parsedAfter = parseV9CaseDossier(current); + assert.equal(parsedAfter?.case.stage, "block_scan"); + assert.equal( + parsedAfter?.case.blockScan?.declinedAtFingerprint, + evidenceLedgerFingerprint(parsedAfter!.evidence), + ); + assert.equal(receipt.nextAction.type, "ask_fact_collection"); + assert.equal(receipt.nextAction.can_adopt, false); + assert.ok(accounting.calls.some((item) => item.fn === "write_agentic_rectification_block_scan")); + const payload = serializeBlockScanPayload(parseBlockScanPayload(blockPayload({ + declined_at_fingerprint: "f".repeat(64), + }))!); + assert.equal(payload.declined_at_fingerprint, "f".repeat(64)); +}); + +test("existing minute-stage cases keep the minute interview", () => { + const parsed = parseV9CaseDossier(dossierFixture({ + evidence: EVIDENCE, + stage: "minute", + candidateRange: { start_time: "04:50", end_time: "05:10" }, + })); + assert.ok(parsed); + const decision = decideFromDossier(parsed); + assert.notEqual(decision.nextAction, "ask_block_choice"); + assert.notEqual(decision.sessionOutcome, "compare_blocks"); +}); diff --git a/frontend/tests/rectification-block-scan-migration.test.ts b/frontend/tests/rectification-block-scan-migration.test.ts new file mode 100644 index 00000000..69f9195e --- /dev/null +++ b/frontend/tests/rectification-block-scan-migration.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const migration = readFileSync( + new URL("../supabase/migrations/20260906030000_rectification_block_scan_stage.sql", import.meta.url), + "utf8", +); + +test("block_scan stage columns and service_role RPCs stay additive", () => { + assert.match(migration, /add column if not exists stage text not null default 'minute'/); + assert.match(migration, /add column if not exists block_scan jsonb/); + assert.match(migration, /check \(stage in \('minute', 'block_scan'\)\)/); + assert.match(migration, /create or replace function public\.set_agentic_rectification_case_stage/); + assert.match(migration, /create or replace function public\.write_agentic_rectification_block_scan/); + assert.match(migration, /create or replace function public\.advance_agentic_rectification_case_from_block_scan/); + assert.match(migration, /grant execute on function public\.set_agentic_rectification_case_stage\(uuid, uuid, text\)\s+to service_role/); + assert.match(migration, /grant execute on function public\.write_agentic_rectification_block_scan\(uuid, uuid, jsonb\)\s+to service_role/); + assert.match(migration, /grant execute on function public\.advance_agentic_rectification_case_from_block_scan\(uuid, uuid, text, text\)\s+to service_role/); + assert.match(migration, /'stage', coalesce\(v_case\.stage, 'minute'\)/); + assert.match(migration, /invalidated_at = pg_catalog\.now\(\)/); + assert.doesNotMatch(migration, /create table public\.agentic_rectification_cases/); +}); diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index 8b86e7db..a03e0c4f 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -128,6 +128,11 @@ test("choice frames ask one biographical event from a server probe, not competin assert.equal(/外貌|疤痕|胎记/.test(frame.option_a_hint + frame.option_b_hint), false); assert.equal(frame.scoring, true); assert.equal(mergeChoiceCard(frame, null), null); + assert.match(frame.why_user, /2016 年前后/); + assert.match(frame.why_user, /分成两组/); + assert.match(frame.answer_impact.A, /05:00/); + assert.match(frame.answer_impact.A, /领先/); + assert.equal(frame.answer_impact.D, "不计分,换一题"); }); test("choice frames keep the engine month lock instead of collapsing to a year", () => { @@ -1841,3 +1846,37 @@ test("event_quality followup with a missing probe key does not fall back to anot }, { probes: [startProbe] }); assert.equal(frame, null); }); + +test("varga style cards explain the chart layer in why_user", () => { + const frame = buildChoiceFrame({ + method_id: "d9_relationship", + ask_theme: "relationship_style", + domain: "relationship", + user_prompt_hint: "unused", + choice_kind: "varga_style", + style_options: D9_STYLE_OPTIONS, + }, { + probes: [{ + year: 0, + year_label: "", + domain: "relationship", + event_family: "相处方式", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "对照分盘类型差异。请写成一句自然语言。", + role: "distinguish", + choice_kind: "varga_style", + style_options: D9_STYLE_OPTIONS, + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + ], + }], + }); + assert.ok(frame); + assert.match(frame.why_user, /D9/); + assert.doesNotMatch(frame.why_user, /概率|置信度|确定/); + assert.match(frame.answer_impact.A, /05:00/); +}); diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index f12f6a7b..55f4a994 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -1352,6 +1352,6 @@ test("interview, choice, refresh and next-action all call the same reducer", () assert.doesNotMatch(tools, /propose_allowed: decision\?\.proposeAllowed \?\? proposeAllowed/); assert.match(tools, /evidence: parsed\.evidence/); assert.match(followup, /evidence: input\.evidence/); - assert.match(adapter, /trainingGateOpen: trainingGate\.open/); + assert.match(adapter, /trainingGateOpen: caseStage === "block_scan" \? blockScanReady : trainingGate\.open/); assert.match(adapter, /blockingMethodsCovered/); }); diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts index 12d2f22d..6350420d 100644 --- a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts +++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts @@ -12,17 +12,23 @@ import { } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; import { + applyRectificationChoice, ensureNonTerminalTurnExit, persistNextInterviewIfIdle, } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; -import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CHOICE_STOP_LABEL } from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts"; +import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts"; +import { finalizeSuccessfulTurnExit } from "../src/lib/rectification-agentic/v9/turn-exit.ts"; import { CASE_ID, FOCUS_ID, + SESSION_ID, TURN_ID, USER_ID, + activeFocusFixture, candidateSnapshotFixture, computeFixture, dossierFixture, @@ -238,9 +244,17 @@ const FINANCE = existenceProbe({ }); const ASKED_PROBES = [D9, D10, CAREER_MONTH, CAREER_YEAR, RELOC, FINANCE]; +const LEFTOVER_PROBE = existenceProbe({ + key: "career.2021.04.dasha_boundary", + domain: "career", + year: 2021, + question: "2021 年 4 月前后有没有入职或换工作", +}); +const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; +const GATE_SENTENCE = /还差(?: \d+ 件)?带月份的经历|两件事的日期还没对清|当前还排不出可比较的候选时间|还差另一个领域的带月份经历/; -function liveState(): InferenceState { - const probes = ASKED_PROBES; +function liveState(extraProbes: readonly ConflictProbe[] = []): InferenceState { + const probes = [...ASKED_PROBES, ...extraProbes]; const rankedActive = [...ACTIVE].sort((left, right) => ( (PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0) || (SCORES[right] ?? 0) - (SCORES[left] ?? 0) @@ -343,12 +357,24 @@ function accidentDossier(extra: { acceptanceAllowed?: boolean; acceptanceReasons?: string[]; mismatchSnapshot?: boolean; + leftoverProbe?: ConflictProbe; + holdoutUnavailable?: boolean; } = {}): DecisionDossier { - const state = liveState(); - const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never); + const base = liveState(extra.leftoverProbe ? [extra.leftoverProbe] : []); + const evidence = extra.holdoutUnavailable + ? EVIDENCE.filter((item) => item.id !== "e-rel-end") + : EVIDENCE; + const state = { + ...base, + events: extra.holdoutUnavailable + ? base.events.filter((item) => item.usage !== "holdout" && item.id !== "e-rel-end") + : base.events, + holdout_passed: extra.holdoutUnavailable ? true : base.holdout_passed, + }; + const fingerprint = evidenceLedgerFingerprint(evidence as never); const acceptanceAllowed = extra.acceptanceAllowed !== false; return { - evidence: EVIDENCE, + evidence, conversationSummary: { activeFocus: null, declinedSkippedTopics: [{ target_domain: "family", status: "declined" }], @@ -375,24 +401,27 @@ function accidentDossier(extra: { confirmation_allowed: false, ...(extra.acceptanceReasons ? { acceptance_reasons: extra.acceptanceReasons } : {}), inference_state: state, - discriminating_event_probes: ASKED_PROBES.map(eventProbeRow), + discriminating_event_probes: [ + ...ASKED_PROBES.map(eventProbeRow), + ...(extra.leftoverProbe ? [eventProbeRow(extra.leftoverProbe)] : []), + ], }, }, case: { acceptedTime: null, status: "collecting_evidence" }, }; } -function rpcDossier(decision: DecisionDossier) { - const evidence = EVIDENCE.map((item) => ({ - id: item.id, +function rpcDossier(decision: DecisionDossier, extra: { activeFocus?: ReturnType } = {}) { + const evidence = decision.evidence.map((item) => ({ + id: item.id ?? "e-unknown", source_turn_id: TURN_ID, - subject: item.domain === "occupation" ? "self" : "self", - event_kind: item.eventKind, + subject: "self", + event_kind: item.eventKind ?? item.domain, domain: item.domain, occurred_from: item.occurredFrom, occurred_to: item.occurredTo, date_precision: item.datePrecision, - summary: item.summary, + summary: item.summary ?? item.domain, status: item.status, supersedes_evidence_id: null, created_at: "2026-09-06T00:00:00.000Z", @@ -403,7 +432,7 @@ function rpcDossier(decision: DecisionDossier) { selectionAllowed: decision.latestResult?.selectionAllowed ?? true, confirmationAllowed: false, representativeTime: "04:51", - evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never), + evidenceLedgerFingerprint: evidenceLedgerFingerprint(decision.evidence as never), candidates: decision.latestResult?.candidates?.map((item, index) => ({ candidate_id: item.candidateId ?? uuidAt(index), time: item.time, @@ -416,7 +445,7 @@ function rpcDossier(decision: DecisionDossier) { conversationSummary: { confirmed_evidence_summary: [], pending_revisions: [], - active_focus: null, + active_focus: extra.activeFocus ?? null, declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, candidate_divergence_summary: null, missing_evidence_categories: [], @@ -427,18 +456,107 @@ function rpcDossier(decision: DecisionDossier) { }); } -function idleHandlers(decision: DecisionDossier) { +function idleHandlers(decision: DecisionDossier, extra: { activeFocus?: ReturnType; allowFocus?: boolean } = {}) { return fakeAccounting({ ...receiptHandlers, - get_agentic_rectification_case_dossier: () => rpcDossier(decision), + get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra), get_agentic_rectification_case_compute: () => computeFixture(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), - set_agentic_rectification_conversation_focus: (_fn, args) => { - throw new Error(`must not persist collect focus ${String(args.p_question_id ?? args.p_target_domain)}`); + apply_agentic_rectification_choice_action: (_fn, args) => ({ + action_id: args.p_action_id, + status: "applied", + idempotent: false, + question_id: args.p_question_id, + option_id: args.p_option_id, + probe_id: args.p_inference && typeof args.p_inference === "object" + ? (args.p_inference as { probe_id?: string }).probe_id ?? "p-cd" + : "p-cd", + revision: Number(args.p_expected_revision) + 1, + source_quote: args.p_source_quote, + derived_context: args.p_derived_context, + narration: args.p_narration, + focus_status: args.p_focus_status, + }), + set_agentic_rectification_conversation_focus: extra.allowFocus + ? (_fn, args) => ({ + focus: { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: args.p_target_evidence_id, + target_domain: args.p_target_domain, + target_kind: args.p_target_kind, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-06T00:00:00.000Z", + resolved_at: null, + asked_turn_id: args.p_asked_turn_id ?? null, + }, + idempotent: false, + }) + : (_fn, args) => { + throw new Error(`must not persist collect focus ${String(args.p_question_id ?? args.p_target_domain)}`); + }, + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), + get_agentic_rectification_turn_receipt: () => null, + }); +} + +function leftoverFocus(probe: ConflictProbe) { + return activeFocusFixture({ + intent: "distinguish_candidates", + targetDomain: probe.domain, + questionId: `probe:${probe.semantic_key}`, + expectedAnswerSchema: { + choice: { + prompt: probe.question, + option_a: EXISTENCE_OPTIONS[0].label, + option_b: EXISTENCE_OPTIONS[1].label, + option_c: EXISTENCE_OPTIONS[2].label, + option_d: EXISTENCE_OPTIONS[3].label, + options: EXISTENCE_OPTIONS.map((option, index) => ({ + key: (["A", "B", "C", "D"] as const)[index]!, + label: option.label, + answer_class: option.answer_class, + })), + }, + probe_id: probe.id, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, }, }); } +function assistantAppendCalls(calls: Array<{ fn: string; args: Record }>) { + return calls.filter((item) => ( + item.fn === "append_agentic_rectification_turn" + && typeof item.args.p_assistant_message === "string" + && String(item.args.p_assistant_message).trim() + )); +} + +function gateAppendCalls(calls: Array<{ fn: string; args: Record }>) { + return assistantAppendCalls(calls).filter((item) => GATE_SENTENCE.test(String(item.args.p_assistant_message))); +} + +function gateBodyCount(text: string | null | undefined) { + return (text ?? "").match(new RegExp(GATE_SENTENCE.source, "g"))?.length ?? 0; +} + +function fakeAgentStream(chunks: Array<{ type: string; payload?: Record }>) { + const streamResult = { + fullStream: (async function* () { + for (const item of chunks) yield item; + })(), + totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + }; + return { + stream: async () => streamResult, + getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }), + }; +} + function warnLines(run: () => Promise | unknown) { const lines: string[] = []; const original = console.warn; @@ -477,14 +595,24 @@ test("USER_COLLECT_QUESTION.other remains only on the opening collect path", () assert.ok(hits.some((line) => line.includes("method-followup.ts")), hits.join("\n")); }); -test("collect spoken stop button and range line share CHOICE_STOP_LABEL", () => { +test("collect spoken stop button keeps CHOICE_STOP_LABEL; range line is status only", () => { + // 原值: 口述停止按钮和范围小字共用 CHOICE_STOP_LABEL / onStop + // 新值: 仅口述态 `.rectification-collect-stop` 使用该文案;范围行是 status 句,没有 onStop + // 原因: BUG-567 范围小字伪装成停止按钮 const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); + const rangeFn = chat.slice( + chat.indexOf("function RectificationReadonlyRange"), + chat.indexOf("function RectificationCandidateCards"), + ); assert.match(chat, /rectification-collect-stop/); assert.match(chat, /CHOICE_STOP_LABEL/); assert.match(chat, /kind === "collect_spoken"/); assert.match(chat, /function submitStop/); assert.match(chat, /RectificationReadonlyRange/); + assert.match(rangeFn, /role="status"/); + assert.doesNotMatch(rangeFn, /onStop/); + assert.doesNotMatch(rangeFn, /