fix(rectification): prevent silent collect focus stalls
This commit is contained in:
@@ -6926,3 +6926,19 @@
|
||||
- 相关记录:BUG-352
|
||||
- 复发自:无
|
||||
- 修复版本:待 staging 部署;本地提交待生成
|
||||
|
||||
## BUG-452 | 采集焦点落库失败后静默结束
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-30
|
||||
- 最近更新:2026-08-30
|
||||
- 影响面:`persistNextInterviewAfterChoice`、`persistFocusAfterChoice`、`persistExhaustionCollect`、POST `/api/rectification/agent` 非终态轮末出口
|
||||
- 用户现象:答完家人采集题后助手只说「记下了,这方面先跳过。」,没有下一问也没有时间卡。
|
||||
- 触发条件:上一采集焦点刚被关闭,下一条 `collect_method_evidence` 焦点落库返回 `duplicate_focus` 或 `skipped`;该轮同时没有可采用的候选承载。
|
||||
- 根因:采集焦点落库失败时 `persistNextInterviewAfterChoice` 返回 `hostNarration=null`,随后被 denial 兜底文案「记下了,这方面先跳过。」吞掉;轮末也没有结构化不变量检查 `current_question` 或真实可采用承载,最终形成非终态但无可继续交互入口的静默死路。
|
||||
- 修复:采集焦点首次落库失败后重读 dossier 并重试一次;若已存在同 `questionId` 的 active focus,则按 `already_open` 复用。重试仍失败时复用 `persistExhaustionCollect` 口径,正文写出可信区间、代表分钟和口述下一问,并尝试落一个可回答的采集焦点。自由文本轮末新增结构化非终态出口检查:若未 accepted、未 confirmed、未 user-stopped,且既无 `current_question` 也无真实可采用承载,则确定性补口述采集焦点、把题干写入正文,并记录不含用户内容的 `rectification_nonterminal_exit_repaired` 日志。删除会成为终局的 denial 单句兜底。
|
||||
- 验证:修复前新增 fixture 证明 `duplicate_focus` 与 `skipped` 均会得到 `hostNarration=null`、`current_question=null`;修复后两条路径均有问题正文并持久化或复用非空焦点。新增非终态出口不变量回归,锁定服务端补出 `collect_spoken` 焦点和题干。复刻五证据、五条已答探针、活跃候选 `05:00/05:07/04:53`、family declined 的 case,锁定 `career.2023.dasha_activation` 以 `no_split_among_active` drop、`nextAction=offer_provisional_range`、`canAdopt=false`,轮末 `current_question.question_id=collect:occupation:collect_method_evidence` 且正文含「你长期做什么工作?」。`assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/)` 保持通过 本地 `npx tsc --noEmit`、改动文件 ESLint、目标四组回归 `122/122` 通过;focused 全套 `730/731`,唯一 Docker/PostgreSQL migration fixture 失败单独重跑 `1/1` 通过;全量基线 `2342/2347`,五项失败均为并行数据库容器/migration 环境波动或本机缺少 PyYAML,未扩大到产品逻辑。
|
||||
- 防复发:非终态轮次结束时必须有 `current_question` 或真实可采用承载;任何采集焦点落库失败都不得静默降级为一句确认话。该不变量只看焦点与 decision/承载字段,不得用正文文本判断助手是否问出问题。不得为无选项题新造视觉容器或复用选择卡样式;不得引入语义正则、关键词表或 A/B/C/D 位置推断;`resolved` 与 `declined` 语义不得互换。
|
||||
- 相关记录:BUG-441、BUG-449、BUG-450
|
||||
- 复发自:BUG-450
|
||||
- 修复版本:`fix(rectification): prevent silent collect focus stalls`(Skill 保持 `10.0.13`)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
evidenceLedgerFingerprint,
|
||||
loadV9CaseCompute,
|
||||
loadV9CaseDossier,
|
||||
loadV9TurnReceipt,
|
||||
persistV9DeterministicTurn,
|
||||
RectificationToolServiceError,
|
||||
transitionV9CaseStatus,
|
||||
@@ -13,6 +14,7 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica
|
||||
import {
|
||||
applyRectificationChoice,
|
||||
applyCollectFocusDenial,
|
||||
ensureNonTerminalTurnExit,
|
||||
persistNextInterviewIfIdle,
|
||||
persistCollectSpokenAssistantIfNew,
|
||||
} from "@/lib/rectification-agentic/v9/answer-choice";
|
||||
@@ -683,6 +685,22 @@ export async function POST(request: Request) {
|
||||
`[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const receipt = await loadV9TurnReceipt(accounting as never, userId, caseId, result.turnId);
|
||||
const exit = await ensureNonTerminalTurnExit({
|
||||
accounting: accounting as never,
|
||||
userId,
|
||||
caseId,
|
||||
adoptCarrierReady: Boolean(receipt?.toolActivities.some((activity) => (
|
||||
activity.tool === "rectification-offer-candidates" && activity.status === "completed"
|
||||
))),
|
||||
});
|
||||
idleHostNarration = exit.hostNarration ?? idleHostNarration;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] nonterminal turn exit repair failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const fallback = await persistCollectSpokenAssistantIfNew({
|
||||
accounting: accounting as never,
|
||||
|
||||
@@ -52,6 +52,7 @@ import { isSafeCollectSpokenPrompt } from "./spoken-answer";
|
||||
import {
|
||||
collectSpokenPromptForNewFocus,
|
||||
composeCollectSpokenAssistantText,
|
||||
projectCurrentQuestion,
|
||||
} from "./turn-decision";
|
||||
|
||||
export type ApplyChoiceCommand = Readonly<{
|
||||
@@ -287,7 +288,7 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
decisionState: InferenceState | null;
|
||||
nextAction: ReturnType<typeof publicNextAction>;
|
||||
birthDate?: string | null;
|
||||
}): Promise<{ hostNarration: string | null; choiceReady: boolean }> {
|
||||
}): Promise<{ hostNarration: string; choiceReady: boolean; persisted?: boolean }> {
|
||||
const latest = input.dossier.latestResult
|
||||
? {
|
||||
...input.dossier.latestResult,
|
||||
@@ -351,7 +352,17 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
) {
|
||||
return { hostNarration: spoken, choiceReady: false };
|
||||
}
|
||||
return { hostNarration: null, choiceReady: false };
|
||||
return persistExhaustionCollect({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
dossier: input.dossier,
|
||||
decision: {
|
||||
credibleRange: input.nextAction.credible_range,
|
||||
representativeTime: input.nextAction.representative_time,
|
||||
},
|
||||
decisionReceipt: latest.decisionReceipt,
|
||||
});
|
||||
}
|
||||
if (!followup) {
|
||||
if (isNonConvergingRangeOffer({
|
||||
@@ -393,8 +404,9 @@ async function persistFocusAfterChoice(input: {
|
||||
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
|
||||
followup: ReturnType<typeof buildMethodFollowupPlan>["next_followup"];
|
||||
}) {
|
||||
let persisted;
|
||||
try {
|
||||
return await persistServerOwnedFocus({
|
||||
persisted = await persistServerOwnedFocus({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
@@ -406,13 +418,32 @@ async function persistFocusAfterChoice(input: {
|
||||
console.warn(
|
||||
`[rectification-v9] persist next focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
|
||||
);
|
||||
return {
|
||||
persisted = {
|
||||
status: "skipped" as const,
|
||||
focus: null,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
if (persisted.status === "created" || persisted.status === "already_open" || !input.followup) {
|
||||
return persisted;
|
||||
}
|
||||
try {
|
||||
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
return await persistServerOwnedFocus({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
activeFocus: dossier.conversationSummary.activeFocus,
|
||||
decisionReceipt: input.decisionReceipt,
|
||||
followup: input.followup,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[rectification-v9] retry next focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`,
|
||||
);
|
||||
return persisted;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCollectFocusDenial(
|
||||
@@ -470,7 +501,7 @@ export async function applyCollectFocusDenial(
|
||||
birthDate,
|
||||
});
|
||||
return {
|
||||
narration: nextInterview.hostNarration ?? "记下了,这方面先跳过。",
|
||||
narration: nextInterview.hostNarration,
|
||||
nextInterviewPersisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
|
||||
nextChoiceReady: nextInterview.choiceReady,
|
||||
};
|
||||
@@ -567,7 +598,7 @@ async function persistExhaustionCollect(input: {
|
||||
return {
|
||||
persisted,
|
||||
choiceReady: false,
|
||||
hostNarration: persisted && spoken
|
||||
hostNarration: spoken
|
||||
? composeCollectSpokenAssistantText(range, spoken).composed
|
||||
: range,
|
||||
};
|
||||
@@ -690,7 +721,11 @@ async function persistApplied(
|
||||
});
|
||||
nextChoiceReady = nextInterview.choiceReady;
|
||||
if (nextInterview.hostNarration) {
|
||||
hostNarration = nextInterview.hostNarration;
|
||||
hostNarration = nextInterview.persisted === false
|
||||
? `${input.narration}
|
||||
|
||||
${nextInterview.hostNarration}`
|
||||
: nextInterview.hostNarration;
|
||||
nextInterviewPersisted = true;
|
||||
}
|
||||
}
|
||||
@@ -744,6 +779,47 @@ async function persistApplied(
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureNonTerminalTurnExit(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
adoptCarrierReady: boolean;
|
||||
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> {
|
||||
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
if (projectCurrentQuestion(dossier.conversationSummary.activeFocus)) {
|
||||
return { persisted: false, choiceReady: false, hostNarration: null };
|
||||
}
|
||||
let birthDate: string | null = null;
|
||||
try {
|
||||
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
|
||||
birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
|
||||
} catch {
|
||||
birthDate = null;
|
||||
}
|
||||
const decision = decideFromDossier(dossier, { birthDate });
|
||||
if (
|
||||
dossier.case.acceptedTime
|
||||
|| dossier.case.confirmedTime
|
||||
|| decision.completionStatus === "provisional_range_user_stopped"
|
||||
|| (decision.canAdopt && input.adoptCarrierReady)
|
||||
) {
|
||||
return { persisted: false, choiceReady: false, hostNarration: null };
|
||||
}
|
||||
console.warn(JSON.stringify({
|
||||
event: "rectification_nonterminal_exit_repaired",
|
||||
case_id: input.caseId,
|
||||
reason: "missing_question_and_adopt_carrier",
|
||||
}));
|
||||
return persistExhaustionCollect({
|
||||
accounting: input.accounting,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
dossier,
|
||||
decision,
|
||||
decisionReceipt: dossier.latestResult?.decisionReceipt,
|
||||
});
|
||||
}
|
||||
|
||||
function optionQuoteFromSchema(schema: Readonly<Record<string, unknown>>, optionId: ChoiceKey): string | null {
|
||||
const choice = schema.choice && typeof schema.choice === "object" && !Array.isArray(schema.choice)
|
||||
? schema.choice as Record<string, unknown>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
userVisibleChoiceLine,
|
||||
} from "../src/lib/rectification-agentic/v9/choice-action.ts";
|
||||
import { isNearBottom, shouldFollowLatest, shouldShowJumpToLatest } from "../src/lib/rectification-sticky-scroll.ts";
|
||||
import { persistServerOwnedFocus, stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import {
|
||||
spokenCollectFallbackFollowup,
|
||||
spokenFollowupForUser,
|
||||
@@ -538,14 +538,12 @@ test("clicking A applies the choice without invoking a language model", async ()
|
||||
start: "2015-01-01",
|
||||
end: "2015-12-31",
|
||||
});
|
||||
assert.equal(applied.narration, composeChoiceNarration({
|
||||
optionId: "A",
|
||||
scoring: true,
|
||||
appliedInference: true,
|
||||
}));
|
||||
assert.match(applied.narration, /已记录你的选择/);
|
||||
assert.match(applied.narration, /当前可信区间/);
|
||||
assert.match(applied.narration, /家里有没有结婚、添丁或住院/);
|
||||
const fns = accounting.calls.map((call) => call.fn);
|
||||
assert.ok(fns.includes("apply_agentic_rectification_choice_action"));
|
||||
assert.equal(fns.includes("append_agentic_rectification_turn"), false);
|
||||
assert.equal(fns.includes("append_agentic_rectification_turn"), true);
|
||||
assert.equal(fns.includes("record_agentic_rectification_evidence_batch"), false);
|
||||
assert.equal(fns.some((fn) => fn === "create_agentic_rectification_run_attempt" || fn.includes("stream")), false);
|
||||
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
|
||||
|
||||
@@ -2,14 +2,15 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { applyAnswerToState, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import { applyAnswerToState, buildInferenceState, candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import {
|
||||
decideFromDossier,
|
||||
rectificationFollowupCatalog,
|
||||
type DecisionDossier,
|
||||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { buildMethodFollowupPlan, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts";
|
||||
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
|
||||
import {
|
||||
applyCollectFocusDenial,
|
||||
persistNextInterviewAfterChoice,
|
||||
persistNextInterviewIfIdle,
|
||||
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
@@ -93,6 +95,19 @@ const EVIDENCE = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const LIVE_CASE_EVIDENCE = [
|
||||
...EVIDENCE,
|
||||
{
|
||||
id: "e-education",
|
||||
status: "confirmed",
|
||||
domain: "education",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2016-01-01",
|
||||
occurredTo: null,
|
||||
eventKind: "education_start",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const FAMILY_2021_COLLECT = {
|
||||
year: 2021,
|
||||
year_label: "2021 年前后",
|
||||
@@ -200,6 +215,34 @@ const CAREER_2023: ConflictProbe = {
|
||||
style_options: EXISTENCE_OPTIONS,
|
||||
};
|
||||
|
||||
const CAREER_2023_ACTIVATION: ConflictProbe = {
|
||||
id: "probe:career.2023.dasha_activation",
|
||||
semantic_key: "career.2023.dasha_activation",
|
||||
candidate_split_hash: "career.2023.activation",
|
||||
domain: "career",
|
||||
year: 2023,
|
||||
question: "2023 年前后大运有没有启动?",
|
||||
candidate_ids: CANDIDATES.map((candidate) => candidate.time),
|
||||
expected_outcomes: [
|
||||
{
|
||||
answer_class: "yes",
|
||||
supports: ["05:15"],
|
||||
conflicts: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
|
||||
},
|
||||
{ answer_class: "weak_yes", supports: [], conflicts: [] },
|
||||
{
|
||||
answer_class: "no",
|
||||
supports: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
|
||||
conflicts: ["05:15"],
|
||||
},
|
||||
{ answer_class: "unsure", supports: [], conflicts: [] },
|
||||
],
|
||||
information_gain: 0.56,
|
||||
source: "dasha_activation",
|
||||
choice_kind: "existence",
|
||||
style_options: EXISTENCE_OPTIONS,
|
||||
};
|
||||
|
||||
const RELOCATION_2015: ConflictProbe = {
|
||||
id: "probe:relocation.2015.dasha_boundary",
|
||||
semantic_key: "relocation.2015.dasha_boundary",
|
||||
@@ -369,21 +412,30 @@ function planFrom(
|
||||
|
||||
function rpcDossier(decision: DecisionDossier, activeFocus?: Record<string, unknown> | null) {
|
||||
return dossierFixture({
|
||||
evidence: EVIDENCE.map((item) => ({
|
||||
evidence: decision.evidence.map((item) => ({
|
||||
id: item.id,
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: item.eventKind,
|
||||
event_kind: item.eventKind ?? "event",
|
||||
domain: item.domain,
|
||||
occurred_from: item.occurredFrom,
|
||||
occurred_to: item.occurredTo,
|
||||
date_precision: item.datePrecision,
|
||||
summary: `${item.occurredFrom} ${item.eventKind}`,
|
||||
summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`,
|
||||
status: item.status,
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-29T00:00:00.000Z",
|
||||
})),
|
||||
latestResult: candidateSnapshotFixture({
|
||||
candidates: decision.latestResult?.candidates?.map((item, index) => ({
|
||||
candidate_id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
|
||||
time: item.time,
|
||||
rank: item.rank ?? index + 1,
|
||||
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
|
||||
tied_minute_count: item.tiedMinuteCount ?? 1,
|
||||
})),
|
||||
representativeTime: decision.latestResult?.representativeTime ?? null,
|
||||
evidenceLedgerFingerprint: decision.latestResult?.evidenceLedgerFingerprint,
|
||||
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
|
||||
}),
|
||||
conversationSummary: {
|
||||
@@ -638,6 +690,294 @@ test("free-text turns persist the next followup so current_question is not null"
|
||||
assert.ok(question?.kind === "collect_spoken" || question?.kind === "choice");
|
||||
});
|
||||
|
||||
function occupationCollectFocus(id = FOCUS_ID) {
|
||||
return {
|
||||
id,
|
||||
case_id: CASE_ID,
|
||||
question_id: "collect:occupation:collect_method_evidence",
|
||||
intent: "collect_method_evidence",
|
||||
target_evidence_id: null,
|
||||
target_domain: "occupation",
|
||||
target_kind: null,
|
||||
expected_answer_schema: {
|
||||
collect: true,
|
||||
prompt: "你长期做什么工作?",
|
||||
},
|
||||
status: "active",
|
||||
asked_at: "2026-08-30T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createdFocusFromArgs(args: Record<string, unknown>, id = FOCUS_ID) {
|
||||
return {
|
||||
id,
|
||||
case_id: CASE_ID,
|
||||
question_id: args.p_question_id,
|
||||
intent: args.p_intent,
|
||||
target_evidence_id: args.p_target_evidence_id,
|
||||
target_domain: args.p_target_domain,
|
||||
target_kind: args.p_target_kind,
|
||||
expected_answer_schema: args.p_expected_answer_schema,
|
||||
status: "active",
|
||||
asked_at: "2026-08-30T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function occupationDossier() {
|
||||
return revision5Dossier(revision5State(), {
|
||||
declinedTopics: [{ target_domain: "family", status: "declined" }],
|
||||
});
|
||||
}
|
||||
|
||||
function liveCaseState(): InferenceState {
|
||||
const times = CANDIDATES.map((candidate) => candidate.time);
|
||||
const answered = [D9, D10, RELOCATION_2015, D24, D7];
|
||||
const active = [
|
||||
{ time: "05:00", score: 18, probability: 0.4 },
|
||||
{ time: "05:07", score: 16, probability: 0.33 },
|
||||
{ time: "04:53", score: 14, probability: 0.27 },
|
||||
] as const;
|
||||
const eliminated = times.filter((time) => !active.some((candidate) => candidate.time === time));
|
||||
const orderedTimes = [...active.map((candidate) => candidate.time), ...eliminated];
|
||||
return {
|
||||
algorithm_version: revision5State().algorithm_version,
|
||||
candidate_set_id: candidateSetId("04:47", "05:15", orderedTimes),
|
||||
revision: 6,
|
||||
phase: "discrimination",
|
||||
result_status: "discriminating",
|
||||
range_start: "04:47",
|
||||
range_end: "05:15",
|
||||
candidates: [
|
||||
...active.map((candidate, index) => ({
|
||||
id: candidate.time,
|
||||
time: candidate.time,
|
||||
cluster_range: [candidate.time, candidate.time] as const,
|
||||
prior_score: candidate.score,
|
||||
posterior_score: candidate.score,
|
||||
probability: candidate.probability,
|
||||
status: "active",
|
||||
rank: index + 1,
|
||||
strong_conflict_count: 0,
|
||||
} as const)),
|
||||
...eliminated.map((time, index) => ({
|
||||
id: time,
|
||||
time,
|
||||
cluster_range: [time, time] as const,
|
||||
prior_score: 4 - index,
|
||||
posterior_score: 4 - index,
|
||||
probability: 0,
|
||||
status: "eliminated" as const,
|
||||
rank: active.length + index + 1,
|
||||
strong_conflict_count: 3,
|
||||
})),
|
||||
],
|
||||
events: [
|
||||
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month", usage: "training" },
|
||||
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month", usage: "training" },
|
||||
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day", usage: "training" },
|
||||
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month", usage: "training" },
|
||||
{ id: "e-education", domain: "education", year: 2016, precision: "year", usage: "training" },
|
||||
],
|
||||
probes: [...answered, CAREER_2023_ACTIVATION],
|
||||
answered_probes: answered.map((probe) => ({
|
||||
probe_id: probe.id,
|
||||
semantic_key: probe.semantic_key,
|
||||
candidate_split_hash: probe.candidate_split_hash,
|
||||
answer_class: probe === D24 ? "unsure" : "no",
|
||||
classified_from: "choice",
|
||||
})),
|
||||
rounds: [],
|
||||
last_inference_round: null,
|
||||
entropy: 1.08,
|
||||
representative_time: "05:00",
|
||||
credible_range: ["04:53", "05:07"],
|
||||
holdout_passed: null,
|
||||
};
|
||||
}
|
||||
|
||||
function liveCaseDossier(): DecisionDossier {
|
||||
const state = liveCaseState();
|
||||
return {
|
||||
evidence: LIVE_CASE_EVIDENCE,
|
||||
conversationSummary: {
|
||||
activeFocus: null,
|
||||
declinedSkippedTopics: [{ target_domain: "family", status: "declined" }],
|
||||
},
|
||||
latestResult: {
|
||||
resultId: "55555555-5555-4555-8555-555555555555",
|
||||
selectionAllowed: true,
|
||||
confirmationAllowed: false,
|
||||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(LIVE_CASE_EVIDENCE as never),
|
||||
candidates: state.candidates.map((candidate, index) => ({
|
||||
candidateId: `77777777-7777-4777-8777-${String(index + 1).padStart(12, "0")}`,
|
||||
time: candidate.time,
|
||||
rank: candidate.rank,
|
||||
relativeSupport: Math.round(candidate.posterior_score),
|
||||
})),
|
||||
representativeTime: state.representative_time,
|
||||
decisionReceipt: {
|
||||
accept_allowed: true,
|
||||
propose_allowed: true,
|
||||
selection_allowed: true,
|
||||
inference_state: state,
|
||||
},
|
||||
},
|
||||
case: { acceptedTime: null },
|
||||
};
|
||||
}
|
||||
|
||||
async function persistOccupationAfterChoice(accounting: ReturnType<typeof fakeAccounting>["client"]) {
|
||||
const dossier = occupationDossier();
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
return persistNextInterviewAfterChoice({
|
||||
accounting,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
dossier,
|
||||
decisionState: revision5State(),
|
||||
nextAction: publicNextAction(decision),
|
||||
birthDate: "1997-08-08",
|
||||
});
|
||||
}
|
||||
|
||||
test("duplicate collect focus reloads the active question instead of returning null narration", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier(), occupationCollectFocus()),
|
||||
set_agentic_rectification_conversation_focus: () => {
|
||||
throw new Error("focus_idempotency_conflict");
|
||||
},
|
||||
});
|
||||
const persisted = await persistOccupationAfterChoice(accounting.client);
|
||||
assert.equal(persisted.hostNarration, "你长期做什么工作?");
|
||||
const currentQuestion = projectCurrentQuestion({
|
||||
id: FOCUS_ID,
|
||||
questionId: "collect:occupation:collect_method_evidence",
|
||||
intent: "collect_method_evidence",
|
||||
targetDomain: "occupation",
|
||||
expectedAnswerSchema: { collect: true, prompt: "你长期做什么工作?" },
|
||||
});
|
||||
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
|
||||
assert.equal(accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length, 1);
|
||||
});
|
||||
|
||||
test("skipped collect focus reloads once and retries persistence", async () => {
|
||||
let writes = 0;
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||||
writes += 1;
|
||||
if (writes === 1) throw new Error("temporary focus write failure");
|
||||
return { focus: createdFocusFromArgs(args), idempotent: false };
|
||||
},
|
||||
});
|
||||
const persisted = await persistOccupationAfterChoice(accounting.client);
|
||||
assert.equal(persisted.hostNarration, "你长期做什么工作?");
|
||||
assert.equal(writes, 2);
|
||||
const write = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").at(-1);
|
||||
const currentQuestion = projectCurrentQuestion({
|
||||
id: FOCUS_ID,
|
||||
questionId: String(write?.args.p_question_id ?? ""),
|
||||
intent: String(write?.args.p_intent ?? ""),
|
||||
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
|
||||
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
|
||||
});
|
||||
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
|
||||
});
|
||||
|
||||
test("nonterminal turn exit deterministically restores a spoken question", async () => {
|
||||
const answerChoiceModule = await import("../src/lib/rectification-agentic/v9/answer-choice.ts") as Record<string, unknown>;
|
||||
const ensureExit = answerChoiceModule.ensureNonTerminalTurnExit as undefined | ((input: {
|
||||
accounting: ReturnType<typeof fakeAccounting>["client"];
|
||||
userId: string;
|
||||
caseId: string;
|
||||
adoptCarrierReady: boolean;
|
||||
}) => Promise<{ hostNarration: string | null; persisted: boolean }>);
|
||||
assert.equal(typeof ensureExit, "function");
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||||
return { focus: createdFocusFromArgs(args), idempotent: false };
|
||||
},
|
||||
});
|
||||
const repaired = await ensureExit!({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
adoptCarrierReady: false,
|
||||
});
|
||||
assert.equal(repaired.persisted, true);
|
||||
assert.match(repaired.hostNarration ?? "", /当前可信区间/);
|
||||
assert.match(repaired.hostNarration ?? "", /05:07/);
|
||||
assert.match(repaired.hostNarration ?? "", /代表分钟/);
|
||||
const write = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||||
const currentQuestion = projectCurrentQuestion({
|
||||
id: FOCUS_ID,
|
||||
questionId: String(write?.args.p_question_id ?? ""),
|
||||
intent: String(write?.args.p_intent ?? ""),
|
||||
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
|
||||
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
|
||||
});
|
||||
assert.equal(currentQuestion?.kind, "collect_spoken");
|
||||
assert.ok(currentQuestion?.prompt);
|
||||
});
|
||||
|
||||
test("live five-evidence case ends on the occupation spoken collect", async () => {
|
||||
const dossier = liveCaseDossier();
|
||||
const state = liveCaseState();
|
||||
assert.equal(dossier.evidence.length, 5);
|
||||
assert.equal(state.answered_probes.length, 5);
|
||||
assert.deepEqual(state.candidates.filter((candidate) => candidate.status === "active").map((candidate) => candidate.time), [
|
||||
"05:00", "05:07", "04:53",
|
||||
]);
|
||||
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
assert.equal(decision.probe, null);
|
||||
assert.equal(decision.nextAction, "offer_provisional_range");
|
||||
assert.equal(decision.canAdopt, false);
|
||||
assert.ok(decision.droppedProbes.some((probe) => (
|
||||
probe.semantic_key === CAREER_2023_ACTIVATION.semantic_key
|
||||
&& probe.reason === "no_split_among_active"
|
||||
)), JSON.stringify(decision.droppedProbes));
|
||||
|
||||
const plan = planFrom(dossier);
|
||||
assert.equal(plan.next_followup?.method_id, "occupation");
|
||||
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
||||
assert.equal(spokenFollowupForUser(plan.next_followup), "你长期做什么工作?");
|
||||
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: createdFocusFromArgs(args),
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const persisted = await persistNextInterviewAfterChoice({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
dossier,
|
||||
decisionState: state,
|
||||
nextAction: publicNextAction(decision),
|
||||
birthDate: "1997-08-08",
|
||||
});
|
||||
assert.match(persisted.hostNarration, /你长期做什么工作?/);
|
||||
const write = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||||
const currentQuestion = projectCurrentQuestion({
|
||||
id: FOCUS_ID,
|
||||
questionId: String(write?.args.p_question_id ?? ""),
|
||||
intent: String(write?.args.p_intent ?? ""),
|
||||
targetDomain: typeof write?.args.p_target_domain === "string" ? write.args.p_target_domain : null,
|
||||
expectedAnswerSchema: write?.args.p_expected_answer_schema as Record<string, unknown>,
|
||||
});
|
||||
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
|
||||
});
|
||||
|
||||
test("coverage incomplete still prefers a dated discriminator over a same-turn yearless varga card", () => {
|
||||
const state = revision5State([DATED_RELOCATION_2016]);
|
||||
const plan = planFrom(revision5Dossier(state));
|
||||
|
||||
Reference in New Issue
Block a user