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
@@ -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) {