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>
388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
/**
|
||
* 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>;
|