fix(rectification): prevent collect focus dead-end after choice answers
This commit is contained in:
+11
-11
@@ -6895,18 +6895,18 @@
|
||||
- 复发自:BUG-441
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-450 | 非收敛区间提议无数值、无时间卡、无下一问
|
||||
## BUG-450 | 点选题答完后的非收敛区间提议卡死
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-30
|
||||
- 最近更新:2026-08-30
|
||||
- 影响面:`offerRangeWithoutAdopt`、`persistNextInterviewIfIdle`、`projectRectificationChoiceCard`、`buildCandidateContrastPacket`、出题层 `rankRenderableDiscriminators`、POST `/api/rectification/agent` 消息快路径
|
||||
- 用户现象:答完最后一问后助手说「可以先按当前区间看盘,也可以再补一件记得时间的经历」,界面既没有可采用的时间卡,也没有下一个问题,正文里连区间数值都没有,对话到此为止。
|
||||
- 触发条件:训练门已开、occupation 等阻塞覆盖未齐、`datedMethodCollectOpen` 为假、引擎已放行 accept/propose、`probe === null`、`canOfferRange=true` 且 `canAdopt` / `selectionAllowed` / `proposeAllowed` 全假。本例活跃候选 04:47/04:51/04:53/04:59,family 已 declined,账本已有 2020 career 与 2024 relationship。
|
||||
- 根因:三段叠加。(1) P0 消费端死胡同:`offerRangeWithoutAdopt` 把 `canOfferRange` 设真却把 `canAdopt`/`selectionAllowed` 保持假;`isNonConvergingRangeOffer` 让 `persistNextInterviewIfIdle` 早退不落下一问;`projectRectificationChoiceCard` 对 `provisional_range` 一律 null;前端时间卡要 `selectionAllowed`。同一句双出口常量又重复写在 `rectification-decision.ts`、`route.ts`、`answer-choice.ts`,正文许诺看盘或再补经历,两个出口都没有承载。(2) P1 `probe=null` 的真实输入:年份探针活在 `inference_state.probes`(对比包)而不是 `discriminating_event_probes`。`buildCandidateContrastPacket` 对非结构化探针在 `provided.has(domain)` 时整域丢弃,2020 career 证据因此杀掉全部 career 存在/大运题,包括 `career.2024.04.dasha_boundary`。这与出题层「Dasha existence 跳过账本已有的那年,不是整个领域」不一致。`remainingConflictProbes` 走年份邻近规则,所以把同一探针放进 `discriminating_event_probes` 的重建会问出来。出题层在 discriminate 模式下于是落到 occupation `method_coverage` 口述题,`followupAsksRenderableDiscriminator` 为假,决策把 `probe` 打成 null。occupation 阻塞 adopt 但不在 `datedMethodCollectOpen` 里,于是进入 `offerRangeWithoutAdopt`。该跳过没有写入 `dropped_probes`。已排除:`askedKeys` 把 `candidate_split_hash` 误判为已答(2023.05 与 2024.04 哈希不同);`activeFocus` 未关(`discriminatorProbeIfFollowupCanAsk` 不传入旧焦点)。(3) P2:即使按年保留,`career.2024.04` 切的是 05:00 之后 vs 之前,`career.2023.dasha_activation` 只拎 05:15;淘汰后活跃四分钟上信息量为 0。出题层原先沿用建题时对全窗口的 IG,零区分力探针既不问也不 drop。
|
||||
- 修复:P0 选口述采集出口,不放开确认门与 `canAdopt`(occupation 仍挡住 adopt,也不新造无选项容器)。`offerRangeWithoutAdopt` 正文改为 `nonConvergingRangeNarration`(区间数值 + 代表分钟 + 既有免责口径);同一轮 `exhaustionSpokenCollectFollowup` 按 family → education → finance 落一条未用过的 `oos_blind` 口述题并持久化,使 `current_question` 非 null。三处相同字符串合并为该导出函数。P1:对比包与 `rankRenderableDiscriminators` 对带年份非结构化探针改为按年跳过,不再整域丢弃。P2:按当前活跃候选重算信息量;零区分力写入 `dropped_probes.reason=no_split_among_active`。未放宽 `MIN_BOUNDARY_DAYS`、distinguish 契约、confirmation gate,未回退 b43808b0 无年份收窄。
|
||||
- 验证:`frontend/tests/rectification-range-offer-deadend.test.ts`。修复前:`persistNextInterviewIfIdle` 在 `isNonConvergingRangeOffer` 返回 `{persisted:false, hostNarration:null}`;正文是无数值的双出口常量;`projectRectificationChoiceCard` 为 null;`canAdopt=false`。修复后:正文含 `04:47–04:53` 与代表分钟 `04:51`,并落 education `collect_spoken`。P1 最小夹具:探针只在 `inference_state.probes`、career 领域已有 2020 证据、occupation 未覆盖;对活跃四分钟仍有切分的 `career.2022.dasha_boundary` 走 `ask_candidate_discriminator`,不得进区间提议。P2:`career.2024.04.dasha_boundary` 与 `career.2023.dasha_activation` 进入 `dropped_probes` 且 reason 为 `no_split_among_active`。`userStopped` 仍走 `provisional_range_user_stopped` 且 `canAdopt=true`。`rectification-collect-stall` / `rectification-spoken-collect` / BUG-442 occupation / b43808b0 无年份收窄保持绿。`assert.doesNotMatch(/answerText\.(?:includes|match|search)\(/)` 仍绿。
|
||||
- 防复发:助手正文承诺的每个出口,同一轮必须有对应的可点承载;区间提议必须带区间数值与代表分钟口径;探针信息量必须对当前活跃候选计算,失去区分力的探针要显式 drop 而不是静默留着。不得为了出看盘按钮而放宽 `canAdopt` / confirmation gate。
|
||||
- 相关记录:BUG-432、BUG-440、BUG-441、BUG-442、BUG-449
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
- 影响面:`offerRangeWithoutAdopt`、`persistNextInterviewIfIdle`、`projectRectificationChoiceCard`、`buildCandidateContrastPacket`、出题层 `rankRenderableDiscriminators`、`record-evidence-batch`、POST `/api/rectification/agent` 消息快路径
|
||||
- 用户现象:点选题答完后,助手说「可以先按当前区间看盘,也可以再补一件记得时间的经历」;界面既没有可采用的时间卡,也没有下一道口述题。用户随后用真实带时间经历回答时,旧焦点仍指向已经回答的采集题,对话到此停止。
|
||||
- 触发条件:训练门已开、occupation 等方法覆盖未齐、`datedMethodCollectOpen` 为假、引擎已放行 accept/propose、`probe === null`、`canOfferRange=true` 但 `canAdopt` / `selectionAllowed` / `proposeAllowed` 全为假;随后 `collect_spoken` 焦点收到已成功入账的真实事件,且本轮工具调用可能先执行 `record-evidence-batch`。
|
||||
- 根因:五段同时成立。(A) 探针耗尽是假耗尽:带年份探针的分支只把已淘汰候选切出,当前活跃候选在所有 `answer_class` 分支中待遇相同,按活跃集合重算信息量为零;旧出题层沿用全窗口信息量,不重算,也不把该探针写入 `dropped_probes`,于是以 `probe=null` 静默失效。(B) `probe=null` 且方法覆盖未齐时,`rectification-decision.ts` 的 `offerRangeWithoutAdopt` 仍把 `canOfferRange` 设为真,但保持 `canAdopt` / `selectionAllowed` / `proposeAllowed` 为假并关闭 focus;这把本应继续补覆盖的状态送进没有承载的区间出口。(C) `provisional_range` 的 `deferAdoption` 原本连 `intent=collect_method_evidence` 的覆盖追问一起吞掉,把它从 `next_followup` 挪到 `deferred_followup`;消费端不读后者,因覆盖不足进入的状态反而结构性阻止补覆盖,形成自锁。(D) 用户用真实事件回答口述采集焦点并成功落库后,服务端没有按 active focus 确定性执行 `resolved`;焦点继续存在,`persistNextInterviewIfIdle` 因 active focus 提前返回,旧题仍出现在 `current_question`。这与「确实没有」必须走 `declined` 的语义不同。(E) 本轮工具顺序可能是 `record-evidence-batch` 先于 `rectification-read-case`,模型未拿到服务端 focusId 时仍能记证据;旧实现把工具顺序当成硬前置,既没有先补读 dossier,也没有保证后续焦点关闭和下一问落库。
|
||||
- 修复:P0:`provisional_range` 只对 `intent=distinguish_candidates` 延后 followup;`collect_method_evidence` 覆盖题继续从 `next_followup` 返回并正常落焦点。`adopt_representative` / `validated_range` / `exact_minute_confirmed` / `provisional_range_user_stopped` / `completed_with_range` 的既有收口行为不变。P1:对比包和 `rankRenderableDiscriminators` 均按当前活跃候选重算探针信息量;零区分力探针显式写入 `dropped_probes.reason=no_split_among_active`,不放宽 `MIN_BOUNDARY_DAYS`、distinguish 合同或确认门。P2:`record-evidence-batch` 成功且当前 active focus 是 `collect_spoken` 时,服务端按 dossier 中的 focus 自行 `resolve(status=resolved, evidenceId=acceptedEvidenceId)`,不依赖模型传 `focusId`;否定回答仍走 `declined`。关闭后继续走下一问持久化。P3:`offerRangeWithoutAdopt` 使用统一的 `nonConvergingRangeNarration` 输出可信区间、代表分钟及「代表分钟不是已确认的唯一出生分钟」口径,并在同一轮持久化至少一个可执行的 `collect_spoken` focus;不为无选项问题新造视觉容器,也不为看盘按钮放宽 `canAdopt` / `confirmationAllowed`。P4:若本轮尚未成功执行 `rectification-read-case`,`record-evidence-batch` 先自行读取一次 dossier 再落库,不因模型工具顺序错误打断用户。
|
||||
- 验证:修复前基线(`79f65ac8`)实际回归输出:record-before-read 时第一条 RPC 为 `insert_agentic_rectification_tool_receipt` 而非 dossier read;`provisional_range.next_followup` 为 `undefined` 而不是 `collect_method_evidence`。修复后 `tests/rectification-eight-method.test.ts` 为 62/62;该套件同时锁定首条 RPC、accepted evidence 使用其 evidence ID 关闭 spoken collect focus、resolve 发生在 record 之后且不再返回旧题;`tests/rectification-range-offer-deadend.test.ts` 覆盖活跃候选零信息量 drop、仍有真实 split 时继续出区分卡、数字区间与代表分钟叙述、口述 focus 落库、userStopped 行为;`tests/rectification-spoken-collect.test.ts` 与 BUG-440 / BUG-442 / b43808b0 无年份收窄回归保持绿。`npx tsc --noEmit` 通过;改动文件 ESLint 0 error(6 条既有 unused-vars warning);focused rectification/contract suite 727/727;全量 `tests/*.test.ts` 2339/2339;`/opt/anaconda3/bin/python3.12 -m pytest tests/test_rectification_event_probes.py tests/test_candidate_discriminator_contract.py` 35/35。
|
||||
- 防复发:任何因缺某项覆盖而进入的状态,不得阻止获取该项覆盖;探针信息量必须针对当前活跃候选计算,失去区分力的探针必须显式 drop;口述采集焦点被真实证据回答后必须由服务端确定性关闭,不依赖模型传 `focusId`;`resolved` 与 `declined` 语义不得互换;助手正文承诺的每个出口,同一轮必须有对应承载,区间提议必须带数值与代表分钟口径;`record-evidence-batch` 不得因未先 read-case 而丢弃用户证据。不得用 `answerText` 文本判断问题是否已经问出,不得引入语义正则、关键词表或 A/B/C/D 位置推断,不得回退无年份 `varga_style` 收窄或放宽 confirmation gate。
|
||||
- 相关记录:BUG-405、BUG-407、BUG-432、BUG-440、BUG-441、BUG-442、BUG-449
|
||||
- 复发自:BUG-432(出题层/决策层/可见承载再次分裂)、BUG-440(覆盖不足状态再次空转)、BUG-441/BUG-449(口述焦点可见性与服务端兜底边界未覆盖本路径)
|
||||
- 修复版本:`fix(rectification): prevent collect focus dead-end after choice answers`(Skill 保持 `10.0.13`)
|
||||
|
||||
@@ -1944,13 +1944,15 @@ export function buildMethodFollowupPlan(input: {
|
||||
const deferAdoption = sessionOutcome === "adopt_representative"
|
||||
|| sessionOutcome === "validated_range"
|
||||
|| sessionOutcome === "exact_minute_confirmed"
|
||||
|| sessionOutcome === "provisional_range"
|
||||
|| sessionOutcome === "provisional_range_user_stopped"
|
||||
|| sessionOutcome === "completed_with_range";
|
||||
const deferProvisionalDiscriminator = sessionOutcome === "provisional_range"
|
||||
&& next?.intent === "distinguish_candidates";
|
||||
const deferFollowup = deferAdoption || deferProvisionalDiscriminator;
|
||||
return {
|
||||
methods,
|
||||
next_followup: deferAdoption ? null : next,
|
||||
deferred_followup: deferAdoption ? next : null,
|
||||
next_followup: deferFollowup ? null : next,
|
||||
deferred_followup: deferFollowup ? next : null,
|
||||
session_outcome: sessionOutcome ?? "collect_evidence",
|
||||
stop_domain_rotation: true,
|
||||
do_not_poll: DO_NOT_POLL,
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
stampChoiceSchemaWithProbe,
|
||||
} from "@/lib/rectification-agentic/v9/inference-adapter";
|
||||
import {
|
||||
isCollectFocusSchema,
|
||||
openQuestionFromPersistedFocus,
|
||||
persistServerOwnedFocus,
|
||||
} from "@/lib/rectification-agentic/v9/server-focus";
|
||||
@@ -738,6 +739,7 @@ export function createRectificationV9ReadOnlyTools(ctx: RectificationV9Context)
|
||||
export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
const { accounting, userId, caseId, turnId, attemptId, userMessage } = ctx;
|
||||
const engineVersion = v9EngineVersion();
|
||||
let hasReadCase = false;
|
||||
|
||||
const receipt = async (
|
||||
toolName: string,
|
||||
@@ -1109,6 +1111,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
)
|
||||
: projectTurnDecision(refreshed);
|
||||
await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) });
|
||||
hasReadCase = true;
|
||||
return projection;
|
||||
}
|
||||
}
|
||||
@@ -1120,6 +1123,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
)
|
||||
: projectTurnDecision(dossier);
|
||||
await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) });
|
||||
hasReadCase = true;
|
||||
return projection;
|
||||
} catch (error) {
|
||||
await receipt("rectification-read-case", "case.loaded", "failed", { inputFingerprint, safeErrorCode: safeToolErrorCode(error) });
|
||||
@@ -1360,6 +1364,10 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
}).strict(),
|
||||
execute: async (input) => {
|
||||
assertCaseRef(input);
|
||||
if (!hasReadCase) {
|
||||
await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
hasReadCase = true;
|
||||
}
|
||||
for (const item of input.items) {
|
||||
if (!isEvidenceKind(item.proposedKind)) throw new RectificationToolServiceError("invalid_event_kind");
|
||||
if (!isEvidenceDomain(item.domain)) throw new RectificationToolServiceError("invalid_domain");
|
||||
@@ -1459,6 +1467,22 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
}));
|
||||
if (result.acceptedCount > 0) {
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
const acceptedEvidenceId = result.items.find(
|
||||
(item) => item.outcome === "accepted" && item.evidenceId,
|
||||
)?.evidenceId ?? null;
|
||||
const activeFocus = dossier.conversationSummary.activeFocus;
|
||||
if (
|
||||
acceptedEvidenceId
|
||||
&& activeFocus
|
||||
&& activeFocus.intent === "collect_method_evidence"
|
||||
&& isCollectFocusSchema(activeFocus.expectedAnswerSchema)
|
||||
) {
|
||||
await resolveV10ConversationFocus(accounting, userId, input.caseId, {
|
||||
focusId: activeFocus.id,
|
||||
status: "resolved",
|
||||
evidenceId: acceptedEvidenceId,
|
||||
});
|
||||
}
|
||||
if (isResumableStatus(dossier.case.status as RectificationCaseStatus)) {
|
||||
await transitionV9CaseStatus(accounting, userId, input.caseId, "collecting_evidence");
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ import {
|
||||
USER_ID,
|
||||
candidateSnapshotFixture,
|
||||
computeFixture,
|
||||
conversationSummaryFixture,
|
||||
dossierFixture,
|
||||
activeFocusFixture,
|
||||
fakeAccounting,
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
@@ -918,14 +920,24 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin
|
||||
assert.match(JSON.stringify(projection.method_followup_plan.next_followup), /自然语言/);
|
||||
});
|
||||
|
||||
test("accepted batch evidence triggers server rescore without offering adoption", async () => {
|
||||
test("accepted batch evidence resolves a spoken collect focus before the next question", async () => {
|
||||
const restore = stubEngine(ENGINE_SCORE);
|
||||
let collectFocusResolved = false;
|
||||
try {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
evidence: [educationEvidence],
|
||||
latestResult: null,
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
activeFocus: collectFocusResolved
|
||||
? null
|
||||
: activeFocusFixture({
|
||||
intent: "collect_method_evidence",
|
||||
targetDomain: "education",
|
||||
expectedAnswerSchema: { collect: true, prompt: "有没有记得住时间的升学经历?" },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
record_agentic_rectification_evidence_batch: () => ({
|
||||
@@ -943,6 +955,15 @@ test("accepted batch evidence triggers server rescore without offering adoption"
|
||||
rejected_count: 0,
|
||||
focus_id: null,
|
||||
}),
|
||||
resolve_agentic_rectification_conversation_focus: (_fn, args) => {
|
||||
collectFocusResolved = true;
|
||||
return {
|
||||
focus_id: args.p_focus_id,
|
||||
status: args.p_status,
|
||||
evidence_id: args.p_evidence_id,
|
||||
idempotent: false,
|
||||
};
|
||||
},
|
||||
persist_agentic_rectification_candidate_v2: () => ({
|
||||
...candidateSnapshotFixture(),
|
||||
cached: false,
|
||||
@@ -959,6 +980,7 @@ test("accepted batch evidence triggers server rescore without offering adoption"
|
||||
execute(input: unknown): Promise<{
|
||||
accepted_count: number;
|
||||
rescore: { status: string; executed_methods: string[]; error_code: string | null };
|
||||
open_question: { prompt?: string } | null;
|
||||
}>;
|
||||
}).execute({
|
||||
caseId: CASE_ID,
|
||||
@@ -975,6 +997,15 @@ test("accepted batch evidence triggers server rescore without offering adoption"
|
||||
assert.equal(result.accepted_count, 1);
|
||||
assert.equal(result.rescore.status, "completed");
|
||||
assert.ok(result.rescore.executed_methods.includes("d1-rashi"));
|
||||
assert.equal(accounting.calls[0]?.fn, "get_agentic_rectification_case_dossier");
|
||||
const resolvedFocus = accounting.calls.find((call) => call.fn === "resolve_agentic_rectification_conversation_focus");
|
||||
assert.equal(resolvedFocus?.args.p_focus_id, FOCUS_ID);
|
||||
assert.equal(resolvedFocus?.args.p_status, "resolved");
|
||||
assert.equal(resolvedFocus?.args.p_evidence_id, EDUCATION_ID);
|
||||
const recordIndex = accounting.calls.findIndex((call) => call.fn === "record_agentic_rectification_evidence_batch");
|
||||
const resolveIndex = accounting.calls.findIndex((call) => call.fn === "resolve_agentic_rectification_conversation_focus");
|
||||
assert.ok(recordIndex >= 0 && resolveIndex > recordIndex);
|
||||
assert.doesNotMatch(result.open_question?.prompt ?? "", /升学经历/);
|
||||
const persistCall = accounting.calls.find((call) => call.fn === "persist_agentic_rectification_candidate_v2");
|
||||
assert.ok(persistCall);
|
||||
const receipt = persistCall.args.p_decision_receipt as { window_scan?: { d9_candidates_differ?: boolean; d9_sign_names?: unknown } };
|
||||
@@ -2306,7 +2337,7 @@ test("coverage-complete tie with encoded D24/D10 collects a dated move instead o
|
||||
}), "discriminate_candidates");
|
||||
});
|
||||
|
||||
test("coverage-complete tie with no remaining split offers a provisional range", () => {
|
||||
test("provisional range still exposes method coverage followup", () => {
|
||||
const packet = buildCandidateContrastPacket({
|
||||
candidateSetVersion: "05:00-05:04",
|
||||
candidateTimes: DUMP_SCORES.map((item) => item.time),
|
||||
@@ -2329,7 +2360,9 @@ test("coverage-complete tie with no remaining split offers a provisional range",
|
||||
contrastPacket: packet,
|
||||
sessionOutcome: "provisional_range",
|
||||
});
|
||||
assert.equal(plan.next_followup, null);
|
||||
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
||||
assert.equal(plan.next_followup?.domain, "horary");
|
||||
assert.equal(plan.deferred_followup, null);
|
||||
assert.equal(conversationalSessionOutcome({
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
|
||||
Reference in New Issue
Block a user