fix(rectification): give the opening collect question concrete examples
Independent Staging Quality Gate / validate (push) Successful in 10m12s
Independent Staging Quality Gate / publish (push) Successful in 1m50s

The other-domain fallback copy was being used as the first question.
Opening now uses GENERIC_COLLECT_QUESTION; after-evidence other copy stays.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-03 14:31:23 +08:00
parent 0c0df42679
commit 8e214e290a
4 changed files with 89 additions and 3 deletions
+16
View File
@@ -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
- 复发自:无
- 修复版本:待发布
@@ -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<Record<string, string>> = {
relationship: "感情这边,还记得哪年认真在一起、分开,或结婚吗?有年份就很好。",
@@ -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;
@@ -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,仍由服务端 persistAgent 不自拟开场题,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 () => {