fix(rectification): resolve typed focus answers deterministically
Independent Staging Quality Gate / validate (push) Successful in 19m52s
Independent Staging Quality Gate / publish (push) Successful in 53m47s

This commit is contained in:
Jesse_Chen
2026-08-27 20:31:05 +08:00
parent f3327235ea
commit 85db4591d3
24 changed files with 695 additions and 168 deletions
@@ -293,25 +293,21 @@ export function applyHoldoutAnswer(
};
}
export function classifyChoiceAnswer(key: string, schema?: unknown): AnswerClass {
if (key === "A") return "yes";
if (key === "B") return "weak_yes";
if (key === "C") {
if (schemaMapsCToUnsure(schema)) return "unsure";
return "no";
}
return "unsure";
}
function schemaMapsCToUnsure(schema: unknown): boolean {
if (!schema || typeof schema !== "object") return false;
export function classifyChoiceAnswer(key: string, schema?: unknown): AnswerClass | null {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return null;
const row = schema as Record<string, unknown>;
if (row.choice_kind !== "varga_style") return false;
const choice = row.choice && typeof row.choice === "object" && !Array.isArray(row.choice)
? row.choice as Record<string, unknown>
: row;
const optionC = typeof choice.option_c === "string" ? choice.option_c : "";
return optionC.includes("都不像");
if (!Array.isArray(choice.options)) return null;
const option = choice.options.find((item) => (
item && typeof item === "object" && !Array.isArray(item) && (item as { key?: unknown }).key === key
));
if (!option || typeof option !== "object") return null;
const answerClass = (option as { answer_class?: unknown }).answer_class;
return answerClass === "yes" || answerClass === "weak_yes" || answerClass === "no" || answerClass === "unsure"
? answerClass
: null;
}
function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswer[]): InferenceState {
@@ -19,7 +19,7 @@ import {
CHOICE_ACTION,
STOP_ACTION,
composeChoiceNarration,
focusStatusForOption,
focusStatusForAnswer,
outcomeIdForOption,
structuredProbeContext,
type ChoiceOptionId,
@@ -47,6 +47,7 @@ export type ApplyChoiceCommand = Readonly<{
probeId?: string | null;
optionId: ChoiceOptionId;
expectedRevision: number;
userDisplay?: string | null;
}>;
export type AppliedChoiceReceipt = Readonly<{
@@ -93,6 +94,10 @@ export async function applyRectificationChoice(
const questionId = focus.questionId;
const scoring = schema.scoring !== false && !questionId.endsWith(":holdout");
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const answerClass = optionId === "stop" ? null : outcomeIdForOption(optionId, schema);
if (optionId !== "stop" && !answerClass) {
throw new RectificationToolServiceError("agentic_rectification_invalid_choice_schema");
}
if (optionId === "stop" || command.action === STOP_ACTION) {
const narration = composeChoiceNarration({
@@ -126,18 +131,18 @@ export async function applyRectificationChoice(
return persistApplied(accounting, command, {
focusId: focus.id,
questionId,
focusStatus: focusStatusForOption(optionId),
focusStatus: focusStatusForAnswer(answerClass, optionId),
probeId: schemaProbeId ?? command.probeId ?? null,
optionId,
scoring,
appliedInference: false,
answerClass: outcomeIdForOption(optionId, schema),
answerClass,
sourceQuote: optionQuoteFromSchema(schema, optionId),
year: null,
expectedRevision: command.expectedRevision,
inference: null,
narration,
userDisplay: userDisplayFromSchema(schema, optionId),
userDisplay: command.userDisplay ?? userDisplayFromSchema(schema, optionId),
dossier,
});
}
@@ -210,7 +215,7 @@ export async function applyRectificationChoice(
return persistApplied(accounting, command, {
focusId: focus.id,
questionId,
focusStatus: focusStatusForOption(optionId),
focusStatus: focusStatusForAnswer(applied.answerClass, optionId),
probeId: applied.probeId ?? schemaProbeId,
optionId,
scoring,
@@ -221,7 +226,7 @@ export async function applyRectificationChoice(
expectedRevision: previous.revision,
inference,
narration,
userDisplay: userDisplayFromSchema(schema, optionId),
userDisplay: command.userDisplay ?? userDisplayFromSchema(schema, optionId),
decisionState: applied.state,
userStopped: false,
dossier,
@@ -262,6 +262,7 @@ const KNOWN_RPC_ERROR_CODES = new Map<string, { status: number; code: string; me
["agentic_rectification_stale_probe", { status: 409, code: "stale_probe", message: "这道区分题已经过期,请回答当前问题" }],
["agentic_rectification_stale_question", { status: 409, code: "stale_question", message: "这道题已经过期,请回答当前问题" }],
["agentic_rectification_focus_not_active", { status: 409, code: "focus_not_active", message: "当前没有等待回答的问题" }],
["agentic_rectification_invalid_choice_schema", { status: 409, code: "invalid_choice_schema", message: "当前问题已更新,请刷新后重新作答" }],
["agentic_rectification_focus_not_found", { status: 409, code: "focus_not_found", message: "当前没有等待回答的问题" }],
["agentic_rectification_revision_conflict", { status: 409, code: "revision_conflict", message: "推断状态已更新,请刷新后再试" }],
["agentic_rectification_inference_patch_retired", { status: 409, code: "inference_patch_retired", message: "不能再原地修改推断回执" }],
@@ -13,13 +13,6 @@ export const CHOICE_ACTION = "answer_choice" as const;
export const STOP_ACTION = "stop_and_review" as const;
export const DETERMINISTIC_CHOICE_MODEL = "deterministic:choice";
export const OPTION_ANSWER_CLASS: Readonly<Record<ChoiceKey, AnswerClass>> = {
A: "yes",
B: "weak_yes",
C: "no",
D: "unsure",
};
export type ChoiceActionKind = typeof CHOICE_ACTION | typeof STOP_ACTION;
export type ChoiceOptionId = ChoiceKey | "stop";
export type ChoiceActionStatus = "received" | "applied" | "narrated";
@@ -31,13 +24,13 @@ export type StructuredProbeDerivedContext = Readonly<{
answerClass: AnswerClass | null;
}>;
export function outcomeIdForOption(optionId: ChoiceKey, schema?: unknown): AnswerClass {
export function outcomeIdForOption(optionId: ChoiceKey, schema?: unknown): AnswerClass | null {
return classifyChoiceAnswer(optionId, schema);
}
export function focusStatusForOption(optionId: ChoiceOptionId): "resolved" | "declined" | "skipped" {
if (optionId === "C") return "declined";
if (optionId === "D" || optionId === "stop") return "skipped";
export function focusStatusForAnswer(answerClass: AnswerClass | null, optionId: ChoiceOptionId): "resolved" | "declined" | "skipped" {
if (optionId === "stop" || answerClass === "unsure") return "skipped";
if (answerClass === "no") return "declined";
return "resolved";
}
@@ -1,13 +1,14 @@
/**
* Choice-card contract for birth-time rectification.
*
* The server owns the discriminator frame and the tap chrome (A/B/C/D keys,
* C = neither / D = unsure roles, scoring vs holdout, 先这样). Event stems
* The server owns the discriminator frame and the tap chrome (A/B/C/D display
* keys, dynamic answer classes, scoring vs holdout, 先这样). Event stems
* persist as lock labels (`period · family`); the Agent writes the spoken
* question. The browser never invents option copy, and never parses A/B/C/D
* out of assistant prose.
*/
import type { AnswerClass } from "../core/types";
import type { DiscriminatingEventProbe, EventProbeChoiceKind, EventProbeStyleOption } from "./refinement-packet";
import type { InternalVargaObservation } from "./varga-observations";
@@ -27,6 +28,7 @@ export type ChoiceKey = "A" | "B" | "C" | "D";
export type RectificationChoiceOption = Readonly<{
key: ChoiceKey;
label: string;
answer_class: AnswerClass;
role: "primary" | "secondary";
}>;
@@ -41,6 +43,10 @@ export type RectificationChoiceFrame = Readonly<{
option_b_hint: string;
neither_label: string;
unsure_label: string;
option_a_answer_class: AnswerClass;
option_b_answer_class: AnswerClass;
option_c_answer_class: AnswerClass;
option_d_answer_class: AnswerClass;
choice_mode: typeof CHOICE_MODE;
stop_label: string;
stop_message: string;
@@ -54,6 +60,11 @@ export type AgentChoiceCopy = Readonly<{
option_b: string;
option_c: string;
option_d: string;
options: readonly Readonly<{
key: ChoiceKey;
label: string;
answer_class: AnswerClass;
}>[];
}>;
export type RectificationChoiceCard = Readonly<{
@@ -179,6 +190,7 @@ type Hypothesis = Readonly<{
b: string;
neither: string;
unsure: string;
answerClasses: readonly [AnswerClass, AnswerClass, AnswerClass, AnswerClass];
}>;
function eventLockPrompt(period: string, family: string): string {
@@ -191,22 +203,26 @@ function withStyleOptionLabels(
varga: string | null,
styleOptions: readonly EventProbeStyleOption[],
): Hypothesis | null {
const labels = new Map<string, string>();
const options: Array<{ label: string; answerClass: AnswerClass }> = [];
const classes = new Set<AnswerClass>();
for (const option of styleOptions) {
const label = clippedCopy(option.label, 4, 80);
if (!label || labels.has(option.answer_class)) return null;
labels.set(option.answer_class, label);
const answerClass = option.answer_class;
if (!label || (answerClass !== "yes" && answerClass !== "weak_yes" && answerClass !== "no" && answerClass !== "unsure")) return null;
if (classes.has(answerClass)) return null;
classes.add(answerClass);
options.push({ label, answerClass });
}
const values = ["yes", "weak_yes", "no", "unsure"].map((answerClass) => labels.get(answerClass));
if (values.some((value) => !value) || new Set(values).size !== values.length) return null;
if (options.length !== 4 || classes.size !== 4 || new Set(options.map((item) => item.label)).size !== 4) return null;
return {
prompt,
why,
varga,
a: values[0]!,
b: values[1]!,
neither: values[2]!,
unsure: values[3]!,
a: options[0]!.label,
b: options[1]!.label,
neither: options[2]!.label,
unsure: options[3]!.label,
answerClasses: options.map((item) => item.answerClass) as [AnswerClass, AnswerClass, AnswerClass, AnswerClass],
};
}
@@ -259,6 +275,10 @@ export function buildChoiceFrame(
option_b_hint: hypothesis.b,
neither_label: hypothesis.neither,
unsure_label: hypothesis.unsure,
option_a_answer_class: hypothesis.answerClasses[0],
option_b_answer_class: hypothesis.answerClasses[1],
option_c_answer_class: hypothesis.answerClasses[2],
option_d_answer_class: hypothesis.answerClasses[3],
choice_mode: CHOICE_MODE,
stop_label: CHOICE_STOP_LABEL,
stop_message: CHOICE_STOP_MESSAGE,
@@ -284,6 +304,27 @@ function clippedCopy(value: unknown, min: number, max: number): string | null {
return text;
}
function isAnswerClass(value: unknown): value is AnswerClass {
return value === "yes" || value === "weak_yes" || value === "no" || value === "unsure";
}
function parseChoiceOptions(value: unknown): AgentChoiceCopy["options"] | null {
if (!Array.isArray(value) || value.length !== 4) return null;
const options: Array<{ key: ChoiceKey; label: string; answer_class: AnswerClass }> = [];
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const row = item as Record<string, unknown>;
const key = row.key;
const label = clippedCopy(row.label, 4, 80);
if ((key !== "A" && key !== "B" && key !== "C" && key !== "D") || !label || !isAnswerClass(row.answer_class)) return null;
options.push({ key, label, answer_class: row.answer_class });
}
if (new Set(options.map((item) => item.key)).size !== 4) return null;
if (new Set(options.map((item) => item.label)).size !== 4) return null;
if (new Set(options.map((item) => item.answer_class)).size !== 4) return null;
return options;
}
export function serverOwnedChoiceCopy(frame: RectificationChoiceFrame): AgentChoiceCopy | null {
const prompt = clippedCopy(frame.prompt, 4, 80)
?? clippedCopy(frame.period, 4, 80);
@@ -294,12 +335,20 @@ export function serverOwnedChoiceCopy(frame: RectificationChoiceFrame): AgentCho
if (!prompt || !optionA || !optionB || !optionC || !optionD) return null;
const labels = [optionA, optionB, optionC, optionD];
if (new Set(labels).size !== labels.length) return null;
const options = parseChoiceOptions([
{ key: "A", label: optionA, answer_class: frame.option_a_answer_class },
{ key: "B", label: optionB, answer_class: frame.option_b_answer_class },
{ key: "C", label: optionC, answer_class: frame.option_c_answer_class },
{ key: "D", label: optionD, answer_class: frame.option_d_answer_class },
]);
if (!options) return null;
return {
prompt,
option_a: optionA,
option_b: optionB,
option_c: optionC,
option_d: optionD,
options,
};
}
@@ -309,12 +358,13 @@ export function parseAgentChoiceCopy(value: unknown): AgentChoiceCopy | null {
const choice = row.choice && typeof row.choice === "object" && !Array.isArray(row.choice)
? row.choice as Record<string, unknown>
: row;
const options = parseChoiceOptions(choice.options);
const prompt = clippedCopy(choice.prompt, 4, 80);
const optionA = clippedCopy(choice.option_a ?? choice.optionA, 4, 80);
const optionB = clippedCopy(choice.option_b ?? choice.optionB, 4, 80);
const optionC = clippedCopy(choice.option_c ?? choice.optionC, 4, 80);
const optionD = clippedCopy(choice.option_d ?? choice.optionD, 4, 80);
if (!prompt || !optionA || !optionB || !optionC || !optionD) return null;
if (!prompt || !optionA || !optionB || !optionC || !optionD || !options) return null;
const labels = [optionA, optionB, optionC, optionD];
if (new Set(labels).size !== labels.length) return null;
return {
@@ -323,6 +373,7 @@ export function parseAgentChoiceCopy(value: unknown): AgentChoiceCopy | null {
option_b: optionB,
option_c: optionC,
option_d: optionD,
options,
};
}
@@ -330,6 +381,7 @@ export function mergeChoiceCard(
frame: RectificationChoiceFrame,
copy: AgentChoiceCopy | null,
meta: {
question_id?: string | null;
probe_id?: string | null;
case_revision?: number | null;
focus_id?: string | null;
@@ -337,18 +389,16 @@ export function mergeChoiceCard(
): RectificationChoiceCard | null {
if (!copy) return null;
return {
question_id: frame.question_id,
question_id: meta.question_id ?? frame.question_id,
method_id: frame.method_id,
prompt: copy.prompt,
why: "",
varga: frame.varga,
choice_mode: CHOICE_MODE,
options: [
{ key: "A", label: copy.option_a, role: "primary" },
{ key: "B", label: copy.option_b, role: "primary" },
{ key: "C", label: copy.option_c, role: "primary" },
{ key: "D", label: copy.option_d, role: "secondary" },
],
options: copy.options.map((option) => ({
...option,
role: option.answer_class === "unsure" ? "secondary" as const : "primary" as const,
})),
stop_label: frame.stop_label,
stop_message: frame.stop_message,
scoring: frame.scoring,
@@ -363,15 +413,6 @@ export function isHoldoutVerificationQuote(quote: string): boolean {
return quote.includes(HOLDOUT_MESSAGE_PREFIX);
}
export function parseChoiceKeyFromUserMessage(message: string | null | undefined): ChoiceKey | null {
if (!message) return null;
const match = message.trim().match(new RegExp(
`^(?:${HOLDOUT_MESSAGE_PREFIX}[:]\\s*)?([ABCD])[.)、.]`,
));
const key = match?.[1];
return key === "A" || key === "B" || key === "C" || key === "D" ? key : null;
}
export function choiceCardUserMessage(
card: RectificationChoiceCard,
key: ChoiceKey,
@@ -394,11 +435,13 @@ export function parseRectificationChoiceCard(value: unknown): RectificationChoic
const option = item as Record<string, unknown>;
if (option.key !== "A" && option.key !== "B" && option.key !== "C" && option.key !== "D") return null;
if (typeof option.label !== "string" || option.label.trim().length === 0) return null;
if (!isAnswerClass(option.answer_class)) return null;
if (option.role !== "primary" && option.role !== "secondary") return null;
parsed.push({ key: option.key, label: option.label.trim(), role: option.role });
parsed.push({ key: option.key, label: option.label.trim(), answer_class: option.answer_class, role: option.role });
}
const keys = new Set(parsed.map((item) => item.key));
if (!keys.has("A") || !keys.has("B") || !keys.has("C")) return null;
if (!keys.has("A") || !keys.has("B") || !keys.has("C") || !keys.has("D")) return null;
if (new Set(parsed.map((item) => item.answer_class)).size !== 4) return null;
const focusId = typeof row.focus_id === "string" ? row.focus_id.trim() : "";
if (!isPersistedFocusId(focusId)) return null;
return {
@@ -17,7 +17,6 @@ import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import type { AnswerClass, ConflictProbe, InferenceState } from "../core/types.ts";
import {
isHoldoutVerificationQuote,
parseChoiceKeyFromUserMessage,
type ChoiceKey,
} from "./choice-card.ts";
import type { DiscriminatingEventProbe } from "./refinement-packet.ts";
@@ -291,16 +290,9 @@ export function isHoldoutChoiceSchema(
export function resolveChoiceKey(input: {
choiceKey?: string | null;
status?: "resolved" | "declined" | "skipped";
userMessage?: string | null;
}): ChoiceKey | null {
const explicit = input.choiceKey?.trim().toUpperCase();
if (explicit === "A" || explicit === "B" || explicit === "C" || explicit === "D") return explicit;
const fromMessage = parseChoiceKeyFromUserMessage(input.userMessage);
if (fromMessage) return fromMessage;
if (input.status === "declined") return "C";
if (input.status === "skipped") return "D";
if (input.status === "resolved") return "B";
return null;
}
@@ -408,14 +400,13 @@ export function applyChoiceWithoutEvidence(
state: InferenceState,
input: {
choiceKey?: string | null;
status?: "resolved" | "declined" | "skipped";
userMessage?: string | null;
schema?: unknown;
questionId?: string | null;
domain?: string | null;
},
): ChoiceWithoutEvidenceResult {
if (!hasChoiceSchema(input.schema) && !input.choiceKey && !parseChoiceKeyFromUserMessage(input.userMessage)) {
if (!hasChoiceSchema(input.schema) && !input.choiceKey) {
return { applied: false, reason: "no_choice", state, answerClass: null, probeId: null };
}
if (isHoldoutChoiceSchema(input.schema, input.questionId, input.userMessage)) {
@@ -447,6 +438,9 @@ export function applyChoiceWithoutEvidence(
}
const lastAnsweredId = state.answered_probes.at(-1)?.probe_id ?? null;
const answerClass = classifyChoiceAnswer(choiceKey, input.schema);
if (!answerClass) {
return { applied: false, reason: "no_choice", state, answerClass: null, probeId: probe.id };
}
const existing = state.answered_probes.find((item) => (
item.probe_id === probe.id || item.semantic_key === probe.semantic_key
));
@@ -35,6 +35,7 @@ export function choiceCardFromCaseDossier(dossier: {
conversationSummary: {
activeFocus: {
id?: string;
questionId?: string;
intent: string;
targetDomain: string | null;
targetKind: string | null;
@@ -133,6 +133,7 @@ export type MethodFollowupEvidence = Readonly<{
export type MethodFollowupFocus = Readonly<{
id?: string;
questionId?: string;
intent: string;
targetDomain: string | null;
targetKind: string | null;
@@ -1273,9 +1274,23 @@ export function projectRectificationChoiceCard(
? input.activeFocus.id.trim()
: "";
if (!isPersistedFocusId(focusId)) return null;
const frame = plan.next_followup?.choice_frame ?? plan.deferred_followup?.choice_frame ?? null;
const followup = plan.next_followup ?? plan.deferred_followup ?? null;
if (!followup) return null;
const frame = followup.choice_frame;
if (!frame) return null;
const schema = input.activeFocus?.expectedAnswerSchema ?? null;
const schemaRow = schema && typeof schema === "object" && !Array.isArray(schema)
? schema as Record<string, unknown>
: null;
if (input.activeFocus?.intent !== followup.intent) return null;
if (followup.semantic_key && schemaRow?.semantic_key !== followup.semantic_key) return null;
if (followup.candidate_split_hash && schemaRow?.candidate_split_hash !== followup.candidate_split_hash) return null;
if (
!followup.semantic_key
&& !followup.candidate_split_hash
&& input.activeFocus?.questionId
&& input.activeFocus.questionId !== frame.question_id
) return null;
const probeId = schema && typeof schema === "object" && typeof (schema as { probe_id?: unknown }).probe_id === "string"
? (schema as { probe_id: string }).probe_id
: null;
@@ -1287,6 +1302,7 @@ export function projectRectificationChoiceCard(
}
: null;
return mergeChoiceCard(frame, overlaid, {
question_id: input.activeFocus?.questionId ?? frame.question_id,
probe_id: probeId,
case_revision: input.caseRevision ?? null,
focus_id: focusId,
@@ -1,4 +1,6 @@
import {
isPersistedFocusId,
parseAgentChoiceCopy,
serverOwnedChoiceCopy,
type RectificationChoiceFrame,
} from "./choice-card";
@@ -22,6 +24,7 @@ export type PersistServerFocusStatus =
| "duplicate_focus"
| "probe_already_answered"
| "zero_information_gain"
| "invalid_choice_schema"
| "skipped";
export type PersistServerFocusResult = Readonly<{
@@ -75,6 +78,7 @@ function expectedAnswerSchemaFor(
option_b: copy.option_b,
option_c: copy.option_c,
option_d: copy.option_d,
options: copy.options,
},
semantic_key: followup.semantic_key ?? null,
candidate_split_hash: followup.candidate_split_hash ?? null,
@@ -99,7 +103,14 @@ export function openQuestionFromPersistedFocus(result: PersistServerFocusResult)
prompt: string;
status: PersistServerFocusStatus;
} | null {
if (!result.prompt) return null;
if (
(result.status !== "created" && result.status !== "already_open")
|| !result.prompt
|| !result.focus
|| !isPersistedFocusId(result.focus.id)
|| result.focus.questionId !== result.questionId
|| !parseAgentChoiceCopy(result.focus.expectedAnswerSchema)
) return null;
return {
question_id: result.questionId,
prompt: result.prompt,
@@ -118,7 +129,12 @@ export async function persistServerOwnedFocus(input: {
const followup = input.followup;
const frame = followup?.choice_frame ?? null;
if (!followup || !frame) {
return { status: "skipped", focus: input.activeFocus, questionId: null, prompt: null };
return {
status: followup?.intent === "distinguish_candidates" ? "invalid_choice_schema" : "skipped",
focus: input.activeFocus,
questionId: null,
prompt: null,
};
}
const skip = shouldSkipDiscriminatorFollowup(followup);
if (skip) {
@@ -136,7 +152,7 @@ export async function persistServerOwnedFocus(input: {
}
const schema = expectedAnswerSchemaFor(frame, questionId, input.decisionReceipt, followup);
if (!schema?.choice) {
return { status: "skipped", focus: input.activeFocus, questionId, prompt: null };
return { status: "invalid_choice_schema", focus: input.activeFocus, questionId, prompt: null };
}
const copy = serverOwnedChoiceCopy(frame);
const prompt = copy?.prompt ?? null;
@@ -0,0 +1,77 @@
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
import type { ResolvedLanguageModel } from "@/mastra/model";
import type { AnswerClass } from "../core/types";
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
import type { ConversationFocus } from "./tool-service";
const turnIntentSchema = z.object({
intent: z.enum([
"answer_current_focus",
"provide_new_evidence",
"stop_rectification",
"ask_about_result",
"unclear",
]),
answer_class: z.enum(["yes", "weak_yes", "no", "unsure"]).nullable(),
}).strict();
export type RectificationTurnIntent = z.infer<typeof turnIntentSchema>;
export function parseRectificationTurnIntent(value: unknown): RectificationTurnIntent | null {
const parsed = turnIntentSchema.safeParse(value);
if (!parsed.success) return null;
if (parsed.data.intent === "answer_current_focus") {
return parsed.data.answer_class ? parsed.data : null;
}
return parsed.data.answer_class === null ? parsed.data : null;
}
export function optionIdForAnswerClass(
focus: ConversationFocus,
answerClass: AnswerClass,
): ChoiceKey | null {
const copy = parseAgentChoiceCopy(focus.expectedAnswerSchema);
return copy?.options.find((option) => option.answer_class === answerClass)?.key ?? null;
}
export async function classifyRectificationTurnIntent(
model: ResolvedLanguageModel,
input: {
focus: ConversationFocus;
userMessage: string;
caseStatus: string;
signal?: AbortSignal;
},
): Promise<RectificationTurnIntent | null> {
const choice = parseAgentChoiceCopy(input.focus.expectedAnswerSchema);
if (!choice) return null;
const agent = new Agent({
id: `rectification-focus-intent-${model.id}`,
name: "Rectification Focus Intent Classifier",
model: model.model,
instructions: `你只做当前生时校正问题的意图分类,不回答用户,也不修改任何状态。
结合当前问题和动态选项判断用户是在回答当前问题、提供新的带时间经历、要求停止整个校正、询问结果,还是语义不清。
若是在回答当前问题,answer_class 必须使用某个选项提供的 answer_class;否则 answer_class 必须为 null。
“当前方面没有、那段时间没有变化”通常是回答当前问题,不是停止整个流程;只有用户明确要求停止整个校正时才分类为 stop_rectification。
不要按 A/B/C/D 的位置猜语义,只按选项 label 与 answer_class 判断。`,
});
const result = await agent.generate([{
role: "user",
content: JSON.stringify({
current_question: choice.prompt,
options: choice.options,
user_message: input.userMessage,
case_status: input.caseStatus,
}),
}], {
abortSignal: input.signal,
structuredOutput: {
schema: turnIntentSchema,
jsonPromptInjection: "inline",
},
});
return parseRectificationTurnIntent(result.object);
}
@@ -114,24 +114,18 @@ export function overlayChoicePromptFromSpoken(
}
/**
* Keep the Agent's own follow-up when it stays on the locked year and domain.
* Lock prompts are period · family, not user-facing questions; never splice
* them into speech. A leftover full question sentence is only a last-resort
* fallback for older stamped copies.
* A persisted choice focus is rendered by the choice card. Keep only the
* acknowledgement in chat so the Agent cannot create a second, divergent
* discriminator in prose.
*/
export function bindSpokenToOpenQuestion(spoken: string, nextQuestion: string | null): string {
const lock = nextQuestion?.trim() ?? "";
if (!lock) return spoken.trim();
const agentQuestion = extractSpokenQuestion(spoken);
if (agentQuestion && spokenQuestionMatchesLock(agentQuestion, lock)) {
return spoken.trim();
}
const ack = spoken
.split(/\n{2,}/)
.flatMap((block) => block.split(/(?<=[])\s*/u))
.map((part) => part.trim())
.filter((part) => part.length > 0 && !/[?]/.test(part) && !part.includes(lock))
.slice(0, 2);
if (!/[?]/.test(lock)) return ack.join("\n\n");
return [...ack, lock].join("\n\n");
return ack.join("\n\n");
}