Graduation no longer gets the college-experience question, and identical D24 splits only ask once. Co-authored-by: Cursor <cursoragent@cursor.com>
641 lines
23 KiB
TypeScript
641 lines
23 KiB
TypeScript
/**
|
||
* Choice-card contract for birth-time rectification.
|
||
*
|
||
* 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 a year-locked spoken question (`period,有没有family?`).
|
||
* The Agent must not repeat that stem. The browser never invents option copy,
|
||
* and never parses A/B/C/D out of assistant prose.
|
||
*/
|
||
|
||
import type { AnswerClass } from "../core/types";
|
||
import { engineMeaningToDisplayCopy } from "../user-copy.ts";
|
||
import { completeStyleOptions, clippedProbeLabel, canRenderYearlessChoice } from "./probe-question-contract";
|
||
import type { DiscriminatingEventProbe, EventProbeChoiceKind, EventProbeStyleOption } from "./refinement-packet";
|
||
import type { InternalVargaObservation } from "./varga-observations";
|
||
|
||
export const CHOICE_MODE = "A/B/C/D";
|
||
export const CHOICE_STOP_LABEL = "先这样,先看当前范围";
|
||
export const CHOICE_STOP_MESSAGE = "先这样";
|
||
export const CHOICE_SKIP_QUESTION_LABEL = "这题跳过";
|
||
export const CHOICE_SKIP_QUESTION_MESSAGE = "这题跳过";
|
||
export const HOLDOUT_MESSAGE_PREFIX = "盘外核对(不计分)";
|
||
export const FORBIDDEN_CHOICE_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/;
|
||
export const FOCUS_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||
|
||
export function isPersistedFocusId(value: string | null | undefined): value is string {
|
||
return typeof value === "string" && FOCUS_ID_PATTERN.test(value.trim());
|
||
}
|
||
|
||
export type ChoiceKey = "A" | "B" | "C" | "D";
|
||
|
||
export type RectificationChoiceOption = Readonly<{
|
||
key: ChoiceKey;
|
||
label: string;
|
||
answer_class: AnswerClass;
|
||
role: "primary" | "secondary";
|
||
}>;
|
||
|
||
export type RectificationChoiceFrame = Readonly<{
|
||
question_id: string;
|
||
method_id: string;
|
||
period: string;
|
||
prompt: string;
|
||
varga: string | null;
|
||
why: string;
|
||
option_a_hint: string;
|
||
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;
|
||
scoring: boolean;
|
||
choice_kind?: EventProbeChoiceKind;
|
||
skip_this_probe?: boolean;
|
||
}>;
|
||
|
||
export type AgentChoiceCopy = Readonly<{
|
||
prompt: string;
|
||
option_a: string;
|
||
option_b: string;
|
||
option_c: string;
|
||
option_d: string;
|
||
options: readonly Readonly<{
|
||
key: ChoiceKey;
|
||
label: string;
|
||
answer_class: AnswerClass;
|
||
}>[];
|
||
}>;
|
||
|
||
export type RectificationChoiceCard = Readonly<{
|
||
question_id: string;
|
||
method_id: string;
|
||
prompt: string;
|
||
why: string;
|
||
varga: string | null;
|
||
choice_mode: typeof CHOICE_MODE;
|
||
options: readonly RectificationChoiceOption[];
|
||
stop_label: string;
|
||
stop_message: string;
|
||
scoring: boolean;
|
||
probe_id: string | null;
|
||
case_revision: number | null;
|
||
focus_id: string | null;
|
||
choice_kind?: EventProbeChoiceKind;
|
||
skip_this_probe?: boolean;
|
||
}>;
|
||
|
||
export type ChoiceCardFollowup = Readonly<{
|
||
method_id: string;
|
||
ask_theme: string;
|
||
domain: string | null;
|
||
user_prompt_hint: string;
|
||
intent?: string;
|
||
source?: string;
|
||
choice_kind?: EventProbeChoiceKind;
|
||
style_options?: readonly EventProbeStyleOption[];
|
||
semantic_key?: string;
|
||
probe_id?: string;
|
||
}>;
|
||
|
||
export type ChoiceCardEvidence = Readonly<{
|
||
status: string;
|
||
domain?: string;
|
||
datePrecision: string;
|
||
occurredFrom: string | null;
|
||
occurredTo: string | null;
|
||
}>;
|
||
|
||
const THEME_DOMAIN: Record<string, string> = {
|
||
relationship_style: "relationship",
|
||
career_style: "career",
|
||
home_change: "relocation",
|
||
education_style: "education",
|
||
family_event: "family",
|
||
finance_change: "finance",
|
||
health_pressure: "health_pressure",
|
||
dated_event: "education",
|
||
oos_blind: "family",
|
||
};
|
||
|
||
const PLACEHOLDER_PERIOD = /^(当前这几个候选|那段时间)$/;
|
||
const YEAR_MONTH_PERIOD = /(?:19|20)\d{2} 年 \d{1,2} 月/;
|
||
const YEAR_PERIOD = /(?:19|20)\d{2}/;
|
||
|
||
function yearFrom(value: string | null): number | null {
|
||
const year = value?.slice(0, 4);
|
||
if (!year || !/^\d{4}$/.test(year)) return null;
|
||
const parsed = Number(year);
|
||
return parsed >= 1900 && parsed <= 2100 ? parsed : null;
|
||
}
|
||
|
||
function monthFrom(value: string | null): number | null {
|
||
const match = value?.match(/^\d{4}-(\d{2})/);
|
||
if (!match) return null;
|
||
const month = Number(match[1]);
|
||
return month >= 1 && month <= 12 ? month : null;
|
||
}
|
||
|
||
function isConfirmedDated(item: ChoiceCardEvidence): boolean {
|
||
return item.status === "confirmed"
|
||
&& item.datePrecision !== "unknown"
|
||
&& Boolean(item.occurredFrom || item.occurredTo);
|
||
}
|
||
|
||
function evidenceItemPeriod(item: ChoiceCardEvidence): string | null {
|
||
const year = yearFrom(item.occurredFrom) ?? yearFrom(item.occurredTo);
|
||
if (year === null) return null;
|
||
if (item.datePrecision === "month" || item.datePrecision === "day") {
|
||
const month = monthFrom(item.occurredFrom) ?? monthFrom(item.occurredTo);
|
||
if (month) return `${year} 年 ${month} 月前后`;
|
||
}
|
||
return `${year} 年前后`;
|
||
}
|
||
|
||
export function lifePeriodLabel(
|
||
evidence: readonly ChoiceCardEvidence[],
|
||
domain?: string | null,
|
||
): string {
|
||
const dated = evidence.filter((item) => {
|
||
if (!isConfirmedDated(item)) return false;
|
||
if (domain && item.domain !== domain) return false;
|
||
return true;
|
||
});
|
||
const labels = [...new Set(dated.flatMap((item) => {
|
||
const label = evidenceItemPeriod(item);
|
||
return label ? [label] : [];
|
||
}))];
|
||
if (labels.length === 1) return labels[0]!;
|
||
const years = dated.flatMap((item) => {
|
||
const from = yearFrom(item.occurredFrom);
|
||
const to = yearFrom(item.occurredTo);
|
||
return [from, to].filter((value): value is number => value !== null);
|
||
});
|
||
if (years.length === 0) return "那段时间";
|
||
const min = Math.min(...years);
|
||
const max = Math.max(...years);
|
||
return min === max ? `${min} 年前后` : `${min}–${max} 年这段里`;
|
||
}
|
||
|
||
function concreteProbePeriod(probe: DiscriminatingEventProbe | null): string | null {
|
||
if (!probe) return null;
|
||
const label = probe.year_label.trim();
|
||
if ((probe.year ?? 0) <= 0 || !label || PLACEHOLDER_PERIOD.test(label)) return null;
|
||
return label;
|
||
}
|
||
|
||
export function periodSpecificity(text: string): number {
|
||
if (YEAR_MONTH_PERIOD.test(text)) return 2;
|
||
if (YEAR_PERIOD.test(text)) return 1;
|
||
return 0;
|
||
}
|
||
|
||
export function isConcreteChoicePeriod(period: string | null | undefined): boolean {
|
||
const text = period?.trim() ?? "";
|
||
if (!text || PLACEHOLDER_PERIOD.test(text)) return false;
|
||
return periodSpecificity(text) > 0;
|
||
}
|
||
|
||
function isCatalogLockPrompt(text: string): boolean {
|
||
return / · /.test(text) && !/[??]/.test(text);
|
||
}
|
||
|
||
function isPlaceholderChoicePrompt(text: string): boolean {
|
||
return isCatalogLockPrompt(text)
|
||
|| /那段时间/.test(text)
|
||
|| /家人相关的明显变化/.test(text)
|
||
|| /出现明显变化/.test(text)
|
||
|| /关系观明显转变/.test(text)
|
||
|| /认真关系进入/.test(text)
|
||
|| /学习环境变化/.test(text)
|
||
|| /职责明显加重/.test(text);
|
||
}
|
||
|
||
export function preferConcreteChoicePrompt(framePrompt: string, copyPrompt: string): string {
|
||
const frameScore = periodSpecificity(framePrompt);
|
||
const copyScore = periodSpecificity(copyPrompt);
|
||
if (frameScore > copyScore) return framePrompt;
|
||
if (frameScore === copyScore && isPlaceholderChoicePrompt(copyPrompt) && !isPlaceholderChoicePrompt(framePrompt)) {
|
||
return framePrompt;
|
||
}
|
||
return copyPrompt;
|
||
}
|
||
|
||
function followupDomain(followup: ChoiceCardFollowup): string | null {
|
||
if (followup.domain) return followup.domain;
|
||
return THEME_DOMAIN[followup.ask_theme] ?? null;
|
||
}
|
||
|
||
function probeMatchesId(item: DiscriminatingEventProbe, probeId: string): boolean {
|
||
return item.semantic_key === probeId;
|
||
}
|
||
|
||
function pickProbe(
|
||
probes: readonly DiscriminatingEventProbe[] | undefined,
|
||
domain: string | null,
|
||
followup?: ChoiceCardFollowup,
|
||
): DiscriminatingEventProbe | null {
|
||
if (!probes?.length) return null;
|
||
const probeId = followup?.probe_id?.trim() ?? "";
|
||
const semanticKey = followup?.semantic_key?.trim() ?? "";
|
||
const hasKey = Boolean(probeId || semanticKey);
|
||
if (probeId) {
|
||
const byId = probes.find((item) => probeMatchesId(item, probeId));
|
||
if (byId) return byId;
|
||
}
|
||
if (semanticKey) {
|
||
const keyed = probes.find((item) => item.semantic_key === semanticKey);
|
||
if (keyed) return keyed;
|
||
}
|
||
if (hasKey) return null;
|
||
const inDomain = domain ? probes.filter((item) => item.domain === domain) : [...probes];
|
||
const pool = inDomain.length > 0 ? inDomain : probes;
|
||
if (followup?.choice_kind === "event_quality") {
|
||
const quality = pool.find((item) =>
|
||
item.source === "known_event_quality" || item.choice_kind === "event_quality"
|
||
);
|
||
if (quality) return quality;
|
||
}
|
||
return pool[0] ?? probes[0] ?? null;
|
||
}
|
||
|
||
function periodFor(
|
||
_evidence: readonly ChoiceCardEvidence[] | undefined,
|
||
domain: string | null,
|
||
probes: readonly DiscriminatingEventProbe[] | undefined,
|
||
_birthDate?: string | null,
|
||
followup?: ChoiceCardFollowup,
|
||
): string {
|
||
const probe = pickProbe(probes, domain, followup);
|
||
const kind = followup?.choice_kind ?? probe?.choice_kind ?? "existence";
|
||
if (kind === "varga_style") return "";
|
||
const fromProbe = concreteProbePeriod(probe);
|
||
if (fromProbe) return fromProbe;
|
||
return "那段时间";
|
||
}
|
||
|
||
type Hypothesis = Readonly<{
|
||
prompt: string;
|
||
why: string;
|
||
varga: string | null;
|
||
a: string;
|
||
b: string;
|
||
neither: string;
|
||
unsure: string;
|
||
answerClasses: readonly [AnswerClass, AnswerClass, AnswerClass, AnswerClass];
|
||
}>;
|
||
|
||
function eventQuestionPrompt(
|
||
period: string,
|
||
family: string,
|
||
kind: EventProbeChoiceKind,
|
||
domain?: string | null,
|
||
probe?: DiscriminatingEventProbe | null,
|
||
): string {
|
||
const time = period.trim();
|
||
const topic = family.replace(/[??。]+$/g, "").trim();
|
||
if (kind === "varga_style") {
|
||
const fromProbe = clippedCopy(probe?.user_meaning, 4, 80);
|
||
if (fromProbe && /[??]$/.test(fromProbe)) return fromProbe;
|
||
return domain === "relationship"
|
||
? "亲密关系里,你更接近哪一种相处方式?"
|
||
: "平时做事,你更接近下面哪一种?";
|
||
}
|
||
if (kind === "event_quality" && probe?.role === "distinguish" && probe.target_evidence_id && probe.user_meaning?.trim()) {
|
||
return engineMeaningToDisplayCopy(probe.user_meaning.trim());
|
||
}
|
||
if (!topic) return time;
|
||
const dated = isConcreteChoicePeriod(period);
|
||
if (kind === "event_quality") {
|
||
return dated ? `${time},有没有${topic}的时候?` : `有没有${topic}的时候?`;
|
||
}
|
||
return dated ? `${time},有没有${topic}?` : `有没有${topic}?`;
|
||
}
|
||
|
||
function withStyleOptionLabels(
|
||
prompt: string,
|
||
why: string,
|
||
varga: string | null,
|
||
styleOptions: readonly EventProbeStyleOption[],
|
||
): Hypothesis | null {
|
||
const options: Array<{ label: string; answerClass: AnswerClass }> = [];
|
||
const classes = new Set<AnswerClass>();
|
||
for (const option of styleOptions) {
|
||
const label = clippedCopy(option.label, 4, 80);
|
||
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 });
|
||
}
|
||
if (options.length !== 4 || classes.size !== 4 || new Set(options.map((item) => item.label)).size !== 4) return null;
|
||
return {
|
||
prompt,
|
||
why,
|
||
varga,
|
||
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],
|
||
};
|
||
}
|
||
|
||
function hypothesisFor(
|
||
followup: ChoiceCardFollowup,
|
||
_observations: readonly InternalVargaObservation[] | undefined,
|
||
evidence: readonly ChoiceCardEvidence[] | undefined,
|
||
probes?: readonly DiscriminatingEventProbe[],
|
||
birthDate?: string | null,
|
||
): Hypothesis | null {
|
||
const domain = followupDomain(followup);
|
||
const probe = pickProbe(probes, domain, followup);
|
||
if (!probe?.event_family?.trim()) return null;
|
||
const styleOptions = completeStyleOptions({
|
||
choiceKind: followup.choice_kind ?? probe.choice_kind,
|
||
styleOptions: followup.style_options ?? probe.style_options ?? [],
|
||
});
|
||
if (!styleOptions.ok) return null;
|
||
const period = periodFor(evidence, domain, probes, birthDate, followup);
|
||
const kind = followup.choice_kind ?? probe.choice_kind ?? "existence";
|
||
if (kind === "varga_style") {
|
||
if (!canRenderYearlessChoice({ choiceKind: kind, styleOptions: styleOptions.options })) return null;
|
||
} else if (!isConcreteChoicePeriod(period)) {
|
||
return null;
|
||
}
|
||
const prompt = eventQuestionPrompt(period, probe.event_family, kind, domain, probe);
|
||
const why = probe.user_meaning?.trim() || followup.user_prompt_hint.trim();
|
||
if (!why) return null;
|
||
return withStyleOptionLabels(prompt, why, null, styleOptions.options);
|
||
}
|
||
|
||
export function buildChoiceFrame(
|
||
followup: ChoiceCardFollowup,
|
||
input: {
|
||
observations?: readonly InternalVargaObservation[];
|
||
evidence?: readonly ChoiceCardEvidence[];
|
||
probes?: readonly DiscriminatingEventProbe[];
|
||
birthDate?: string | null;
|
||
scoring?: boolean;
|
||
} = {},
|
||
): RectificationChoiceFrame | null {
|
||
const scoring = input.scoring !== false;
|
||
const hypothesis = hypothesisFor(
|
||
followup,
|
||
input.observations,
|
||
input.evidence,
|
||
input.probes,
|
||
input.birthDate,
|
||
);
|
||
if (!hypothesis) return null;
|
||
const domain = followupDomain(followup);
|
||
const skipThisProbe = followup.intent === "reverse_verify"
|
||
|| followup.source === "reverse_verify";
|
||
const skipQuestion = skipThisProbe
|
||
|| followup.intent === "out_of_sample_check"
|
||
|| followup.source === "oos_blind";
|
||
const probeKey = followup.semantic_key?.trim() || followup.probe_id?.trim() || "";
|
||
const questionBase = `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`;
|
||
return {
|
||
question_id: probeKey ? `${questionBase}:${probeKey}` : questionBase,
|
||
method_id: followup.method_id,
|
||
period: periodFor(input.evidence, domain, input.probes, input.birthDate, followup),
|
||
prompt: hypothesis.prompt,
|
||
varga: hypothesis.varga,
|
||
why: hypothesis.why,
|
||
option_a_hint: hypothesis.a,
|
||
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: skipQuestion ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
|
||
stop_message: skipQuestion ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
|
||
scoring,
|
||
choice_kind: hypothesisKind(followup, input.probes),
|
||
...(skipThisProbe ? { skip_this_probe: true } : {}),
|
||
};
|
||
}
|
||
|
||
function hypothesisKind(
|
||
followup: ChoiceCardFollowup,
|
||
probes?: readonly DiscriminatingEventProbe[],
|
||
): EventProbeChoiceKind {
|
||
return followup.choice_kind
|
||
?? pickProbe(probes, followupDomain(followup), followup)?.choice_kind
|
||
?? "existence";
|
||
}
|
||
|
||
function clippedCopy(value: unknown, min: number, max: number): string | null {
|
||
return clippedProbeLabel(value, min, max);
|
||
}
|
||
|
||
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);
|
||
const optionA = clippedCopy(frame.option_a_hint, 4, 80);
|
||
const optionB = clippedCopy(frame.option_b_hint, 4, 80);
|
||
const optionC = clippedCopy(frame.neither_label, 4, 80);
|
||
const optionD = clippedCopy(frame.unsure_label, 4, 80);
|
||
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,
|
||
};
|
||
}
|
||
|
||
export function parseAgentChoiceCopy(value: unknown): AgentChoiceCopy | null {
|
||
if (!value || typeof value !== "object") return null;
|
||
const row = value as Record<string, unknown>;
|
||
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 || !options) return null;
|
||
const labels = [optionA, optionB, optionC, optionD];
|
||
if (new Set(labels).size !== labels.length) return null;
|
||
return {
|
||
prompt,
|
||
option_a: optionA,
|
||
option_b: optionB,
|
||
option_c: optionC,
|
||
option_d: optionD,
|
||
options,
|
||
};
|
||
}
|
||
|
||
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;
|
||
} = {},
|
||
): RectificationChoiceCard | null {
|
||
if (!copy?.prompt.trim()) return null;
|
||
return {
|
||
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: copy.options.map((option) => ({
|
||
...option,
|
||
role: "primary" as const,
|
||
})),
|
||
stop_label: frame.stop_label,
|
||
stop_message: frame.stop_message,
|
||
scoring: frame.scoring,
|
||
probe_id: meta.probe_id ?? null,
|
||
case_revision: meta.case_revision ?? null,
|
||
focus_id: meta.focus_id ?? null,
|
||
choice_kind: frame.choice_kind,
|
||
...(frame.skip_this_probe ? { skip_this_probe: true } : {}),
|
||
};
|
||
}
|
||
|
||
export function choiceCardFromPersistedVerifyCopy(input: {
|
||
copy: AgentChoiceCopy;
|
||
questionId: string;
|
||
methodId: string;
|
||
scoring: boolean;
|
||
probeId: string | null;
|
||
caseRevision: number | null;
|
||
focusId: string;
|
||
}): RectificationChoiceCard | null {
|
||
if (!input.copy.prompt.trim() || !input.questionId.trim()) return null;
|
||
if (!isPersistedFocusId(input.focusId)) return null;
|
||
return {
|
||
question_id: input.questionId,
|
||
method_id: input.methodId,
|
||
prompt: input.copy.prompt,
|
||
why: "",
|
||
varga: null,
|
||
choice_mode: CHOICE_MODE,
|
||
options: input.copy.options.map((option) => ({
|
||
...option,
|
||
role: "primary" as const,
|
||
})),
|
||
stop_label: CHOICE_SKIP_QUESTION_LABEL,
|
||
stop_message: CHOICE_SKIP_QUESTION_MESSAGE,
|
||
scoring: input.scoring,
|
||
probe_id: input.probeId,
|
||
case_revision: input.caseRevision,
|
||
focus_id: input.focusId,
|
||
choice_kind: "existence",
|
||
skip_this_probe: true,
|
||
};
|
||
}
|
||
|
||
export function isHoldoutVerificationQuote(quote: string): boolean {
|
||
return quote.includes(HOLDOUT_MESSAGE_PREFIX);
|
||
}
|
||
|
||
export function choiceCardUserMessage(
|
||
card: RectificationChoiceCard,
|
||
key: ChoiceKey,
|
||
): string {
|
||
const option = card.options.find((item) => item.key === key);
|
||
const line = `${key}. ${option?.label ?? ""}`.trim();
|
||
return card.scoring ? line : `${HOLDOUT_MESSAGE_PREFIX}:${line}`;
|
||
}
|
||
|
||
export function parseRectificationChoiceCard(value: unknown): RectificationChoiceCard | null {
|
||
if (!value || typeof value !== "object") return null;
|
||
const row = value as Record<string, unknown>;
|
||
if (typeof row.question_id !== "string" || typeof row.method_id !== "string") return null;
|
||
if (typeof row.prompt !== "string" || row.prompt.trim().length === 0) return null;
|
||
if (row.choice_mode !== CHOICE_MODE) return null;
|
||
if (!Array.isArray(row.options) || row.options.length < 3) return null;
|
||
const parsed: RectificationChoiceOption[] = [];
|
||
for (const item of row.options) {
|
||
if (!item || typeof item !== "object") return null;
|
||
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(), 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") || !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 {
|
||
question_id: row.question_id,
|
||
method_id: row.method_id,
|
||
prompt: row.prompt.trim(),
|
||
why: typeof row.why === "string" ? row.why : "",
|
||
varga: typeof row.varga === "string" && row.varga.trim() ? row.varga : null,
|
||
choice_mode: CHOICE_MODE,
|
||
options: parsed,
|
||
stop_label: typeof row.stop_label === "string" && row.stop_label.trim()
|
||
? row.stop_label
|
||
: CHOICE_STOP_LABEL,
|
||
stop_message: typeof row.stop_message === "string" && row.stop_message.trim()
|
||
? row.stop_message
|
||
: CHOICE_STOP_MESSAGE,
|
||
scoring: row.scoring !== false,
|
||
probe_id: typeof row.probe_id === "string" && row.probe_id.trim() ? row.probe_id : null,
|
||
case_revision: typeof row.case_revision === "number" && Number.isFinite(row.case_revision)
|
||
? row.case_revision
|
||
: null,
|
||
focus_id: focusId,
|
||
...(row.choice_kind === "existence" || row.choice_kind === "varga_style" || row.choice_kind === "event_quality"
|
||
? { choice_kind: row.choice_kind }
|
||
: {}),
|
||
...(row.skip_this_probe === true ? { skip_this_probe: true } : {}),
|
||
};
|
||
}
|