fix(rectification): stop speaking unstampable distinguish stems (BUG-674/675)
Independent Staging Quality Gate / validate (push) Failing after 20s
Independent Staging Quality Gate / publish (push) Skipped

Distinguish followups that cannot stamp a probe now go to persistExhaustionCollect instead of repeating the card stem in the assistant body. Choice focuses without asked_turn_id hang on the last assistant turn, and persisted_question with a live choice_card renders the existing card. Representative-time inconsistency is warn-only (BUG-676 investigating).
This commit is contained in:
jesse-ux
2026-09-14 03:42:48 +08:00
parent 9375012f17
commit 69ffa07d6b
16 changed files with 887 additions and 46 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-14 — 探针耗尽后不再把判别题念进正文;有卡不再画裸题
生时校正里,带年月题问完、探针盖不上号时,不会再把同一句判别题写进正文让人打字。会改出定向补事点选卡。若快照里已经有选择题卡,界面不再只画一行题干。代表时间如果落在被淘汰候选或范围外,会记一条告警,排序逻辑本轮不动。Skill 版本不变。
## 2026-09-13 — 定向补事存在题不再落成口述裸题
生时校正里,定向补事「有没有这件事」必须是四点选。若焦点被写成口述采集,刷新后不再只剩一行没有卡片的题干,会复原成可点的卡,或给出「接着问」修复入口。答完上一题后,下一问如果没落库,题干会写进本轮旁白,不会把计划中的 D9 题悄悄吞掉。年份追问仍是口述。Skill 版本不变。
+48
View File
@@ -10484,3 +10484,51 @@
- 相关记录:BUG-670、BUG-669、BUG-671、BUG-661
- 复发自:BUG-670
- 修复版本:待发布
## BUG-674 | 探针耗尽后判别题被念进正文,同一题反复问且答了不算数
- 状态:resolved
- 首次发现:2026-09-14
- 最近更新:2026-09-14
- 影响面:`persistNextInterviewAfterChoice``expectedAnswerSchemaFor` / `stampChoiceSchemaWithProbe``persistExhaustionCollect`
- 用户现象:答完带年月题后,正文反复出现同一句判别题(无 A/B/C/D)。用户打字回答「没有」只得到「记下了,这方面先跳过」,同一句再出现一次。
- 触发条件:`prospective_probes` 为空,精度阶段仍给出带 `choice_frame` 的判别题;`stampChoiceSchemaWithProbe` 盖不上 `probe_id`persist 返回 `invalid_choice_schema`
- 根因:上一单把「焦点没写成功」当成「题还在、只是没进消息」,把卡片题干拼进旁白。探针耗尽时这道题根本不该再问,也没有焦点可答。
- 修复:判别题(`intent === distinguish_candidates`,或带 `choice_frame` 的非采集题)落不了库时改走 `persistExhaustionCollect`,不得把题干写进正文。采集题仍可进正文,但与上一条助手消息相同的题干会去重并转耗尽分支。
- 验证:`frontend/tests/rectification-unstampable-probe-20260914.test.ts`
- 防复发:判别题落不了库时不得把题干写进正文;探针耗尽必须转定向补事卡。
- 相关记录:BUG-673、BUG-652、BUG-661
- 复发自:BUG-673(D3 把未落库题干拼进旁白)
- 修复版本:待发布
## BUG-675 | 快照已有 choice_card,界面仍画一行裸题
- 状态:resolved
- 首次发现:2026-09-14
- 最近更新:2026-09-14
- 影响面:`attachQuestionsToTurns``persisted_question` 渲染、`persistedQuestionSurface`
- 用户现象:`目前范围` 下面一行裸题(如「结过婚或订过婚吗?」),无头像、无选项、也没有「接着问」。
- 触发条件:`current_question.kind === "choice"` 且 GET `choice_card` 非空,但焦点没有 `asked_turn_id`,卡片无法挂到消息上;`persisted_question` 只画 `prompt`
- 根因:`persisted_question` 从设计起只服务口述题。BUG-673 把定向题修成点选后,这个纯文本块成了新的裸题来源。
- 修复:无 `asked_turn_id` 的活动选择题挂到最后一条助手消息,走既有消息内卡片。`persisted_question` 在有卡时渲染同一套 `RectificationChoiceCard`,不再只画一行字。
- 验证:`frontend/tests/rectification-unstampable-probe-20260914.test.ts``rectification-surface-state.test.ts`
- 防复发:`current_question.kind === "choice"` 且有 `choice_card` 时必须渲染可点卡,不得只画题干。不得新增第二套卡片组件。
- 相关记录:BUG-673、BUG-669、BUG-671
- 复发自:BUG-673(数据形态修好后渲染仍走口述兜底)
- 修复版本:待发布
## BUG-676 | 代表时间取到被淘汰候选
- 状态:investigating
- 首次发现:2026-09-14
- 最近更新:2026-09-14
- 影响面:`evaluateCandidateSeparation``rankActive`、decision receipt `representative_time` / `last_inference_round`
- 用户现象:同一轮 receipt 里 `representative_time` 落在 `eliminated_ids` 中(例如 05:14),界面范围是 04:48–05:07,也不含该分钟。
- 触发条件:本轮有淘汰名单,对外代表时间与 `winner_id` / 范围不一致。
- 根因:未知。已确认 `evaluateCandidateSeparation` 按 score 取 `ranked[0]``evaluateConvergence` 先滤 `status === eliminated` 再按后验排序。尚未拿到同一份 `inference_state.candidates`id / time / status / probability / posterior_score)与 `credible_range`,不能断定是 status 没写回、后验没重算,还是两套分数不是同一份。
- 修复:本轮只加不变量告警 `rectification_representative_time_inconsistent`(代表时间在 eliminated 内或落在 credible_range 外)。不改排序。
- 验证:`frontend/tests/rectification-unstampable-probe-20260914.test.ts` 两条告警路径。
- 防复发:代表时间必须不在本轮 `eliminated_ids` 内,且落在 `credible_range` 内。根因确认后另立修复单,不得为了「数字看起来对」顺手改排序。
- 相关记录:BUG-442
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,23 @@
# 进度 · 探针耗尽、裸题与代表时间(2026-09-14)
## 范围
- 分支:`codex/rectification-unstampable-probe-20260914`(基于 `origin/staging` @ `9375012f`
- 任务单:`docs/tasks/TASK-rectification-unstampable-probe-and-naked-card-20260914.md`
- BUG-674 / BUG-675 resolvedBUG-676 investigating
- 上一单 D3 已在任务书里标注推翻
## 完成
- T1`persistNextInterviewAfterChoice` 尾部对判别题改走 `persistExhaustionCollect`;采集题保留题干进正文,但与上一条助手消息相同则去重。
- T2`attachQuestionsToTurns` 把无 `asked_turn_id` 的活动选择题挂到最后一条助手消息;`persisted_question` 有卡时渲染既有 `RectificationChoiceCard`
- T3`warnRepresentativeTimeInconsistency`,不改排序。
- T4BUG_HISTORY、CHANGELOG、真机清单。
## 验收数字
- `./node_modules/.bin/tsc --noEmit`0 错
- `npm run lint`0 error120 warning,与基线同类,无新增 error)
- 相关单测 93/93`rectification-unstampable-probe-20260914.test.ts` 8/8,加上 collect-prompt / question-in-message / surface-state / targeted-spoken / targeted-card-live / answer-choice
- `npm test`2985 tests2818 pass / 153 fail / 14 skip。失败项是本机 Windows 基线(Caddy/CRLF、Docker、bash、workflow YAML、Skill SHA),新增用例均绿,没有新的校正产品失败
- `next build`:本工作树 `frontend/node_modules` 是 junctionTurbopack 拒绝软链;`--webpack` 编译成功后在收集 page data 时因本机 Skill 10.0.26 SHA 与 registry 不一致失败(环境问题,未改 skill 包)。`/` 是否仍 `○ Static` 需在有真实 `node_modules` 且 skill 哈希匹配的树上复核
+1 -1
View File
@@ -184,7 +184,7 @@
| `TASK-rectification-targeted-card-dead-20260913.md` | `PROGRESS-rectification-targeted-card-dead-20260913.md` | **P0**:定向补事卡在快照投影里拿不到 `choice_card`(承接焦点分支不重建 `choice_frame`),卡片看得见点不动、流程停在采集等待态;模型还会把定向题改写成口述题(BUG-669~671)。先于 tie-break 修复单执行 | 待验收 | `codex/rectification-targeted-card-dead-20260913` |
| `TASK-rectification-unstampable-probe-and-naked-card-20260914.md` | `PROGRESS-rectification-unstampable-probe-20260914.md` | **P0**:探针池空后判别题盖不上 probe → 焦点写不进 → 题干被念进正文、同一题反复问且答了不算数(BUG-674,推翻前一单 D3);快照已有 `choice_card` 但 `persisted_question` 仍只画一行裸题(BUG-675);同一轮 receipt 里 `representative_time` 取到 `eliminated_ids` 里的候选(BUG-676investigating | 待执行 | `codex/rectification-unstampable-probe-20260914` |
| `TASK-rectification-unstampable-probe-and-naked-card-20260914.md` | `PROGRESS-rectification-unstampable-probe-20260914.md` | **P0**:探针池空后判别题盖不上 probe → 焦点写不进 → 题干被念进正文、同一题反复问且答了不算数(BUG-674,推翻前一单 D3);快照已有 `choice_card` 但 `persisted_question` 仍只画一行裸题(BUG-675);同一轮 receipt 里 `representative_time` 取到 `eliminated_ids` 里的候选(BUG-676investigating | 待验收 | `codex/rectification-unstampable-probe-20260914` |
| `TASK-rectification-targeted-collect-spoken-focus-20260913.md` | `PROGRESS-rectification-targeted-collect-spoken-focus-20260913.md` | **P0**:定向补事题以采集型 schema 存进焦点,前端只能裸画一行题干(无头像无卡无修复入口),本轮计划的 D9 题被静默吞掉;BUG-670 的识别条件只认 `targeted_collect` 章,复原不了(BUG-673,复发自 BUG-670 | 待验收 | `codex/rectification-targeted-collect-spoken-focus-20260913` |
@@ -0,0 +1,20 @@
# 探针耗尽、裸题与代表时间:真机清单(2026-09-14)
分支 `codex/rectification-unstampable-probe-20260914`。本环境无 staging 登录态。
## BUG-674
进入当时那个校正会话,或再走一遍「答完带年月题、探针池空」。
- [ ] 正文不再反复出现同一句「2023 年 5 月前后,有没有开始一段认真关系…」且没有选项。
- [ ] 下一问是定向补事四选一卡,或诚实说明为什么现在没有下一题。
- [ ] 打字回答「没有」不会让同一句判别题再出现一次。
## BUG-675
- [ ] 「结过婚或订过婚吗?」或「家里添过丁或长辈住过院吗?」出现时带四个选项,可点。
- [ ] 刷新后仍是卡,不是一行裸题,也不应只剩「再说一件带年月的事就能继续」。
## BUG-676
- [ ] 打开当时那轮的 receipt(脱敏):记下 `inference_state.candidates` 每条的 `id` / `time` / `status` / `probability` / `posterior_score`,以及 `credible_range`。把这段交给修复单,不要改排序。
@@ -69,6 +69,7 @@ import {
caseStageFromSnapshot,
interviewCollectWaiting,
interviewChoiceCardUnavailable,
persistedQuestionSurface,
interviewStopReasonFromSnapshot,
interviewSessionOutcomeFromSnapshot,
type RectificationCaseSnapshotPayload,
@@ -1868,7 +1869,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{questionGap === "persisted_question" && currentQuestion?.prompt && (
<div className="rectification-message-wrap rectification-message-entry" data-testid="persisted-question">
<div className="rectification-message-question">
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
{persistedQuestionSurface({
questionKind: currentQuestion.kind,
hasChoiceCard: Boolean(choiceCard),
questionPersisted: true,
}) === "choice_card" && choiceCard ? (
<>
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}:persisted`}
variant="embedded"
card={choiceCard}
pending={busy}
disabled={busy || readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
</>
) : (
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
)}
</div>
</div>
)}
@@ -2,6 +2,7 @@ import { applyProbeOutcome, outcomeByMinuteForAnswer } from "./apply-probe-outco
import type { TransitionSignLookup } from "./sign-from-transitions.ts";
import { clusterRangeFor, clusterEquivalentCandidates } from "./cluster-candidates.ts";
import { evaluateConvergence, holdoutStillRanksFirst, rankActive } from "./convergence-evaluator.ts";
import { warnRepresentativeTimeInconsistency } from "./representative-time-guard.ts";
import { rangeFromTimes, unionStillValidRange } from "./credible-range.ts";
import { entropyFromScores, normalizeScores } from "./entropy.ts";
import { selectHighestGainProbe } from "./select-probe.ts";
@@ -197,6 +198,14 @@ export function buildInferenceState(input: {
...(transitions ? { transitions } : {}),
};
const decision = evaluateConvergence({ ...draft, holdout_passed: holdoutPassed });
const lastRound = rounds.at(-1) ?? null;
warnRepresentativeTimeInconsistency({
representativeTime: decision.representative_time,
eliminatedIds: lastRound?.eliminated_ids,
credibleRange: decision.credible_range ?? draft.credible_range,
winnerId: lastRound?.winner_id,
scoresAfter: lastRound?.scores_after,
});
return {
...draft,
phase: phaseFor(decision.result_status, draft.phase),
@@ -0,0 +1,77 @@
export type RepresentativeTimeGuardInput = Readonly<{
representativeTime: string | null | undefined;
eliminatedIds?: readonly string[] | null;
credibleRange?: readonly string[] | null;
winnerId?: string | null;
scoresAfter?: Readonly<Record<string, number>> | null;
}>;
export type RepresentativeTimeGuardResult = Readonly<{
ok: boolean;
inEliminated: boolean;
outsideRange: boolean;
}>;
function clockMinutes(value: string): number | null {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
function outsideCredibleRange(time: string, range: readonly string[]): boolean {
if (range.length === 0) return false;
if (range.includes(time)) return false;
if (range.length < 2) return true;
const start = clockMinutes(range[0] ?? "");
const end = clockMinutes(range[range.length - 1] ?? "");
const point = clockMinutes(time);
if (start === null || end === null || point === null) return true;
if (start <= end) return point < start || point > end;
return point < start && point > end;
}
function scoreExtremes(scores: Readonly<Record<string, number>> | null | undefined): {
max?: readonly [string, number];
min?: readonly [string, number];
} {
let max: readonly [string, number] | undefined;
let min: readonly [string, number] | undefined;
for (const [time, score] of Object.entries(scores ?? {})) {
if (!Number.isFinite(score)) continue;
if (!max || score > max[1]) max = [time, score];
if (!min || score < min[1]) min = [time, score];
}
return { max, min };
}
export function inspectRepresentativeTime(input: RepresentativeTimeGuardInput): RepresentativeTimeGuardResult {
const time = typeof input.representativeTime === "string" ? input.representativeTime.trim() : "";
if (!time) {
return { ok: true, inEliminated: false, outsideRange: false };
}
const inEliminated = (input.eliminatedIds ?? []).includes(time);
const outsideRange = outsideCredibleRange(time, input.credibleRange ?? []);
return {
ok: !inEliminated && !outsideRange,
inEliminated,
outsideRange,
};
}
export function warnRepresentativeTimeInconsistency(input: RepresentativeTimeGuardInput): RepresentativeTimeGuardResult {
const result = inspectRepresentativeTime(input);
if (result.ok) return result;
const extremes = scoreExtremes(input.scoresAfter);
console.warn(JSON.stringify({
event: "rectification_representative_time_inconsistent",
winner_id: input.winnerId ?? null,
representative_time: input.representativeTime ?? null,
credible_range: input.credibleRange ?? null,
eliminated_ids: input.eliminatedIds ?? [],
scores_after_max: extremes.max ?? null,
scores_after_min: extremes.min ?? null,
in_eliminated: result.inEliminated,
outside_range: result.outsideRange,
}));
return result;
}
@@ -924,6 +924,7 @@ export async function persistNextInterviewAfterChoice(input: {
narrateAdopt?: AdoptNarrationWriter;
skipRefresh?: boolean;
continueTieBreakRound?: boolean;
followup?: MethodFollowup | null;
}): Promise<{
hostNarration: string;
choiceReady: boolean;
@@ -1010,7 +1011,7 @@ export async function persistNextInterviewAfterChoice(input: {
}
}
const plan = planWithDateReliability(buildMethodFollowupPlan(planInput), liveDossier.evidence, input.askedTurnId);
const followup = interviewToPersist(plan);
const followup = input.followup !== undefined ? input.followup : interviewToPersist(plan);
if (shouldSkipFollowupPersist({
canAdopt: nextAction.can_adopt,
nextAction: nextAction.type,
@@ -1048,6 +1049,15 @@ export async function persistNextInterviewAfterChoice(input: {
followup: null,
};
}
const exhaustion = {
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
};
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
@@ -1068,6 +1078,9 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
if (open?.kind === "collect_spoken" && open.prompt) {
if (stemAlreadyInLastAssistant(open.prompt, input.dossier)) {
return persistExhaustionCollect(exhaustion);
}
return {
hostNarration: open.prompt,
choiceReady: false,
@@ -1078,20 +1091,15 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
if (followup && isTargetedCollectFollowup(followup) && followup.choice_frame) {
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
return persistExhaustionCollect(exhaustion);
}
if (followup?.intent === "collect_method_evidence" && followup.choice_frame) {
const spokenFollowup = spokenCollectFallbackFollowup(followup);
const spoken = spokenFollowupForUser(spokenFollowup, liveDossier.evidence);
if (spoken) {
if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
return persistExhaustionCollect(exhaustion);
}
const fallback = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
@@ -1121,17 +1129,12 @@ export async function persistNextInterviewAfterChoice(input: {
spoken
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
) {
if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
return persistExhaustionCollect(exhaustion);
}
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus, followup };
}
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
return persistExhaustionCollect(exhaustion);
}
if (!followup) {
if (input.dossier.case.acceptedTime) {
@@ -1141,15 +1144,13 @@ export async function persistNextInterviewAfterChoice(input: {
followup: null,
};
}
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
return persistExhaustionCollect(exhaustion);
}
if (
followup.intent === "distinguish_candidates"
|| (followup.choice_frame && followup.intent !== "collect_method_evidence")
) {
return persistExhaustionCollect(exhaustion);
}
const spoken = (spokenFollowupForUser(followup) ?? "").trim();
if (!spoken) {
@@ -1160,15 +1161,10 @@ export async function persistNextInterviewAfterChoice(input: {
followup: null,
};
}
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
return persistExhaustionCollect(exhaustion);
}
if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
return persistExhaustionCollect(exhaustion);
}
return {
hostNarration: spoken,
@@ -1178,6 +1174,23 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
function stemAlreadyInLastAssistant(
stem: string,
dossier: { turns?: readonly { role?: string; text?: string | null }[] },
): boolean {
const needle = stem.trim();
if (needle.length < 8) return false;
const turns = dossier.turns ?? [];
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn?.role !== "assistant") continue;
const text = typeof turn.text === "string" ? turn.text : "";
if (!text.trim()) continue;
return text.includes(needle);
}
return false;
}
export async function requestTieBreakPersonality(input: {
accounting: AccountingClient;
userId: string;
@@ -1747,6 +1760,7 @@ async function persistExhaustionCollect(input: {
choiceReady: boolean;
hostNarration: string;
focus?: ConversationFocus | null;
followup?: MethodFollowup | null;
terminalNote?: boolean;
}> {
let dossier = input.dossier as DecisionDossier;
@@ -1833,6 +1847,7 @@ async function persistExhaustionCollect(input: {
choiceReady: true,
hostNarration: targetedCollectHostNarration(followup, open.prompt),
focus: persistedFocus.focus,
followup,
};
}
const spoken = followup.choice_frame
@@ -26,6 +26,7 @@ import {
type RectificationDecision,
} from "../core/rectification-decision.ts";
import { evaluateCandidateSeparation } from "../core/candidate-separation.ts";
import { warnRepresentativeTimeInconsistency } from "../core/representative-time-guard.ts";
import type { ConflictProbe, InferenceState } from "../core/types.ts";
import {
askedDiscriminatorKeys,
@@ -1031,18 +1032,19 @@ export function overlayPublicDecision<T extends object>(
completionStatus: ReturnType<typeof publicDecisionFields>["completion_status"];
} {
const fields = publicDecisionFields(decision);
const receipt = (snapshot as DecisionDossier["latestResult"])?.decisionReceipt ?? null;
const inference = previousInferenceFromReceipt(receipt);
const round = inference?.last_inference_round ?? null;
const projection = authoritativeCandidateProjection(snapshot as DecisionDossier["latestResult"]);
const inconsistent = !projection.consistent;
const ceiling = engineCapabilityCeilingFromReceipt(
(snapshot as DecisionDossier["latestResult"])?.decisionReceipt ?? null,
);
const ceiling = engineCapabilityCeilingFromReceipt(receipt);
const canAdopt = !inconsistent && fields.can_adopt && ceiling.acceptanceAllowed;
const selectionAllowed = !inconsistent && fields.selection_allowed && ceiling.selectionAllowed;
const proposeAllowed = !inconsistent && fields.propose_allowed && ceiling.proposeAllowed;
const canConfirmExactMinute = !inconsistent
&& fields.can_confirm_exact_minute
&& ceiling.confirmationAllowed;
return {
const overlaid = {
...snapshot,
...fields,
can_adopt: canAdopt,
@@ -1062,4 +1064,12 @@ export function overlayPublicDecision<T extends object>(
validated: fields.validated,
completionStatus: fields.completion_status,
};
warnRepresentativeTimeInconsistency({
representativeTime: overlaid.representative_time,
eliminatedIds: round?.eliminated_ids,
credibleRange: overlaid.credible_range ?? inference?.credible_range,
winnerId: round?.winner_id,
scoresAfter: round?.scores_after,
});
return overlaid;
}
@@ -104,10 +104,20 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
focuses: readonly ConversationFocus[],
): Array<T & { question: TurnQuestion | null; text: string | null }> {
const byTurn = new Map<string, ConversationFocus>();
const hanging: ConversationFocus[] = [];
for (const focus of focuses) {
if (!focus.askedTurnId) continue;
if (!focus.askedTurnId) {
if (focus.status === "active" && turnQuestionKind(focus) === "choice") {
hanging.push(focus);
}
continue;
}
byTurn.set(focus.askedTurnId, focus);
}
const lastAssistant = [...turns].reverse().find((turn) => turn.role === "assistant");
if (hanging[0] && lastAssistant && !byTurn.has(lastAssistant.id)) {
byTurn.set(lastAssistant.id, hanging[0]);
}
return turns.map((turn) => {
if (turn.role !== "assistant") return { ...turn, question: null };
const focus = byTurn.get(turn.id);
@@ -347,6 +347,16 @@ export function interviewSessionOutcomeFromSnapshot(payload: RectificationCaseSn
return null;
}
export function persistedQuestionSurface(input: Readonly<{
questionKind?: string | null;
hasChoiceCard: boolean;
questionPersisted: boolean;
}>): "none" | "spoken" | "choice_card" {
if (!input.questionPersisted) return "none";
if (input.questionKind === "choice" && input.hasChoiceCard) return "choice_card";
return "spoken";
}
export function interviewChoiceCardUnavailable(input: Readonly<{
questionKind?: string | null;
hasChoiceCard: boolean;
@@ -89,7 +89,10 @@ test("runtime no longer composes the stem into assistant_message", () => {
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(attach, /stripQuestionSentences/);
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
// 原值: if (!focus.askedTurnId) continue
// 新值: 无 askedTurnId 的活动选择题挂到最后一条助手消息后再 continue
// 原因: BUG-675 焦点没有 asked_turn_id 时卡片仍要出现在消息上
assert.match(attach, /if \(!focus\.askedTurnId\) \{/);
});
test("stripQuestionSentences drops a rewritten trailing question and its tag", () => {
@@ -77,7 +77,10 @@ test("GET turns attach question by asked_turn_id and never by timestamps", () =>
assert.match(caseProjection, /question: turn\.question/);
assert.match(caseProjection, /question_source: questionSourceFromFocusList\(listed\)/);
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
// 原值: if (!focus.askedTurnId) continue
// 新值: 无 askedTurnId 的活动选择题挂到最后一条助手消息后再 continue
// 原因: BUG-675
assert.match(attach, /if \(!focus\.askedTurnId\) \{/);
assert.doesNotMatch(attach, /askedAt|createdAt/);
});
@@ -16,6 +16,7 @@ import {
rectificationQuestionRetryActive,
interviewCollectWaiting,
interviewChoiceCardUnavailable,
persistedQuestionSurface,
interviewStopReasonFromSnapshot,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
rectificationReadonlyRangeCopy,
@@ -111,6 +112,17 @@ test("question gap: nothing is shown while busy, readonly, regenerating, or for
assert.equal(rectificationQuestionGapState({ ...gapBase, resumableCase: false }), "idle");
});
test("a persisted choice with a live card is not the spoken-only prompt surface", () => {
assert.equal(
persistedQuestionSurface({
questionKind: "choice",
hasChoiceCard: true,
questionPersisted: true,
}),
"choice_card",
);
});
test("question gap: a persisted focus question is shown instead of preparing", () => {
assert.equal(
rectificationQuestionGapState({ ...gapBase, questionMissing: false, questionPersisted: true }),
@@ -0,0 +1,575 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyRectificationChoice, persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import {
expectedAnswerSchemaFor,
persistServerOwnedFocus,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { persistedQuestionSurface } from "../src/lib/rectification-surface-state.ts";
import {
inspectRepresentativeTime,
warnRepresentativeTimeInconsistency,
} from "../src/lib/rectification-agentic/core/representative-time-guard.ts";
import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
CASE_ID,
FOCUS_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const STEM = "2023 年 5 月前后,有没有开始一段认真关系、分手或结婚?";
const OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
function emptyInference() {
return {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: "04:48-05:07",
revision: 6,
phase: "discrimination",
result_status: "discriminating",
range_start: "04:48",
range_end: "05:07",
candidates: [
{
id: "04:53",
time: "04:53",
cluster_range: ["04:48", "05:07"] as const,
prior_score: 16,
posterior_score: 16,
probability: 0.4,
status: "active" as const,
rank: 1,
strong_conflict_count: 0,
},
{
id: "05:06",
time: "05:06",
cluster_range: ["04:48", "05:07"] as const,
prior_score: 15,
posterior_score: 15,
probability: 0.35,
status: "active" as const,
rank: 2,
strong_conflict_count: 0,
},
],
events: [],
probes: [],
answered_probes: [],
rounds: [],
entropy: 1,
representative_time: "04:53",
credible_range: ["04:48", "05:07"] as const,
};
}
const WINDOW_SCAN = {
scanned: true,
d9_lagna_count: 2,
d10_lagna_count: 2,
d4_lagna_count: 2,
d9_candidates_differ: true,
d10_candidates_differ: true,
d4_candidates_differ: true,
};
function distinguishFollowup(): MethodFollowup {
const frame = buildChoiceFrame({
method_id: "d9_relationship",
ask_theme: "dated_event",
domain: "relationship",
user_prompt_hint: "ask",
}, {
probes: [{
year: 2023,
year_label: "2023 年 5 月前后",
domain: "relationship",
event_family: "开始一段认真关系、分手或结婚",
source: "dasha_boundary",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: STEM,
role: "distinguish",
information_gain: 0.4,
semantic_key: "relationship.2023.05.dasha_boundary",
style_options: OPTIONS,
}],
});
assert.ok(frame);
return {
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "dated_event",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: frame,
source: "precision_stage",
information_gain: 0.4,
semantic_key: "relationship.2023.05.dasha_boundary",
probe_year: 2023,
year_label: "2023 年 5 月前后",
candidate_ids: ["04:48", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:48"], conflicts: ["05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["04:48"] },
],
};
}
const DATED_EVIDENCE = [
{
id: "e-edu",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "2016年9月上大学",
},
{
id: "e-career",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "2020年4月入职",
},
{
id: "e-rel",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "year" as const,
occurredFrom: "2018-01-01",
occurredTo: "2018-12-31",
eventKind: "relationship_start",
summary: "2018年恋爱",
},
{
id: "e-move",
status: "confirmed" as const,
domain: "relocation",
datePrecision: "year" as const,
occurredFrom: "2015-01-01",
occurredTo: "2015-12-31",
eventKind: "relocation",
summary: "2015年搬家",
},
];
const PERSIST_EVIDENCE = DATED_EVIDENCE.filter((row) => row.domain !== "relationship");
function focusAccounting() {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_compute: () => computeFixture(),
get_agentic_rectification_case_dossier: () => snakeDossier(),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id ?? null,
target_domain: args.p_target_domain ?? null,
target_kind: args.p_target_kind ?? null,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-14T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
resolve_agentic_rectification_conversation_focus: () => ({
focus: { id: FOCUS_ID, status: "resolved" },
idempotent: false,
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
probe_id: "p-cd",
revision: Number(args.p_expected_revision ?? 6) + 1,
source_quote: args.p_source_quote,
derived_context: args.p_derived_context,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
});
}
function snakeEvidence(rows: typeof DATED_EVIDENCE = DATED_EVIDENCE) {
return rows.map((row) => ({
id: row.id,
status: row.status,
domain: row.domain,
date_precision: row.datePrecision,
occurred_from: row.occurredFrom,
occurred_to: row.occurredTo,
event_kind: row.eventKind,
summary: row.summary,
}));
}
function snakeDossier(overrides: Parameters<typeof dossierFixture>[0] = {}) {
return dossierFixture({
evidence: snakeEvidence(PERSIST_EVIDENCE),
evidenceCount: PERSIST_EVIDENCE.length,
stage: "minute",
candidateRange: { start_time: "04:48", end_time: "05:07" },
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:53",
candidates: [
{ candidate_id: "04:53", rank: 1, time: "04:53", relative_support: 16 },
{ candidate_id: "05:06", rank: 2, time: "05:06", relative_support: 15 },
],
decisionReceipt: {
inference_state: emptyInference(),
prospective_probes: [],
precision_stage: { current: "d9_refine" },
discriminating_event_probes: [],
window_scan: WINDOW_SCAN,
},
}),
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
expectedAnswerSchema: {
prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?",
probe_id: "p-cd",
semantic_key: "career.2015",
scoring: true,
choice: {
prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?",
option_a: "是,大概就在那段时间",
option_b: "有类似,但年份不对或不够重大",
option_c: "没有明显发生",
option_d: "不记得 / 不确定",
options: [
{ key: "A", label: "是,大概就在那段时间", answer_class: "yes" },
{ key: "B", label: "有类似,但年份不对或不够重大", answer_class: "weak_yes" },
{ key: "C", label: "没有明显发生", answer_class: "no" },
{ key: "D", label: "不记得 / 不确定", answer_class: "unsure" },
],
},
},
}),
}),
turns: [
{
id: TURN_ID,
role: "assistant",
text: "已记录,范围收到 04:4805:07。",
status: "completed",
},
],
...overrides,
});
}
function camelDossier() {
return {
evidence: PERSIST_EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
decisionReceipt: {
inference_state: emptyInference(),
prospective_probes: [],
precision_stage: { current: "d9_refine" },
discriminating_event_probes: [],
window_scan: WINDOW_SCAN,
},
selectionAllowed: true,
candidates: [
{ time: "04:53", rank: 1, relativeSupport: 16 },
{ time: "05:06", rank: 2, relativeSupport: 15 },
],
},
case: {
acceptedTime: null,
status: "collecting_evidence",
candidateRange: { start_time: "04:48", end_time: "05:07" },
stage: "minute" as const,
},
turns: [
{ role: "assistant" as const, text: "已记录,范围收到 04:4805:07。" },
],
};
}
test("empty probe pool cannot stamp a scoring distinguish schema", () => {
const followup = distinguishFollowup();
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
stableFollowupQuestionId(followup),
{ inference_state: emptyInference() },
followup,
);
assert.equal(schema, null);
});
test("unstampable distinguish persist is invalid_choice_schema", async () => {
const accounting = focusAccounting();
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: { inference_state: emptyInference() },
followup: distinguishFollowup(),
});
assert.equal(result.status, "invalid_choice_schema");
});
test("persistNextInterviewAfterChoice does not speak an unstampable distinguish stem", async () => {
const followup = distinguishFollowup();
const stem = followup.choice_frame?.prompt ?? STEM;
const accounting = focusAccounting();
const dossier = camelDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const first = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: emptyInference() as never,
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
skipRefresh: true,
followup,
});
assert.equal(first.hostNarration.includes(stem), false, first.hostNarration);
assert.notEqual(first.followup?.intent, "distinguish_candidates");
assert.ok(
first.choiceReady === true || first.followup?.intent === "collect_method_evidence",
JSON.stringify({ choiceReady: first.choiceReady, intent: first.followup?.intent, host: first.hostNarration }),
);
const second = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier: {
...dossier,
turns: [{ role: "assistant" as const, text: stem }],
},
decisionState: emptyInference() as never,
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
skipRefresh: true,
followup,
});
assert.equal(second.hostNarration.includes(stem), false, second.hostNarration);
});
test("collect stem already in the last assistant turn is not spoken again", async () => {
const stem = "请再补充一件带年月的搬家或长期住到外地的经历。";
const followup: MethodFollowup = {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "relocation",
kind_hint: "home_change",
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: null,
spoken_prompt: stem,
source: "method_coverage",
};
const accounting = focusAccounting();
const dossier = {
...camelDossier(),
turns: [{ role: "assistant" as const, text: `记下了。\n\n${stem}` }],
};
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const result = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: emptyInference() as never,
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
skipRefresh: true,
followup,
});
assert.equal(result.hostNarration.includes(stem), false, result.hostNarration);
});
test("applyRectificationChoice narration does not re-ask an unstampable distinguish stem", async () => {
const followup = distinguishFollowup();
const stem = followup.choice_frame?.prompt ?? STEM;
const accounting = focusAccounting();
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: CHOICE_ACTION,
focusId: FOCUS_ID,
questionId: "question-1",
probeId: "p-cd",
optionId: "C",
expectedRevision: 6,
});
assert.equal(applied.applied, true);
assert.equal(applied.narration.includes(stem), false, applied.narration);
const nextMeaning = JSON.stringify(applied.nextUserAction ?? {});
assert.equal(nextMeaning.includes(stem), false, nextMeaning);
});
test("active choice focus without asked_turn_id hangs on the last assistant turn", () => {
const prompt = "结过婚或订过婚吗?";
const attached = attachQuestionsToTurns(
[
{ id: TURN_ID, role: "assistant" as const, text: "已记录,范围收到 04:4805:07。" },
],
[{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:targeted:relationship",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relationship",
targetKind: "targeted:relationship",
expectedAnswerSchema: {
targeted_collect: true,
prompt,
choice: {
prompt,
option_a: "有过这件事",
option_b: "没有发生过",
option_c: "记不太清楚",
option_d: "这条先跳过",
options: [
{ key: "A", label: "有过这件事", answer_class: "yes" },
{ key: "B", label: "没有发生过", answer_class: "no" },
{ key: "C", label: "记不太清楚", answer_class: "unsure" },
{ key: "D", label: "这条先跳过", answer_class: "weak_yes" },
],
},
},
status: "active",
askedAt: "2026-09-14T00:00:00.000Z",
resolvedAt: null,
askedTurnId: null,
answerOption: null,
}],
);
assert.equal(attached[0]?.question?.kind, "choice");
assert.equal(attached[0]?.question?.focus_id, FOCUS_ID);
assert.equal(attached[0]?.question?.options?.length, 4);
assert.equal(attached[0]?.question?.prompt, prompt);
});
test("persisted question with a live choice card is not the spoken-only surface", () => {
assert.equal(
persistedQuestionSurface({
questionKind: "choice",
hasChoiceCard: true,
questionPersisted: true,
}),
"choice_card",
);
assert.equal(
persistedQuestionSurface({
questionKind: "collect_spoken",
hasChoiceCard: false,
questionPersisted: true,
}),
"spoken",
);
assert.equal(
persistedQuestionSurface({
questionKind: "choice",
hasChoiceCard: true,
questionPersisted: false,
}),
"none",
);
});
test("representative time in eliminated ids or outside the range warns once each", () => {
const lines: string[] = [];
const original = console.warn;
console.warn = (value: unknown) => {
lines.push(String(value));
};
try {
const eliminated = warnRepresentativeTimeInconsistency({
representativeTime: "05:14",
eliminatedIds: ["05:14", "05:08"],
credibleRange: ["04:48", "05:07"],
winnerId: "04:53",
scoresAfter: { "04:53": 16, "05:14": 7 },
});
assert.equal(eliminated.ok, false);
assert.equal(eliminated.inEliminated, true);
const outside = warnRepresentativeTimeInconsistency({
representativeTime: "05:14",
eliminatedIds: [],
credibleRange: ["04:48", "05:07"],
winnerId: "04:53",
scoresAfter: { "04:53": 16, "05:14": 7 },
});
assert.equal(outside.ok, false);
assert.equal(outside.outsideRange, true);
const healthy = warnRepresentativeTimeInconsistency({
representativeTime: "04:53",
eliminatedIds: ["05:14"],
credibleRange: ["04:48", "05:07"],
winnerId: "04:53",
scoresAfter: { "04:53": 16, "05:14": 7 },
});
assert.equal(healthy.ok, true);
assert.equal(inspectRepresentativeTime({
representativeTime: "04:53",
eliminatedIds: ["05:14"],
credibleRange: ["04:48", "05:07"],
}).ok, true);
} finally {
console.warn = original;
}
assert.equal(lines.filter((line) => line.includes("rectification_representative_time_inconsistent")).length, 2);
assert.match(lines[0] ?? "", /"representative_time":"05:14"/);
assert.doesNotMatch(lines.join("\n"), /birth|email|jwt/i);
});