Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/probe-explain.ts
T
Jesse_ChenandCursor 0263658477 fix(rectification): host-fallback empty evidence turns and choice range narration (BUG-633, BUG-634)
Evidence turns that already recorded a batch no longer fail the whole run when the model emits no text; unchanged ranges now name which clock spans lead or lag, and the timeline no longer says 收窄.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:57:07 +08:00

266 lines
9.5 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.
/**
* Server-owned user copy for discriminator cards and post-answer narration.
* The model writes the stem; this module writes "why" and "what answering does".
*/
import { publicRectificationMethodLabel } from "../../rectification-varga-sentence.ts";
import type { AnswerClass } from "../core/types.ts";
import type { DiscriminatingEventProbe, ProbeExpectedOutcome } from "./refinement-packet.ts";
export type ChoiceKey = "A" | "B" | "C" | "D";
export type ProbeExplainCandidate = Readonly<{
id?: string;
time: string;
cluster_range?: readonly [string, string];
}>;
export type ClusterScoreDelta = Readonly<{
range: readonly [string, string];
delta: number;
}>;
export type ProbeAnswerImpact = Readonly<{
A: string;
B: string;
C: string;
D: string;
}>;
export type ProbeUserExplain = Readonly<{
why_user: string;
answer_impact: ProbeAnswerImpact;
}>;
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
export const PROBE_EXPLAIN_COPY = {
unsureImpact: "不计分,换一题",
splitGroups: "分成两组",
vargaWhyPrefix: "这题对照",
vargaWhySuffix: "的类型差异",
} as const;
export function formatClusterRange(range: readonly [string, string]): string {
const start = range[0]?.slice(0, 5) ?? "";
const end = range[1]?.slice(0, 5) ?? "";
if (!CLOCK.test(start)) return "";
if (!CLOCK.test(end) || start === end) return `${start} 这段`;
return `${start}${end} 这段`;
}
export function formatClusterRangeList(ranges: readonly (readonly [string, string])[]): string {
const unique: string[] = [];
const seen = new Set<string>();
for (const range of ranges) {
const label = formatClusterRange(range);
if (!label || seen.has(label)) continue;
seen.add(label);
unique.push(label);
}
return unique.join("、");
}
function clockTime(value: string | null | undefined): string | null {
const time = value?.slice(0, 5) ?? "";
return CLOCK.test(time) ? time : null;
}
function rangeForTime(
time: string,
candidates: readonly ProbeExplainCandidate[] | undefined,
): readonly [string, string] {
const clock = clockTime(time) ?? time;
const match = candidates?.find((item) => (
item.time === clock || item.id === time || item.id === clock
));
const start = clockTime(match?.cluster_range?.[0]) ?? clock;
const end = clockTime(match?.cluster_range?.[1]) ?? clock;
return [start, end];
}
function rangesForTimes(
times: readonly string[],
candidates: readonly ProbeExplainCandidate[] | undefined,
): readonly (readonly [string, string])[] {
return times.map((time) => rangeForTime(time, candidates));
}
function trackLabels(tracks: readonly string[] | undefined): string {
const labels = [...new Set((tracks ?? []).flatMap((track) => {
const label = publicRectificationMethodLabel(track);
return label ? [label] : [];
}))];
return labels.join("、");
}
function vargaChartName(input: {
probe: DiscriminatingEventProbe | null;
methodId?: string | null;
domain?: string | null;
}): string | null {
const blob = `${input.methodId ?? ""} ${input.probe?.semantic_key ?? ""}`.toLowerCase();
if (/\bd24\b/.test(blob)) return "D24";
if (/\bd9\b/.test(blob) || input.domain === "relationship") return "D9";
if (/\bd10\b/.test(blob) || input.domain === "career" || input.domain === "occupation") return "D10";
return null;
}
function outcomeFor(
outcomes: readonly ProbeExpectedOutcome[] | undefined,
answerClass: AnswerClass,
): ProbeExpectedOutcome | null {
return outcomes?.find((item) => item.answer_class === answerClass) ?? null;
}
function impactForOutcome(
outcome: ProbeExpectedOutcome | null,
candidates: readonly ProbeExplainCandidate[] | undefined,
): string {
if (!outcome) return "";
const supports = formatClusterRangeList(rangesForTimes(outcome.supports, candidates));
const conflicts = formatClusterRangeList(rangesForTimes(outcome.conflicts, candidates));
if (supports && conflicts) return `会让 ${supports}领先、${conflicts}落后`;
if (supports) return `会让 ${supports}领先`;
if (conflicts) return `会让 ${conflicts}落后`;
return "";
}
function clusterCount(
probe: DiscriminatingEventProbe,
candidates: readonly ProbeExplainCandidate[] | undefined,
): number {
const fromCandidates = new Set(
(probe.candidate_ids ?? []).map((id) => formatClusterRange(rangeForTime(id, candidates))),
);
fromCandidates.delete("");
if (fromCandidates.size >= 2) return fromCandidates.size;
const fromOutcomes = new Set<string>();
for (const outcome of probe.expected_outcomes ?? []) {
for (const time of [...outcome.supports, ...outcome.conflicts]) {
const label = formatClusterRange(rangeForTime(time, candidates));
if (label) fromOutcomes.add(label);
}
}
if (fromOutcomes.size >= 2) return fromOutcomes.size;
return Math.max(fromCandidates.size, 2);
}
export function explainProbeForUser(input: {
probe: DiscriminatingEventProbe | null;
period: string;
choiceKind?: DiscriminatingEventProbe["choice_kind"];
methodId?: string | null;
domain?: string | null;
candidates?: readonly ProbeExplainCandidate[];
}): ProbeUserExplain {
const empty: ProbeUserExplain = {
why_user: "",
answer_impact: { A: "", B: "", C: "", D: PROBE_EXPLAIN_COPY.unsureImpact },
};
const probe = input.probe;
if (!probe) return empty;
const kind = input.choiceKind ?? probe.choice_kind ?? "existence";
const varga = vargaChartName({ probe, methodId: input.methodId, domain: input.domain });
const whyUser = kind === "varga_style" && varga
? `${PROBE_EXPLAIN_COPY.vargaWhyPrefix} ${varga} ${PROBE_EXPLAIN_COPY.vargaWhySuffix}`
: explainExistenceWhy(probe, input.period, input.candidates);
const yes = impactForOutcome(outcomeFor(probe.expected_outcomes, "yes"), input.candidates);
const weak = impactForOutcome(outcomeFor(probe.expected_outcomes, "weak_yes"), input.candidates) || yes;
const no = impactForOutcome(outcomeFor(probe.expected_outcomes, "no"), input.candidates);
return {
why_user: whyUser,
answer_impact: {
A: yes,
B: weak,
C: no,
D: PROBE_EXPLAIN_COPY.unsureImpact,
},
};
}
function explainExistenceWhy(
probe: DiscriminatingEventProbe,
period: string,
candidates: readonly ProbeExplainCandidate[] | undefined,
): string {
const when = period.trim() || probe.year_label.trim();
if (!when) return "";
const count = clusterCount(probe, candidates);
const tracks = trackLabels(probe.tracks);
const groups = `${PROBE_EXPLAIN_COPY.splitGroups}${tracks ? `${tracks}` : ""}`;
return `${when} 这段经历能把当前 ${count} 段候选${groups}`;
}
export function clusterScoreDeltas(
candidates: readonly ProbeExplainCandidate[],
deltas: Readonly<Record<string, number>>,
): readonly ClusterScoreDelta[] {
const summed = new Map<string, ClusterScoreDelta>();
for (const [id, delta] of Object.entries(deltas)) {
if (!Number.isFinite(delta) || delta === 0) continue;
const match = candidates.find((item) => item.id === id || item.time === id);
const matchedTime = clockTime(match?.time);
const range = match?.cluster_range
?? (matchedTime ? [matchedTime, matchedTime] as const : clockTime(id) ? [id.slice(0, 5), id.slice(0, 5)] as const : null);
if (!range) continue;
const key = `${range[0]}|${range[1]}`;
const current = summed.get(key);
summed.set(key, { range, delta: (current?.delta ?? 0) + delta });
}
return [...summed.values()].filter((item) => item.delta !== 0);
}
function clockSpanLabel(range: readonly [string, string]): string {
const start = range[0]?.slice(0, 5) ?? "";
const end = range[1]?.slice(0, 5) ?? "";
if (!CLOCK.test(start)) return "";
if (!CLOCK.test(end) || start === end) return start;
return `${start}${end}`;
}
export function explainScoreMovement(deltas: readonly ClusterScoreDelta[]): string {
if (deltas.length === 0) return "";
const rising = [...deltas].filter((item) => item.delta > 0).sort((a, b) => b.delta - a.delta)[0] ?? null;
const falling = [...deltas].filter((item) => item.delta < 0).sort((a, b) => a.delta - b.delta)[0] ?? null;
const up = rising ? clockSpanLabel(rising.range) : "";
const down = falling ? clockSpanLabel(falling.range) : "";
if (up && down) return `${up} 领先,${down} 落后`;
if (up) return `${up} 领先`;
if (down) return `${down} 落后`;
return "";
}
function clockMinutes(value: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
function rangeWidthMinutes(start: string, end: string): number | null {
const from = clockMinutes(start);
const to = clockMinutes(end);
if (from == null || to == null) return null;
const width = to >= from ? to - from : to + 24 * 60 - from;
return width > 0 ? width : null;
}
export function explainRangeChange(
before: readonly [string, string] | null | undefined,
after: readonly [string, string] | null | undefined,
): string {
const startBefore = clockTime(before?.[0]);
const endBefore = clockTime(before?.[1]);
const startAfter = clockTime(after?.[0]);
const endAfter = clockTime(after?.[1]);
if (!startBefore || !endBefore || !startAfter || !endAfter) return "";
if (startBefore === startAfter && endBefore === endAfter) return "范围没变";
const afterLabel = `${startAfter}${endAfter}`;
const widthBefore = rangeWidthMinutes(startBefore, endBefore);
const widthAfter = rangeWidthMinutes(startAfter, endAfter);
if (widthAfter != null && widthBefore != null && widthAfter > widthBefore) {
return `范围变为 ${afterLabel}`;
}
return `范围收到 ${afterLabel}`;
}