/** * 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 = { 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 = { 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; const choice = row.choice && typeof row.choice === "object" && !Array.isArray(row.choice) ? row.choice as Record : 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; 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; 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, }; }