29750d3835
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>
421 lines
15 KiB
TypeScript
421 lines
15 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
}
|