diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 06686985..9b6426c8 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7761,3 +7761,19 @@ - 复发自:无 - 修复版本:待发布 +## BUG-504 | 开场采集题用「再说一件」措辞,用户不知从何说起 + +- 状态:resolved +- 首次发现:2026-09-03 +- 最近更新:2026-09-03 +- 影响面:`spokenFollowupForUser`、`GENERIC_COLLECT_QUESTION` +- 用户现象:新用户开场招呼后看到「也可以再说一件你记得大概时间的事。」不知道该写什么;招呼段按设计不提问、不举例。 +- 触发条件:零证据开场,`OPENING_COLLECT_DOMAIN = other`,followup `source=method_coverage`。 +- 根因:`e8c98c37` 把开场 domain 改成 `other` 后,`spokenFollowupForUser` 按 domain 查 `USER_COLLECT_QUESTION`。`other` 是给已有证据后的补问兜底(「也可以再说一件」),真正开场文案 `GENERIC_COLLECT_QUESTION` 只在 domain 查不到时才用。domain→文案查表把兜底文案当开场。 +- 修复:`source === method_coverage && domain === other && collect_retry !== true` 时用 `GENERIC_COLLECT_QUESTION`。文案补上「一件事」和「哪年搬的家」。`oos_blind` 兜底与 `collect_retry` 的 `other` 仍用原补问。不改 `OPENING_COLLECT_DOMAIN` / questionId / 记账。 +- 验证:`rectification-server-focus` 零证据开场 spoken prompt 等于 `GENERIC_COLLECT_QUESTION`,不含「也可以再」,含「比如」;exhaustion `oos_blind` 与 `collect_retry` 仍走 `USER_COLLECT_QUESTION.other` / retry。源码锁至少两个具体例子。 +- 防复发:开场题文案必须带具体例子;`USER_COLLECT_QUESTION.other` 只用于已有证据后的补问。 +- 相关记录:BUG-501 +- 复发自:无 +- 修复版本:待发布 + diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 86402b9a..145954ca 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -25,7 +25,7 @@ export type CollectDomain = | "health_pressure" | "other"; -export const GENERIC_COLLECT_QUESTION = "从你最容易想起来的开始就好——比如哪年上的大学、哪年换的工作,记得大概年份就行。"; +export const GENERIC_COLLECT_QUESTION = "从你最容易想起来的一件事开始就好——比如哪年上的大学、哪年换的工作、哪年搬的家,记得大概年份就行。"; export const USER_COLLECT_QUESTION: Readonly> = { relationship: "感情这边,还记得哪年认真在一起、分开,或结婚吗?有年份就很好。", diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 5afbc907..0ed459e0 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -978,9 +978,14 @@ export function spokenFollowupForUser(followup: MethodFollowup | null): string | if (followup.choice_frame) return serverOwnedChoiceCopy(followup.choice_frame)?.prompt ?? null; if (followup.intent !== "collect_method_evidence") return null; const domain = followup.domain ?? ""; + const openingOther = followup.source === "method_coverage" + && domain === "other" + && followup.collect_retry !== true; const base = followup.collect_retry === true ? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION) - : (USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION); + : openingOther + ? GENERIC_COLLECT_QUESTION + : (USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION); const period = followup.year_label ?? (followup.probe_year && followup.probe_year > 0 ? `${followup.probe_year} 年前后` : null); return period ? `${period},${base}` : base; diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index 4279495b..492d0060 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; @@ -13,10 +14,16 @@ import { import { buildChoiceFrame, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { buildMethodFollowupPlan, + exhaustionSpokenCollectFollowup, spokenCollectFallbackFollowup, spokenFollowupForUser, type MethodFollowup, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { + GENERIC_COLLECT_QUESTION, + USER_COLLECT_QUESTION, + USER_COLLECT_QUESTION_RETRY, +} from "../src/lib/rectification-agentic/user-copy.ts"; import type { EventProbeStyleOption } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import { fakeAccounting, CASE_ID, FOCUS_ID, USER_ID, activeFocusFixture } from "./rectification-v9-test-support.ts"; @@ -512,8 +519,14 @@ test("zero-evidence opening persists the server-owned first question", async () // 新:开场是「随便一件带时间的经历」,domain = other。 // 保留语义:仍是 collect_spoken,仍由服务端 persist,Agent 不自拟开场题,id 不含 unknown。 assert.equal(followup.domain, "other"); + assert.equal(followup.source, "method_coverage"); assert.doesNotMatch(stableFollowupQuestionId(followup), /unknown/); - const expected = spokenFollowupForUser(followup); + assert.equal(stableFollowupQuestionId(followup), "collect:other:collect_method_evidence"); + // 旧:开场 domain=other 后查表落到「也可以再说一件」→ 新:method_coverage + other 且非 retry 用 GENERIC_COLLECT_QUESTION → 保留 开场题由服务端出、带具体例子 + assert.equal(spokenFollowupForUser(followup), GENERIC_COLLECT_QUESTION); + assert.doesNotMatch(spokenFollowupForUser(followup) ?? "", /^也可以再/); + assert.match(spokenFollowupForUser(followup) ?? "", /比如/); + const expected = GENERIC_COLLECT_QUESTION; const accounting = fakeAccounting({ set_agentic_rectification_conversation_focus: (_fn, args) => ({ @@ -551,6 +564,58 @@ test("zero-evidence opening persists the server-owned first question", async () openKind: "collect_spoken", openPrompt: expected, }); + assert.equal(persisted.focus?.targetDomain, "other"); +}); + +test("exhaustion other fallback and other retry keep the after-evidence copy", () => { + const dated = (domain: string, year: string, extra: { eventKind?: string } = {}) => ({ + status: "confirmed" as const, + domain, + datePrecision: extra.eventKind ? "unknown" as const : "year" as const, + occurredFrom: extra.eventKind ? null : `${year}-01-01`, + occurredTo: null, + ...(extra.eventKind ? { eventKind: extra.eventKind } : {}), + }); + const exhausted = exhaustionSpokenCollectFollowup({ + evidence: [ + dated("education", "2016"), + dated("career", "2020"), + dated("relationship", "2018"), + dated("finance", "2024"), + dated("relocation", "2022"), + dated("health_pressure", "2021"), + dated("occupation", "2020", { eventKind: "occupation_note" }), + ], + declinedTopics: [{ target_domain: "family", status: "declined" }], + }); + assert.equal(exhausted?.domain, "other"); + assert.equal(exhausted?.source, "oos_blind"); + assert.equal(spokenFollowupForUser(exhausted), USER_COLLECT_QUESTION.other); + + const retry: MethodFollowup = { + method_id: "dasha_events", + intent: "collect_method_evidence", + ask_theme: "dated_event", + domain: "other", + kind_hint: null, + user_prompt_hint: "retry", + must_not_label: false, + choice_frame: null, + source: "method_coverage", + collect_retry: true, + }; + assert.equal(spokenFollowupForUser(retry), USER_COLLECT_QUESTION_RETRY.other); +}); + +test("GENERIC_COLLECT_QUESTION keeps concrete opening examples", () => { + const source = readFileSync(new URL("../src/lib/rectification-agentic/user-copy.ts", import.meta.url), "utf8"); + assert.match(GENERIC_COLLECT_QUESTION, /比如/); + assert.ok( + [...GENERIC_COLLECT_QUESTION.matchAll(/上的大学|换的工作|搬的家/g)].length >= 2, + GENERIC_COLLECT_QUESTION, + ); + // 旧:「…开始就好——比如哪年上的大学、哪年换的工作…」→ 新:加「哪年搬的家」→ 保留 开场题必须带具体例子、由服务端出 + assert.match(source, /GENERIC_COLLECT_QUESTION = "从你最容易想起来的一件事开始就好/); }); test("degraded spoken collect does not keep discriminator identity or block a later card", async () => {