diff --git a/CHANGELOG.md b/CHANGELOG.md index f5100c87..02ad05e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-12 — 生时校正刷新只有真有新题才写库 + +带年月选择题问完后若引擎没有新的可问题,不会再写空的推理记录,也不会改掉当前候选集。新并入的题号跟已经答过的题用同一套命名,避免下一问在页面上选中了、落库时被拒绝。Skill 版本不变。 + + ## 2026-09-11 — 带年月题问完还会按剩下的候选再问、再请你补事 生时校正里,带年月选择题问完、范围还没收窄到可以结束时,会先按剩下的候选再出一批带年月题;如果还是没有可问的,会点名还能把两端分开的那几条线(比如家里添丁、收入明显变过),请你记得哪件说哪件。你说「没有了」之后才给出目前范围。卡片标题是「目前范围」,下面会写还能再收窄什么。不会把「题问完」当成已经结束。Skill 10.0.24。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f03b8b80..2c238fbb 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10145,7 +10145,7 @@ - 修复:池空且未收敛时 `refreshDiscriminatorProbes` 用活跃候选 `column_times`、全部已答键 `asked_probe_keys`、`refresh_probes=true` 调引擎,探针并入 `inference_state.probes`,不改 `candidate_set_id` / 已答题。每个候选集最多刷新 2 次。剩余候选 ≤5 时刷新上限 12/4。带月份的家人 `dasha_boundary` 不再因先验丢弃,只用于排序。 - 验证:`frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts` T0/T1;`tests/test_candidate_discriminator_contract.py` 刷新上限与家人月级边界。 - 防复发:刷新必须走现有 `keepAnswers` 回放路径(BUG-587 / BUG-594);`asked_probe_keys` 带全部已答键,同域同年去重(BUG-559)。性格题不得进刷新池(BUG-651)。 -- 相关记录:BUG-651、BUG-654、BUG-629、BUG-559、BUG-587、BUG-594 +- 相关记录:BUG-651、BUG-654、BUG-655、BUG-629、BUG-559、BUG-587、BUG-594 - 复发自:BUG-651(池空即交付,未先按剩余候选再出题) - 修复版本:待发布 @@ -10161,7 +10161,7 @@ - 修复:刷新后仍无带年月题则出定向补事口述题(剩余换升层映射到领域,≥2 个具体例子,不带推算年份)。答新事则重算回 S2;答「没有了」才交付。交付条件改为收敛,或刷新与定向补事都用尽,或用户主动停。卡片标题「目前范围」,卡下「还能再收窄」取定向补事首条。禁用「这次给出」「最终」。 - 验证:`frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts` T3/T4;`rectification-collection-question-pool.test.ts` 定向补事;`agent-voice-copy-contract.test.ts` 禁词。 - 防复发:`!separation.sufficient && !probe` 在 `refreshExhausted && targetedCollectExhausted` 之前不得 `completeWithRange` / `finish`。不得静默空载体(BUG-652)。 -- 相关记录:BUG-653、BUG-651、BUG-652、BUG-629 +- 相关记录:BUG-653、BUG-651、BUG-652、BUG-655、BUG-629 - 复发自:BUG-651(池空即交付,未做定向补事) - 修复版本:待发布 @@ -10180,3 +10180,19 @@ - 相关记录:BUG-651、BUG-627、BUG-442 - 复发自:BUG-627(`ensureNonTerminalTurnExit` 只在点「接着问」时触发,不在答题事务里) - 修复版本:`66f63c76` + +## BUG-655 | 刷新无新题不得写推理账本;并入探针须与已答同名 + +- 状态:resolved +- 首次发现:2026-09-11 +- 最近更新:2026-09-12 +- 影响面:`refreshDatedDiscriminatorPoolIfNeeded`、`persistV9InferenceState`、GET `decideFromDossier` 与 `persistServerOwnedFocus` +- 用户现象:带年月题问完后刷新,GET 已选出下一道探针,但持久化层拒绝,界面再次停在没有下一问。 +- 触发条件:训练门已开、带年月池空、刷新调用引擎后没有可并入的新题,或并入探针 id 与已答 id 命名不一致,或写入时候选被清空 / 候选集被改掉。 +- 根因:刷新在引擎没有新题时仍写 `reason=supersede` 的推理记录;GET 侧可能选中未映射进 `inference_state.probes` 的引擎探针,`expectedAnswerSchemaFor` 盖不上 `probe_id` 后 persist 返回 `invalid_choice_schema` / `skipped`。 +- 修复:只在引擎映射出可问新题时写库;写入前断言候选非空且 `candidate_set_id` 不变,候选沿用刷新前集合;并入探针 id 与已答同一规则(`probe:` 或带 split hash);刷新只把可问探针写进 `inference_state.probes` 和 receipt,GET 选中的 key 必须能 stamp 上 `probe_id`。 +- 验证:`frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts` T0 打印最后一行推理记录与 GET 探针 key / persist 状态;T3 空刷新与候选集变更不写库。 +- 防复发:刷新写库必须同时满足新题、候选非空、候选集不变、探针 id 与已答同名。不得靠客户端「接着问」补救(BUG-652)。 +- 相关记录:BUG-653、BUG-654、BUG-652、BUG-587、BUG-594 +- 复发自:BUG-653(刷新总会写库,未校验新题与候选集) +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-targeted-collect-persist-20260911.md b/docs/tasks/PROGRESS-rectification-targeted-collect-persist-20260911.md new file mode 100644 index 00000000..785fd3d2 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-targeted-collect-persist-20260911.md @@ -0,0 +1,35 @@ +# 进度 · 刷新只在真有新题时写库,GET 选中探针必须落下(2026-09-12) + +## 范围 + +- 分支:`codex/rectification-targeted-collect-persist-20260911`(基于 `origin/staging` @ `6c9a0896`) +- 任务单 `TASK-rectification-targeted-collect-persist-20260911.md` 不在本仓 / 远端;按用户补进 T0/T3 的条款执行 +- BUG-655:刷新无新题不得写推理账本;并入探针须与已答同名;GET 选中的 key 必须能 stamp 上 `probe_id` +- 不改确认门、`SCORE_DELTA`、Skill 版本 + +## T0 取证(夹具,不含真实案例 ID) + +任务单 §1b 快照文件未找到。用与 0b 相同的虚构第六题后状态取证: + +- 未映射进 `inference_state.probes` 时:GET key `family.2018.05.dasha_boundary`,persist `invalid_choice_schema` +- 刷新并入后最后一行推理记录:`reason=supersede`,`candidates=6`,`candidate_set_id=04:48-05:07:04:48,04:53,04:54,04:59,05:06,05:07`,`refresh_count=1` +- 并入后 GET 同一 key,persist `created` + +## 完成 + +- 刷新只在映射出可问新题时写库;空题 / 空候选 / `candidate_set_id` 变更都 `rectification_refresh_persist_skipped` +- 并入探针 id 跟已答同一规则:`probe:` 或带 split hash +- 引擎只给 receipt 探针时也会映射进 `inference_state.probes` 再交给 GET +- BUG-655、CHANGELOG、场景 0b/0c + +## 验收(本机,未部署) + +- `frontend` `tsc --noEmit` 0 错 +- eslint 变更文件 0 error +- `rectification-probe-pool-exhausted-20260911.test.ts` 10 + `rectification-server-focus.test.ts` 20 + `rectification-answer-choice.test.ts` 34:**64 / 64** +- Python 未改。`run_quality_gate.py --profile quick`:733 passed / 1 failed / 1 skipped。失败项是既有 `test_shadbala_endpoint_returns_ranked_planet_strength`(`core_sixfold` vs 含 `advanced_evidence`),与本单刷新持久化无关 + +## 未做 + +- 未部署后真机按 0c 走一遍 +- 任务单原文与 §1b 真机快照未入库,无法查真实 Case 最后一行 diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index b3642673..c85aa963 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -28,12 +28,24 @@ 期望: - 第六题答完不得直接出卡;先出现新的带年月题(家人 / 财务 / 迁居等换运边界),或一条定向补事口述题 +- 刷新后若引擎给出新的带年月题,下一张卡必须真正出现;不得只是决策选中了探针、落库被拒后停在「没有拿到下一个问题」 - 答一件家人事后应重算并回到带年月题;答「没有了 / 就这些」后才出卡 - 卡片标题为「目前范围 …(对照了 N 件经历)」,卡下有「还能再收窄:如果记得 …」 - 不得出现「这次给出」「最终」「没有拿到下一个问题」 - 不得先出 D9/D10 性格对照卡挡住结果 - 时间线有卡时写「选择题已问完,下面是当前范围」,不得写「才会变」 +## 0c. 刷新后下一问必须落下(GET 选中的探针不得被持久化拒绝) + +同一条虚构序列走到第六题之后。 + +期望: + +- 引擎若没有新的带年月题,不得新写推理记录,界面走定向补事或已有载体 +- 引擎若有新题,下一问必须出现;不得「GET 已有探针、界面没有下一问」 +- 刷新不得把候选集改空或换成另一套 `candidate_set_id` + + ## 1. 范围:家人说两点到四点 资料:家人记得大概时间,钟点 15:00,范围「前后一小时」。地点任意公开城市。 diff --git a/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts b/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts index 061f01bb..c7bc4a3d 100644 --- a/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts +++ b/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts @@ -7,7 +7,7 @@ */ import { probeFromEngine } from "../core/probes-from-engine.ts"; -import type { ConflictProbe, InferenceState } from "../core/types.ts"; +import type { ConflictProbe, InferenceState, ProbeAnswer } from "../core/types.ts"; import { askedDiscriminatorKeys, previousInferenceFromReceipt } from "./inference-adapter.ts"; import { runV9CandidateScore, @@ -71,21 +71,55 @@ function mergeEventProbes( return [...byKey.values()]; } +function isRefreshableDatedConflict(probe: ConflictProbe): boolean { + if (probe.year <= 0 || probe.choice_kind === "varga_style" || probe.source === "nakshatra_boundary") { + return false; + } + return true; +} + +function probeIdPatternFromAnswered(answered: readonly ProbeAnswer[]): "simple" | "hashed" { + for (const item of answered) { + if (!item.probe_id || !item.semantic_key) continue; + if (item.probe_id === `probe:${item.semantic_key}` || item.probe_id === item.semantic_key) { + return "simple"; + } + if ( + item.candidate_split_hash + && item.probe_id === `probe:${item.semantic_key}:${item.candidate_split_hash}` + ) { + return "hashed"; + } + } + return "simple"; +} + +export function alignedProbeId(probe: ConflictProbe, answered: readonly ProbeAnswer[]): string { + return probeIdPatternFromAnswered(answered) === "hashed" + ? `probe:${probe.semantic_key}:${probe.candidate_split_hash}` + : `probe:${probe.semantic_key}`; +} + +function alignedIncomingProbe(probe: ConflictProbe, answered: readonly ProbeAnswer[]): ConflictProbe { + const id = alignedProbeId(probe, answered); + return probe.id === id ? probe : { ...probe, id }; +} + function mergeConflictProbes( existing: readonly ConflictProbe[], incoming: readonly ConflictProbe[], + answered: readonly ProbeAnswer[] = [], ): ConflictProbe[] { const byKey = new Map(); for (const probe of existing) { byKey.set(probe.semantic_key, probe); } for (const probe of incoming) { - if (probe.year <= 0 || probe.choice_kind === "varga_style" || probe.source === "nakshatra_boundary") { - continue; - } - const current = byKey.get(probe.semantic_key); - if (!current || probe.information_gain > current.information_gain) { - byKey.set(probe.semantic_key, probe); + if (!isRefreshableDatedConflict(probe)) continue; + const next = alignedIncomingProbe(probe, answered); + const current = byKey.get(next.semantic_key); + if (!current || next.information_gain > current.information_gain) { + byKey.set(next.semantic_key, next); } } return [...byKey.values()]; @@ -103,6 +137,35 @@ function askedKeysFromState(state: InferenceState, dossier: DecisionDossier): st ])]; } +function mappedAskableProbes( + previous: InferenceState, + eventProbes: readonly DiscriminatingEventProbe[], + extra: readonly ConflictProbe[], + dossier: DecisionDossier, +): ConflictProbe[] { + const asked = new Set(askedKeysFromState(previous, dossier)); + const existingKeys = new Set(previous.probes.map((item) => item.semantic_key)); + const askable: ConflictProbe[] = []; + const incoming = [ + ...extra.filter(isRefreshableDatedConflict), + ...eventProbes.flatMap((probe) => { + if (!isRefreshableDatedProbe(probe)) return []; + const mapped = probeFromEngine(probe); + return mapped ? [mapped] : []; + }), + ]; + for (const probe of incoming) { + const aligned = alignedIncomingProbe(probe, previous.answered_probes); + if (existingKeys.has(aligned.semantic_key)) continue; + if (asked.has(aligned.semantic_key) || asked.has(aligned.id) || asked.has(aligned.candidate_split_hash)) { + continue; + } + existingKeys.add(aligned.semantic_key); + askable.push(aligned); + } + return askable; +} + function activeCandidateTimes(state: InferenceState): string[] { return [...new Set( state.candidates @@ -166,7 +229,7 @@ async function defaultRefreshDiscriminatorProbes( state: withRefreshCount( input.state, nextCount, - mergeConflictProbes(input.state.probes, incoming), + mergeConflictProbes(input.state.probes, incoming, input.state.answered_probes), ), eventProbes, candidateSetId: input.state.candidate_set_id, @@ -244,6 +307,40 @@ function shouldRefreshDatedPool(input: { return true; } +function refreshWriteBlockedReason( + previous: InferenceState, + result: RefreshDiscriminatorProbesResult, + askableCount: number, +): string | null { + if (askableCount <= 0) return "no_new_probes"; + if ( + previous.candidates.length === 0 + || !Array.isArray(result.state.candidates) + || result.state.candidates.length === 0 + ) { + return "empty_candidates"; + } + if ( + result.state.candidate_set_id !== previous.candidate_set_id + || result.candidateSetId !== previous.candidate_set_id + ) { + return "candidate_set_changed"; + } + return null; +} + +function refreshPersistBlockedReason(input: { + previous: InferenceState; + next: InferenceState; + askableCount: number; +}): string | null { + if (input.askableCount <= 0) return "no_new_probes"; + if (input.next.candidates.length === 0) return "empty_candidates"; + if (input.previous.candidates.length === 0) return "empty_candidates"; + if (input.next.candidate_set_id !== input.previous.candidate_set_id) return "candidate_set_changed"; + return null; +} + async function persistRefreshedInference(input: { accounting: AccountingClient; userId: string; @@ -251,9 +348,22 @@ async function persistRefreshedInference(input: { dossier: DecisionDossier; previous: InferenceState; next: InferenceState; -}): Promise { + askableCount: number; +}): Promise { + const blocked = refreshPersistBlockedReason(input); + if (blocked) { + console.warn(JSON.stringify({ + event: "rectification_refresh_persist_skipped", + case_id: input.caseId, + reason: blocked, + candidates: input.next.candidates.length, + candidate_set_id: input.next.candidate_set_id, + refresh_count: input.next.refresh_count ?? 0, + })); + return false; + } const last = input.next.answered_probes.at(-1); - if (!last) return; + if (!last) return false; const evidenceFp = input.dossier.latestResult?.evidenceLedgerFingerprint ?? evidenceLedgerFingerprint(input.dossier.evidence as never); try { @@ -282,12 +392,14 @@ async function persistRefreshedInference(input: { idempotencyKey: `refresh_probes:${input.next.candidate_set_id}:${input.next.refresh_count ?? 1}`, candidateSetId: input.next.candidate_set_id, }); + return true; } catch (error) { console.warn( `[rectification-v9] persist refreshed probes failed case=${input.caseId} reason=${ error instanceof Error ? error.message : String(error) }`, ); + return false; } } @@ -321,17 +433,44 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: { dossier: input.dossier, state, }); - await persistRefreshedInference({ + const askable = mappedAskableProbes(state, result.eventProbes, result.state.probes, input.dossier); + const blocked = refreshWriteBlockedReason(state, result, askable.length); + if (blocked) { + console.warn(JSON.stringify({ + event: "rectification_refresh_persist_skipped", + case_id: input.caseId, + reason: blocked, + candidates: result.state.candidates.length, + candidate_set_id: result.state.candidate_set_id, + refresh_count: state.refresh_count ?? 0, + })); + return { dossier: input.dossier, state, refreshed: false }; + } + const nextState: InferenceState = { + ...state, + probes: mergeConflictProbes(state.probes, askable, state.answered_probes), + refresh_count: (state.refresh_count ?? 0) + 1, + refresh_answer_count: state.answered_probes.length, + }; + const written = await persistRefreshedInference({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, dossier: input.dossier, previous: state, - next: result.state, + next: nextState, + askableCount: askable.length, + }); + if (!written) { + return { dossier: input.dossier, state, refreshed: false }; + } + const persistableEventProbes = result.eventProbes.filter((probe) => { + const key = probe.semantic_key?.trim() ?? `${probe.domain}.${probe.year}`; + return askable.some((item) => item.semantic_key === key); }); return { - dossier: applyRefreshedProbesToDossier(input.dossier, result.state, result.eventProbes), - state: result.state, + dossier: applyRefreshedProbesToDossier(input.dossier, nextState, persistableEventProbes), + state: nextState, refreshed: true, }; } diff --git a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts index f5bca2e0..b07b5fc3 100644 --- a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts +++ b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts @@ -27,6 +27,8 @@ import { buildNextUserAction, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { + alignedProbeId, + refreshDatedDiscriminatorPoolIfNeeded, resetRefreshDiscriminatorProbesForTests, setRefreshDiscriminatorProbesForTests, } from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts"; @@ -471,6 +473,7 @@ function rpcDossier(decision: DecisionDossier, extra: { function idleHandlers(decision: DecisionDossier, extra: { activeFocus?: ReturnType | null; throwOnFocus?: boolean; + transitions?: Record[]; } = {}) { return fakeAccounting({ ...receiptHandlers, @@ -513,13 +516,27 @@ function idleHandlers(decision: DecisionDossier, extra: { }), finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), get_agentic_rectification_turn_receipt: () => null, - append_agentic_rectification_inference_transition: (_fn, args) => ({ - result_id: "55555555-5555-4555-8555-555555555555", - revision: Number(args.p_expected_revision ?? 0) + 1, - idempotent: false, - decision_receipt: {}, - decision_state_fingerprint: args.p_decision_state_fingerprint, - }), + append_agentic_rectification_inference_transition: (_fn, args) => { + extra.transitions?.push(args as Record); + const inference = args.p_inference_state as { + candidates?: unknown[]; + candidate_set_id?: string; + refresh_count?: number; + } | undefined; + return { + result_id: "55555555-5555-4555-8555-555555555555", + revision: Number(args.p_expected_revision ?? 0) + 1, + idempotent: false, + decision_receipt: { + inference_state: args.p_inference_state, + }, + decision_state_fingerprint: args.p_decision_state_fingerprint, + reason: args.p_reason, + candidates: inference?.candidates?.length ?? 0, + candidate_set_id: args.p_candidate_set_id ?? inference?.candidate_set_id, + refresh_count: inference?.refresh_count ?? 0, + }; + }, }); } @@ -775,3 +792,206 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前 assert.doesNotMatch(idle.hostNarration ?? "", /这次给出|最终/); assert.doesNotMatch(idle.hostNarration ?? "", /平时做事|月宿性格/); }); + +test("T0: GET-selected receipt probe is rejected until refresh merges it into inference_state", async () => { + resetDeliveryTurnGuardForTests(); + resetRefreshDiscriminatorProbesForTests(); + const base = accidentDossier(6); + const mismatched = { + ...base, + latestResult: { + ...base.latestResult!, + decisionReceipt: { + ...(base.latestResult?.decisionReceipt ?? {}), + discriminating_event_probes: [ + ...((base.latestResult?.decisionReceipt?.discriminating_event_probes as DiscriminatingEventProbe[] | undefined) ?? []), + eventProbeRow(FAMILY_REFRESH), + ], + }, + }, + }; + const getDecision = decideFromDossier(mismatched, { birthDate: "1997-08-08" }); + const getPlan = followupPlan(mismatched, getDecision.sessionOutcome); + const getKey = getPlan.next_followup?.semantic_key ?? null; + const persist = await persistServerOwnedFocus({ + accounting: idleHandlers(mismatched).client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: mismatched.latestResult?.decisionReceipt ?? null, + followup: getPlan.next_followup, + }); + console.log("T0 GET probe vs persist rejection", { getKey, persistStatus: persist.status }); + assert.equal(getKey, FAMILY_REFRESH.semantic_key, String(getKey)); + assert.equal(persist.status, "invalid_choice_schema", persist.status); +}); + +test("T0: last inference row and GET probe key vs persist status after a real refresh", async () => { + resetDeliveryTurnGuardForTests(); + resetRefreshDiscriminatorProbesForTests(); + setRefreshDiscriminatorProbesForTests(async ({ state }) => { + const nextCount = (state.refresh_count ?? 0) + 1; + return { + state: { ...state, refresh_count: nextCount }, + eventProbes: [eventProbeRow(FAMILY_REFRESH)], + candidateSetId: state.candidate_set_id, + refreshCount: nextCount, + }; + }); + const dossier = accidentDossier(6); + const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); + const transitions: Record[] = []; + const accounting = idleHandlers(dossier, { transitions }); + const next = await persistNextInterviewAfterChoice({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + decisionState: liveState(6), + nextAction: publicNextAction(decision), + decision, + birthDate: "1997-08-08", + }); + const last = transitions.at(-1); + const inference = last?.p_inference_state as { + candidates?: unknown[]; + candidate_set_id?: string; + refresh_count?: number; + probes?: ReadonlyArray<{ id: string; semantic_key: string }>; + } | undefined; + const lastRow = { + reason: last?.p_reason, + candidates: inference?.candidates?.length ?? 0, + candidate_set_id: last?.p_candidate_set_id ?? inference?.candidate_set_id, + refresh_count: inference?.refresh_count ?? 0, + }; + console.log("T0 last inference row", lastRow); + assert.equal(lastRow.reason, "supersede"); + assert.equal(lastRow.candidates, TIMES.length); + assert.equal(lastRow.candidate_set_id, liveState(6).candidate_set_id); + assert.equal(lastRow.refresh_count, 1); + const merged = inference?.probes?.find((item) => item.semantic_key === FAMILY_REFRESH.semantic_key); + assert.equal( + merged?.id, + alignedProbeId(FAMILY_REFRESH, liveState(6).answered_probes), + merged?.id, + ); + const refreshedDossier = { + ...dossier, + latestResult: { + ...dossier.latestResult!, + decisionReceipt: { + ...(dossier.latestResult?.decisionReceipt ?? {}), + inference_state: inference, + discriminating_event_probes: [eventProbeRow(FAMILY_REFRESH)], + }, + }, + }; + const getDecision = decideFromDossier(refreshedDossier, { birthDate: "1997-08-08" }); + const getPlan = followupPlan(refreshedDossier, getDecision.sessionOutcome); + const getKey = getPlan.next_followup?.semantic_key ?? null; + const persist = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: refreshedDossier.latestResult?.decisionReceipt ?? null, + followup: getPlan.next_followup, + }); + console.log("T0 GET probe vs persist", { getKey, persistStatus: persist.status }); + assert.equal(getKey, FAMILY_REFRESH.semantic_key, String(getKey)); + assert.ok( + persist.status === "created" || persist.status === "already_open", + persist.status, + ); + assert.equal(next.choiceReady, true, next.hostNarration); + resetRefreshDiscriminatorProbesForTests(); +}); + +test("T3: refresh without new engine probes does not write inference", async () => { + resetDeliveryTurnGuardForTests(); + resetRefreshDiscriminatorProbesForTests(); + setRefreshDiscriminatorProbesForTests(async ({ state }) => ({ + state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 }, + eventProbes: [], + candidateSetId: state.candidate_set_id, + refreshCount: (state.refresh_count ?? 0) + 1, + })); + const dossier = accidentDossier(6); + const transitions: Record[] = []; + const idle = await persistNextInterviewIfIdle({ + accounting: idleHandlers(dossier, { transitions }).client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.equal(transitions.length, 0, JSON.stringify(transitions.at(-1) ?? {})); + assert.ok((idle.hostNarration ?? "").trim()); + resetRefreshDiscriminatorProbesForTests(); +}); + +test("T3: changed candidate set is not persisted even when engine returns a probe", async () => { + resetDeliveryTurnGuardForTests(); + resetRefreshDiscriminatorProbesForTests(); + setRefreshDiscriminatorProbesForTests(async ({ state }) => ({ + state: { ...state, candidate_set_id: "changed-set" }, + eventProbes: [eventProbeRow(FAMILY_REFRESH)], + candidateSetId: "changed-set", + refreshCount: 1, + })); + const dossier = accidentDossier(6); + const transitions: Record[] = []; + await persistNextInterviewAfterChoice({ + accounting: idleHandlers(dossier, { transitions }).client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + decisionState: liveState(6), + nextAction: publicNextAction(decideFromDossier(dossier, { birthDate: "1997-08-08" })), + birthDate: "1997-08-08", + }); + assert.equal(transitions.length, 0); + resetRefreshDiscriminatorProbesForTests(); +}); + +test("T3: empty candidate list is not persisted even when engine returns a probe", async () => { + resetDeliveryTurnGuardForTests(); + resetRefreshDiscriminatorProbesForTests(); + setRefreshDiscriminatorProbesForTests(async ({ state }) => ({ + state: { ...state, candidates: [] }, + eventProbes: [eventProbeRow(FAMILY_REFRESH)], + candidateSetId: state.candidate_set_id, + refreshCount: 1, + })); + const dossier = accidentDossier(6); + const transitions: Record[] = []; + const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({ + accounting: idleHandlers(dossier, { transitions }).client, + userId: USER_ID, + caseId: CASE_ID, + dossier, + state: liveState(6), + hasDatedProbe: false, + }); + assert.equal(refreshed.refreshed, false); + assert.equal(transitions.length, 0); + resetRefreshDiscriminatorProbesForTests(); +}); + +test("T3: merged probe ids follow the answered naming rule", () => { + const hashedIncoming = { + ...FAMILY_REFRESH, + id: `probe:${FAMILY_REFRESH.semantic_key}:${FAMILY_REFRESH.candidate_split_hash}`, + }; + assert.equal( + alignedProbeId(hashedIncoming, liveState(6).answered_probes), + `probe:${FAMILY_REFRESH.semantic_key}`, + ); + const hashedAnswers = liveState(6).answered_probes.map((item) => ({ + ...item, + probe_id: `probe:${item.semantic_key}:${item.candidate_split_hash}`, + })); + assert.equal( + alignedProbeId(FAMILY_REFRESH, hashedAnswers), + `probe:${FAMILY_REFRESH.semantic_key}:${FAMILY_REFRESH.candidate_split_hash}`, + ); +});