fix(rectification): keep single-event opening on the existing collect path (BUG-604)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-09 14:43:25 +08:00
co-authored by Cursor
parent 16343355a6
commit 1453fb16ed
21 changed files with 150 additions and 38 deletions
+1
View File
@@ -46,6 +46,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 |
| 也可以再说一件你记得大概时间的事。 | 能分开候选的问题已经问完,先按现有材料给你结果。 / 还差带月份的经历,领域不限。 | 采集问完后交付或说清门槛,不再开放采集。 |
| 口述采集没有停止按钮,范围小字不可点 | 选择题卡内有「先这样,先看当前范围」;生成中有「停止回答」。口述采集输入框上方是「没有」「记不清」两个 44px 次要按钮,不是先这样。范围小字是状态句,不可点。 | 没有=拒答,记不清=本会话先放着。 |
| 只报一件后追问「还有吗,还能想起别的吗」 | 下一问仍是既有逐领域采集。第一道题干末尾由服务端加一次「想到别的也可以一起说。」 | 邀请不是要求;不插追问轮,也不重复开场。 |
| 点选后旁白写「04:31–04:39 这段领先,05:00–05:07 这段落后」 | 已记录,范围收到 04:31–04:39。 | 旁白只说范围变没变。领先/落后留在卡内「答了会怎样」,不进气泡。 |
| 每轮只有技法清单,不知道现在第几步 | 输入框上方有「第 N 步 · 原因 · 下一步」。 | 用户始终看得见该做什么。 |
| 完全不清楚出生时间后劝退「生时校正以后需要时再做」 | 可以直接开始生时校正:先从你记得的经历比出大致时段。 | 不知道钟点时先比时段,不劝退、不给分钟卡。 |
@@ -30,6 +30,15 @@ export type CollectDomain =
export const GENERIC_COLLECT_QUESTION = "先说你最容易想起的一两件,年月大概就行。";
/** One-time suffix on the first dated-collect stem. Not a chase-more turn. */
export const FIRST_DATED_COLLECT_INVITE = "想到别的也可以一起说。";
export function withFirstDatedCollectInvite(stem: string): string {
const spoken = stem.trim();
if (!spoken || spoken.includes(FIRST_DATED_COLLECT_INVITE)) return spoken;
return `${spoken}${FIRST_DATED_COLLECT_INVITE}`;
}
/** Six everyday domains named in the opening body. Not years. */
export const OPENING_COLLECT_DOMAINS = [
"升学",
@@ -108,6 +117,7 @@ export const RECTIFICATION_USER_COPY = {
collectHandoff: "接下来我们继续。",
collectDeclinedAck: "记下了,这方面先跳过。",
collectSkippedAck: "记下了,这题先放着。",
firstDatedCollectInvite: FIRST_DATED_COLLECT_INVITE,
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
probePoolExhaustedStop: "能分开候选的问题已经问完,先按现有材料给你结果。",
@@ -428,6 +438,7 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.collectHandoff,
RECTIFICATION_USER_COPY.collectDeclinedAck,
RECTIFICATION_USER_COPY.collectSkippedAck,
FIRST_DATED_COLLECT_INVITE,
GENERIC_COLLECT_QUESTION,
openingSpokenBody(["04:45", "05:15"]),
RECTIFICATION_USER_COPY.uncertaintyStop,
@@ -84,6 +84,7 @@ import {
GENERIC_COLLECT_QUESTION,
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
withFirstDatedCollectInvite,
} from "../user-copy.ts";
export { GENERIC_COLLECT_QUESTION };
@@ -194,6 +195,7 @@ export type MethodFollowup = Readonly<{
selection_score?: number;
probe_id?: string;
collect_retry?: boolean;
invite_more_once?: boolean;
block_periods?: Readonly<Record<"A" | "B" | "C", BlockScanBlock>>;
widen_windows?: Readonly<Record<"A" | "B", import("./window-widen.ts").WidenWindowOption>>;
date_reliability_evidence_id?: string;
@@ -1211,7 +1213,12 @@ export function spokenFollowupForUser(
const mapped = followup.collect_retry === true
? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain])
: USER_COLLECT_QUESTION[domain];
return mapped ?? null;
if (!mapped) return null;
const dated = (DATED_COLLECT_ORDER as readonly string[]).includes(domain);
if (followup.invite_more_once && dated && followup.collect_retry !== true) {
return withFirstDatedCollectInvite(mapped);
}
return mapped;
}
export const DATED_COLLECT_ORDER = [
@@ -1260,15 +1267,29 @@ function datedCollectDomainBlocked(
|| answeredYes.has(domain);
}
export function datedCollectAlreadyAsked(
declined: ReadonlySet<string>,
topics: readonly Readonly<Record<string, unknown>>[] = [],
): boolean {
if (DATED_COLLECT_ORDER.some((domain) => declined.has(domain))) return true;
return DATED_COLLECT_ORDER.some((domain) => domainCollectFocusAsked(topics, domain));
}
export function nextDatedCollectFollowup(
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
answeredYes: ReadonlySet<string> = new Set(),
options?: { askedDatedCollect?: boolean },
): MethodFollowup | null {
for (const domain of DATED_COLLECT_ORDER) {
if (datedCollectDomainBlocked(domain, evidence, declined, answeredYes)) continue;
const next = datedCollectFollowup(domain, evidence);
if (next) return next;
if (next) {
return {
...next,
invite_more_once: options?.askedDatedCollect !== true,
};
}
}
return null;
}
@@ -1357,7 +1378,9 @@ export function exhaustionSpokenCollectFollowup(input: {
}): MethodFollowup | null {
const declined = declinedDomains(input.declinedTopics ?? []);
const answeredYes = domainsAnsweredYes(input.answeredProbes, input.eventProbes);
const dated = nextDatedCollectFollowup(input.evidence, declined, answeredYes);
const dated = nextDatedCollectFollowup(input.evidence, declined, answeredYes, {
askedDatedCollect: datedCollectAlreadyAsked(declined, input.declinedTopics ?? []),
});
if (dated) return dated;
if (
!declined.has("occupation")
@@ -1883,9 +1906,15 @@ export function buildMethodFollowupPlan(input: {
const collectRetry = base.intent === "collect_method_evidence"
&& typeof base.domain === "string"
&& domainCollectFocusAsked(askedRows, base.domain);
const inviteMoreOnce = base.intent === "collect_method_evidence"
&& typeof base.domain === "string"
&& (DATED_COLLECT_ORDER as readonly string[]).includes(base.domain)
&& !collectRetry
&& !datedCollectAlreadyAsked(declinedDomains(input.declinedTopics ?? []), askedRows);
return {
...base,
...(collectRetry ? { collect_retry: true } : {}),
...(inviteMoreOnce ? { invite_more_once: true } : { invite_more_once: false }),
choice_frame: attach
? buildChoiceFrame(base, {
observations: input.observations,
@@ -2512,7 +2541,12 @@ export function buildMethodFollowupPlan(input: {
&& precisionCard?.choice_frame
) {
next = precisionCard;
} else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined, answeredYes))) {
} else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined, answeredYes, {
askedDatedCollect: datedCollectAlreadyAsked(declined, [
...(input.closedCollectFocuses ?? []),
...(input.declinedTopics ?? []),
]),
}))) {
next = makeFollowup(datedCollect);
} else if (!occupationCovered) {
if (meetsAcceptanceEventQuality(input.evidence)) {
@@ -2688,7 +2722,12 @@ export function buildMethodFollowupPlan(input: {
next = null;
}
if (!next) {
const leftoverDated = nextDatedCollectFollowup(input.evidence, declined, answeredYes);
const leftoverDated = nextDatedCollectFollowup(input.evidence, declined, answeredYes, {
askedDatedCollect: datedCollectAlreadyAsked(declined, [
...(input.closedCollectFocuses ?? []),
...(input.declinedTopics ?? []),
]),
});
if (leftoverDated) {
next = makeFollowup(leftoverDated);
} else if (!occupationCovered) {
@@ -275,6 +275,8 @@ test("opening body lists domains without years and the stem no longer lists year
assert.equal(GENERIC_COLLECT_QUESTION, "先说你最容易想起的一两件,年月大概就行。");
assert.doesNotMatch(GENERIC_COLLECT_QUESTION, /比如/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectSkippedAck));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.firstDatedCollectInvite));
assert.doesNotMatch(listUserVisibleCopy().join("\n"), /还有吗|还能想起别的吗/);
assert.ok(MACHINE_VOICE_LEXICON.includes("领先"));
assert.ok(MACHINE_VOICE_LEXICON.includes("落后"));
});
@@ -30,7 +30,7 @@ import {
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { RECTIFICATION_TERMINATION_COPY, ADOPT_OUTCOMES } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { containsBoundarySemantics, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { containsBoundarySemantics, RECTIFICATION_USER_COPY, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts";
import {
evidenceLedgerFingerprint,
parseV9CaseDossier,
@@ -978,8 +978,13 @@ test("answering the last discriminator persists a year-locked family collect foc
const schema = setFocus.args.p_expected_answer_schema as { choice?: unknown; prompt?: string; collect?: boolean };
assert.equal(schema.choice, undefined);
assert.equal(schema.collect, true);
// 旧:prompt 含 2021 → 新:口语等于 USER_COLLECT_QUESTION.family → 决策 7 采集题不带年份前缀
assert.equal(schema.prompt, RECTIFICATION_USER_COPY.collectQuestionByDomain.family);
// 旧:prompt 等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604;年份仍不进采集题干
assert.equal(
schema.prompt,
withFirstDatedCollectInvite(RECTIFICATION_USER_COPY.collectQuestionByDomain.family),
);
assert.match(schema.prompt ?? "", /家人|结婚|添丁|住院/);
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.nextChoiceReady, false);
@@ -28,7 +28,7 @@ import {
parseV9CaseDossier,
RectificationToolServiceError,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts";
import { turnQuestionKind } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
@@ -186,7 +186,10 @@ test("family collect spoken stem has no year prefix while probe_year stays dated
});
assert.equal(plan.next_followup?.domain, "family");
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 只报一件也走既有采集,不插「还有吗」追问轮
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
});
test("four scoreable events skip holdout for domains already in the ledger", () => {
@@ -582,13 +585,13 @@ test("career discriminator yes covers career collect without ledger career evide
});
assert.equal(unanswered.next_followup?.domain, "career");
assert.equal(unanswered.next_followup?.intent, "collect_method_evidence");
assert.equal(spokenFollowupForUser(unanswered.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(unanswered.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
const yes = careerAnswerPlan("yes");
assert.notEqual(yes.next_followup?.domain, "career");
assert.equal(yes.next_followup?.domain, "education");
assert.equal(yes.next_followup?.intent, "collect_method_evidence");
assert.equal(spokenFollowupForUser(yes.next_followup), USER_COLLECT_QUESTION.education);
assert.equal(spokenFollowupForUser(yes.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.education));
assert.equal(
yes.methods.find((item) => item.method_id === "d10_career")?.status,
"covered",
@@ -600,17 +603,17 @@ test("career discriminator yes covers career collect without ledger career evide
const no = careerAnswerPlan("no");
assert.equal(no.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(no.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(no.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
const unsure = careerAnswerPlan("unsure");
assert.equal(unsure.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(unsure.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(unsure.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
});
test("career discriminator yes does not invent coverage from a semantic_key prefix", () => {
const plan = careerAnswerPlan("yes", { eventProbes: [] });
assert.equal(plan.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
});
test("ledger-classified career probe answers do not cover career collect", () => {
@@ -623,7 +626,7 @@ test("ledger-classified career probe answers do not cover career collect", () =>
}],
});
assert.equal(plan.next_followup?.domain, "career");
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.career));
});
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
@@ -33,6 +33,7 @@ import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9
import {
RECTIFICATION_USER_COPY,
USER_COLLECT_QUESTION,
withFirstDatedCollectInvite,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
ATTEMPT_ID,
@@ -486,7 +487,10 @@ test("revision 5 with uncovered relatives asks the dated family collect, not a y
// 旧:year_label 含 2021 → 新:采集口语不带年份前缀,year_label 不进 followup
// 原因:决策 7;probe_year 仍锁定 dated 家庭采集,不出无年份 D12 卡
assert.equal(plan.next_followup?.year_label, undefined);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 单事件开场不插追问轮
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
assert.equal(plan.dropped_probes.some((item) => (
item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast"
)), true);
@@ -673,7 +673,10 @@ test("nonterminal invariant 3: answer_choice response waits until incident fallb
// 保留语义:answer_choice 出口仍必须先把家人采集题干写入 focus/turn,再返回 HTTP。
assert.equal(decision.canAdopt, true);
assert.ok(currentQuestion?.prompt);
assert.equal(currentQuestion.prompt, "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。");
// 旧:家人采集题干原句
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 单事件开场不插追问轮
assert.equal(currentQuestion.prompt, "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。想到别的也可以一起说。");
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.equal(setFocus.args.p_intent, "collect_method_evidence");
@@ -13,7 +13,7 @@ import {
parseWindowScan,
} from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import { WINDOW_SCAN_DISPLAY_LAYER_ORDER } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION, withFirstDatedCollectInvite } from "../src/lib/rectification-agentic/user-copy.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
@@ -2381,7 +2381,10 @@ test("family collect attaches the collection-probe year instead of a yearless D2
// 原因:决策 7,年份只用于计分与去重,不进采集题干
assert.equal(plan.next_followup?.year_label, undefined);
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d24/);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 单事件开场不插追问轮
assert.equal(spokenFollowupForUser(plan.next_followup), withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
});
test("already-open yearless family card is abandoned instead of kept as a scoring frame", () => {
@@ -11,7 +11,10 @@ import {
buildNextUserAction,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
USER_COLLECT_QUESTION,
withFirstDatedCollectInvite,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationQuestionGapState,
@@ -204,6 +207,9 @@ test("family collect spoken stem equals USER_COLLECT_QUESTION.family while probe
assert.equal(plan.next_followup?.probe_year, 2021);
assert.equal(plan.next_followup?.choice_frame, null);
const spoken = spokenFollowupForUser(plan.next_followup);
assert.equal(spoken, USER_COLLECT_QUESTION.family);
// 旧:口语等于 USER_COLLECT_QUESTION.family
// 新:第一道逐领域采集题干末尾加一次「想到别的也可以一起说」
// 原因:BUG-604 只报一件也走既有采集,不插「还有吗」追问轮
assert.equal(spoken, withFirstDatedCollectInvite(USER_COLLECT_QUESTION.family));
assert.doesNotMatch(spoken ?? "", /^\d{4} 年前后,/);
});
@@ -14,6 +14,7 @@ import {
declinedCollectDomains,
nextDatedCollectFollowup,
reverseVerifyRemainingForAdopt,
spokenFollowupForUser,
spokenCollectFallbackFollowup,
type MethodFollowup,
type MethodFollowupEvidence,
@@ -482,6 +483,34 @@ test("skipped collect domains are not re-asked this session; reverse-verify may
assert.equal(remaining.some((probe) => probe.domain === "family"), true);
});
test("single confirmed domain opening goes to dated collect without a chase-more turn", () => {
const evidence: MethodFollowupEvidence[] = [
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
];
const next = nextDatedCollectFollowup(evidence, new Set());
assert.ok(next);
assert.notEqual(next.domain, "education");
assert.equal(next.domain, "family");
const spoken = spokenFollowupForUser(next);
assert.ok(spoken);
assert.match(spoken, /家里/);
assert.match(spoken, /想到别的也可以一起说/);
assert.doesNotMatch(spoken, /还有吗|别的吗|还能想起别的吗/);
assert.doesNotMatch(spoken, /先说你最容易想起的一两件/);
const later = nextDatedCollectFollowup(evidence, new Set(), new Set(), { askedDatedCollect: true });
assert.equal(later?.domain, "family");
assert.doesNotMatch(spokenFollowupForUser(later) ?? "", /想到别的也可以一起说/);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /recordV10EvidenceBatch/);
assert.match(tools, /result\.acceptedCount > 0[\s\S]*autoRescoreAfterEvidenceChange/);
const copy = readFileSync(new URL("../src/lib/rectification-agentic/user-copy.ts", import.meta.url), "utf8");
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.doesNotMatch(copy, /还有吗|还能想起别的吗/);
assert.doesNotMatch(agent, /还有吗|还能想起别的吗/);
assert.doesNotMatch(agentRun, /还有吗|还能想起别的吗/);
});
test("three confirmed domains from one message are not re-asked by dated collect", () => {
const evidence: MethodFollowupEvidence[] = [
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
+1 -1
View File
@@ -86,7 +86,7 @@ test("checked-in registry verifies hashed product packages and leaves consult on
{
name: "jyotish-birth-time-rectification",
version: "10.0.19",
sha256: "a21b33abbbe3a530d725b94719b57ff8417cfcb850c54b547d5c18e390ec3d2b",
sha256: "a68885d3cf2110ee456bff65210c60e6bef4da4ea45e2a790a5462fd6d7307c8",
},
{
name: "jyotish-personal-report",