/** * 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(); 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(); 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>, ): readonly ClusterScoreDelta[] { const summed = new Map(); 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}`; }