fix(rectification): keep choice options and scoring on the server
Independent Staging Quality Gate / validate (push) Successful in 9m30s
Independent Staging Quality Gate / publish (push) Successful in 1m50s

Agent set-focus was writing or dropping choice schema, so probes never scored. Force server-owned options, fail closed when focus list RPC errors, and require the probe year in spokenPrompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-02 20:47:16 +08:00
co-authored by Cursor
parent 679d773b28
commit 8dfe457f21
17 changed files with 942 additions and 117 deletions
@@ -942,7 +942,7 @@ function collectHint(
extra = "",
evidence: readonly MethodFollowupEvidence[] = [],
): string {
return `${why}本题绑定 ${varga}${extra}${recordedKindYearHint(evidence)}用 rectification-set-focus 的 spokenPrompt 用自然语言写出采集题。正文不提问,界面不出点选卡。允许模糊年份。不得把未证实的年份说成已经发生。`.replace(/\s+/g, " ").trim();
return `${why}本题绑定 ${varga}${extra}${recordedKindYearHint(evidence)}用 rectification-set-focus 的 spokenPrompt 用自然语言写出采集题。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。正文不提问,界面不出点选卡。允许模糊年份。不得把未证实的年份说成已经发生。`.replace(/\s+/g, " ").trim();
}
function agentHint(
@@ -951,7 +951,7 @@ function agentHint(
extra = "",
evidence: readonly MethodFollowupEvidence[] = [],
): string {
return `${why}本题绑定 ${varga}${extra}${recordedKindYearHint(evidence)}点选卡只出 A/B/C/D。用 rectification-set-focus 的 spokenPrompt 写出题干;时间范围和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改写时间范围,不得改问其他领域,不得把探针时间说成已经发生的事实。正文不要提问、不要复述选项。`.replace(/\s+/g, " ").trim();
return `${why}本题绑定 ${varga}${extra}${recordedKindYearHint(evidence)}点选卡只出 A/B/C/D。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。时间范围和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改写时间范围,不得改问其他领域,不得把探针时间说成已经发生的事实。正文不要提问、不要复述选项。`.replace(/\s+/g, " ").trim();
}
export function shouldAttachChoiceFrame(
@@ -1544,8 +1544,8 @@ export function buildMethodFollowupPlan(input: {
domain: focus.targetDomain,
kind_hint: focus.targetKind,
user_prompt_hint: keepChoice
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。不要写 expectedAnswerSchema.choice。",
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
source: "active_focus",
...(liveProbe && focus.intent === "distinguish_candidates"
? {
@@ -112,8 +112,8 @@ export async function regenerateV9AssistantTurn(
throw new RectificationToolServiceError("agentic_rectification_case_terminal");
}
const focuses = await listV10ConversationFocuses(accounting, userId, caseId);
const turns = attachQuestionsToTurns(dossier.turns, focuses);
const listed = await listV10ConversationFocuses(accounting, userId, caseId);
const turns = attachQuestionsToTurns(dossier.turns, listed.focuses);
const target = turns.find((turn) => (
turn.id === turnId
&& turn.role === "assistant"
@@ -71,7 +71,7 @@ export function shouldSkipDiscriminatorFollowup(followup: MethodFollowup): Persi
return null;
}
function expectedAnswerSchemaFor(
export function expectedAnswerSchemaFor(
frame: RectificationChoiceFrame,
questionId: string,
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined,
@@ -224,7 +224,7 @@ export async function linkFocusAskedTurn(input: {
}
}
function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> | null {
export function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> | null {
const prompt = spokenFollowupForUser({ ...followup, choice_frame: null });
if (!prompt) return null;
const schema: Record<string, unknown> = {
@@ -235,6 +235,24 @@ function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> |
return schema;
}
export function serverOwnedExpectedAnswerSchema(
followup: MethodFollowup,
decisionReceipt?: Readonly<Record<string, unknown>> | null,
): Record<string, unknown> | null {
const frame = followup.choice_frame;
if (frame) {
const schema = expectedAnswerSchemaFor(
frame,
stableFollowupQuestionId(followup),
decisionReceipt,
followup,
);
if (schema?.choice) return schema;
return collectFocusSchema(spokenCollectFallbackFollowup(followup));
}
return collectFocusSchema(followup);
}
export function isCollectFocusSchema(schema: Readonly<Record<string, unknown>> | null | undefined): boolean {
return schema?.[COLLECT_FOCUS_SCHEMA_KEY] === true && typeof schema.prompt === "string";
}
@@ -61,10 +61,23 @@ export function validateSpokenPrompt(input: {
if (followup.choice_frame && input.questionId && input.questionId !== stableFollowupQuestionId(followup)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "probe_mismatch" };
}
if (followup.probe_year) {
const years = prompt.match(/19\d{2}|20\d{2}/g) ?? [];
if (years.some((year) => Number(year) !== followup.probe_year)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "year_mismatch" };
if (followup.choice_frame) {
if (followup.probe_year) {
const year = String(followup.probe_year);
const years = prompt.match(/19\d{2}|20\d{2}/g) ?? [];
if (years.some((item) => item !== year)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "year_mismatch" };
}
if (!prompt.includes(year)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "year_missing" };
}
} else {
const periodYears = followup.choice_frame.period.match(/19\d{2}|20\d{2}/g) ?? [];
for (const year of periodYears) {
if (!prompt.includes(year)) {
return { ok: false, error: "invalid_spoken_prompt", reason: "year_missing" };
}
}
}
}
}
@@ -1089,28 +1089,51 @@ export async function resolveV10ConversationFocus(
};
}
export type ConversationFocusList = Readonly<{
focuses: readonly ConversationFocus[];
available: boolean;
}>;
export function questionSourceFromFocusList(listed: ConversationFocusList): "focus" | "unavailable" {
return listed.available ? "focus" : "unavailable";
}
export async function listV10ConversationFocuses(
accounting: AccountingClient,
userId: string,
caseId: string,
): Promise<readonly ConversationFocus[]> {
): Promise<ConversationFocusList> {
try {
const { data, error } = await accounting.rpc(
"list_agentic_rectification_conversation_focuses",
{ p_user_id: userId, p_case_id: caseId },
);
if (error) return [];
if (error) {
const message = error instanceof Error
? error.message
: rowText((error as { message?: unknown }).message) ?? String(error);
console.warn(
`[rectification-v9] list focuses failed case=${caseId} reason=${safeToolErrorCode(new Error(message))}`,
);
return { focuses: [], available: false };
}
const items = Array.isArray(data)
? data
: Array.isArray((data as { value?: unknown } | null)?.value)
? (data as { value: unknown[] }).value
: [];
return items.flatMap((item) => {
const focus = parseConversationFocus(item);
return focus ? [focus] : [];
});
} catch {
return [];
return {
focuses: items.flatMap((item) => {
const focus = parseConversationFocus(item);
return focus ? [focus] : [];
}),
available: true,
};
} catch (error) {
console.warn(
`[rectification-v9] list focuses failed case=${caseId} reason=${safeToolErrorCode(error)}`,
);
return { focuses: [], available: false };
}
}
@@ -1792,6 +1815,7 @@ export function safeToolErrorCode(error: unknown): string {
"invalid_choice_copy",
"precision_downgrade",
"offer_not_allowed",
"no_pending_question",
"no_candidate_result",
"stale_probe",
"revision_conflict",
@@ -12,6 +12,7 @@ import type { V9CaseDossier } from "./tool-service";
import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract";
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
import { parseAgentChoiceCopy } from "./choice-card";
import { isCollectFocusSchema } from "./server-focus";
export const TURN_DECISION_MAX_BYTES = 6 * 1024;
export const TURN_DECISION_RECENT_TURNS = 6;
@@ -93,6 +94,17 @@ export function projectCurrentQuestion(
};
}
const collectPrompt = spoken || "";
if (isCollectFocusSchema(schema) && collectPrompt) {
return {
question_id: focus.questionId ?? null,
focus_id: focus.id ?? null,
probe_id: probeId,
prompt: collectPrompt,
kind: "collect_spoken",
intent: focus.intent,
domain: focus.targetDomain ?? null,
};
}
if (focus.intent === "collect_method_evidence" && collectPrompt && !looksLikeChoiceSchema(schema)) {
return {
question_id: focus.questionId ?? null,