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, /