fix(rectification): replay answered probes after evidence rescore (BUG-587, BUG-588)
Carry answered probe defs into the new candidate set, replay them by minute, and announce range changes on evidence turns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-08 — 补经历后仍按已答对照题收范围,变宽会说出来
|
||||
|
||||
生时校正答完对照卡后再补一件经历,会把已经答过的题按分钟重算进去,不会因为候选分钟换了两个就把范围弹回开工窗口。如果这次比较让范围变了,旁白末尾会写「范围从 A–B 变为 C–D」。Skill 版本仍是 10.0.15。
|
||||
|
||||
## 2026-09-08 — 生时校正每一轮只说一段旁白,题目只出现在问题块
|
||||
|
||||
生时校正记下你刚说的事后,助手气泡里只留一段说明。下一问只出现在下面的问题块,不会在正文里再问一遍。Skill 版本仍是 10.0.15。
|
||||
|
||||
@@ -9094,3 +9094,51 @@
|
||||
- 复发自:BUG-558(同现象、不同来路;558 只堵了穷尽采集 `otherCollectFollowup`)
|
||||
- 修复版本:待发布(`codex/rectification-other-collect-fallback-20260908`)
|
||||
|
||||
## BUG-587 | 新证据重算后已答题无法重放,范围弹回搜索窗口
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-08
|
||||
- 最近更新:2026-09-08
|
||||
- 影响面:`buildInferenceState`、`buildCaseInferenceState`、分盘对照 `semantic_key`、证据轮 compare 后的 `inference_state`
|
||||
- 用户现象:答完多张对照卡后范围已经收到大约 8 分钟,再补一件带年月经历,交付时范围又回到大约 29 分钟,候选分数跟引擎 prior 一样。
|
||||
- 触发条件:已有 5 条点选答案;新证据触发 compare,引擎 9 个分钟换掉其中 2 个;本轮 `probes` 按 asked keys 排除已答题。
|
||||
- 根因:BUG-559 让引擎和对照包不再生成已答题。第一次重算还能从 `previous.probes` 找到定义,但新状态的 `probes` 不携带这些题。第二次重算时 `previous.probes` 也没有了,`if (!probe) continue` 把 5 条答案变成死账本,`rounds: []`、`posterior === prior`。分盘风格题的 `semantic_key` 还把分钟列表编进去,候选集一变 key 也对不上。BUG-581 的 `keepAnswers` 修的是停止路径同一根因的另一半。
|
||||
- 修复:已答题的定义随状态携带(`carried: true`),重放按候选分钟,新分钟落中性。分盘 `semantic_key` 改为星座分区,例如 `varga.d9.天秤座|天蝎座|射手座`,不再内嵌 `HH:MM`。`candidate_split_hash` 仍按当前候选集计算。
|
||||
- 验证:`frontend/tests/rectification-probe-replay-loss-20260908.test.ts`(连续两次重算仍 5 轮、未变分钟 delta 不变、新分钟 delta 0);指定 TS 切片 1000/0;`tsc --noEmit` 0。
|
||||
- 防复发:候选集变化后 `answered_probes` 对应的题目定义必须仍在 `state.probes`。新生成的分盘 `semantic_key` 不得匹配 `\d\d:\d\d`。`nextProbe` 不得把携带题再问一遍。
|
||||
- 相关记录:BUG-559、BUG-581、BUG-588、BUG-589
|
||||
- 复发自:BUG-559(去重删生成侧定义);BUG-581(停止路径已修,证据路径未修)
|
||||
- 修复版本:待提交(`codex/rectification-probe-replay-loss-20260908`)
|
||||
|
||||
## BUG-588 | 证据轮旁白不报范围变化
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-08
|
||||
- 最近更新:2026-09-08
|
||||
- 影响面:`runV9AgentTurn` 证据/比较轮结算、`withRangeChangedAfterEvidence`
|
||||
- 用户现象:范围从 8 分钟变回 29 分钟时,三轮证据旁白只说「记下了」,用户要到交付卡才看见变宽。
|
||||
- 触发条件:本轮调用了 `rectification-compare-candidates`,compare 前后 `credible_range` 不同。
|
||||
- 根因:点选题由服务端写「范围从 X 收到 Y」;证据轮旁白是模型写的,服务端不补范围句。
|
||||
- 修复:结算时先按 BUG-585 剪问句,再比较本轮 compare 前后的 `credible_range`。变了就把「范围从 A–B 变为 C–D。」接到正文末尾;没变不写。
|
||||
- 验证:`frontend/tests/rectification-probe-replay-loss-20260908.test.ts`;`frontend/tests/agent-voice-copy-contract.test.ts` 收录新句并锁 `agent-run` 先剪后接。
|
||||
- 防复发:证据轮只要跑过 compare 且范围变了,落库 `assistant_message` 必须含范围句。不得靠提示词让模型自己报。
|
||||
- 相关记录:BUG-585、BUG-587、BUG-569
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(`codex/rectification-probe-replay-loss-20260908`)
|
||||
|
||||
## BUG-589 | 交付/选择卡在最后一题出现前可能闪现
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现:2026-09-08
|
||||
- 最近更新:2026-09-08
|
||||
- 影响面:`showSelectionCards`、`loadCaseSnapshot`
|
||||
- 用户现象:最后一题的选择卡出现前一瞬,时间选择卡片弹出又消失。
|
||||
- 触发条件:用户报告发生在证据轮之后、下一张对照卡出现之前。按导出最终状态 `can_adopt=false`,正常结算后不应出卡。
|
||||
- 根因:未证实。可能是 `loadCaseSnapshot` 先写入 `candidateResult`,问题合并进消息是另一次渲染,中间 `interviewQuestionBlocksAdoptOffer` 看不到新问题。
|
||||
- 修复:源码加守卫:`showSelectionCards` 要求 `!busy && caseSnapshotLoaded`;快照与问题合并放进同一次 `startTransition`。不得声称已复现闪现。
|
||||
- 验证:`frontend/tests/rectification-probe-replay-loss-20260908.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts` 源码锁。无复现实验。
|
||||
- 防复发:选择卡不得在 `busy` 或快照未加载时出现。
|
||||
- 相关记录:BUG-587
|
||||
- 复发自:无
|
||||
- 修复版本:待提交(守卫已加,现象仍 investigating)
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# PROGRESS · 新证据重算后已答题全部失效(2026-09-08)
|
||||
|
||||
工作树:`.worktrees/rectification-probe-replay-loss-20260908`
|
||||
分支:`codex/rectification-probe-replay-loss-20260908`
|
||||
任务书:`docs/tasks/TASK-rectification-probe-replay-loss-20260908.md`
|
||||
基线:`origin/staging` @ `fec316b3`(代码头含 BUG-584/585 `9d1c08ca`、BUG-586 `9aec5029`)
|
||||
|
||||
本单状态:**待验收**。未 commit、未 push。
|
||||
|
||||
未改:采用门、确认门、`MIN_SEPARATION_LEAD`、`_relative_support`、`minute_step=1` 指纹、四选项合同、`SCORE_DELTA`、Skill 版本(仍 10.0.15)。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- **决策 1** `buildInferenceState` 把 `previous.probes` 里已答题且本轮没有同 key 的定义带进新状态,标 `carried: true`。重放循环改查合并后的 `probes`。
|
||||
- **决策 2** `sameSet=false` 时仍从答案账本重建 `rounds`(原循环)。`applyProbeOutcome` 不改;新分钟不在旧 supports/conflicts 里则 delta 0。
|
||||
- **决策 3** 分盘生成 key 改为 `vargaSignPartitionKey`:`varga.d9.天秤座|天蝎座|射手座`,不再内嵌分钟列表。缺星座时用 `varga.d9.unsigned`。已答旧 key 靠携带题命中。
|
||||
- **决策 4** `agent-run` 在本轮用过 `rectification-compare-candidates` 时,先 `stripQuestionSentences` 再接 `withRangeChangedAfterEvidence`。
|
||||
- **决策 5** `showSelectionCards` 增加 `!busy && caseSnapshotLoaded`;`loadCaseSnapshot` 把快照与消息合并放进同一次 `startTransition`。闪现未复现,BUG-589 写 investigating。
|
||||
|
||||
## 三栏(被触碰断言)
|
||||
|
||||
| 用例 | 原值 | 新值 | 理由 |
|
||||
| --- | --- | --- | --- |
|
||||
| 候选集换 2 分钟后 `rounds.length` | 0(找不到题定义) | 5 | 决策 1 携带已答题 |
|
||||
| 连续两次重算 posterior | 全员 `=== prior` | 7 个未变分钟保持原 delta,2 个新分钟 0 | 决策 2 |
|
||||
| 生成侧 D9 `semantic_key` | `varga.d9.04:50\|…/04:57` | `varga.d9.天秤座\|天蝎座\|射手座` | 决策 3 |
|
||||
| 证据轮旁白范围变化 | 无 | `范围从 04:50–04:57 变为 04:47–05:15。` | 决策 4 |
|
||||
| `showSelectionCards` | 不看 `busy` / 快照 | `!busy && caseSnapshotLoaded` | 决策 5,P3 未复现 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `frontend` `./node_modules/.bin/tsc --noEmit`:exit 0
|
||||
- 本单改动文件 eslint:0 error(`rectification-agentic-chat.tsx` 两处既有 hooks warning)
|
||||
- 任务书指定 TS 切片(`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):**1000 passed / 0 failed**
|
||||
- `.venv/bin/python -m pytest tests/test_rectification_v5_services.py -q`:其余绿;`test_block_scan_seven_events_finishes_within_fifteen_seconds` 本机 63.4s > 15s。这是既有壁钟门(BUG-570 P2),与 asked_probe_keys / 本单无关,记环境缺口
|
||||
- Docker `database-rectification-*.test.ts`:本机未跑,记 blocked
|
||||
- 浏览器真人走查:无登录态,见 `docs/testing/rectification-scenarios-20260907.md` §8
|
||||
|
||||
## 偏离
|
||||
|
||||
1. 验收形状写在新建 `frontend/tests/rectification-probe-replay-loss-20260908.test.ts`,没有只扩 `rectification-inference-machine.test.ts` / `rectification-stale-compare-fix-20260907.test.ts`。
|
||||
2. 分盘分区用中文星座名(`天秤座`)而不是任务书例子里的 `libra`,与现有 `window_scan.transitions` / 类型表一致;验收只要求不含 `\d\d:\d\d` 且同分区稳定。
|
||||
3. 决策 3 当天做了,没有后置为 P2。
|
||||
4. P3 闪现未复现,只加守卫,BUG-589 保持 investigating。
|
||||
@@ -74,7 +74,7 @@
|
||||
| `TASK-rectification-range-delivery-fix-20260907.md` | `PROGRESS-rectification-range-delivery-fix-20260907.md` | 区间卡修复单:`candidateResult` 判空提到最前(tsc 阻断);预测窗口接到采用后首轮或删句;「再补一件经历」隐藏卡后给采集提示;进度记录须贴 tsc 原文 | 待验收 | `codex/rectification-range-delivery-fix-20260907`(BUG-583) |
|
||||
| `TASK-rectification-duplicate-narration-20260907.md` | `PROGRESS-rectification-duplicate-narration-20260907.md` | 每轮采集旁白说两遍 + 题干在正文与问题块各一次:`step-answer` 的 set-focus 例外(BUG-533)放行了 set-focus 前后两个 step 的正文并拼接;`detachCollectSpokenAssistantText` 只剥逐字后缀,模型写进正文的问句没有代码守卫(BUG-488 防线只在提示词) | 待验收 | `codex/rectification-duplicate-narration-20260907`(BUG-584~585) |
|
||||
| `TASK-rectification-other-collect-fallback-20260908.md` | `PROGRESS-rectification-other-collect-fallback-20260908.md` | 七领域问完落到「也可以再说一件」、职业题从未出现、不自动出卡:`USER_COLLECT_QUESTION.other` 仍有三条活路(职业焦点 `target_domain` 压成 other 后被 active-focus 承接 followup 重建;set-focus 两次无效兜底查表;无领域采集归 other),`collect:other:*` 焦点挂着让 `persistNextInterviewIfIdle` 的出卡路径永远不跑 | 待验收(2.2 触发链仍 investigating) | `codex/rectification-other-collect-fallback-20260908`(BUG-586) |
|
||||
| `TASK-rectification-probe-replay-loss-20260908.md` | `PROGRESS-rectification-probe-replay-loss-20260908.md` | **P0** 收敛不了的直接原因:答完 5 道题范围收到 8 分钟,之后每补一件经历触发的重算换了候选集,`buildInferenceState` 找不到已答题定义(引擎与对照包按 asked keys 排除、状态 probes 不携带、分盘 key 内嵌分钟列表)→ `rounds:[]`、posterior=prior,交付回到 29 分钟;证据轮旁白不报范围变化;交付卡闪现待证实 | 待执行 | `codex/rectification-probe-replay-loss-20260908`(BUG-587~588) |
|
||||
| `TASK-rectification-probe-replay-loss-20260908.md` | `PROGRESS-rectification-probe-replay-loss-20260908.md` | **P0** 收敛不了的直接原因:答完 5 道题范围收到 8 分钟,之后每补一件经历触发的重算换了候选集,`buildInferenceState` 找不到已答题定义(引擎与对照包按 asked keys 排除、状态 probes 不携带、分盘 key 内嵌分钟列表)→ `rounds:[]`、posterior=prior,交付回到 29 分钟;证据轮旁白不报范围变化;交付卡闪现待证实 | 待验收 | `codex/rectification-probe-replay-loss-20260908`(BUG-587~588;P3 闪现仍 investigating) |
|
||||
| `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 |
|
||||
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) |
|
||||
|
||||
|
||||
@@ -114,3 +114,15 @@
|
||||
- 题干只出现在同一条消息的问题块里一次
|
||||
- 正文不以问号结尾,也不把下一题再问一遍
|
||||
|
||||
## 8. 答完点选题再补经历,范围不得无故变宽
|
||||
|
||||
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
|
||||
|
||||
开场后说到能出对照卡,答完几张分盘风格或大运边界卡,等到旁白报出已经收到大约 8 分钟。然后再补一件带年月的虚构经历(财务或迁居均可)。
|
||||
|
||||
期望:
|
||||
|
||||
- 补经历后的范围宽度不得超过答题后宽度加上新增的分钟数
|
||||
- 若范围变了,证据轮旁白必须出现「范围从 … 变为 …」
|
||||
- 不得出现:只说「记下了」,交付时范围弹回大约半小时,候选分数跟没答过题一样
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { startTransition, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useQueuedMessage } from "@/hooks/use-queued-message";
|
||||
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -635,7 +635,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCaseSnapshot = useCallback(async (): Promise<{
|
||||
const loadCaseSnapshot = useCallback(async (
|
||||
mergeMessages?: (turns: readonly unknown[]) => void,
|
||||
): Promise<{
|
||||
question: CurrentQuestionModel | null;
|
||||
turns: readonly unknown[];
|
||||
} | null | undefined> => {
|
||||
@@ -650,10 +652,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if (!response.ok) return undefined;
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (controller.signal.aborted) return undefined;
|
||||
applyCaseSnapshot(payload);
|
||||
const turns = snapshotTurns(payload);
|
||||
startTransition(() => {
|
||||
applyCaseSnapshot(payload);
|
||||
mergeMessages?.(turns);
|
||||
});
|
||||
return {
|
||||
question: currentQuestionFromSnapshot(payload?.current_question),
|
||||
turns: snapshotTurns(payload),
|
||||
turns,
|
||||
};
|
||||
} catch {
|
||||
// Snapshot refresh is best-effort; the durable Case remains on the server.
|
||||
@@ -960,10 +966,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
if (succeeded) {
|
||||
runOutcome = "succeeded";
|
||||
const snapshot = await loadCaseSnapshot();
|
||||
if (snapshot?.turns.length) {
|
||||
setMessages((current) => mergeTurnQuestions(current, snapshot.turns));
|
||||
}
|
||||
await loadCaseSnapshot((turns) => {
|
||||
if (turns.length) {
|
||||
setMessages((current) => mergeTurnQuestions(current, turns));
|
||||
}
|
||||
});
|
||||
onMessagesChange?.([
|
||||
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
|
||||
{ role: "assistant", text: parsed.text },
|
||||
@@ -1121,15 +1128,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
: "";
|
||||
if (payloadNextActionId) setNextUserActionId(payloadNextActionId);
|
||||
onCompleted?.();
|
||||
const snapshot = await loadCaseSnapshot();
|
||||
const turns = snapshot?.turns ?? [];
|
||||
if (willContinue) {
|
||||
// The follow-up turn continues on the row already in place: no removed
|
||||
// row, no effect hop, and `busy` never drops in between (so the next
|
||||
// card cannot flash before the turn hides it).
|
||||
setMessages((current) => mergeTurnQuestions(current, turns));
|
||||
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
|
||||
} else {
|
||||
await loadCaseSnapshot((turns) => {
|
||||
if (willContinue) {
|
||||
setMessages((current) => mergeTurnQuestions(current, turns));
|
||||
return;
|
||||
}
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
? payload.narration.trim()
|
||||
: "已记录你的选择。";
|
||||
@@ -1151,6 +1154,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
if (willContinue) {
|
||||
// The follow-up turn continues on the row already in place: no removed
|
||||
// row, no effect hop, and `busy` never drops in between (so the next
|
||||
// card cannot flash before the turn hides it).
|
||||
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
|
||||
} else {
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
? payload.narration.trim()
|
||||
: "已记录你的选择。";
|
||||
onMessagesChange?.([
|
||||
{ role: "assistant", text: narration },
|
||||
]);
|
||||
@@ -1400,6 +1413,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
|
||||
const showSelectionCards = Boolean(
|
||||
candidateResult
|
||||
&& caseSnapshotLoaded
|
||||
&& !busy
|
||||
&& selectionCardMessageKey
|
||||
&& (canOfferCards || Boolean(candidateResult.selectedTime))
|
||||
&& !messages.some((message) => (
|
||||
|
||||
@@ -55,7 +55,13 @@ export function buildInferenceState(input: {
|
||||
const previous = sameSet ? input.previous : null;
|
||||
const events = stickyHoldoutEvents(input.events, previous?.events);
|
||||
const holdoutKeys = holdoutDomainYears(events);
|
||||
const probes = input.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
|
||||
const liveProbes = input.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
|
||||
const probes = [
|
||||
...liveProbes,
|
||||
...carriedAnsweredProbes(input.previous, liveProbes).filter((probe) => (
|
||||
!holdoutKeys.has(`${probe.domain}:${probe.year}`)
|
||||
)),
|
||||
];
|
||||
const prior = Object.fromEntries(input.candidates.map((item) => [item.id, item.relative_support]));
|
||||
const trainingPrior = subtractHoldout(prior, input.candidates, events, input.event_ledger);
|
||||
const answers = mergeAnswers(input.previous?.answered_probes ?? [], input.answered_probes ?? []);
|
||||
@@ -73,8 +79,8 @@ export function buildInferenceState(input: {
|
||||
// Engine event scores already include dated evidence. A structured A/yes
|
||||
// choice is not an evidence row, so it must still move the posterior.
|
||||
if (answer.answer_class === "yes" && answer.classified_from === "evidence") continue;
|
||||
const probe = input.probes.find((item) => item.id === answer.probe_id)
|
||||
?? input.probes.find((item) => item.semantic_key === answer.semantic_key)
|
||||
const probe = probes.find((item) => item.id === answer.probe_id)
|
||||
?? probes.find((item) => item.semantic_key === answer.semantic_key)
|
||||
?? input.previous?.probes.find((item) => item.id === answer.probe_id)
|
||||
?? input.previous?.probes.find((item) => item.semantic_key === answer.semantic_key);
|
||||
if (!probe) continue;
|
||||
@@ -239,7 +245,9 @@ export function replayInferenceState(
|
||||
|
||||
export function nextProbe(state: InferenceState): ConflictProbe | null {
|
||||
const holdoutKeys = holdoutDomainYears(state.events);
|
||||
const probes = state.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`));
|
||||
const probes = state.probes.filter((probe) => (
|
||||
!probe.carried && !holdoutKeys.has(`${probe.domain}:${probe.year}`)
|
||||
));
|
||||
return selectHighestGainProbe(probes, state.answered_probes);
|
||||
}
|
||||
|
||||
@@ -328,6 +336,24 @@ function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswe
|
||||
});
|
||||
}
|
||||
|
||||
export function carriedAnsweredProbes(
|
||||
previous: InferenceState | null | undefined,
|
||||
current: readonly ConflictProbe[],
|
||||
): ConflictProbe[] {
|
||||
if (!previous) return [];
|
||||
const present = new Set(current.flatMap((probe) => [probe.id, probe.semantic_key]));
|
||||
return previous.probes.flatMap((probe) => {
|
||||
const answered = previous.answered_probes.some((item) => (
|
||||
item.probe_id === probe.id || item.semantic_key === probe.semantic_key
|
||||
));
|
||||
if (!answered) return [];
|
||||
if (present.has(probe.id) || present.has(probe.semantic_key)) return [];
|
||||
present.add(probe.id);
|
||||
present.add(probe.semantic_key);
|
||||
return [{ ...probe, carried: true }];
|
||||
});
|
||||
}
|
||||
|
||||
function mergeAnswers(previous: readonly ProbeAnswer[], incoming: readonly ProbeAnswer[]): ProbeAnswer[] {
|
||||
const rows = [...previous];
|
||||
for (const item of incoming) {
|
||||
|
||||
@@ -792,6 +792,21 @@ export function isStructuredDiscriminator(probe: Pick<CandidateDiscriminatorProb
|
||||
|| probe.semanticKey.startsWith("varga.");
|
||||
}
|
||||
|
||||
const CLOCK_TOKEN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
export function vargaSignPartitionKey(
|
||||
layer: string,
|
||||
signs: readonly (string | null | undefined)[],
|
||||
): string {
|
||||
const parts = signs.flatMap((item) => {
|
||||
const raw = typeof item === "string" ? item.trim() : "";
|
||||
if (!raw || CLOCK_TOKEN.test(raw)) return [];
|
||||
return [signKey(raw)];
|
||||
});
|
||||
if (parts.length >= 2) return `varga.${layer}.${parts.join("|")}`;
|
||||
return `varga.${layer}.unsigned`;
|
||||
}
|
||||
|
||||
function vargaProbes(
|
||||
remainingSplits: readonly RemainingVargaSplit[],
|
||||
candidateSetVersion: string,
|
||||
@@ -825,7 +840,7 @@ function vargaProbeFromRemaining(
|
||||
const outcomes = remainingOutcomes(split.groups, allMinutes, choiceKind);
|
||||
const ids = new Set(outcomes.flatMap((row) => [...row.supportsCandidateIds, ...row.conflictsCandidateIds]));
|
||||
if (outcomes.length < 2 || ids.size < 2) return null;
|
||||
const semanticKey = `varga.${split.layer}.${split.groups.map((group) => group.join("|")).join("/")}`;
|
||||
const semanticKey = vargaSignPartitionKey(split.layer, split.signs);
|
||||
const layerLabel = split.layer.toUpperCase();
|
||||
const domain = remainingDomain(split.layer);
|
||||
return {
|
||||
|
||||
@@ -91,7 +91,8 @@ function isConflictProbe(value: unknown): boolean {
|
||||
&& isFiniteNumber(value.information_gain)
|
||||
&& typeof value.source === "string" && value.source.length > 0
|
||||
&& (value.choice_kind === undefined
|
||||
|| (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind)));
|
||||
|| (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind)))
|
||||
&& (value.carried === undefined || value.carried === true || value.carried === false);
|
||||
}
|
||||
|
||||
function isProbeAnswer(value: unknown): boolean {
|
||||
|
||||
@@ -91,6 +91,8 @@ export type ConflictProbe = Readonly<{
|
||||
answer_class: AnswerClass;
|
||||
sign?: string;
|
||||
}>[];
|
||||
/** Kept only so an answered probe can be replayed after the candidate set changes. */
|
||||
carried?: boolean;
|
||||
}>;
|
||||
|
||||
export type ProbeAnswer = Readonly<{
|
||||
|
||||
@@ -189,6 +189,30 @@ export function formatClockRange(range: readonly [string, string] | null | undef
|
||||
return range[0] === range[1] ? range[0] : `${range[0]}–${range[1]}`;
|
||||
}
|
||||
|
||||
export function rangeChangedAfterEvidence(
|
||||
from: readonly [string, string] | null | undefined,
|
||||
to: readonly [string, string] | null | undefined,
|
||||
): string | null {
|
||||
const fromText = formatClockRange(from);
|
||||
const toText = formatClockRange(to);
|
||||
if (!fromText || !toText || fromText === toText) return null;
|
||||
return `范围从 ${fromText} 变为 ${toText}。`;
|
||||
}
|
||||
|
||||
export function withRangeChangedAfterEvidence(
|
||||
body: string,
|
||||
from: readonly [string, string] | null | undefined,
|
||||
to: readonly [string, string] | null | undefined,
|
||||
): string {
|
||||
const notice = rangeChangedAfterEvidence(from, to);
|
||||
if (!notice) return body;
|
||||
const spoken = body.trim();
|
||||
if (!spoken) return notice;
|
||||
if (spoken.includes(notice)) return spoken;
|
||||
const prefix = /[。!?]$/.test(spoken) ? spoken : `${spoken}。`;
|
||||
return `${prefix}${notice}`;
|
||||
}
|
||||
|
||||
export function openingRangeFromCandidateRange(
|
||||
range: { start_time?: string | null; end_time?: string | null } | null | undefined,
|
||||
): readonly [string, string] | null {
|
||||
@@ -341,6 +365,7 @@ export function listUserVisibleCopy(): string[] {
|
||||
RECTIFICATION_USER_COPY.divergenceTitle,
|
||||
RECTIFICATION_USER_COPY.divergenceUnlike,
|
||||
RECTIFICATION_USER_COPY.divergenceUnsure,
|
||||
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "",
|
||||
rangeDeliveryEventCopy(3),
|
||||
rangeDeliveryStableCopy(["事业方向"]),
|
||||
rangeDeliverySensitiveCopy(["婚恋(D9)"]),
|
||||
|
||||
@@ -34,9 +34,10 @@ import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./dat
|
||||
import { decideFromDossier } from "./decision-from-dossier";
|
||||
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
|
||||
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
|
||||
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice } from "../user-copy";
|
||||
import { RECTIFICATION_USER_COPY, withCompareFailedRetryNotice, withRangeChangedAfterEvidence } from "../user-copy";
|
||||
import { stripQuestionSentences } from "./collect-prompt";
|
||||
import { focusSpokenPrompt } from "./turn-question";
|
||||
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
|
||||
import {
|
||||
resolveExactSkillPackage,
|
||||
type ResolvedSkillPackageIdentity,
|
||||
@@ -637,6 +638,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
const emittedActivities = new Set<string>();
|
||||
const repeatedCalls = new Map<string, number>();
|
||||
let phaseSequence = 0;
|
||||
const rangeBeforeCompare = previousInferenceFromReceipt(
|
||||
dossier.latestResult?.decisionReceipt ?? null,
|
||||
)?.credible_range ?? null;
|
||||
|
||||
const recordPhase = async (phase: string, tool: string | null = null) => {
|
||||
if (
|
||||
@@ -967,9 +971,20 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (stem) {
|
||||
const stripped = stripQuestionSentences(answerText, stem);
|
||||
const next = stripped || RECTIFICATION_USER_COPY.collectHandoff;
|
||||
if (next !== answerText) await emitVisibleSpoken(next);
|
||||
if (next !== answerText) answerText = next;
|
||||
}
|
||||
}
|
||||
if (toolsUsed.has("rectification-compare-candidates")) {
|
||||
const rangeAfterCompare = previousInferenceFromReceipt(
|
||||
latestDossier.latestResult?.decisionReceipt ?? null,
|
||||
)?.credible_range ?? null;
|
||||
answerText = withRangeChangedAfterEvidence(
|
||||
answerText,
|
||||
rangeBeforeCompare,
|
||||
rangeAfterCompare,
|
||||
);
|
||||
}
|
||||
if (answerText !== visibleEmitted) await emitVisibleSpoken(answerText);
|
||||
return completeAttempt();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -192,6 +192,7 @@ test("settled assistant body with a focus has no question-mark sentences", () =>
|
||||
assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/);
|
||||
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||||
assert.match(agentRun, /stripQuestionSentences/);
|
||||
assert.match(agentRun, /withRangeChangedAfterEvidence/);
|
||||
});
|
||||
|
||||
test("delivery narration defers career windows and the range card replaces minute cards", () => {
|
||||
|
||||
@@ -593,7 +593,7 @@ test("time-selection cards use server adoption state and stay mutually exclusive
|
||||
assert.match(chat, /persistedOfferFromTurn/);
|
||||
assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/);
|
||||
assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/);
|
||||
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& selectionCardMessageKey/);
|
||||
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& caseSnapshotLoaded\s*&& !busy\s*&& selectionCardMessageKey/);
|
||||
assert.doesNotMatch(
|
||||
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const collectSpokenPrompt")),
|
||||
/offeredSelectionOnce/,
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildCandidateContrastPacket,
|
||||
vargaSignPartitionKey,
|
||||
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
|
||||
import {
|
||||
buildInferenceState,
|
||||
nextProbe,
|
||||
} from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { selectHighestGainProbe } from "../src/lib/rectification-agentic/core/select-probe.ts";
|
||||
import type { ConflictProbe, ProbeAnswer } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import {
|
||||
rangeChangedAfterEvidence,
|
||||
rangeWidthMinutes,
|
||||
withRangeChangedAfterEvidence,
|
||||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
|
||||
const S1_TIMES = [
|
||||
"04:50", "04:51", "04:52", "04:53", "04:54", "04:55", "04:56", "04:57", "05:13",
|
||||
] as const;
|
||||
const KEPT_TIMES = S1_TIMES.slice(0, 7);
|
||||
const REPLACED = ["04:57", "05:13"] as const;
|
||||
const NEW_TIMES = ["04:59", "05:14"] as const;
|
||||
const S2_TIMES = [...KEPT_TIMES, ...NEW_TIMES] as const;
|
||||
|
||||
const EVENTS = [
|
||||
{ id: "e-edu-2010", domain: "education", year: 2010, precision: "year" as const },
|
||||
{ id: "e-family-2011", domain: "family", year: 2011, precision: "year" as const },
|
||||
{ id: "e-finance-2012", domain: "finance", year: 2012, precision: "year" as const },
|
||||
{ id: "e-health-2013", domain: "health_pressure", year: 2013, precision: "year" as const },
|
||||
];
|
||||
|
||||
function clockProbe(input: {
|
||||
id: string;
|
||||
semanticKey: string;
|
||||
domain: string;
|
||||
year: number;
|
||||
source: string;
|
||||
choiceKind?: ConflictProbe["choice_kind"];
|
||||
yesSupports: readonly string[];
|
||||
yesConflicts: readonly string[];
|
||||
}): ConflictProbe {
|
||||
return {
|
||||
id: input.id,
|
||||
semantic_key: input.semanticKey,
|
||||
candidate_split_hash: `${input.semanticKey}:${input.yesSupports.join(",")}`,
|
||||
domain: input.domain,
|
||||
year: input.year,
|
||||
question: input.semanticKey,
|
||||
candidate_ids: [...input.yesSupports, ...input.yesConflicts],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: input.yesSupports, conflicts: input.yesConflicts },
|
||||
{ answer_class: "weak_yes", supports: input.yesSupports, conflicts: input.yesConflicts },
|
||||
{ answer_class: "no", supports: input.yesConflicts, conflicts: input.yesSupports },
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 0.4,
|
||||
source: input.source,
|
||||
...(input.choiceKind ? { choice_kind: input.choiceKind } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function answeredProbes(): ConflictProbe[] {
|
||||
const yesSupports = [...KEPT_TIMES];
|
||||
const yesConflicts = [...REPLACED];
|
||||
return [
|
||||
clockProbe({
|
||||
id: "probe:varga.d9.old",
|
||||
semanticKey: "varga.d9.04:50|04:51|04:52|04:53|04:54|04:55|04:56/04:57|05:13",
|
||||
domain: "relationship",
|
||||
year: 0,
|
||||
source: "varga_contrast",
|
||||
choiceKind: "varga_style",
|
||||
yesSupports,
|
||||
yesConflicts,
|
||||
}),
|
||||
clockProbe({
|
||||
id: "probe:varga.d10.old",
|
||||
semanticKey: "varga.d10.04:50|04:51|04:52|04:53|04:54|04:55|04:56/04:57|05:13",
|
||||
domain: "career",
|
||||
year: 0,
|
||||
source: "varga_contrast",
|
||||
choiceKind: "varga_style",
|
||||
yesSupports,
|
||||
yesConflicts,
|
||||
}),
|
||||
clockProbe({
|
||||
id: "probe:career.2023",
|
||||
semanticKey: "career.2023",
|
||||
domain: "career",
|
||||
year: 2023,
|
||||
source: "dasha_boundary",
|
||||
yesSupports,
|
||||
yesConflicts,
|
||||
}),
|
||||
clockProbe({
|
||||
id: "probe:career.2024",
|
||||
semanticKey: "career.2024",
|
||||
domain: "career",
|
||||
year: 2024,
|
||||
source: "dasha_boundary",
|
||||
yesSupports,
|
||||
yesConflicts,
|
||||
}),
|
||||
clockProbe({
|
||||
id: "probe:relocation.2015",
|
||||
semanticKey: "relocation.2015",
|
||||
domain: "relocation",
|
||||
year: 2015,
|
||||
source: "dasha_boundary",
|
||||
yesSupports,
|
||||
yesConflicts,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function engineCandidates(times: readonly string[]) {
|
||||
return times.map((time) => ({ id: time, time, relative_support: 20 }));
|
||||
}
|
||||
|
||||
function choiceAnswers(probes: readonly ConflictProbe[]): ProbeAnswer[] {
|
||||
return probes.map((probe) => ({
|
||||
probe_id: probe.id,
|
||||
semantic_key: probe.semantic_key,
|
||||
candidate_split_hash: probe.candidate_split_hash,
|
||||
answer_class: "yes" as const,
|
||||
classified_from: "choice" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function deltaFor(state: ReturnType<typeof buildInferenceState>, time: string): number {
|
||||
const row = state.candidates.find((item) => item.time === time);
|
||||
assert.ok(row, time);
|
||||
return row.posterior_score - row.prior_score;
|
||||
}
|
||||
|
||||
function width(range: readonly [string, string] | null | undefined): number {
|
||||
if (!range) return Number.POSITIVE_INFINITY;
|
||||
return rangeWidthMinutes(range[0], range[1]) ?? Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function buildS1() {
|
||||
const probes = answeredProbes();
|
||||
return buildInferenceState({
|
||||
range_start: "04:45",
|
||||
range_end: "05:15",
|
||||
candidates: engineCandidates(S1_TIMES),
|
||||
events: EVENTS,
|
||||
probes,
|
||||
answered_probes: choiceAnswers(probes),
|
||||
});
|
||||
}
|
||||
|
||||
function rescoreWithoutLiveProbes(
|
||||
previous: ReturnType<typeof buildInferenceState>,
|
||||
times: readonly string[],
|
||||
liveProbes: readonly ConflictProbe[] = [],
|
||||
) {
|
||||
return buildInferenceState({
|
||||
range_start: "04:45",
|
||||
range_end: "05:15",
|
||||
candidates: engineCandidates(times),
|
||||
events: EVENTS,
|
||||
probes: liveProbes,
|
||||
previous,
|
||||
});
|
||||
}
|
||||
|
||||
test("BUG-587 (a) a changed candidate set still replays five answered probes", () => {
|
||||
const s1 = buildS1();
|
||||
assert.equal(s1.answered_probes.length, 5);
|
||||
assert.equal(s1.rounds.length, 5);
|
||||
assert.ok(width(s1.credible_range) <= 8);
|
||||
|
||||
const s2 = rescoreWithoutLiveProbes(s1, S2_TIMES);
|
||||
assert.equal(s2.rounds.length, 5);
|
||||
assert.equal(s2.answered_probes.length, 5);
|
||||
assert.equal(s2.probes.filter((item) => item.carried).length, 5);
|
||||
|
||||
for (const time of KEPT_TIMES) {
|
||||
assert.equal(deltaFor(s2, time), deltaFor(s1, time), time);
|
||||
}
|
||||
for (const time of NEW_TIMES) {
|
||||
assert.equal(deltaFor(s2, time), 0, time);
|
||||
}
|
||||
assert.ok(width(s2.credible_range) <= width(s1.credible_range) + NEW_TIMES.length);
|
||||
});
|
||||
|
||||
test("BUG-587 (b) a second rescore still replays every answer", () => {
|
||||
const s1 = buildS1();
|
||||
const afterFinance = rescoreWithoutLiveProbes(s1, S2_TIMES);
|
||||
const afterRelocation = rescoreWithoutLiveProbes(afterFinance, S2_TIMES);
|
||||
assert.equal(afterRelocation.rounds.length, 5);
|
||||
for (const time of KEPT_TIMES) {
|
||||
assert.equal(deltaFor(afterRelocation, time), deltaFor(s1, time), time);
|
||||
}
|
||||
for (const time of NEW_TIMES) {
|
||||
assert.equal(deltaFor(afterRelocation, time), 0, time);
|
||||
}
|
||||
assert.equal(
|
||||
afterRelocation.candidates.every((item) => item.posterior_score === item.prior_score),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("BUG-587 (c) varga semantic keys use the sign partition, not the minute list", () => {
|
||||
const transitions = [
|
||||
{ layer: "d9", at: "04:53", from_sign: "天秤座", to_sign: "天蝎座" },
|
||||
{ layer: "d9", at: "04:57", from_sign: "天蝎座", to_sign: "射手座" },
|
||||
];
|
||||
const first = buildCandidateContrastPacket({
|
||||
candidateSetVersion: "set-a",
|
||||
candidateTimes: ["04:50", "04:53", "04:57"],
|
||||
transitions,
|
||||
});
|
||||
const second = buildCandidateContrastPacket({
|
||||
candidateSetVersion: "set-b",
|
||||
candidateTimes: ["04:50", "04:52", "04:53", "04:57"],
|
||||
transitions,
|
||||
});
|
||||
const firstKey = first.probes.find((item) => item.semanticKey.startsWith("varga.d9."))?.semanticKey;
|
||||
const secondKey = second.probes.find((item) => item.semanticKey.startsWith("varga.d9."))?.semanticKey;
|
||||
assert.equal(firstKey, "varga.d9.天秤座|天蝎座|射手座");
|
||||
assert.equal(secondKey, firstKey);
|
||||
assert.doesNotMatch(firstKey ?? "", /\d\d:\d\d/);
|
||||
assert.equal(vargaSignPartitionKey("d9", ["天秤座", "天蝎座", "射手座"]), firstKey);
|
||||
|
||||
const s1 = buildS1();
|
||||
const s2 = rescoreWithoutLiveProbes(s1, S2_TIMES);
|
||||
const carried = s2.probes.find((item) => item.semantic_key.includes("varga.d9."));
|
||||
assert.ok(carried?.carried);
|
||||
assert.match(carried.semantic_key, /\d\d:\d\d/);
|
||||
assert.equal(
|
||||
s2.answered_probes.some((item) => item.semantic_key === carried.semantic_key),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("BUG-587 (d) nextProbe does not re-ask a carried probe", () => {
|
||||
const s1 = buildS1();
|
||||
const fresh: ConflictProbe = clockProbe({
|
||||
id: "probe:education.2016",
|
||||
semanticKey: "education.2016",
|
||||
domain: "education",
|
||||
year: 2016,
|
||||
source: "dasha_boundary",
|
||||
yesSupports: ["04:50", "04:51"],
|
||||
yesConflicts: ["05:14"],
|
||||
});
|
||||
const s2 = rescoreWithoutLiveProbes(s1, S2_TIMES, [fresh]);
|
||||
const selected = nextProbe(s2);
|
||||
assert.equal(selected?.id, fresh.id);
|
||||
assert.equal(s2.probes.some((item) => item.carried && item.id === selected?.id), false);
|
||||
assert.equal(
|
||||
selectHighestGainProbe(s2.probes, s2.answered_probes)?.id,
|
||||
fresh.id,
|
||||
);
|
||||
for (const probe of s2.probes.filter((item) => item.carried)) {
|
||||
assert.equal(selectHighestGainProbe([probe], s2.answered_probes), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("BUG-588 evidence narration names a widened range after compare", () => {
|
||||
const changed = withRangeChangedAfterEvidence(
|
||||
"记下了。",
|
||||
["04:50", "04:57"],
|
||||
["04:47", "05:15"],
|
||||
);
|
||||
assert.match(changed, /范围从 04:50–04:57 变为 04:47–05:15。/);
|
||||
assert.equal(
|
||||
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]),
|
||||
"范围从 04:50–04:57 变为 04:47–05:15。",
|
||||
);
|
||||
assert.equal(
|
||||
withRangeChangedAfterEvidence("记下了。", ["04:50", "04:57"], ["04:50", "04:57"]),
|
||||
"记下了。",
|
||||
);
|
||||
assert.equal(rangeChangedAfterEvidence(["04:50", "04:57"], ["04:50", "04:57"]), null);
|
||||
|
||||
const agentRun = readFileSync(
|
||||
new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const askedFocusAt = agentRun.indexOf("const askedFocus = latestDossier.conversationSummary.activeFocus");
|
||||
const settlement = askedFocusAt >= 0 ? agentRun.slice(askedFocusAt) : "";
|
||||
const stripAt = settlement.indexOf("stripQuestionSentences");
|
||||
const rangeAt = settlement.indexOf("withRangeChangedAfterEvidence");
|
||||
assert.ok(stripAt >= 0 && rangeAt > stripAt);
|
||||
assert.match(agentRun, /rectification-compare-candidates/);
|
||||
});
|
||||
|
||||
test("P3 selection cards stay gated by busy and a loaded snapshot", () => {
|
||||
const chat = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const block = chat.slice(
|
||||
chat.indexOf("const showSelectionCards"),
|
||||
chat.indexOf("const showReadonlyRange"),
|
||||
);
|
||||
assert.match(block, /!busy/);
|
||||
assert.match(block, /caseSnapshotLoaded/);
|
||||
assert.match(chat, /startTransition\(/);
|
||||
});
|
||||
Reference in New Issue
Block a user