fix(rectification): resolve typed focus answers deterministically
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user