Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/choice-card.ts
T
Jesse_ChenandCursor 40c623edf6
Independent Staging Quality Gate / validate (push) Successful in 23m15s
Independent Staging Quality Gate / publish (push) Failing after 36s
fix(rectification): bind quality cards to the followup probe
Graduation no longer gets the college-experience question, and identical D24 splits only ask once.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 23:47:51 +08:00

641 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } : {}),
};
}