/** * Structured choice / stop commands for birth-time rectification. * * A/B/C/D taps and “先这样” are server-owned state transitions. They are not * chat messages and must not be interpreted by the language model. */ import type { AnswerClass } from "../core/types"; import { classifyChoiceAnswer } from "../core/build-state"; import { RECTIFICATION_TERMINATION_COPY } from "../core/rectification-decision"; import { CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, HOLDOUT_MESSAGE_PREFIX, type ChoiceKey, type RectificationChoiceCard, } from "./choice-card"; export const CHOICE_ACTION = "answer_choice" as const; export const STOP_ACTION = "stop_and_review" as const; export const DETERMINISTIC_CHOICE_MODEL = "deterministic:choice"; export type ChoiceActionKind = typeof CHOICE_ACTION | typeof STOP_ACTION; export type ChoiceOptionId = ChoiceKey | "stop"; export type ChoiceActionStatus = "received" | "applied" | "narrated"; export type StructuredProbeDerivedContext = Readonly<{ sourceType: "structured_probe_answer"; derivedFromProbeId: string | null; referencedDateRange: Readonly<{ start: string; end: string }> | null; answerClass: AnswerClass | null; }>; export function outcomeIdForOption(optionId: ChoiceKey, schema?: unknown): AnswerClass | null { return classifyChoiceAnswer(optionId, schema); } export function focusStatusForAnswer(answerClass: AnswerClass | null, optionId: ChoiceOptionId): "resolved" | "declined" | "skipped" { if (optionId === "stop" || answerClass === "unsure") return "skipped"; if (answerClass === "no") return "declined"; return "resolved"; } export function choiceOptionQuote(card: RectificationChoiceCard, key: ChoiceKey): string { return (card.options.find((item) => item.key === key)?.label ?? "").trim(); } export function userVisibleChoiceLine(card: RectificationChoiceCard, key: ChoiceKey): string { const label = choiceOptionQuote(card, key); return `${key}. ${label}`.trim(); } const STRUCTURED_CHOICE_LINE = /^(?:盘外核对(不计分):)?[A-D]\. \S/; /** Taps are state transitions. Hide the synthetic A/B/C/D or 先这样 chat line. */ export function isStructuredChoiceUserText(text: string | null | undefined): boolean { const trimmed = text?.trim() ?? ""; if (!trimmed) return false; if (trimmed === CHOICE_STOP_MESSAGE || trimmed === CHOICE_STOP_LABEL) return true; if (trimmed === CHOICE_SKIP_QUESTION_MESSAGE || trimmed === CHOICE_SKIP_QUESTION_LABEL) return true; if (trimmed.startsWith(`${HOLDOUT_MESSAGE_PREFIX}:`) && STRUCTURED_CHOICE_LINE.test(trimmed)) return true; return STRUCTURED_CHOICE_LINE.test(trimmed) && trimmed.length <= 90; } export function derivedProbeDateRange(year: number | null | undefined): { start: string; end: string } | null { if (!year || year < 1900 || year > 2100) return null; return { start: `${year}-01-01`, end: `${year}-12-31` }; } export function structuredProbeContext(input: { probeId: string | null; year?: number | null; answerClass: AnswerClass | null; }): StructuredProbeDerivedContext { return { sourceType: "structured_probe_answer", derivedFromProbeId: input.probeId, referencedDateRange: derivedProbeDateRange(input.year), answerClass: input.answerClass, }; } export function quoteIsFromAssistantQuestion(quote: string, question: string): boolean { const normalizedQuote = quote.replace(/\s+/g, ""); const normalizedQuestion = question.replace(/\s+/g, ""); if (!normalizedQuote || normalizedQuote.length < 2) return false; if (normalizedQuote === normalizedQuestion) return true; return /(?:19|20)\d{2}年/.test(normalizedQuote) && normalizedQuestion.includes(normalizedQuote); } export function composeChoiceNarration(input: { optionId: ChoiceOptionId; scoring: boolean; appliedInference: boolean; answerClass?: AnswerClass | null; }): string { if (input.optionId === "stop") { return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`; } if (input.answerClass === "unsure") { return "已记录。这题先不计分,换一件事问。"; } if (!input.scoring) { return "已记录你的选择。这是盘外核对,不会改候选分数。"; } if (input.appliedInference) { return "已记录你的选择,并更新了候选比较。"; } return "已记录你的选择。"; } export function shouldContinueAfterStructuredChoice( nextAction: unknown, receipt?: { nextInterviewPersisted?: boolean } | null, ): boolean { if (receipt?.nextInterviewPersisted) return false; if (!nextAction || typeof nextAction !== "object") return false; const type = (nextAction as { type?: unknown }).type; return type === "ask_fact_collection" || type === "ask_candidate_discriminator" || type === "ask_holdout_validation"; } export function stableChoiceActionKey(focusId: string, optionId: ChoiceOptionId): string { return `${focusId}:${optionId}`; }