fix(rectification): stop speaking unstampable distinguish stems (BUG-674/675)
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:
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-14 — 探针耗尽后不再把判别题念进正文;有卡不再画裸题
|
||||
|
||||
生时校正里,带年月题问完、探针盖不上号时,不会再把同一句判别题写进正文让人打字。会改出定向补事点选卡。若快照里已经有选择题卡,界面不再只画一行题干。代表时间如果落在被淘汰候选或范围外,会记一条告警,排序逻辑本轮不动。Skill 版本不变。
|
||||
|
||||
|
||||
## 2026-09-13 — 定向补事存在题不再落成口述裸题
|
||||
|
||||
生时校正里,定向补事「有没有这件事」必须是四点选。若焦点被写成口述采集,刷新后不再只剩一行没有卡片的题干,会复原成可点的卡,或给出「接着问」修复入口。答完上一题后,下一问如果没落库,题干会写进本轮旁白,不会把计划中的 D9 题悄悄吞掉。年份追问仍是口述。Skill 版本不变。
|
||||
|
||||
@@ -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 resolved;BUG-676 investigating
|
||||
- 上一单 D3 已在任务书里标注推翻
|
||||
|
||||
## 完成
|
||||
|
||||
- T1:`persistNextInterviewAfterChoice` 尾部对判别题改走 `persistExhaustionCollect`;采集题保留题干进正文,但与上一条助手消息相同则去重。
|
||||
- T2:`attachQuestionsToTurns` 把无 `asked_turn_id` 的活动选择题挂到最后一条助手消息;`persisted_question` 有卡时渲染既有 `RectificationChoiceCard`。
|
||||
- T3:`warnRepresentativeTimeInconsistency`,不改排序。
|
||||
- T4:BUG_HISTORY、CHANGELOG、真机清单。
|
||||
|
||||
## 验收数字
|
||||
|
||||
- `./node_modules/.bin/tsc --noEmit`:0 错
|
||||
- `npm run lint`:0 error(120 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 tests,2818 pass / 153 fail / 14 skip。失败项是本机 Windows 基线(Caddy/CRLF、Docker、bash、workflow YAML、Skill SHA),新增用例均绿,没有新的校正产品失败
|
||||
- `next build`:本工作树 `frontend/node_modules` 是 junction,Turbopack 拒绝软链;`--webpack` 编译成功后在收集 page data 时因本机 Skill 10.0.26 SHA 与 registry 不一致失败(环境问题,未改 skill 包)。`/` 是否仍 `○ Static` 需在有真实 `node_modules` 且 skill 哈希匹配的树上复核
|
||||
@@ -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-676,investigating) | 待执行 | `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-676,investigating) | 待验收 | `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:48–05: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:48–05: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:48–05: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);
|
||||
});
|
||||
Reference in New Issue
Block a user