diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1ffd7ff2..7141f957 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7653,15 +7653,15 @@ - 状态:resolved - 首次发现:2026-09-02 -- 最近更新:2026-09-02 -- 影响面:`publicDecisionFields`、`POST .../candidates/accept`、`parseRectificationCandidateResult`、`rectification-agentic-chat` -- 用户现象:采集题还在问时,同一条助手消息下面出现三张「采用此时间」卡。 -- 触发条件:`session_outcome=collect_evidence`,内部 `capability.canAdopt=true`,当前题是 `collect_spoken`。 -- 根因:`collect()` 把 `canAdopt` 原样投影成公开 `can_adopt`。前端 `showSelectionCards` 只靠 `!showLiveChoiceCard`,采集题没有 A–D 卡,采用卡就露出来。v9 删掉 offer 门后,BUG-313 / BUG-323 的防复发条款失效。 -- 修复:公开 `can_adopt` 只在 `adopt_representative` / `provisional_range_user_stopped` / `awaiting_confirmation` / `validated_range` 为真(后者是 holdout 已过、ready_to_adopt 的既有语义,不是新开口)。accept 在 `can_adopt=false` 时 409;前端再按 `session_outcome` 双保险。采集阶段只显示只读范围行,composer 旁提供「先这样」。 -- 验证:`rectification-adopt-flow-20260902`、`rectification-decide-next-action`、`rectification-confirmation-gate`、`rectification-candidate-result`;accept 源码锁 409 在 RPC 之前。 -- 防复发:`can_adopt` 只能收紧,不得在 `collect_evidence` / `discriminate_candidates` / `validate_holdout` 放行。`exact_minute_confirmed` / `completed_with_range` 不得新增放行。 -- 相关记录:BUG-313、BUG-323 +- 最近更新:2026-09-03 +- 影响面:`publicDecisionFields`、`POST .../candidates/accept`、`parseRectificationCandidateResult`、`rectification-agentic-chat`、`OPENING_COLLECT_DOMAIN` +- 用户现象:采集题还在问时,同一条助手消息下面出现三张「采用此时间」卡。开场「随便一件带时间的经历」被跳过或答成搬家后,真正的教育采集题变成「再补一件」,或教育域被当成已拒。 +- 触发条件:`session_outcome=collect_evidence`,内部 `capability.canAdopt=true`,当前题是 `collect_spoken`。开场题曾以 `collect:education:collect_method_evidence` 持久化。 +- 根因:`collect()` 把 `canAdopt` 原样投影成公开 `can_adopt`。前端 `showSelectionCards` 只靠 `!showLiveChoiceCard`,采集题没有 A–D 卡,采用卡就露出来。v9 删掉 offer 门后,BUG-313 / BUG-323 的防复发条款失效。开场题不是教育题,却以 `education` 持久化。 +- 修复:公开 `can_adopt` 只在 `adopt_representative` / `provisional_range_user_stopped` / `awaiting_confirmation` / `validated_range` 为真(后者是 holdout 已过、ready_to_adopt 的既有语义,不是新开口)。accept 在 `can_adopt=false` 时 409;前端再按 `session_outcome` 双保险。采集阶段只显示只读范围行,composer 旁提供「先这样」。开场域改为 `other`(id `collect:other:collect_method_evidence`);`declinedDomains` / `domainCollectFocusAsked` 忽略 `collect:other:` 开场行。occupation 仍走 `collect:occupation:`。 +- 验证:`rectification-adopt-flow-20260902`、`rectification-decide-next-action`、`rectification-confirmation-gate`、`rectification-candidate-result`;accept 源码锁 409 在 RPC 之前。`rectification-adopt-flow-fix-20260903` 跳过开场后 exhaustion 仍给 education 且无 `collect_retry`。 +- 防复发:`can_adopt` 只能收紧,不得在 `collect_evidence` / `discriminate_candidates` / `validate_holdout` 放行。`exact_minute_confirmed` / `completed_with_range` 不得新增放行。开场题不得占用 education/career/relationship/family,不得为 unknown。不改 DB check 约束。 +- 相关记录:BUG-313、BUG-323、BUG-501 - 复发自:BUG-313、BUG-323 - 修复版本:待发布 @@ -7669,14 +7669,14 @@ - 状态:resolved - 首次发现:2026-09-02 -- 最近更新:2026-09-02 -- 影响面:`keepAcceptedFocus`、`projectRectificationChoiceCard`、`rectification-set-focus` -- 用户现象:点「采用此时间」后出现核对题,嵌入卡 disabled,点不了 A–D。 -- 触发条件:已采用,active focus 为 `reverse_verify:education_style:score`,随后 GET / set-focus 重算 followup。 -- 根因:承接分支用 `method_id: active_focus` 重建 `question_id`,与已持久化 id 不一致;`projectRectificationChoiceCard` 按设计拒绝串题,返回 null。核对题 schema 还被最高增益区分探针盖上 relocation identity。 -- 修复:`keepAcceptedFocus` / `out_of_sample_check` 沿用已持久化 `questionId`、`ask_theme`、`domain` 和选项。不放宽 choice-card 校验。reverse_verify schema 不盖区分探针。spokenPrompt 含钟点时保留服务端题干以免卡片不可解析。区分题 keep 仍走 `probe:` semantic_key。 -- 验证:`rectification-adopt-flow-20260902`、`rectification-question-ownership` 采用后续跑 set-focus。 -- 防复发:无 semantic_key 的 followup 必须 `focus.questionId === frame.question_id`。 +- 最近更新:2026-09-03 +- 影响面:`keepAcceptedFocus`、`projectRectificationChoiceCard`、`rectification-set-focus`、`expectedAnswerSchemaFor` +- 用户现象:点「采用此时间」后出现核对题,嵌入卡 disabled,点不了 A–D。题干有两个年份时沿用错年;schema 缺 `probe_year` 时从文案反推。 +- 触发条件:已采用,active focus 为 `reverse_verify:education_style:score`,随后 GET / set-focus 重算 followup。schema 无 `probe_year` 且题干含 `19xx`/`20xx`。 +- 根因:承接分支用 `method_id: active_focus` 重建 `question_id`,与已持久化 id 不一致;`projectRectificationChoiceCard` 按设计拒绝串题,返回 null。核对题 schema 还被最高增益区分探针盖上 relocation identity。`keepProbeYear` 用题干正则兜底,和「不得用字符串反推状态」同向。 +- 修复:`keepAcceptedFocus` / `out_of_sample_check` 沿用已持久化 `questionId`、`ask_theme`、`domain` 和选项。不放宽 choice-card 校验。reverse_verify schema 不盖区分探针。spokenPrompt 含钟点时保留服务端题干以免卡片不可解析。区分题 keep 仍走 `probe:` semantic_key。reverse_verify / oos schema 写入 `followup.probe_year`;keep 只取匹配探针或 schema;缺年则省略。删除题干正则。 +- 验证:`rectification-adopt-flow-20260902`、`rectification-question-ownership` 采用后续跑 set-focus。`rectification-adopt-flow-fix-20260903` 无 schema 年且无匹配探针时 followup 不带 `probe_year`,`method-followup.ts` 无年份正则。 +- 防复发:无 semantic_key 的 followup 必须 `focus.questionId === frame.question_id`。不得从题干/正文抠年份或其它状态。 - 相关记录:BUG-492、BUG-497 - 复发自:无 - 修复版本:待发布 @@ -7685,15 +7685,15 @@ - 状态:resolved - 首次发现:2026-09-02 -- 最近更新:2026-09-02 +- 最近更新:2026-09-03 - 影响面:`rectification-agentic-chat`、`attachOfferResultToTurns`、核对题 skip 文案 -- 用户现象:采用后三张卡消失,列表末尾出现「用这个时间看盘」;续跑结束后卡又出现在新消息下,与核对题并存。被关掉的采集题无痕消失。 +- 用户现象:采用后三张卡消失,列表末尾出现「用这个时间看盘」;续跑结束后卡又出现在新消息下,与核对题并存。被关掉的采集题无痕消失。状态条「改选」曾是不可点的 ``。 - 触发条件:采用后续跑 `busy`;`savedStatus=accepted` 渲染 `rectification-consult-handoff`。 -- 根因:卡永远锚在最新助手消息;采用后的界面动作散落在列表末尾,不跟出卡消息走。 -- 修复:出卡消息拥有 `candidateOffer`;采用后原地结算「已采用 / 改选」;状态条承接「改选 / 用这个时间看盘」;核对题「这题跳过」;被关掉的采集题标「已跳过」。 -- 验证:`rectification-agentic-entry`、`rectification-activity-receipt`、`rectification-adopt-flow-20260902` 组件源码锁。 -- 防复发:已采用后不得把卡锚到最新消息;不得再渲染 `rectification-consult-handoff`。 -- 相关记录:BUG-497、BUG-490 +- 根因:卡永远锚在最新助手消息;采用后的界面动作散落在列表末尾,不跟出卡消息走。「改选」赋了 `offerSectionRef` 但从不清滚动。 +- 修复:出卡消息拥有 `candidateOffer`;采用后原地结算「已采用 / 改选」;状态条承接「改选」;核对题「这题跳过」;被关掉的采集题标「已跳过」。「改选」改为 ` + )} + 之后新建对话即按此时间排盘。 )} {(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && ( diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index e6c9e788..fb39f3f2 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -312,12 +312,6 @@ export function useSessionManagement(params: SessionManagementParams) { } } - async function startConsultationAfterRectification() { - await startNewChat(); - setDraft("请用刚才采用的代表性出生时间看盘。"); - setComposerNotice("已用刚才采用的时间作为当前排盘。这还不是唯一分钟确认。"); - } - function selectSession(sessionId: string) { const nextSession = sessions.find((session) => session.id === sessionId); setActiveSessionId(sessionId); @@ -437,7 +431,6 @@ export function useSessionManagement(params: SessionManagementParams) { toggleArchivedSession, shareSession, startNewChat, - startConsultationAfterRectification, selectSession, selectSessionModel, }; diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 1e678ffa..785bfa61 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -8,7 +8,7 @@ import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint"; import { nextProbe } from "../core/build-state"; -import { RECTIFICATION_TERMINATION_COPY, isNonConvergingRangeOffer, nonConvergingRangeNarration, publicNextAction } from "../core/rectification-decision.ts"; +import { RECTIFICATION_TERMINATION_COPY, isNonConvergingRangeOffer, nonConvergingRangeNarration, publicCanAdopt, publicNextAction } from "../core/rectification-decision.ts"; import { deliveryAdoptNarration, openingRangeFromCandidateRange, @@ -709,7 +709,7 @@ export async function persistNextInterviewIfIdle(input: { }); const followup = interviewToPersist(plan); if (shouldSkipFollowupPersist({ - canAdopt: decision.canAdopt, + canAdopt: publicCanAdopt(decision), nextAction: decision.nextAction, followup, methods: plan.methods, @@ -1008,7 +1008,7 @@ async function inspectNonTerminalTurnExit(input: { || dossier.case.acceptedTime || dossier.case.confirmedTime || decision.completionStatus === "provisional_range_user_stopped" - || decision.canAdopt + || publicCanAdopt(decision) ); return { dossier, decision, satisfied }; } diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 80b705df..5afbc907 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -348,16 +348,33 @@ function declinedHealth(declined: Set): boolean { return declined.has("health") || declined.has("health_pressure"); } +function topicDomain(topic: Readonly>): string | null { + return typeof topic.target_domain === "string" + ? topic.target_domain + : typeof topic.targetDomain === "string" + ? topic.targetDomain + : null; +} + +function topicQuestionId(topic: Readonly>): string { + return typeof topic.questionId === "string" + ? topic.questionId + : typeof topic.question_id === "string" + ? topic.question_id + : ""; +} + +function isOpeningOtherCollectTopic(topic: Readonly>): boolean { + return topicDomain(topic) === "other" && topicQuestionId(topic).startsWith("collect:other:"); +} + function declinedDomains( topics: readonly Readonly>[], ): Set { const domains = new Set(); for (const topic of topics) { - const domain = typeof topic.target_domain === "string" - ? topic.target_domain - : typeof topic.targetDomain === "string" - ? topic.targetDomain - : null; + if (isOpeningOtherCollectTopic(topic)) continue; + const domain = topicDomain(topic); if (domain) domains.add(domain); } return domains; @@ -370,16 +387,9 @@ function domainCollectFocusAsked( return topics.some((topic) => { const status = typeof topic.status === "string" ? topic.status : ""; if (status === "active" || status === "declined") return false; - const target = typeof topic.target_domain === "string" - ? topic.target_domain - : typeof topic.targetDomain === "string" - ? topic.targetDomain - : null; - const questionId = typeof topic.questionId === "string" - ? topic.questionId - : typeof topic.question_id === "string" - ? topic.question_id - : ""; + if (isOpeningOtherCollectTopic(topic)) return false; + const target = topicDomain(topic); + const questionId = topicQuestionId(topic); const intent = typeof topic.intent === "string" ? topic.intent : ""; if (questionId.startsWith(`collect:${domain}:`)) return true; return target === domain @@ -393,16 +403,8 @@ function occupationCollectFocusClosed( return topics.some((topic) => { const status = typeof topic.status === "string" ? topic.status : ""; if (status === "active") return false; - const domain = typeof topic.target_domain === "string" - ? topic.target_domain - : typeof topic.targetDomain === "string" - ? topic.targetDomain - : null; - const questionId = typeof topic.questionId === "string" - ? topic.questionId - : typeof topic.question_id === "string" - ? topic.question_id - : ""; + const domain = topicDomain(topic); + const questionId = topicQuestionId(topic); const intent = typeof topic.intent === "string" ? topic.intent : ""; if (questionId.startsWith("collect:occupation:")) return true; return domain === "occupation" @@ -1065,7 +1067,7 @@ export function exhaustionSpokenCollectFollowup(input: { }; } -export const OPENING_COLLECT_DOMAIN = "education"; +export const OPENING_COLLECT_DOMAIN = "other"; export function collectQuestionDomain(domain: string | null | undefined): string { if (domain && domain !== "unknown" && domain !== "active_focus") return domain; @@ -1589,16 +1591,7 @@ export function buildMethodFollowupPlan(input: { const schemaYear = typeof schema?.probe_year === "number" && schema.probe_year > 0 ? schema.probe_year : undefined; - const promptYear = (() => { - const prompt = typeof schema?.prompt === "string" ? schema.prompt : ""; - const choice = schema?.choice && typeof schema.choice === "object" - ? schema.choice as Record - : null; - const text = `${prompt} ${typeof choice?.prompt === "string" ? choice.prompt : ""}`; - const year = text.match(/19\d{2}|20\d{2}/); - return year ? Number(year[0]) : undefined; - })(); - const keepProbeYear = matchingVerify?.year || schemaYear || promptYear; + const keepProbeYear = matchingVerify?.year || schemaYear; return { method_id: keepMethodId, intent: focus.intent || "reverse_verify", diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 4c6062e0..d1a136bf 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -112,6 +112,9 @@ export function expectedAnswerSchemaFor( candidate_split_hash: followup.candidate_split_hash, }, ); + if (typeof followup.probe_year === "number" && followup.probe_year > 0) { + stamped.probe_year = followup.probe_year; + } if (verifyOnly) return stamped; return state && stamped.scoring !== false && !schemaProbeId(stamped) ? null : stamped; } diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 6f81d808..f8fcce1a 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -259,7 +259,8 @@ test("rectify-first handoffs stay as Agent context", () => { assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/); assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/); - assert.match(chat, /之后会回到你原来的问题/); + // 旧 横幅承诺按钮会带回原问题 → 新 按钮已删,改成结束后新建对话再问 → 保留 pendingConsultationQuestion 传递链 + assert.match(chat, /结束后新建对话,按采用的时间再问/); }); test("ordinary consultation uses current birth data without a rectification notice", () => { diff --git a/frontend/tests/rectification-adopt-flow-20260902.test.ts b/frontend/tests/rectification-adopt-flow-20260902.test.ts index cd8be0b0..1bceb6c0 100644 --- a/frontend/tests/rectification-adopt-flow-20260902.test.ts +++ b/frontend/tests/rectification-adopt-flow-20260902.test.ts @@ -343,10 +343,11 @@ test("post-adopt live question identity matches the last reverse_verify turn", ( assert.equal(card?.question_id, question?.question_id); }); -test("opening collect domain is education rather than unknown", () => { - assert.equal(OPENING_COLLECT_DOMAIN, "education"); - assert.equal(collectQuestionDomain(null), "education"); - assert.equal(collectQuestionDomain("unknown"), "education"); +test("opening collect domain is other rather than unknown", () => { + // 旧 education → 新 other → 保留 不得为 unknown、stable id 不含 unknown + assert.equal(OPENING_COLLECT_DOMAIN, "other"); + assert.equal(collectQuestionDomain(null), "other"); + assert.equal(collectQuestionDomain("unknown"), "other"); const fallback = spokenCollectFallbackFollowup({ method_id: "dasha_events", intent: "collect_method_evidence", @@ -358,8 +359,8 @@ test("opening collect domain is education rather than unknown", () => { choice_frame: null, source: "method_coverage", }); - assert.equal(fallback.domain, "education"); - assert.equal(stableFollowupQuestionId(fallback), "collect:education:collect_method_evidence"); + assert.equal(fallback.domain, "other"); + assert.equal(stableFollowupQuestionId(fallback), "collect:other:collect_method_evidence"); assert.doesNotMatch(stableFollowupQuestionId(fallback), /unknown/); }); @@ -399,7 +400,9 @@ test("chat owns adopt cards on the offering message and drops the list-end hando assert.match(chat, /candidateOffer/); assert.match(chat, /!candidateResult\?\.selectedTime/); assert.match(chat, /rectification-adopt-status/); - assert.match(chat, /用这个时间看盘/); + // 旧 状态条含「用这个时间看盘」→ 新 产品删按钮 → 保留 状态条仍在且含「改选」,不得再渲染 consult-handoff + assert.doesNotMatch(chat, /用这个时间看盘/); + assert.match(chat, /改选/); assert.match(chat, /CHOICE_STOP_LABEL/); assert.match(chat, /已跳过(已采用/); assert.match(chat, /rectificationLiveProgressLabel/); diff --git a/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts b/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts new file mode 100644 index 00000000..d1f74b64 --- /dev/null +++ b/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts @@ -0,0 +1,389 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import test from "node:test"; + +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { publicCanAdopt } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { + decideFromDossier, + type DecisionDossier, +} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { + buildMethodFollowupPlan, + exhaustionSpokenCollectFollowup, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { ensureNonTerminalTurnExit } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { + CASE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, + candidateSnapshotFixture, + computeFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const STYLE_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 }, +]; + +const COLLECT_EVIDENCE = [ + { + id: "e-career-entry", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-04-01", + occurredTo: null, + eventKind: "career_entry", + }, + { + id: "e-career-exit", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-10-01", + occurredTo: null, + eventKind: "career_exit", + }, + { + id: "e-rel-end", + status: "confirmed", + domain: "relationship", + datePrecision: "day", + occurredFrom: "2024-08-08", + occurredTo: null, + eventKind: "relationship_end", + }, + { + id: "e-rel-start", + status: "confirmed", + domain: "relationship", + datePrecision: "month", + occurredFrom: "2024-05-01", + occurredTo: null, + eventKind: "relationship_start", + }, +] as const; + +const OPENING_SKIPPED = { + questionId: "collect:other:collect_method_evidence", + target_domain: "other", + status: "skipped", +}; + +function collectDecisionDossier(declinedTopics: ReadonlyArray> = []): DecisionDossier { + const state = buildInferenceState({ + range_start: "04:53", + range_end: "05:06", + candidates: [ + { id: "05:06", time: "05:06", relative_support: 14 }, + { id: "04:53", time: "04:53", relative_support: 13 }, + { id: "05:03", time: "05:03", relative_support: 13 }, + ], + events: [ + { id: "e-career-entry", domain: "career", year: 2020, precision: "month" }, + { id: "e-career-exit", domain: "career", year: 2020, precision: "month" }, + { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" }, + { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" }, + ], + probes: [], + }); + return { + evidence: COLLECT_EVIDENCE, + conversationSummary: { + activeFocus: null, + declinedSkippedTopics: declinedTopics, + }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + selectionAllowed: true, + confirmationAllowed: false, + evidenceLedgerFingerprint: evidenceLedgerFingerprint(COLLECT_EVIDENCE as never), + candidates: state.candidates.map((candidate) => ({ + candidateId: candidate.id, + time: candidate.time, + rank: candidate.rank, + relativeSupport: Math.round(candidate.posterior_score), + })), + representativeTime: state.representative_time, + decisionReceipt: { + acceptance_allowed: true, + accept_allowed: true, + propose_allowed: true, + selection_allowed: true, + confirmation_allowed: false, + inference_state: state, + }, + }, + case: { acceptedTime: null }, + }; +} + +function rpcDossier(decision: DecisionDossier, activeFocus: Record | null = null) { + const evidence = decision.evidence.map((item) => ({ + id: item.id, + source_turn_id: TURN_ID, + subject: "self", + event_kind: item.eventKind ?? "event", + domain: item.domain, + occurred_from: item.occurredFrom, + occurred_to: item.occurredTo, + date_precision: item.datePrecision, + summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`, + status: item.status, + supersedes_evidence_id: null, + created_at: "2026-09-03T00:00:00.000Z", + })); + return dossierFixture({ + evidence, + 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: evidenceLedgerFingerprint(evidence.map((item) => ({ + id: item.id, + eventKind: item.event_kind, + domain: item.domain, + occurredFrom: item.occurred_from, + occurredTo: item.occurred_to, + datePrecision: item.date_precision, + summary: item.summary, + status: item.status, + })) as never), + decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) }, + }), + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: activeFocus, + declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics, + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-09-03T00:00:00.000Z", + }, + }); +} + +function createdFocusFromArgs(args: Record) { + return { + 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, + 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-09-03T00:00:00.000Z", + resolved_at: null, + }; +} + +test("collect-phase nonterminal exit persists a spoken collect when public can_adopt is closed", async () => { + const decisionDossier = collectDecisionDossier(); + const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" }); + assert.equal(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.canAdopt, true); + assert.equal(publicCanAdopt(decision), false); + + const store: { focus: ReturnType | null } = { focus: null }; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map((item) => String(item)).join(" ")); + }; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpcDossier(decisionDossier, store.focus), + get_agentic_rectification_case_compute: () => computeFixture(), + set_agentic_rectification_conversation_focus: (_fn, args) => { + store.focus = createdFocusFromArgs(args); + return { focus: store.focus, idempotent: false }; + }, + }); + try { + const repaired = await ensureNonTerminalTurnExit({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.equal(repaired.persisted, true); + } finally { + console.warn = originalWarn; + } + assert.ok(warnings.some((item) => item.includes("rectification_nonterminal_exit_repaired"))); + assert.equal(store.focus?.intent, "collect_method_evidence"); + const question = projectCurrentQuestion({ + id: FOCUS_ID, + questionId: String(store.focus?.question_id ?? ""), + intent: String(store.focus?.intent ?? ""), + targetDomain: typeof store.focus?.target_domain === "string" ? store.focus.target_domain : null, + expectedAnswerSchema: store.focus?.expected_answer_schema as Record, + }); + assert.equal(question?.kind, "collect_spoken"); +}); + +test("adopt_representative nonterminal exit stays satisfied without a new question", async () => { + const decisionDossier = collectDecisionDossier([{ target_domain: "family", status: "declined" }]); + const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" }); + assert.equal(decision.sessionOutcome, "adopt_representative"); + assert.equal(publicCanAdopt(decision), true); + + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpcDossier(decisionDossier), + get_agentic_rectification_case_compute: () => computeFixture(), + set_agentic_rectification_conversation_focus: () => { + throw new Error("adoptable offer must not persist another question"); + }, + }); + const repaired = await ensureNonTerminalTurnExit({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + assert.equal(repaired.persisted, false); + assert.equal(repaired.hostNarration, null); + assert.equal( + accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), + false, + ); +}); + +test("改选 is a button that scrolls to the offering message", () => { + const chat = readFileSync( + new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), + "utf8", + ); + const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + assert.match(chat, / { + const family = { + id: "e-family", + status: "confirmed", + domain: "family", + datePrecision: "year", + occurredFrom: "2021-01-01", + occurredTo: null, + }; + const exhausted = exhaustionSpokenCollectFollowup({ + evidence: [family], + declinedTopics: [OPENING_SKIPPED], + }); + assert.equal(exhausted?.domain, "education"); + assert.notEqual(exhausted?.collect_retry, true); + + const plan = buildMethodFollowupPlan({ + evidence: COLLECT_EVIDENCE, + closedCollectFocuses: [OPENING_SKIPPED], + sessionOutcome: "collect_evidence", + }); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.domain, "family"); + assert.notEqual(plan.next_followup?.collect_retry, true); + + const educationPlan = buildMethodFollowupPlan({ + evidence: [...COLLECT_EVIDENCE, family], + closedCollectFocuses: [ + OPENING_SKIPPED, + { + questionId: "collect:occupation:collect_method_evidence", + target_domain: "occupation", + status: "skipped", + }, + ], + sessionOutcome: "collect_evidence", + precisionStage: "d5_refine", + }); + const educationCollect = educationPlan.next_followup?.intent === "collect_method_evidence" + && educationPlan.next_followup.domain === "education" + ? educationPlan.next_followup + : exhausted; + assert.equal(educationCollect?.domain, "education"); + assert.notEqual(educationCollect?.collect_retry, true); +}); + +test("consult handoff button and startConsultationAfterRectification are gone", () => { + const srcRoot = new URL("../src/", import.meta.url); + const files = readdirSync(srcRoot, { recursive: true, encoding: "utf8" }) + .filter((entry) => /\.(ts|tsx|css)$/.test(entry)); + const banned = [ + "startConsultationAfterRectification", + "onStartConsultation", + "请用刚才采用的代表性出生时间看盘", + "rectification-consult-handoff", + ]; + for (const file of files) { + const source = readFileSync(new URL(file.split("\\").join("/"), srcRoot), "utf8"); + for (const token of banned) { + assert.equal(source.includes(token), false, `${file} still contains ${token}`); + } + } + const chat = readFileSync( + new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), + "utf8", + ); + assert.match(chat, /之后新建对话即按此时间排盘/); + assert.match(chat, /结束后新建对话,按采用的时间再问/); +}); + +test("keepAcceptedFocus omits probe_year when schema and probes have none", () => { + const followup = readFileSync( + new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), + "utf8", + ); + assert.doesNotMatch(followup, /\/19\\d\{2\}\|20\\d\{2\}\//); + + const plan = buildMethodFollowupPlan({ + evidence: COLLECT_EVIDENCE, + accepted: true, + sessionOutcome: "adopt_representative", + eventProbes: [], + activeFocus: { + id: FOCUS_ID, + questionId: "reverse_verify:education_style:score", + intent: "reverse_verify", + targetDomain: "education", + targetKind: "education_milestone", + expectedAnswerSchema: { + prompt: "2016 年前后,升学结果或学习环境有没有明显变化?", + choice: { + prompt: "2016 年前后,升学结果或学习环境有没有明显变化?", + option_a: STYLE_OPTIONS[0].label, + option_b: STYLE_OPTIONS[1].label, + option_c: STYLE_OPTIONS[2].label, + option_d: STYLE_OPTIONS[3].label, + options: STYLE_OPTIONS.map((option, index) => ({ + key: (["A", "B", "C", "D"] as const)[index]!, + label: option.label, + answer_class: option.answer_class, + })), + }, + }, + }, + }); + assert.equal(plan.next_followup?.intent, "reverse_verify"); + assert.equal(plan.next_followup?.probe_year, undefined); +}); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index ad53cc16..30c7768b 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -292,7 +292,9 @@ test("Agentic rectification follows the conversation tail only while the reader assert.match(styles, /\.jump-to-latest \{[^}]*justify-content: center/); assert.match(styles, /--rectification-jump-clearance/); assert.match(styles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/); - assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/); + assert.doesNotMatch(chat, /conversationEnd/); + // 旧 禁止任何 scrollIntoView → 新 改选滚到出卡消息 → 保留 不得再做 conversationEnd 第二套滚动 + assert.match(chat, /offerSectionRef\.current\?\.scrollIntoView\(\{ block: "center", behavior: "smooth" \}\)/); assert.doesNotMatch(styles, /scrollIntoView/); }); @@ -683,13 +685,14 @@ test("choice card answers and stop share one stacked primary list", () => { }); test("adopted time offers a consultation handoff without unique-minute copy", () => { - assert.match(chat, /用这个时间看盘/); - assert.match(chat, /onStartConsultation/); + // 旧 状态条含「用这个时间看盘」和 onStartConsultation → 新 产品删除该按钮 → 保留 不得再写唯一分钟收口文案 + assert.doesNotMatch(chat, /用这个时间看盘/); + assert.doesNotMatch(chat, /onStartConsultation/); assert.match(board, /换升时刻/); assert.match(board, /经历与大运对照/); assert.match(chat, / { }); test("nonterminal turn exit is already satisfied once dated coverage can adopt", async () => { + // 旧:内部 canAdopt 即 satisfied、不建题(采集期也会被拦住)。 + // 新:只有 publicCanAdopt 才算有出口;五条证据 live case 对齐账本指纹后用户能采用。 + // 保留:用户真能采用时不得再塞采集题。 const answerChoiceModule = await import("../src/lib/rectification-agentic/v9/answer-choice.ts") as Record; const ensureExit = answerChoiceModule.ensureNonTerminalTurnExit as undefined | ((input: { accounting: ReturnType["client"]; @@ -899,9 +903,26 @@ test("nonterminal turn exit is already satisfied once dated coverage can adopt", caseId: string; }) => Promise<{ hostNarration: string | null; persisted: boolean }>); assert.equal(typeof ensureExit, "function"); + const decision = liveCaseDossier(); + const current = rpcDossier(decision); + const fingerprint = evidenceLedgerFingerprint( + decision.evidence.map((item) => ({ + id: item.id, + eventKind: item.eventKind ?? "event", + domain: item.domain, + occurredFrom: item.occurredFrom, + occurredTo: item.occurredTo, + datePrecision: item.datePrecision, + summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`, + status: item.status, + })) as never, + ); + (current.latest_result as { evidence_ledger_fingerprint: string }).evidence_ledger_fingerprint = fingerprint; + const next = decideFromDossier(decision, { birthDate: "1997-08-08" }); + assert.equal(publicCanAdopt(next), true); const accounting = fakeAccounting({ ...receiptHandlers, - get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()), + get_agentic_rectification_case_dossier: () => current, set_agentic_rectification_conversation_focus: () => { throw new Error("adoptable offer must not persist another question"); }, diff --git a/frontend/tests/rectification-question-ownership.test.ts b/frontend/tests/rectification-question-ownership.test.ts index a76d4426..b6d5d9dd 100644 --- a/frontend/tests/rectification-question-ownership.test.ts +++ b/frontend/tests/rectification-question-ownership.test.ts @@ -697,4 +697,5 @@ test("adopt continuation set-focus keeps the persisted reverse_verify question i ? result.expected_answer_schema as Record : {}; assert.notEqual(schemaRow.semantic_key, RELOCATION_2015.semantic_key); + assert.equal(schemaRow.probe_year, 2016); }); diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index d946afbb..4279495b 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -508,10 +508,10 @@ test("spoken collect followup persists a collect focus without a choice card", a test("zero-evidence opening persists the server-owned first question", async () => { const followup = buildMethodFollowupPlan({ evidence: [] }).next_followup; if (!followup) throw new Error("zero-evidence opening must produce a follow-up"); - // 旧:开场 domain 落成 unknown,题干是 GENERIC_COLLECT_QUESTION。 - // 新:开场 domain 取轮转第一个领域 education,题干走该领域采集句。 - // 保留语义:仍是 collect_spoken,仍由服务端 persist,Agent 不自拟开场题。 - assert.equal(followup.domain, "education"); + // 旧:开场 domain 落成 unknown,后一度写成 education。 + // 新:开场是「随便一件带时间的经历」,domain = other。 + // 保留语义:仍是 collect_spoken,仍由服务端 persist,Agent 不自拟开场题,id 不含 unknown。 + assert.equal(followup.domain, "other"); assert.doesNotMatch(stableFollowupQuestionId(followup), /unknown/); const expected = spokenFollowupForUser(followup); @@ -660,8 +660,9 @@ test("collect persist maps occupation to other and health_pressure to health", a assert.equal(persistableFocusDomain("horary"), "horary"); assert.equal(persistableFocusDomain(null), null); assert.equal(persistableFocusDomain("unknown"), null); - assert.equal(stableFollowupQuestionId(collectFollowup({ domain: "unknown" })), "collect:education:collect_method_evidence"); - assert.equal(stableFollowupQuestionId(collectFollowup({ domain: null })), "collect:education:collect_method_evidence"); + // 旧 collect:education → 新 collect:other → 保留 unknown/null 不得写入 unknown + assert.equal(stableFollowupQuestionId(collectFollowup({ domain: "unknown" })), "collect:other:collect_method_evidence"); + assert.equal(stableFollowupQuestionId(collectFollowup({ domain: null })), "collect:other:collect_method_evidence"); const occupation = collectFollowup({ method_id: "occupation",