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>
266 lines
9.5 KiB
TypeScript
266 lines
9.5 KiB
TypeScript
/**
|
||
* 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}`;
|
||
}
|