fix(rectification): equal-weight collect, year-cue questions, classifier no/unsure (BUG-641–643)

Ask finance and health like other domains, put year-cued collect questions first, and let the classifier distinguish decline vs skip. Skill 10.0.22.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 21:53:10 +08:00
co-authored by Cursor
parent 71eaeb2e8b
commit 52db714ba9
46 changed files with 1033 additions and 160 deletions
@@ -93,6 +93,27 @@ export const USER_COLLECT_QUESTION: Readonly<Record<string, string>> = {
health_pressure: "身体或压力这边,还记得哪年生病、受伤,或特别难熬的一段时间吗?",
};
/** Year is a recall cue, not a limiter. Must not also say “记得大概哪年就行”. */
export const COLLECT_QUESTION_YEAR_CUE: Readonly<Record<string, (year: number) => string>> = {
relationship: (year) => `感情这边,${year} 年前后有没有认真在一起、分开,或结婚?有就说个大概年月,别的年份也行。`,
career: (year) => `工作上呢,${year} 年前后有没有入职、换工作,或职责一下子变重?有就说个大概年月,别的年份也行。`,
family: (year) => `家里在 ${year} 年前后有没有结婚、添丁或住院?有就说个大概年月,别的年份也行。`,
education: (year) => `上学这边,${year} 年前后有没有升学、转学或大考?有就说个大概年月,别的年份也行。`,
relocation: (year) => `${year} 年前后有没有搬家,或开始长期住在外地?有就说个大概年月,别的年份也行。`,
finance: (year) => `钱的方面,${year} 年前后有没有收入明显变过、有过大笔支出,或欠过债?有就说个大概年月,别的年份也行。`,
health_pressure: (year) => `身体或压力这边,${year} 年前后有没有生病、受伤,或特别难熬的一段时间?有就说个大概年月,别的年份也行。`,
};
export function collectQuestionForDomain(
domain: string,
probeYear?: number | null,
): string | undefined {
if (typeof probeYear === "number" && probeYear > 0) {
return COLLECT_QUESTION_YEAR_CUE[domain]?.(probeYear) ?? USER_COLLECT_QUESTION[domain];
}
return USER_COLLECT_QUESTION[domain];
}
/** Second phrasing when that domain's collect focus was already established and not declined. */
export const USER_COLLECT_QUESTION_RETRY: Readonly<Record<string, string>> = {
relationship: "回到感情这边——刚才说的工作我记下了,哪年认真在一起或分开还记得吗?",
@@ -544,6 +565,7 @@ export function listUserVisibleCopy(): string[] {
"性格底色",
...Object.values(USER_COLLECT_QUESTION),
...Object.values(USER_COLLECT_QUESTION_RETRY),
...Object.keys(COLLECT_QUESTION_YEAR_CUE).map((domain) => COLLECT_QUESTION_YEAR_CUE[domain](2021)),
];
return values;
}
@@ -116,6 +116,7 @@ export type V9AgentRunOptions = Readonly<{
generationModel?: unknown;
attemptTimeoutMs?: number;
expectedWrite?: "evidence" | "none" | "unknown";
collectIntent?: "classified" | "unclassified" | null;
}>;
export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000;
@@ -1163,6 +1164,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
lastCompletedTool: lastCompletedPublicTool(toolTerminalStatus),
stateMutationCommitted: publicWriteToolCompleted(toolTerminalStatus) || hostFallbackUsed,
expectedWrite: options.expectedWrite ?? null,
collectIntent: options.collectIntent ?? null,
}));
}
}
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
export const RECTIFICATION_SKILL_VERSION = "10.0.21";
export const RECTIFICATION_SKILL_VERSION = "10.0.22";
@@ -84,6 +84,7 @@ import {
GENERIC_COLLECT_QUESTION,
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
collectQuestionForDomain,
withFirstDatedCollectInvite,
} from "../user-copy.ts";
@@ -1242,7 +1243,7 @@ export function spokenFollowupForUser(
}
const mapped = followup.collect_retry === true
? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain])
: USER_COLLECT_QUESTION[domain];
: collectQuestionForDomain(domain, followup.probe_year);
if (!mapped) return null;
const dated = (DATED_COLLECT_ORDER as readonly string[]).includes(domain);
if (followup.invite_more_once && dated && followup.collect_retry !== true) {
@@ -1309,14 +1310,23 @@ export function nextDatedCollectFollowup(
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
answeredYes: ReadonlySet<string> = new Set(),
options?: { askedDatedCollect?: boolean },
options?: {
askedDatedCollect?: boolean;
collectionProbes?: readonly DiscriminatingEventProbe[];
},
): MethodFollowup | null {
for (const domain of DATED_COLLECT_ORDER) {
if (datedCollectDomainBlocked(domain, evidence, declined, answeredYes)) continue;
const probes = options?.collectionProbes ?? [];
const remaining = DATED_COLLECT_ORDER.filter(
(domain) => !datedCollectDomainBlocked(domain, evidence, declined, answeredYes),
);
const withYearCue = remaining.filter((domain) => datedCollectionProbe(probes, domain));
const withoutCue = remaining.filter((domain) => !datedCollectionProbe(probes, domain));
for (const domain of [...withYearCue, ...withoutCue]) {
const next = datedCollectFollowup(domain, evidence);
if (next) {
return {
...next,
...collectionYearFields(datedCollectionProbe(probes, domain)),
invite_more_once: options?.askedDatedCollect !== true,
};
}
@@ -1410,6 +1420,7 @@ export function exhaustionSpokenCollectFollowup(input: {
const answeredYes = domainsAnsweredYes(input.answeredProbes, input.eventProbes);
const dated = nextDatedCollectFollowup(input.evidence, declined, answeredYes, {
askedDatedCollect: datedCollectAlreadyAsked(declined, input.declinedTopics ?? []),
collectionProbes: input.eventProbes,
});
if (dated) return dated;
if (
@@ -2447,10 +2458,6 @@ export function buildMethodFollowupPlan(input: {
};
const firstRenderableYearlessFollowup = (): MethodFollowup | null => {
for (const ranked of yearlessDiscriminators) {
const domain = contrastFollowupDomain(
ranked.eventProbe?.domain ?? ranked.contrastProbe?.domain ?? null,
);
if (declined.has(domain)) continue;
const key = rankedDiscriminatorKey(ranked);
if (key && askedKeys.has(key)) continue;
const candidate = followupFromRanked(ranked);
@@ -2551,7 +2558,8 @@ export function buildMethodFollowupPlan(input: {
if (!next) {
const precisionCard = takeRenderableDistinguish(precisionStageFollowup());
if (!relationshipCovered && !declined.has("relationship")) {
next = sameDomainYearlessCard("relationship") ?? makeFollowup({
const relationshipCollect = datedCollectionProbe(input.evidenceCollectionProbes, "relationship");
next = makeFollowup({
method_id: "d9_relationship",
intent: "collect_method_evidence",
ask_theme: "relationship_style",
@@ -2563,9 +2571,11 @@ export function buildMethodFollowupPlan(input: {
"对照 D9 上升类型表(白羊主动热情、天蝎深刻占有等)只作校时方法,不是命运承诺。",
),
source: "method_coverage",
...collectionYearFields(relationshipCollect),
});
} else if (!careerCovered && !declined.has("career")) {
next = sameDomainYearlessCard("career") ?? makeFollowup({
const careerCollect = datedCollectionProbe(input.evidenceCollectionProbes, "career");
next = makeFollowup({
method_id: "d10_career",
intent: "collect_method_evidence",
ask_theme: "career_style",
@@ -2577,10 +2587,11 @@ export function buildMethodFollowupPlan(input: {
"可用 D10 事业类型表作校时对照。",
),
source: "method_coverage",
...collectionYearFields(careerCollect),
});
} else if (!familyCovered && !declined.has("family")) {
const familyCollect = datedCollectionProbe(input.evidenceCollectionProbes, "family");
next = sameDomainYearlessCard("family") ?? makeFollowup({
next = makeFollowup({
method_id: "relatives",
intent: "collect_method_evidence",
ask_theme: "family_event",
@@ -2609,6 +2620,7 @@ export function buildMethodFollowupPlan(input: {
...(input.closedCollectFocuses ?? []),
...(input.declinedTopics ?? []),
]),
collectionProbes: input.evidenceCollectionProbes,
}))) {
next = makeFollowup(datedCollect);
} else if (!occupationCovered) {
@@ -2790,6 +2802,7 @@ export function buildMethodFollowupPlan(input: {
...(input.closedCollectFocuses ?? []),
...(input.declinedTopics ?? []),
]),
collectionProbes: input.evidenceCollectionProbes,
});
if (leftoverDated) {
next = makeFollowup(leftoverDated);
@@ -33,6 +33,7 @@ export type RectificationRunDiagnostic = Readonly<{
lastCompletedTool: string | null;
stateMutationCommitted: boolean;
expectedWrite: "evidence" | "none" | "unknown" | null;
collectIntent: "classified" | "unclassified" | null;
}>;
const USER_COPY: Readonly<Record<string, string>> = {
@@ -30,18 +30,19 @@ export function parseRectificationTurnIntent(value: unknown): RectificationTurnI
return parsed.data.answer_class === null ? parsed.data : null;
}
export function collectFocusCloseStatus(
classified: RectificationTurnIntent | null,
): "declined" | "skipped" | null {
if (classified?.intent !== "answer_current_focus") return null;
if (classified.answer_class === "no") return "declined";
if (classified.answer_class === "unsure") return "skipped";
return null;
}
export function shouldDeclineCollectFocus(
classified: RectificationTurnIntent | null,
): boolean {
return classified?.intent === "answer_current_focus" && classified.answer_class === "no";
}
export function isCollectDeclineUtterance(message: string): boolean {
return message.trim().replace(/[。!?.!?]+$/u, "") === "没有";
}
export function isCollectSkipUtterance(message: string): boolean {
return message.trim().replace(/[。!?.!?]+$/u, "") === "记不清";
return collectFocusCloseStatus(classified) === "declined";
}
export function shouldContinueAgentForDatedEvent(
@@ -54,11 +55,20 @@ export type ExpectedWriteSignal = "evidence" | "none" | "unknown";
export function expectedWriteFromCollectIntent(
classified: RectificationTurnIntent | null,
focus?: ConversationFocus | null,
): "evidence" | "none" {
if (classified?.intent === "provide_new_evidence") return "evidence";
if (classified?.intent === "answer_current_focus" && classified.has_new_dated_event === true) {
return "evidence";
}
if (
focus
&& isCollectFocusSchema(focus.expectedAnswerSchema)
&& classified?.intent === "answer_current_focus"
&& (classified.answer_class === "yes" || classified.answer_class === "weak_yes")
) {
return "evidence";
}
return "none";
}
@@ -75,7 +85,10 @@ export async function classifyTurnIntentWithRetry(
try {
const classified = await classifyRectificationTurnIntent(model, input);
if (classified) {
return { classified, expectedWrite: expectedWriteFromCollectIntent(classified) };
return {
classified,
expectedWrite: expectedWriteFromCollectIntent(classified, input.focus),
};
}
} catch {
// One retry, then fail-open as unknown.
@@ -120,9 +133,12 @@ has_new_dated_event 仅在用户同一句里除了回答当前问题之外,还
不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。`
: `你只做当前生时校正采集题的意图分类,不回答用户,也不修改任何状态。
当前问题没有点选选项。判断用户是在回答当前采集题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。
若用户明确表示这个方面没有发生过、没有这类事,intent 为 answer_current_focus,answer_class 为 no。
「没有、没发生过、这方面没什么」→ intent 为 answer_current_focus,answer_class 为 no(该方面没有事,本次不再问)。
「记不清、不记得、忘了、想不起来、以后再说」→ intent 为 answer_current_focus,answer_class 为 unsure(先放着,以后可补)。
用户用带大概年月的经历直接回答当前采集题 → intent 为 answer_current_focus,answer_class 为 yes(程度较弱时为 weak_yes),has_new_dated_event 为 false。
不要把「没有」或「记不清」标成 yes。若既没有否定、也没有说记不清、也没有给出带年月经历,intent 为 unclear,answer_class 必须为 null。
若用户只在补充带时间的经历、并没有回答当前采集题,intent 为 provide_new_evidence,answer_class 必须为 null。
has_new_dated_event 仅在用户同一句里除了回答当前采集题之外,还提供了新的、带大概时间的经历时为 true;单纯的否定必须为 false。
has_new_dated_event 仅在用户同一句里除了回答当前采集题之外,还提供了新的、带大概时间的经历时为 true;单纯的否定或记不清必须为 false。
若同一句话既明确否定当前采集题又补充了新的带时间经历,intent 仍为 answer_current_focus 且 answer_class 为 no,不要改成 provide_new_evidence。
“当前方面没有”通常是回答当前采集题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。
不要按关键词表或正则猜测,只根据当前问题与用户这句话的语义分类。`,