Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/choice-action.ts
T
Jesse_Chen 0c0df42679
Independent Staging Quality Gate / validate (push) Successful in 9m19s
Independent Staging Quality Gate / publish (push) Successful in 1m52s
fix(rectification): require three answers before uncertainty stop
One "一时说不好" no longer ends the interview. Plateau streaks use the
same sample floor. Delivery copy states why the range stopped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 12:08:37 +08:00

129 lines
4.9 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.
/**
* 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}`;
}