fix(rectification): keep choice options and scoring on the server
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user