Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/choice-card.ts
T
Jesse_Chen 29750d3835
Independent Staging Quality Gate / validate (push) Failing after 9m53s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(web): persist C/D rectification answers without waiting for rescore
Choice C/D without new evidence never changed the candidate posterior until the next dated-event rescore, and persist-v2 would cache-hit on the same evidence fingerprint. Patch the latest decision_receipt.inference_state in place so the next follow-up sees the asked split immediately.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 22:41:01 +08:00

421 lines
15 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 keys,
* C = neither / D = unsure roles, scoring vs holdout, 先这样). The Agent
* writes the question and all four option labels. The browser never invents
* option copy, and never parses A/B/C/D out of assistant prose.
*/
import type { DiscriminatingEventProbe } 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 HOLDOUT_MESSAGE_PREFIX = "盘外核对(不计分)";
export const FORBIDDEN_CHOICE_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/;
export type ChoiceKey = "A" | "B" | "C" | "D";
export type RectificationChoiceOption = Readonly<{
key: ChoiceKey;
label: string;
role: "primary" | "secondary";
}>;
export type RectificationChoiceFrame = Readonly<{
question_id: string;
method_id: string;
period: string;
varga: string | null;
why: string;
option_a_hint: string;
option_b_hint: string;
neither_label: string;
unsure_label: string;
choice_mode: typeof CHOICE_MODE;
stop_label: string;
stop_message: string;
scoring: boolean;
}>;
export type AgentChoiceCopy = Readonly<{
prompt: string;
option_a: string;
option_b: string;
option_c: string;
option_d: string;
}>;
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;
}>;
export type ChoiceCardFollowup = Readonly<{
method_id: string;
ask_theme: string;
domain: string | null;
user_prompt_hint: string;
}>;
export type ChoiceCardEvidence = Readonly<{
status: string;
domain?: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
}>;
const PRIMARY_C = "没有明显发生";
const SECONDARY_D = "不记得 / 不确定";
const OPTION_A = "是,大概就在那段时间";
const OPTION_B = "有类似,但年份不对或不够重大";
const AGE_BAND: Record<string, { lo: number; hi: number; family: string; varga: string | null }> = {
education: { lo: 16, hi: 18, family: "升学、高考、转学或学习环境变化", varga: "D5 / D24" },
relocation: { lo: 18, hi: 24, family: "搬家、离乡或长期异地", varga: "D4" },
relationship: { lo: 21, hi: 26, family: "认真关系进入、结束或关系观明显转变", varga: "D9" },
career: { lo: 22, hi: 30, family: "入职、升职或职责明显加重", varga: "D10" },
family: { lo: 18, hi: 30, family: "家人相关的明显变化", varga: "D12 / D7 / D3" },
finance: { lo: 22, hi: 32, family: "收入、资产或财务明显变化", varga: "D2 / D11" },
health_pressure: { lo: 16, hi: 40, family: "健康、事故或持续压力明显变化", varga: "D30" },
};
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",
};
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 isConfirmedDated(item: ChoiceCardEvidence): boolean {
return item.status === "confirmed"
&& item.datePrecision !== "unknown"
&& Boolean(item.occurredFrom || item.occurredTo);
}
export function lifePeriodLabel(
evidence: readonly ChoiceCardEvidence[],
domain?: string | null,
): string {
const years = evidence.flatMap((item) => {
if (!isConfirmedDated(item)) return [];
if (domain && item.domain !== domain) return [];
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 followupDomain(followup: ChoiceCardFollowup): string | null {
if (followup.domain) return followup.domain;
return THEME_DOMAIN[followup.ask_theme] ?? null;
}
function pickProbe(
probes: readonly DiscriminatingEventProbe[] | undefined,
domain: string | null,
): DiscriminatingEventProbe | null {
if (!probes?.length) return null;
if (domain) {
const matched = probes.find((item) => item.domain === domain);
if (matched) return matched;
}
return probes[0] ?? null;
}
function ageBandPeriod(birthDate: string | null | undefined, domain: string | null): string {
const year = yearFrom(birthDate ?? null);
const band = domain ? AGE_BAND[domain] : null;
if (year && band) return `${year + Math.floor((band.lo + band.hi) / 2)} 年前后`;
return "那段时间";
}
function periodFor(
evidence: readonly ChoiceCardEvidence[] | undefined,
domain: string | null,
probes: readonly DiscriminatingEventProbe[] | undefined,
birthDate?: string | null,
): string {
const probe = pickProbe(probes, domain);
if (probe) return probe.year_label;
if (domain && (evidence ?? []).some((item) => item.domain === domain && isConfirmedDated(item))) {
return lifePeriodLabel(evidence ?? [], domain);
}
const band = ageBandPeriod(birthDate, domain);
if (band !== "那段时间") return band;
return lifePeriodLabel(evidence ?? [], domain);
}
type Hypothesis = Readonly<{
prompt: string;
why: string;
varga: string | null;
a: string;
b: string;
neither: string;
}>;
function eventHypothesis(
period: string,
family: string,
why: string,
varga: string | null,
): Hypothesis {
return {
prompt: `${period},有没有明显${family}`,
why,
varga,
a: OPTION_A,
b: OPTION_B,
neither: PRIMARY_C,
};
}
function hypothesisFor(
followup: ChoiceCardFollowup,
observations: readonly InternalVargaObservation[] | undefined,
evidence: readonly ChoiceCardEvidence[] | undefined,
probes?: readonly DiscriminatingEventProbe[],
birthDate?: string | null,
): Hypothesis {
const theme = followup.ask_theme;
if (theme === "occupation") {
return {
prompt: "长期工作更像哪一类?",
why: "职业说明独立于带日期的事业事件,对照本命第 10 宫和 D10。",
varga: "D1-H10 + D10",
a: "长期偏对外、领导或经营",
b: "长期偏研究、技术或幕后转化",
neither: "都不是,或职业经常变",
};
}
if (theme === "horary") {
return {
prompt: "有没有第一次认真问起这件事的时间?",
why: "占问只作观察,不计分,也不挡给出时间卡。",
varga: "占问观察盘",
a: "记得第一次认真问起的大概时间",
b: "有问起,但时间很模糊",
neither: "没有专门问起过",
};
}
if (theme === "nakshatra_trait") {
return {
prompt: "近年处事方式更像哪一组?",
why: "升点靠近两段日常节奏的交界。只用来偏置时间窗,不能确认唯一分钟。",
varga: null,
a: "更干脆、外放、说做就做",
b: "更慢热、内收、反复权衡",
neither: "都不像,或两边都有",
};
}
if (theme === "active_focus") {
const domain = followup.domain;
const probe = pickProbe(probes, domain);
const period = probe?.year_label ?? lifePeriodLabel(evidence ?? [], domain);
return eventHypothesis(
period,
probe?.event_family ?? "刚才那件待确认的经历",
probe?.user_meaning ?? "先承接当前问题,不要另开领域清单。题干自己写。",
probe ? AGE_BAND[probe.domain]?.varga ?? null : null,
);
}
const domain = followupDomain(followup);
const probe = pickProbe(probes, domain);
const family = probe?.event_family
?? (domain ? AGE_BAND[domain]?.family : null)
?? "带大概年份的经历";
const varga = probe
? AGE_BAND[probe.domain]?.varga ?? null
: domain
? AGE_BAND[domain]?.varga ?? null
: "本命 Dasha + 行运";
const period = periodFor(evidence, domain, probes, birthDate);
const reverse = followup.method_id === "reverse_verify";
const holdout = theme === "oos_blind";
const why = reverse
? "按当前采用时间核对一件前事。对得上写入账本并重算;对不上可以改选其他候选。不确认唯一分钟。题干自己写,年份不得发明。"
: holdout
? "这是采用后的盘外核对,答案不会改候选分数。题干自己写。"
: probe?.user_meaning
?? "用一件带年份的具体生平分开还在比的时间窗。题干自己写,年份不得发明。";
void observations;
return eventHypothesis(period, family, why, varga);
}
export function buildChoiceFrame(
followup: ChoiceCardFollowup,
input: {
observations?: readonly InternalVargaObservation[];
evidence?: readonly ChoiceCardEvidence[];
probes?: readonly DiscriminatingEventProbe[];
birthDate?: string | null;
scoring?: boolean;
} = {},
): RectificationChoiceFrame {
const scoring = input.scoring !== false;
const hypothesis = hypothesisFor(
followup,
input.observations,
input.evidence,
input.probes,
input.birthDate,
);
const domain = followupDomain(followup);
return {
question_id: `${followup.method_id}:${followup.ask_theme}:${scoring ? "score" : "holdout"}`,
method_id: followup.method_id,
period: periodFor(input.evidence, domain, input.probes, input.birthDate),
varga: hypothesis.varga,
why: hypothesis.why,
option_a_hint: hypothesis.a,
option_b_hint: hypothesis.b,
neither_label: hypothesis.neither,
unsure_label: SECONDARY_D,
choice_mode: CHOICE_MODE,
stop_label: CHOICE_STOP_LABEL,
stop_message: CHOICE_STOP_MESSAGE,
scoring,
};
}
function clippedCopy(value: unknown, min: number, max: number): string | null {
if (typeof value !== "string") return null;
const text = value.trim().replace(/\s+/g, " ");
if (text.length < min || text.length > max) return null;
if (FORBIDDEN_CHOICE_COPY.test(text)) return null;
return text;
}
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 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;
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,
};
}
export function mergeChoiceCard(
frame: RectificationChoiceFrame,
copy: AgentChoiceCopy | null,
): RectificationChoiceCard | null {
if (!copy) return null;
return {
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" },
],
stop_label: frame.stop_label,
stop_message: frame.stop_message,
scoring: frame.scoring,
};
}
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,
): 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 (option.role !== "primary" && option.role !== "secondary") return null;
parsed.push({ key: option.key, label: option.label.trim(), role: option.role });
}
const keys = new Set(parsed.map((item) => item.key));
if (!keys.has("A") || !keys.has("B") || !keys.has("C")) 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,
};
}