fix(rectification): persist refreshed probes only when the engine has new questions (BUG-655)
Empty refreshes were writing inference rows, and GET-selected probe keys could miss inference_state, so persist rejected the next card. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# 印度占星 Skill 更新日志
|
# 印度占星 Skill 更新日志
|
||||||
|
|
||||||
|
## 2026-09-12 — 生时校正刷新只有真有新题才写库
|
||||||
|
|
||||||
|
带年月选择题问完后若引擎没有新的可问题,不会再写空的推理记录,也不会改掉当前候选集。新并入的题号跟已经答过的题用同一套命名,避免下一问在页面上选中了、落库时被拒绝。Skill 版本不变。
|
||||||
|
|
||||||
|
|
||||||
## 2026-09-11 — 带年月题问完还会按剩下的候选再问、再请你补事
|
## 2026-09-11 — 带年月题问完还会按剩下的候选再问、再请你补事
|
||||||
|
|
||||||
生时校正里,带年月选择题问完、范围还没收窄到可以结束时,会先按剩下的候选再出一批带年月题;如果还是没有可问的,会点名还能把两端分开的那几条线(比如家里添丁、收入明显变过),请你记得哪件说哪件。你说「没有了」之后才给出目前范围。卡片标题是「目前范围」,下面会写还能再收窄什么。不会把「题问完」当成已经结束。Skill 10.0.24。
|
生时校正里,带年月选择题问完、范围还没收窄到可以结束时,会先按剩下的候选再出一批带年月题;如果还是没有可问的,会点名还能把两端分开的那几条线(比如家里添丁、收入明显变过),请你记得哪件说哪件。你说「没有了」之后才给出目前范围。卡片标题是「目前范围」,下面会写还能再收窄什么。不会把「题问完」当成已经结束。Skill 10.0.24。
|
||||||
|
|||||||
+18
-2
@@ -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` 不再因先验丢弃,只用于排序。
|
- 修复:池空且未收敛时 `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` 刷新上限与家人月级边界。
|
- 验证:`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)。
|
- 防复发:刷新必须走现有 `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(池空即交付,未先按剩余候选再出题)
|
- 复发自:BUG-651(池空即交付,未先按剩余候选再出题)
|
||||||
- 修复版本:待发布
|
- 修复版本:待发布
|
||||||
|
|
||||||
@@ -10161,7 +10161,7 @@
|
|||||||
- 修复:刷新后仍无带年月题则出定向补事口述题(剩余换升层映射到领域,≥2 个具体例子,不带推算年份)。答新事则重算回 S2;答「没有了」才交付。交付条件改为收敛,或刷新与定向补事都用尽,或用户主动停。卡片标题「目前范围」,卡下「还能再收窄」取定向补事首条。禁用「这次给出」「最终」。
|
- 修复:刷新后仍无带年月题则出定向补事口述题(剩余换升层映射到领域,≥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` 禁词。
|
- 验证:`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)。
|
- 防复发:`!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(池空即交付,未做定向补事)
|
- 复发自:BUG-651(池空即交付,未做定向补事)
|
||||||
- 修复版本:待发布
|
- 修复版本:待发布
|
||||||
|
|
||||||
@@ -10180,3 +10180,19 @@
|
|||||||
- 相关记录:BUG-651、BUG-627、BUG-442
|
- 相关记录:BUG-651、BUG-627、BUG-442
|
||||||
- 复发自:BUG-627(`ensureNonTerminalTurnExit` 只在点「接着问」时触发,不在答题事务里)
|
- 复发自:BUG-627(`ensureNonTerminalTurnExit` 只在点「接着问」时触发,不在答题事务里)
|
||||||
- 修复版本:`66f63c76`
|
- 修复版本:`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:<semantic_key>` 或带 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(刷新总会写库,未校验新题与候选集)
|
||||||
|
- 修复版本:待发布
|
||||||
|
|||||||
@@ -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:<semantic_key>` 或带 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 最后一行
|
||||||
@@ -28,12 +28,24 @@
|
|||||||
期望:
|
期望:
|
||||||
|
|
||||||
- 第六题答完不得直接出卡;先出现新的带年月题(家人 / 财务 / 迁居等换运边界),或一条定向补事口述题
|
- 第六题答完不得直接出卡;先出现新的带年月题(家人 / 财务 / 迁居等换运边界),或一条定向补事口述题
|
||||||
|
- 刷新后若引擎给出新的带年月题,下一张卡必须真正出现;不得只是决策选中了探针、落库被拒后停在「没有拿到下一个问题」
|
||||||
- 答一件家人事后应重算并回到带年月题;答「没有了 / 就这些」后才出卡
|
- 答一件家人事后应重算并回到带年月题;答「没有了 / 就这些」后才出卡
|
||||||
- 卡片标题为「目前范围 …(对照了 N 件经历)」,卡下有「还能再收窄:如果记得 …」
|
- 卡片标题为「目前范围 …(对照了 N 件经历)」,卡下有「还能再收窄:如果记得 …」
|
||||||
- 不得出现「这次给出」「最终」「没有拿到下一个问题」
|
- 不得出现「这次给出」「最终」「没有拿到下一个问题」
|
||||||
- 不得先出 D9/D10 性格对照卡挡住结果
|
- 不得先出 D9/D10 性格对照卡挡住结果
|
||||||
- 时间线有卡时写「选择题已问完,下面是当前范围」,不得写「才会变」
|
- 时间线有卡时写「选择题已问完,下面是当前范围」,不得写「才会变」
|
||||||
|
|
||||||
|
## 0c. 刷新后下一问必须落下(GET 选中的探针不得被持久化拒绝)
|
||||||
|
|
||||||
|
同一条虚构序列走到第六题之后。
|
||||||
|
|
||||||
|
期望:
|
||||||
|
|
||||||
|
- 引擎若没有新的带年月题,不得新写推理记录,界面走定向补事或已有载体
|
||||||
|
- 引擎若有新题,下一问必须出现;不得「GET 已有探针、界面没有下一问」
|
||||||
|
- 刷新不得把候选集改空或换成另一套 `candidate_set_id`
|
||||||
|
|
||||||
|
|
||||||
## 1. 范围:家人说两点到四点
|
## 1. 范围:家人说两点到四点
|
||||||
|
|
||||||
资料:家人记得大概时间,钟点 15:00,范围「前后一小时」。地点任意公开城市。
|
资料:家人记得大概时间,钟点 15:00,范围「前后一小时」。地点任意公开城市。
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { probeFromEngine } from "../core/probes-from-engine.ts";
|
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 { askedDiscriminatorKeys, previousInferenceFromReceipt } from "./inference-adapter.ts";
|
||||||
import {
|
import {
|
||||||
runV9CandidateScore,
|
runV9CandidateScore,
|
||||||
@@ -71,21 +71,55 @@ function mergeEventProbes(
|
|||||||
return [...byKey.values()];
|
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(
|
function mergeConflictProbes(
|
||||||
existing: readonly ConflictProbe[],
|
existing: readonly ConflictProbe[],
|
||||||
incoming: readonly ConflictProbe[],
|
incoming: readonly ConflictProbe[],
|
||||||
|
answered: readonly ProbeAnswer[] = [],
|
||||||
): ConflictProbe[] {
|
): ConflictProbe[] {
|
||||||
const byKey = new Map<string, ConflictProbe>();
|
const byKey = new Map<string, ConflictProbe>();
|
||||||
for (const probe of existing) {
|
for (const probe of existing) {
|
||||||
byKey.set(probe.semantic_key, probe);
|
byKey.set(probe.semantic_key, probe);
|
||||||
}
|
}
|
||||||
for (const probe of incoming) {
|
for (const probe of incoming) {
|
||||||
if (probe.year <= 0 || probe.choice_kind === "varga_style" || probe.source === "nakshatra_boundary") {
|
if (!isRefreshableDatedConflict(probe)) continue;
|
||||||
continue;
|
const next = alignedIncomingProbe(probe, answered);
|
||||||
}
|
const current = byKey.get(next.semantic_key);
|
||||||
const current = byKey.get(probe.semantic_key);
|
if (!current || next.information_gain > current.information_gain) {
|
||||||
if (!current || probe.information_gain > current.information_gain) {
|
byKey.set(next.semantic_key, next);
|
||||||
byKey.set(probe.semantic_key, probe);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...byKey.values()];
|
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[] {
|
function activeCandidateTimes(state: InferenceState): string[] {
|
||||||
return [...new Set(
|
return [...new Set(
|
||||||
state.candidates
|
state.candidates
|
||||||
@@ -166,7 +229,7 @@ async function defaultRefreshDiscriminatorProbes(
|
|||||||
state: withRefreshCount(
|
state: withRefreshCount(
|
||||||
input.state,
|
input.state,
|
||||||
nextCount,
|
nextCount,
|
||||||
mergeConflictProbes(input.state.probes, incoming),
|
mergeConflictProbes(input.state.probes, incoming, input.state.answered_probes),
|
||||||
),
|
),
|
||||||
eventProbes,
|
eventProbes,
|
||||||
candidateSetId: input.state.candidate_set_id,
|
candidateSetId: input.state.candidate_set_id,
|
||||||
@@ -244,6 +307,40 @@ function shouldRefreshDatedPool(input: {
|
|||||||
return true;
|
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: {
|
async function persistRefreshedInference(input: {
|
||||||
accounting: AccountingClient;
|
accounting: AccountingClient;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -251,9 +348,22 @@ async function persistRefreshedInference(input: {
|
|||||||
dossier: DecisionDossier;
|
dossier: DecisionDossier;
|
||||||
previous: InferenceState;
|
previous: InferenceState;
|
||||||
next: InferenceState;
|
next: InferenceState;
|
||||||
}): Promise<void> {
|
askableCount: number;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
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);
|
const last = input.next.answered_probes.at(-1);
|
||||||
if (!last) return;
|
if (!last) return false;
|
||||||
const evidenceFp = input.dossier.latestResult?.evidenceLedgerFingerprint
|
const evidenceFp = input.dossier.latestResult?.evidenceLedgerFingerprint
|
||||||
?? evidenceLedgerFingerprint(input.dossier.evidence as never);
|
?? evidenceLedgerFingerprint(input.dossier.evidence as never);
|
||||||
try {
|
try {
|
||||||
@@ -282,12 +392,14 @@ async function persistRefreshedInference(input: {
|
|||||||
idempotencyKey: `refresh_probes:${input.next.candidate_set_id}:${input.next.refresh_count ?? 1}`,
|
idempotencyKey: `refresh_probes:${input.next.candidate_set_id}:${input.next.refresh_count ?? 1}`,
|
||||||
candidateSetId: input.next.candidate_set_id,
|
candidateSetId: input.next.candidate_set_id,
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[rectification-v9] persist refreshed probes failed case=${input.caseId} reason=${
|
`[rectification-v9] persist refreshed probes failed case=${input.caseId} reason=${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,17 +433,44 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
|
|||||||
dossier: input.dossier,
|
dossier: input.dossier,
|
||||||
state,
|
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,
|
accounting: input.accounting,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
caseId: input.caseId,
|
caseId: input.caseId,
|
||||||
dossier: input.dossier,
|
dossier: input.dossier,
|
||||||
previous: state,
|
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 {
|
return {
|
||||||
dossier: applyRefreshedProbesToDossier(input.dossier, result.state, result.eventProbes),
|
dossier: applyRefreshedProbesToDossier(input.dossier, nextState, persistableEventProbes),
|
||||||
state: result.state,
|
state: nextState,
|
||||||
refreshed: true,
|
refreshed: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
buildNextUserAction,
|
buildNextUserAction,
|
||||||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||||
import {
|
import {
|
||||||
|
alignedProbeId,
|
||||||
|
refreshDatedDiscriminatorPoolIfNeeded,
|
||||||
resetRefreshDiscriminatorProbesForTests,
|
resetRefreshDiscriminatorProbesForTests,
|
||||||
setRefreshDiscriminatorProbesForTests,
|
setRefreshDiscriminatorProbesForTests,
|
||||||
} from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts";
|
} from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts";
|
||||||
@@ -471,6 +473,7 @@ function rpcDossier(decision: DecisionDossier, extra: {
|
|||||||
function idleHandlers(decision: DecisionDossier, extra: {
|
function idleHandlers(decision: DecisionDossier, extra: {
|
||||||
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
|
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
|
||||||
throwOnFocus?: boolean;
|
throwOnFocus?: boolean;
|
||||||
|
transitions?: Record<string, unknown>[];
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return fakeAccounting({
|
return fakeAccounting({
|
||||||
...receiptHandlers,
|
...receiptHandlers,
|
||||||
@@ -513,13 +516,27 @@ function idleHandlers(decision: DecisionDossier, extra: {
|
|||||||
}),
|
}),
|
||||||
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
|
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
|
||||||
get_agentic_rectification_turn_receipt: () => null,
|
get_agentic_rectification_turn_receipt: () => null,
|
||||||
append_agentic_rectification_inference_transition: (_fn, args) => ({
|
append_agentic_rectification_inference_transition: (_fn, args) => {
|
||||||
result_id: "55555555-5555-4555-8555-555555555555",
|
extra.transitions?.push(args as Record<string, unknown>);
|
||||||
revision: Number(args.p_expected_revision ?? 0) + 1,
|
const inference = args.p_inference_state as {
|
||||||
idempotent: false,
|
candidates?: unknown[];
|
||||||
decision_receipt: {},
|
candidate_set_id?: string;
|
||||||
decision_state_fingerprint: args.p_decision_state_fingerprint,
|
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 ?? "", /这次给出|最终/);
|
||||||
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<string, unknown>[] = [];
|
||||||
|
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<string, unknown>[] = [];
|
||||||
|
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<string, unknown>[] = [];
|
||||||
|
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<string, unknown>[] = [];
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user