Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts
T
Jesse_ChenandCursor 7e3b6cdc7a
Independent Staging Quality Gate / validate (push) Successful in 25m9s
Independent Staging Quality Gate / publish (push) Successful in 37m19s
fix(rectification): simplify delivery card and guard double turns (BUG-595, BUG-596)
Make the range card row-select, drop the composer 先这样 control and adopt status bar, keep delivery copy to three sentences with a folded verification report, and skip a second no-message agent run after a terminal delivery turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 19:56:48 +08:00

388 lines
14 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 facts and fail-closed checks for the adopt-delivery narration.
*
* The decision layer remains the only authority. This module only explains
* why the interview stopped, what range is on offer, and what will be
* checked after adopt. It never changes sessionOutcome or focus.
*/
import type { RectificationDecision } from "../core/rectification-decision.ts";
import { openingRangeFromCandidateRange } from "../user-copy.ts";
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
import type { DecisionDossier } from "./decision-from-dossier.ts";
import { askedDiscriminatorKeys, previousInferenceFromReceipt } from "./inference-adapter.ts";
import { reverseVerifyChecksFromProbes, reverseVerifyRemainingForAdopt } from "./method-followup.ts";
import { refinementFromDecisionReceipt } from "./refinement-packet.ts";
import type { DroppedProbe } from "./probe-question-contract.ts";
export const ADOPT_NARRATION_MAX_CHARS = 240;
export type AdoptStopFactKind =
| "indistinguishable"
| "yearless_varga"
| "window_ends_only";
export type AdoptStopFact = Readonly<{
kind: AdoptStopFactKind;
label: string;
count: number;
}>;
export type AdoptCandidateSupport = Readonly<{
time: string;
relative_support: number;
}>;
export type AdoptPostAdoptCheck = Readonly<{
kind: "reverse_verify";
domain: string;
year_label: string;
}>;
export type AdoptDeliveryFacts = Readonly<{
precision_stage: string;
already_accepted: boolean;
credible_range: readonly [string, string] | null;
representative_minute: string | null;
runner_up_minute: string | null;
opening_window: readonly [string, string] | null;
active_candidates: readonly AdoptCandidateSupport[];
answered_rounds: number;
stop_facts: readonly AdoptStopFact[];
post_adopt_verification: readonly AdoptPostAdoptCheck[];
minutes: readonly string[];
years: readonly number[];
}>;
const CLOCK = /^(\d{1,2}):(\d{2})$/;
const FOUR_DIGIT_YEAR = /(?<!\d)((?:19|20)\d{2})(?!\d)/g;
const CLOCK_TOKEN = /\d{1,2}:\d{2}/g;
export function padClock(value: string | null | undefined): string | null {
const match = CLOCK.exec((value ?? "").trim());
if (!match) return null;
return `${match[1]!.padStart(2, "0")}:${match[2]}`;
}
function uniqueClocks(values: readonly (string | null | undefined)[]): string[] {
const seen = new Set<string>();
for (const value of values) {
const clock = padClock(value);
if (clock) seen.add(clock);
}
return [...seen];
}
function uniqueYears(values: readonly (number | null | undefined)[]): number[] {
return [...new Set(values.filter((item): item is number => (
typeof item === "number" && Number.isInteger(item) && item >= 1900 && item <= 2100
)))];
}
function activeCandidatesFromDossier(dossier: DecisionDossier): AdoptCandidateSupport[] {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const fromInference = (inference?.candidates ?? [])
.filter((item) => item.status === "active")
.map((item) => ({
time: padClock(item.time) ?? item.time,
relative_support: Math.round(item.posterior_score),
}))
.filter((item) => CLOCK.test(item.time))
.sort((left, right) => right.relative_support - left.relative_support);
if (fromInference.length > 0) return fromInference;
return (dossier.latestResult?.candidates ?? [])
.map((item) => ({
time: padClock(item.time) ?? item.time,
relative_support: Math.round(item.relativeSupport ?? item.posterior_score ?? 0),
}))
.filter((item) => CLOCK.test(item.time))
.sort((left, right) => right.relative_support - left.relative_support);
}
function stopFactsFromDropped(
dropped: readonly DroppedProbe[],
representative: string | null,
runnerUp: string | null,
): AdoptStopFact[] {
const facts: AdoptStopFact[] = [];
const noSplit = dropped.filter((item) => item.reason === "no_split_among_active").length;
const yearless = dropped.filter((item) => item.reason === "yearless_ungrounded_contrast").length;
const windowEnds = dropped.filter((item) => (
item.reason === "insufficient_candidates" || item.reason === "not_renderable"
)).length;
if (representative && runnerUp && representative !== runnerUp) {
facts.push({
kind: "indistinguishable",
label: `再问下去也分不开 ${representative} 和 ${runnerUp}`,
count: Math.max(1, noSplit),
});
} else if (noSplit > 0) {
facts.push({
kind: "indistinguishable",
label: "再问下去也分不出更准的时间了",
count: noSplit,
});
}
if (yearless > 0) {
facts.push({
kind: "yearless_varga",
label: "没有年份的分盘题不再问",
count: yearless,
});
}
if (windowEnds > 0) {
facts.push({
kind: "window_ends_only",
label: "婚恋题只覆盖窗口两端",
count: windowEnds,
});
}
return facts;
}
export function adoptDeliveryFacts(
decision: Pick<RectificationDecision, "precisionStage" | "credibleRange" | "representativeTime" | "droppedProbes">,
dossier: DecisionDossier,
): AdoptDeliveryFacts {
const receipt = dossier.latestResult?.decisionReceipt ?? null;
const inference = previousInferenceFromReceipt(receipt);
const refinement = refinementFromDecisionReceipt(receipt);
const active = activeCandidatesFromDossier(dossier);
const representative = padClock(decision.representativeTime)
?? padClock(active[0]?.time)
?? null;
const runnerUp = active.find((item) => item.time !== representative)?.time ?? null;
const range = decision.credibleRange
? [
padClock(decision.credibleRange[0]) ?? decision.credibleRange[0],
padClock(decision.credibleRange[1]) ?? decision.credibleRange[1],
] as const
: null;
const opening = openingRangeFromCandidateRange(dossier.case.candidateRange ?? null);
const askedKeys = askedDiscriminatorKeys(receipt, dossier.evidence);
const remaining = reverseVerifyRemainingForAdopt({
eventProbes: refinement.discriminating_event_probes,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
askedProbeKeys: askedKeys,
});
const verification = reverseVerifyChecksFromProbes(remaining);
const years = uniqueYears([
...remaining.map((item) => item.year),
...verification.flatMap((item) => {
const found: number[] = [];
for (const match of item.year_label.matchAll(FOUR_DIGIT_YEAR)) {
found.push(Number(match[1]));
}
return found;
}),
...(inference?.events ?? []).map((item) => item.year),
]);
const minutes = uniqueClocks([
representative,
runnerUp,
range?.[0],
range?.[1],
opening?.[0],
opening?.[1],
...active.map((item) => item.time),
]);
return {
precision_stage: decision.precisionStage,
already_accepted: Boolean(dossier.case.acceptedTime),
credible_range: range,
representative_minute: representative,
runner_up_minute: runnerUp,
opening_window: opening
? [
padClock(opening[0]) ?? opening[0],
padClock(opening[1]) ?? opening[1],
] as const
: null,
active_candidates: active,
answered_rounds: inference?.answered_probes.length ?? 0,
stop_facts: stopFactsFromDropped(decision.droppedProbes, representative, runnerUp),
post_adopt_verification: verification,
minutes,
years,
};
}
function topicOrFocusIsReverseVerify(value: unknown): boolean {
return Boolean(
value
&& typeof value === "object"
&& (value as { intent?: unknown }).intent === "reverse_verify",
);
}
/** True when reverse-verify questions were asked, skipped, or are still remaining. */
export function hadPostAdoptVerifyQuestions(
decision: Parameters<typeof adoptDeliveryFacts>[0],
dossier: DecisionDossier,
): boolean {
if (topicOrFocusIsReverseVerify(dossier.conversationSummary.activeFocus)) return true;
if (dossier.conversationSummary.declinedSkippedTopics.some(topicOrFocusIsReverseVerify)) {
return true;
}
return adoptDeliveryFacts(decision, dossier).post_adopt_verification.length > 0;
}
export function shouldWriteAdoptNarration(facts: AdoptDeliveryFacts): boolean {
return facts.precision_stage === "ready_to_adopt" && !facts.already_accepted;
}
export function templatePostAdoptExplain(facts: AdoptDeliveryFacts): string {
if (facts.post_adopt_verification.length === 0) {
return "采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。";
}
const labels = facts.post_adopt_verification.map((item) => (
item.year_label ? `${item.year_label}${item.domain}` : item.domain
));
return `采用后会拿${labels.join("、")}来核对。`;
}
export function templateStopExplain(facts: AdoptDeliveryFacts): string | null {
const split = facts.stop_facts.find((item) => item.kind === "indistinguishable");
if (split && facts.representative_minute && facts.runner_up_minute
&& facts.representative_minute !== facts.runner_up_minute) {
return `再问下去也分不开 ${facts.representative_minute} 和 ${facts.runner_up_minute}。`;
}
if (split) return `${split.label.replace(/。?$/, "")}。`;
return null;
}
export function allowedMinuteSet(facts: AdoptDeliveryFacts): Set<string> {
const allowed = new Set<string>();
for (const minute of facts.minutes) {
allowed.add(minute);
const padded = padClock(minute);
if (padded) {
allowed.add(padded);
allowed.add(`${Number(padded.slice(0, 2))}:${padded.slice(3)}`);
}
}
return allowed;
}
export function validateAdoptNarration(
text: string,
facts: AdoptDeliveryFacts,
): { ok: true; text: string } | { ok: false; reason: string } {
const trimmed = text.trim().replace(/\s+/g, " ");
if (!trimmed) return { ok: false, reason: "empty" };
if (trimmed.length > ADOPT_NARRATION_MAX_CHARS) return { ok: false, reason: "length" };
const sentences = trimmed.split(/(?<=[。!?!?.])/).map((item) => item.trim()).filter(Boolean);
if (sentences.some((item) => /[??]$/.test(item))) {
return { ok: false, reason: "question" };
}
if (/确认|精确/.test(trimmed)) return { ok: false, reason: "promise" };
if (
facts.post_adopt_verification.length === 0
&& /会拿[\s\S]{0,40}核对/.test(trimmed)
) {
return { ok: false, reason: "promise" };
}
const minutes = allowedMinuteSet(facts);
for (const token of trimmed.match(CLOCK_TOKEN) ?? []) {
const padded = padClock(token);
if (!padded || (!minutes.has(token) && !minutes.has(padded))) {
return { ok: false, reason: "unknown_minute" };
}
}
const years = new Set(facts.years);
for (const match of trimmed.matchAll(FOUR_DIGIT_YEAR)) {
const year = Number(match[1]);
if (!years.has(year)) return { ok: false, reason: "unknown_year" };
}
const allowedNumbers = allowedFactIntegers(facts);
const leftover = trimmed
.replace(CLOCK_TOKEN, " ")
.replace(FOUR_DIGIT_YEAR, " ");
for (const token of leftover.match(/\d+/g) ?? []) {
if (!allowedNumbers.has(Number(token))) {
return { ok: false, reason: "unknown_number" };
}
}
return { ok: true, text: trimmed };
}
export function allowedFactIntegers(facts: AdoptDeliveryFacts): Set<number> {
const allowed = new Set<number>();
for (const item of facts.active_candidates) allowed.add(item.relative_support);
allowed.add(facts.answered_rounds);
for (const item of facts.stop_facts) allowed.add(item.count);
for (const year of facts.years) allowed.add(year);
for (const minute of facts.minutes) {
const padded = padClock(minute);
if (!padded) continue;
allowed.add(Number(padded.slice(0, 2)));
allowed.add(Number(padded.slice(3)));
}
return allowed;
}
export function appendAdoptCue(text: string): string {
const trimmed = text.trim();
if (!trimmed) return RECTIFICATION_USER_COPY.adoptCue;
if (trimmed.includes(RECTIFICATION_USER_COPY.adoptCue)) return trimmed;
return `${trimmed} ${RECTIFICATION_USER_COPY.adoptCue}`;
}
export const RANGE_READING_THEME_LABELS: Readonly<Record<string, string>> = {
career: "事业方向",
marriage: "婚恋(D9)",
wealth: "财富",
health: "身体",
timing: "应期",
general: "性格底色",
};
export const RANGE_READING_COPY = {
boundary: "这不是已确认的唯一出生分钟。",
coarseLook: "这只是粗看。",
stableSuffix: "的判断是稳定的",
sensitiveSuffix: "会随分钟变,看盘时按范围读。",
emptyStable: "没有整段都稳定的主题",
emptySensitive: "没有会随分钟变的主题。",
} as const;
export type RangeReadingExplainInput = Readonly<{
widthMinutes?: number | null;
stableThemes?: readonly string[] | null;
sensitiveThemes?: readonly string[] | null;
coarseLook?: boolean;
}>;
function themeLabel(theme: string): string {
return RANGE_READING_THEME_LABELS[theme] ?? "";
}
export function templateRangeReadingExplain(
input: RangeReadingExplainInput | null | undefined,
): string | null {
if (!input) return null;
const stable = [...new Set((input.stableThemes ?? []).map(themeLabel).filter(Boolean))];
const sensitive = [...new Set((input.sensitiveThemes ?? []).map(themeLabel).filter(Boolean))];
if (stable.length === 0 && sensitive.length === 0) return null;
const prefix = input.coarseLook ? RANGE_READING_COPY.coarseLook : "";
const width = input.widthMinutes;
const window = typeof width === "number" && Number.isInteger(width) && width > 0
? `这 ${width} 分钟里,`
: "这段时间里,";
const stableClause = stable.length
? `${stable.join("、")}${RANGE_READING_COPY.stableSuffix}`
: RANGE_READING_COPY.emptyStable;
const sensitiveClause = sensitive.length
? `${sensitive.join("、")}${RANGE_READING_COPY.sensitiveSuffix}`
: RANGE_READING_COPY.emptySensitive;
return `${prefix}${window}${stableClause};${sensitiveClause}${RANGE_READING_COPY.boundary}`
.replace(/\s+/g, " ")
.trim();
}
export type AdoptNarrationWriter = (
facts: AdoptDeliveryFacts,
fallback: string,
) => Promise<string>;