From e9328535a04dbcc8592a72a38a4ba8a52b4cbd70 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 00:41:12 +0800 Subject: [PATCH] feat: generate grounded rectification turns --- .../evidence-extractor.ts | 142 ++++++++ .../narrative-agent.ts | 327 ++++++++++++++++++ .../technical-packet.ts | 242 +++++++++++++ .../conversational-evidence-extractor.test.ts | 85 +++++ .../conversational-narrative-agent.test.ts | 218 ++++++++++++ .../conversational-technical-packet.test.ts | 181 ++++++++++ 6 files changed, 1195 insertions(+) create mode 100644 frontend/src/lib/conversational-rectification/evidence-extractor.ts create mode 100644 frontend/src/lib/conversational-rectification/narrative-agent.ts create mode 100644 frontend/src/lib/conversational-rectification/technical-packet.ts create mode 100644 frontend/tests/conversational-evidence-extractor.test.ts create mode 100644 frontend/tests/conversational-narrative-agent.test.ts create mode 100644 frontend/tests/conversational-technical-packet.test.ts diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts new file mode 100644 index 00000000..f20a0be3 --- /dev/null +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import type { RectificationEvidenceDomain } from "./technical-packet.ts"; + +export type ExtractedLifeEventEvidence = { + readonly id: string; + readonly rawText: string; + readonly domain: RectificationEvidenceDomain; + readonly eventSummary: string; + readonly dateValue: string | null; + readonly datePrecision: "day" | "month" | "year" | "unknown"; + readonly extractionStatus: "clear" | "needs_clarification" | "corrected"; + readonly scoreable: boolean; +}; + +export type ExtractLifeEventEvidenceInput = { + readonly rawText: string; + readonly sourceTurnId: string; + readonly asOfDate: string; + readonly correctionOfEvidenceIds?: readonly string[]; +}; + +type ParsedDate = { + readonly value: string; + readonly precision: "day" | "month" | "year"; +}; + +const chineseDatePattern = /(?:19|20)\d{2}\s*年(?:\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*(?:日|号))?)?/g; +const isoDatePattern = /(?:19|20)\d{2}-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?/g; + +function normalizedDate(value: string): ParsedDate | null { + const chinese = value.match(/^((?:19|20)\d{2})\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/); + const iso = value.match(/^((?:19|20)\d{2})-(\d{2})(?:-(\d{2}))?$/); + const match = chinese ?? iso; + if (!match) return null; + const year = Number(match[1]); + const rawMonth = match[2]; + if (!rawMonth) return { value: String(year), precision: "year" }; + const month = Number(rawMonth); + if (month < 1 || month > 12) return null; + const rawDay = match[3]; + if (!rawDay) { + return { value: `${year}-${String(month).padStart(2, "0")}`, precision: "month" }; + } + const day = Number(rawDay); + const candidate = new Date(Date.UTC(year, month - 1, day)); + if (candidate.getUTCFullYear() !== year + || candidate.getUTCMonth() !== month - 1 + || candidate.getUTCDate() !== day) return null; + return { + value: `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`, + precision: "day", + }; +} + +function datesIn(value: string): ParsedDate[] { + const matches = [...value.matchAll(chineseDatePattern), ...value.matchAll(isoDatePattern)] + .sort((left, right) => (left.index ?? 0) - (right.index ?? 0)); + return matches.flatMap((match) => { + const parsed = normalizedDate(match[0]); + return parsed ? [parsed] : []; + }); +} + +function eventSummary(fragment: string): string { + const withoutDates = fragment + .replace(chineseDatePattern, "") + .replace(isoDatePattern, "") + .replace(/^\s*(?:更正|纠正|修正)\s*[::]?\s*/, "") + .replace(/^\s*(?:后来|然后|同时|又)\s*/, "") + .trim() + .replace(/^[,,、::\s]+|[,,、::\s]+$/g, ""); + return withoutDates || fragment.trim(); +} + +function classifyDomain(summary: string): RectificationEvidenceDomain { + if (/毕业|入学|升学|转学|学校|专业|考试|留学|学业/.test(summary)) return "education"; + if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) return "relocation"; + if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) return "relationship"; + if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) return "family"; + if (/工作|入职|离职|辞职|升职|创业|职业|公司|项目/.test(summary)) return "career"; + return "other"; +} + +function dateIsFuture(date: ParsedDate, asOfDate: string): boolean { + switch (date.precision) { + case "year": return date.value > asOfDate.slice(0, 4); + case "month": return date.value > asOfDate.slice(0, 7); + case "day": return date.value > asOfDate; + } +} + +function evidenceId(input: ExtractLifeEventEvidenceInput, index: number, summary: string): string { + const hex = createHash("sha256") + .update(`${input.sourceTurnId}\0${index}\0${input.rawText}\0${summary}`) + .digest("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} + +function splitSentences(value: string): string[][] { + const sentences = value.split(/[。!?!?;;]/) + .map((sentence) => sentence.trim()) + .filter(Boolean) + .map((sentence) => sentence.split(/\s*(?:并且|并|以及|同时|然后|后来又|又|,|,)\s*/) + .map((fragment) => fragment.trim()) + .filter(Boolean)); + return sentences.length > 0 ? sentences : [[value.trim()]]; +} + +export function extractLifeEventEvidence( + input: ExtractLifeEventEvidenceInput, +): readonly ExtractedLifeEventEvidence[] { + if (!input.rawText.trim()) throw new TypeError("life-event raw text is required"); + if (!input.sourceTurnId.trim()) throw new TypeError("source turn id is required"); + if (!/^\d{4}-\d{2}-\d{2}$/.test(input.asOfDate)) throw new TypeError("asOfDate must be YYYY-MM-DD"); + const corrections = [...(input.correctionOfEvidenceIds ?? [])]; + const events: ExtractedLifeEventEvidence[] = []; + + for (const fragments of splitSentences(input.rawText.normalize("NFKC"))) { + const sentenceDates = datesIn(fragments.join("并")); + const sharedDate = sentenceDates.length === 1 ? sentenceDates[0] ?? null : null; + for (const fragment of fragments) { + const ownDates = datesIn(fragment); + const date = ownDates.length === 1 ? ownDates[0] ?? null : sharedDate; + const summary = eventSummary(fragment); + const complete = summary.length > 0 && date !== null; + const extractionStatus = !complete + ? "needs_clarification" + : corrections.length > 0 ? "corrected" : "clear"; + events.push({ + id: evidenceId(input, events.length, summary), + rawText: input.rawText, + domain: classifyDomain(summary), + eventSummary: summary, + dateValue: date?.value ?? null, + datePrecision: date?.precision ?? "unknown", + extractionStatus, + scoreable: complete && !dateIsFuture(date, input.asOfDate), + }); + } + } + return events; +} diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts new file mode 100644 index 00000000..c2d94a6c --- /dev/null +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -0,0 +1,327 @@ +import { z } from "zod"; +import { + projectRectificationTechnicalPacket, + type RectificationEvidenceDomain, + type RectificationTechnicalPacket, +} from "./technical-packet.ts"; + +export type RectificationNarrativePhase = "first" | "intermediate" | "final"; + +const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); +const modelIdSchema = z.string().trim().min(1).max(120); +const validatorVersion = "rectification-narrative-grounding-v1"; +const domainSchema = z.enum(["career", "education", "relocation", "relationship", "family", "other"]); +const narrativeOutputSchema = z.object({ + narrative: z.string().trim().min(1).max(12_000), + candidateStatus: z.enum(["pending_validation", "ready_for_confirmation"]), + representativeTime: timeSchema, + rangeStart: timeSchema, + rangeEnd: timeSchema, + useBoundary: z.string().trim().min(1).max(1_000), + stableLayers: z.array(z.string().trim().min(1)).max(20), + sensitiveLayers: z.array(z.string().trim().min(1)).max(20), + referenceIds: z.array(z.string().trim().min(1)).max(80), + domainReasons: z.array(z.object({ + domain: domainSchema, + layer: z.string().trim().min(1), + reason: z.string().trim().min(8).max(1_000), + }).strict()).max(6), + evidenceRequest: z.object({ + domains: z.array(domainSchema).min(2).max(4), + datePrecision: z.enum(["month_preferred", "year_accepted"]), + prompt: z.string().trim().min(1).max(1_000), + }).strict().nullable(), +}).strict(); + +export type RectificationNarrativeModelOutput = z.infer; + +export type NarrativeValidation = { + readonly valid: boolean; + readonly issues: readonly string[]; +}; + +export interface RectificationNarrativeGenerator { + readonly modelId: string; + generate(prompt: string): Promise<{ readonly text: string }>; +} + +export type RectificationNarrativeResult = { + readonly narrative: string; + readonly output: RectificationNarrativeModelOutput; + readonly attempts: 1 | 2; + readonly fallbackUsed: boolean; + readonly allowEvidenceScoringAdvance: boolean; + readonly validationReceipt: { + readonly modelId: string; + readonly schemaValidated: boolean; + readonly validatorVersion: string; + readonly retryCount: 0 | 1; + readonly fallbackUsed: boolean; + readonly issues: readonly string[]; + }; +}; + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +function sameMembers(actual: readonly string[], expected: readonly string[]): boolean { + const left = [...new Set(actual)].sort(); + const right = [...new Set(expected)].sort(); + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function parseModelOutput(text: string): RectificationNarrativeModelOutput { + const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""); + const start = normalized.indexOf("{"); + const end = normalized.lastIndexOf("}"); + if (start < 0 || end <= start) throw new TypeError("narrative output is not JSON"); + return narrativeOutputSchema.parse(JSON.parse(normalized.slice(start, end + 1))); +} + +function narrativeTimes(value: string): string[] { + return unique(value.match(/(?:[01]\d|2[0-3]):[0-5]\d/g) ?? []); +} + +function narrativeLayers(value: string): string[] { + return unique(value.match(/\bD\d{1,3}\b|\b(?:UL|A7|A10|KP_cusp)\b/g) ?? []); +} + +function narrativeReferences(value: string): string[] { + const bracketed = [...value.matchAll(/【([^】]+)】/g)] + .map((match) => match[1] ?? "") + .filter(Boolean); + const plainTechnicalIds = value.match(/\b[A-Za-z][A-Za-z0-9]*(?:[-_][A-Za-z0-9]+)+\b/g) ?? []; + return unique([...bracketed, ...plainTechnicalIds]); +} + +export function validateNarrativeAgainstPacket( + output: RectificationNarrativeModelOutput, + packet: RectificationTechnicalPacket, + phase: RectificationNarrativePhase = "first", +): NarrativeValidation { + const issues: string[] = []; + const candidate = packet.candidate; + if (output.candidateStatus !== candidate.status) { + issues.push(`candidateStatus ${output.candidateStatus} is not packet-grounded`); + } + if (output.representativeTime !== candidate.representativeTime) { + issues.push(`representativeTime ${output.representativeTime} is not packet-grounded`); + } + if (output.rangeStart !== candidate.range.startTime || output.rangeEnd !== candidate.range.endTime) { + issues.push("candidate range is not packet-grounded"); + } + if (output.useBoundary !== packet.useBoundary) issues.push("useBoundary is not packet-grounded"); + + const allowedStable = packet.stableLayers.map((item) => item.layer); + const allowedSensitive = packet.sensitiveLayers.map((item) => item.layer); + for (const layer of output.stableLayers) { + if (!allowedStable.includes(layer)) issues.push(`stable layer ${layer} is not packet-grounded`); + } + for (const layer of output.sensitiveLayers) { + if (!allowedSensitive.includes(layer)) issues.push(`sensitive layer ${layer} is not packet-grounded`); + } + if (phase === "first" && !sameMembers(output.stableLayers, allowedStable)) { + issues.push("first turn must carry every stable layer"); + } + if (phase === "first" && !sameMembers(output.sensitiveLayers, allowedSensitive)) { + issues.push("first turn must carry every sensitive layer"); + } + + for (const reference of output.referenceIds) { + if (!packet.referenceIds.includes(reference)) issues.push(`reference ${reference} is not packet-grounded`); + } + const allowedDomains = new Map(packet.suggestedDomains.map((item) => [item.domain, item.layer])); + for (const reason of output.domainReasons) { + if (allowedDomains.get(reason.domain) !== reason.layer || !reason.reason.includes(reason.layer)) { + issues.push(`domain reason ${reason.domain}/${reason.layer} is not packet-grounded`); + } + } + if (phase === "first" && output.domainReasons.length < 2) { + issues.push("first turn requires two discriminating domain reasons"); + } + if (output.evidenceRequest) { + for (const domain of output.evidenceRequest.domains) { + if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`); + } + if (!/(?:已经发生|已发生|过去)/.test(output.evidenceRequest.prompt) + || !/年/.test(output.evidenceRequest.prompt) + || !/月/.test(output.evidenceRequest.prompt)) { + issues.push("evidence request must ask for a real past event by year and month"); + } + } else if (phase !== "final") { + issues.push("non-final turns require an evidence request"); + } + + const allowedTimes = [candidate.representativeTime, candidate.range.startTime, candidate.range.endTime]; + for (const time of narrativeTimes(output.narrative)) { + if (!allowedTimes.includes(time)) issues.push(`narrative time ${time} is not packet-grounded`); + } + const allowedLayers = [...allowedStable, ...allowedSensitive]; + for (const layer of narrativeLayers(output.narrative)) { + if (!allowedLayers.includes(layer)) issues.push(`narrative layer ${layer} is not packet-grounded`); + } + for (const reference of narrativeReferences(output.narrative)) { + if (!packet.referenceIds.includes(reference)) issues.push(`narrative reference ${reference} is not packet-grounded`); + } + if (phase === "first") { + if (!output.narrative.includes(candidate.representativeTime) + || !/(?:待验证|候选)/.test(output.narrative)) { + issues.push("first narrative must state the pending candidate time"); + } + if (!allowedStable.every((layer) => output.narrative.includes(layer)) + || !allowedSensitive.every((layer) => output.narrative.includes(layer))) { + issues.push("first narrative must explain stable and sensitive layers"); + } + if (!/(?:已经发生|已发生|过去)/.test(output.narrative) + || !/年/.test(output.narrative) + || !/月/.test(output.narrative)) { + issues.push("first narrative must request real past events by year and month"); + } + if (!/(?:不是[\s\S]*确认|不能[\s\S]*确定|仅[\s\S]*候选|必须[\s\S]*确认)/.test(output.narrative)) { + issues.push("first narrative must state the candidate use boundary"); + } + } + return { valid: issues.length === 0, issues }; +} + +function grounding(packet: RectificationTechnicalPacket) { + const projected = projectRectificationTechnicalPacket(packet); + return { + calculationVersion: packet.calculationVersion, + candidate: projected.candidate, + useBoundary: packet.useBoundary, + stableLayers: packet.stableLayers, + sensitiveLayers: packet.sensitiveLayers, + scoredHistoricalEvidence: packet.scoredHistoricalEvidence, + suggestedDomains: packet.suggestedDomains, + referenceIds: packet.referenceIds, + futureWindows: projected.futureWindows, + }; +} + +function boundedReceiptIssues(issues: readonly string[]): string[] { + return issues + .slice(0, 20) + .map((issue) => issue.trim().slice(0, 240) || "narrative_mismatch"); +} + +function promptFor( + phase: RectificationNarrativePhase, + packet: RectificationTechnicalPacket, + retryIssues: readonly string[] = [], +): string { + return JSON.stringify({ + task: "write_grounded_rectification_narrative", + phase, + packet: grounding(packet), + outputContract: { + candidateFactsMustMatch: true, + onlyListedLayersAndReferences: true, + requestRealPastEventsByYearAndMonth: phase !== "final", + futureWindowsAreContextOnly: true, + genericBroadYearRangeQuestionnaireForbidden: true, + }, + retryIssues: boundedReceiptIssues(retryIssues), + }); +} + +function fallbackNarrative(packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase): string { + const candidate = packet.candidate; + const stable = packet.stableLayers + .map((item) => `${item.layer}(${item.values.join(" / ")})保持稳定`) + .join(";"); + const sensitive = packet.sensitiveLayers + .map((item) => `${item.layer}(${item.values.join(" / ")})`) + .join(";"); + const reasons = packet.suggestedDomains + .map((item) => `${item.domain}事件可区分 ${item.layer}`) + .join(";"); + const phaseLine = phase === "final" + ? "当前证据已形成候选总结,但仍有残余不确定性;只有明确确认后才会替换当前排盘时间。" + : `下一步请提供上述领域已经发生的真实事件,尽量写明哪一年、哪一月以及发生了什么;${reasons}。`; + return [ + `${candidate.representativeTime} 是 ${candidate.range.startTime}–${candidate.range.endTime} 范围内的待验证候选。`, + packet.useBoundary, + `${stable || "D1 稳定性暂不可用"};${sensitive} 是当前支持的分钟敏感层。`, + phaseLine, + "未来窗口只能作为背景,不能计入既成事件评分。", + ].join("\n"); +} + +function fallbackOutput( + packet: RectificationTechnicalPacket, + phase: RectificationNarrativePhase, +): RectificationNarrativeModelOutput { + return { + narrative: fallbackNarrative(packet, phase), + candidateStatus: packet.candidate.status, + representativeTime: packet.candidate.representativeTime, + rangeStart: packet.candidate.range.startTime, + rangeEnd: packet.candidate.range.endTime, + useBoundary: packet.useBoundary, + stableLayers: packet.stableLayers.map((item) => item.layer), + sensitiveLayers: packet.sensitiveLayers.map((item) => item.layer), + referenceIds: [], + domainReasons: packet.suggestedDomains.map((item) => ({ ...item })), + evidenceRequest: phase === "final" ? null : { + domains: packet.suggestedDomains.slice(0, 4).map((item) => item.domain), + datePrecision: "month_preferred", + prompt: "请提供已经发生的真实事件,并尽量写明哪一年、哪一月以及发生了什么。", + }, + }; +} + +export async function generateRectificationNarrative(input: { + readonly phase: RectificationNarrativePhase; + readonly packet: RectificationTechnicalPacket; + readonly generator: RectificationNarrativeGenerator; +}): Promise { + const modelId = modelIdSchema.parse(input.generator.modelId); + let issues: readonly string[] = []; + for (const attempt of [1, 2] as const) { + try { + const generated = await input.generator.generate(promptFor(input.phase, input.packet, issues)); + const output = parseModelOutput(generated.text); + const validation = validateNarrativeAgainstPacket(output, input.packet, input.phase); + if (validation.valid) { + return { + narrative: output.narrative, + output, + attempts: attempt, + fallbackUsed: false, + allowEvidenceScoringAdvance: true, + validationReceipt: { + modelId, + schemaValidated: true, + validatorVersion, + retryCount: attempt === 1 ? 0 : 1, + fallbackUsed: false, + issues: [], + }, + }; + } + issues = validation.issues; + } catch (error) { + issues = [error instanceof Error ? error.name : "NarrativeOutputError"]; + } + } + const output = fallbackOutput(input.packet, input.phase); + return { + narrative: output.narrative, + output, + attempts: 2, + fallbackUsed: true, + allowEvidenceScoringAdvance: false, + validationReceipt: { + modelId, + schemaValidated: false, + validatorVersion, + retryCount: 1, + fallbackUsed: true, + issues: boundedReceiptIssues(issues), + }, + }; +} + +export type { RectificationEvidenceDomain }; diff --git a/frontend/src/lib/conversational-rectification/technical-packet.ts b/frontend/src/lib/conversational-rectification/technical-packet.ts new file mode 100644 index 00000000..60acbf7c --- /dev/null +++ b/frontend/src/lib/conversational-rectification/technical-packet.ts @@ -0,0 +1,242 @@ +import type { CandidateDifferenceBuild } from "../birth-time-dynamic-choice-internal.ts"; +import type { CandidateResult } from "../birth-time-evidence.ts"; +import type { RectificationQuestionnaire } from "../birth-time-journey-service.ts"; + +export type RectificationEvidenceDomain = + | "career" + | "education" + | "relocation" + | "relationship" + | "family" + | "other"; + +export type ServerComputedRectificationConsultation = { + readonly source: "server_consultation_workflow"; + readonly calculationVersion: string; + readonly availableLayers: readonly string[]; + readonly layerReferences: Readonly>; + readonly boundaryDistanceMinutes: number | null; + readonly futureWindows: readonly { + readonly label: string; + readonly startDate: string; + readonly endDate: string; + }[]; +}; + +export type RectificationLayerEvidence = { + readonly layer: string; + readonly values: readonly string[]; + readonly referenceIds: readonly string[]; +}; + +export type SuggestedEvidenceDomain = { + readonly domain: RectificationEvidenceDomain; + readonly layer: string; + readonly reason: string; +}; + +export type RectificationTechnicalPacket = { + readonly calculationVersion: string; + readonly candidate: { + readonly status: "pending_validation" | "ready_for_confirmation"; + readonly representativeTime: string; + readonly range: { readonly startTime: string; readonly endTime: string }; + }; + readonly useBoundary: string; + readonly candidateModelRefs: readonly string[]; + readonly candidateDifferenceRefs: readonly string[]; + readonly candidateWeights: Readonly>; + readonly partitionIds: readonly string[]; + readonly d1Stability: "stable" | "sensitive" | "unavailable"; + readonly boundaryDistanceMinutes: number | null; + readonly stableLayers: readonly RectificationLayerEvidence[]; + readonly sensitiveLayers: readonly RectificationLayerEvidence[]; + readonly supportedSensitiveLayers: readonly string[]; + readonly scoredHistoricalEvidence: readonly { + readonly evidenceId: string; + readonly domain: RectificationEvidenceDomain; + readonly candidateTime: string | null; + readonly score: number; + readonly ruleRefs: readonly string[]; + }[]; + readonly suggestedDomains: readonly SuggestedEvidenceDomain[]; + readonly referenceIds: readonly string[]; + readonly futureWindows: readonly { + readonly label: string; + readonly startDate: string; + readonly endDate: string; + readonly scoreable: false; + }[]; +}; + +type PacketInput = { + readonly scan: RectificationQuestionnaire; + readonly candidateDifferences: CandidateDifferenceBuild; + readonly eventScore: CandidateResult | null; + readonly consultation: ServerComputedRectificationConsultation; +}; + +const layerFields = [ + ["D1", "ascendantSign"], + ["D4", "d4Sign"], + ["D9", "d9Sign"], + ["D10", "d10Sign"], + ["D24", "d24Sign"], + ["D30", "d30Sign"], +] as const; + +const domainByLayer = { + D9: "relationship", + D10: "career", + D24: "education", + D4: "relocation", +} as const satisfies Readonly>; + +function unique(values: readonly string[]): string[] { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} + +function timeToMinute(value: string): number { + const [hour = 0, minute = 0] = value.split(":").map(Number); + return hour * 60 + minute; +} + +function minuteToTime(value: number): string { + const normalized = ((value % 1_440) + 1_440) % 1_440; + return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`; +} + +function midpoint(startTime: string, endTime: string): string { + const start = timeToMinute(startTime); + let end = timeToMinute(endTime); + if (end < start) end += 1_440; + return minuteToTime(Math.round((start + end) / 2)); +} + +function candidateWeights(model: Readonly>): Readonly> { + const raw = model.candidateWeights ?? model.candidate_weights; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const entries: [string, number][] = []; + for (const [time, weight] of Object.entries(raw)) { + if (typeof weight === "number" && Number.isFinite(weight) && weight >= 0) { + entries.push([time, weight]); + } + } + return Object.fromEntries(entries.sort((left, right) => left[0].localeCompare(right[0]))); +} + +function eventDomain(domain: CandidateResult["evidence"][number]["domain"]): RectificationEvidenceDomain { + return domain === "finance" || domain === "health_pressure" ? "other" : domain; +} + +function layerEvidence(input: PacketInput): RectificationLayerEvidence[] { + return layerFields.map(([layer, field]) => ({ + layer, + values: unique(input.scan.samples.map((sample) => sample[field] ?? "")), + referenceIds: unique(input.consultation.layerReferences[layer] ?? []), + })).filter((item) => item.values.length > 0); +} + +function suggestedDomains(layers: readonly RectificationLayerEvidence[]): SuggestedEvidenceDomain[] { + return layers.flatMap((item) => { + const domain = domainByLayer[item.layer as keyof typeof domainByLayer]; + if (!domain) return []; + return [{ + domain, + layer: item.layer, + reason: `${item.layer} 在候选范围内呈现 ${item.values.join(" / ")} 差异,可用已发生的${domain}事件区分。`, + }]; + }); +} + +export function buildRectificationTechnicalPacket(input: PacketInput): RectificationTechnicalPacket { + if (input.consultation.source !== "server_consultation_workflow") { + throw new TypeError("rectification packet requires server-computed consultation data"); + } + const eventSegment = input.eventScore?.winningSegment; + const range = eventSegment + ? { startTime: eventSegment.startTime, endTime: eventSegment.endTime } + : input.candidateDifferences.packet.currentRange; + const representativeTime = eventSegment?.representativeTime + ?? midpoint(range.startTime, range.endTime); + const layers = layerEvidence(input); + const d1 = layers.find((item) => item.layer === "D1"); + const d1Stability = !d1 ? "unavailable" : d1.values.length === 1 ? "stable" : "sensitive"; + const available = new Set(input.consultation.availableLayers); + const sensitiveLayers = layers.filter((item) => item.layer !== "D1" + && item.values.length > 1 + && available.has(item.layer)); + const domains = suggestedDomains(sensitiveLayers); + if (domains.length < 2) { + throw new TypeError("rectification packet requires two server-computed discriminating domains"); + } + const scoredHistoricalEvidence = (input.eventScore?.evidence ?? []).map((item) => ({ + evidenceId: item.eventId, + domain: eventDomain(item.domain), + candidateTime: item.candidateTime ?? null, + score: item.points, + ruleRefs: [...item.ruleIds], + })); + const opportunityRefs = input.candidateDifferences.packet.opportunities.map((item) => item.opportunityId); + const ruleRefs = scoredHistoricalEvidence.flatMap((item) => item.ruleRefs); + const layerRefs = layers.flatMap((item) => item.referenceIds); + const modelVersion = input.candidateDifferences.candidateModel.version; + const candidateModelRefs = unique([ + input.candidateDifferences.packet.scoringVersion, + typeof modelVersion === "string" ? modelVersion : "", + input.eventScore?.algorithmVersion ?? "", + ]); + const partitionIds = unique(Object.values(input.candidateDifferences.scoringPartitions) + .flatMap((partitions) => partitions.map((partition) => partition.partitionId))); + + return { + calculationVersion: input.consultation.calculationVersion, + candidate: { + status: input.eventScore?.canApply ? "ready_for_confirmation" : "pending_validation", + representativeTime, + range, + }, + useBoundary: input.eventScore?.canApply + ? "该候选已达到确认门槛,但必须由用户明确确认后才能替换当前排盘时间。" + : "该时间与范围仅是待验证候选,可用于比较稳定层和分钟敏感层,不能视为出生记录中的确定分钟。", + candidateModelRefs, + candidateDifferenceRefs: unique([...opportunityRefs, ...ruleRefs, ...layerRefs]), + candidateWeights: candidateWeights(input.candidateDifferences.candidateModel), + partitionIds, + d1Stability, + boundaryDistanceMinutes: input.consultation.boundaryDistanceMinutes, + stableLayers: d1Stability === "stable" && d1 ? [d1] : [], + sensitiveLayers, + supportedSensitiveLayers: sensitiveLayers.map((item) => item.layer), + scoredHistoricalEvidence, + suggestedDomains: domains.slice(0, 4), + referenceIds: unique([...opportunityRefs, ...ruleRefs, ...layerRefs]), + futureWindows: input.consultation.futureWindows.map((window) => ({ ...window, scoreable: false })), + }; +} + +export function projectRectificationTechnicalPacket(packet: RectificationTechnicalPacket) { + return { + candidate: { + status: packet.candidate.status, + representativeTime: packet.candidate.representativeTime, + rangeStart: packet.candidate.range.startTime, + rangeEnd: packet.candidate.range.endTime, + }, + useBoundary: packet.useBoundary, + technicalReceipt: { + calculationVersion: packet.calculationVersion, + stableLayers: packet.stableLayers.map((item) => item.layer), + sensitiveLayers: [...packet.supportedSensitiveLayers], + candidateDifferenceRefs: packet.candidateDifferenceRefs + .filter((reference) => reference.trim().length > 0 && reference.length <= 120) + .slice(0, 40), + }, + evidenceRequest: { + domains: packet.suggestedDomains.map((item) => item.domain), + datePrecision: "month_preferred" as const, + freeTextAllowed: true as const, + }, + futureWindows: packet.futureWindows.map((window) => ({ ...window })), + }; +} diff --git a/frontend/tests/conversational-evidence-extractor.test.ts b/frontend/tests/conversational-evidence-extractor.test.ts new file mode 100644 index 00000000..f13dfb8d --- /dev/null +++ b/frontend/tests/conversational-evidence-extractor.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { extractLifeEventEvidence } from "../src/lib/conversational-rectification/evidence-extractor.ts"; +import { lifeEventEvidenceSchema } from "../src/lib/conversational-rectification/persistence-contracts.ts"; + +const sourceTurnId = "00000000-0000-4000-8000-000000000610"; + +test("preserves raw text and splits two clear facts sharing an explicit month", () => { + const rawText = "2021年7月毕业并去外地工作"; + const evidence = extractLifeEventEvidence({ + rawText, + sourceTurnId, + asOfDate: "2026-07-20", + }); + + assert.equal(evidence.length, 2); + assert.deepEqual(evidence.map((item) => item.rawText), [rawText, rawText]); + assert.deepEqual(evidence.map((item) => item.eventSummary), ["毕业", "去外地工作"]); + assert.deepEqual(evidence.map((item) => item.domain), ["education", "relocation"]); + assert.deepEqual(evidence.map((item) => item.dateValue), ["2021-07", "2021-07"]); + assert.deepEqual(evidence.map((item) => item.datePrecision), ["month", "month"]); + assert.ok(evidence.every((item) => item.extractionStatus === "clear" && item.scoreable)); + assert.ok(evidence.every((item) => lifeEventEvidenceSchema.safeParse(item).success)); + assert.equal(new Set(evidence.map((item) => item.id)).size, 2); +}); + +test("keeps vague evidence non-scoreable and asks for clarification", () => { + const rawText = "那几年工作不太顺"; + const [evidence] = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" }); + + assert.equal(evidence?.rawText, rawText); + assert.equal(evidence?.eventSummary, rawText); + assert.equal(evidence?.dateValue, null); + assert.equal(evidence?.datePrecision, "unknown"); + assert.equal(evidence?.extractionStatus, "needs_clarification"); + assert.equal(evidence?.scoreable, false); +}); + +test("preserves a nonblank punctuation-only answer as one clarification row", () => { + const rawText = "?"; + const evidence = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" }); + + assert.equal(evidence.length, 1); + assert.equal(evidence[0]?.rawText, rawText); + assert.equal(evidence[0]?.extractionStatus, "needs_clarification"); + assert.equal(evidence[0]?.scoreable, false); +}); + +test("never invents a missing month or day", () => { + const [evidence] = extractLifeEventEvidence({ + rawText: "2021年毕业", + sourceTurnId, + asOfDate: "2026-07-20", + }); + + assert.equal(evidence?.dateValue, "2021"); + assert.equal(evidence?.datePrecision, "year"); + assert.equal(evidence?.eventSummary, "毕业"); +}); + +test("marks a future event as context-only and non-scoreable", () => { + const [evidence] = extractLifeEventEvidence({ + rawText: "2030年3月计划结婚", + sourceTurnId, + asOfDate: "2026-07-20", + }); + + assert.equal(evidence?.dateValue, "2030-03"); + assert.equal(evidence?.datePrecision, "month"); + assert.equal(evidence?.extractionStatus, "clear"); + assert.equal(evidence?.scoreable, false); +}); + +test("clear replacement evidence is explicitly marked corrected", () => { + const [evidence] = extractLifeEventEvidence({ + rawText: "更正:2020年11月离职", + sourceTurnId, + asOfDate: "2026-07-20", + correctionOfEvidenceIds: ["00000000-0000-4000-8000-000000000611"], + }); + + assert.equal(evidence?.eventSummary, "离职"); + assert.equal(evidence?.extractionStatus, "corrected"); + assert.equal(evidence?.scoreable, true); +}); diff --git a/frontend/tests/conversational-narrative-agent.test.ts b/frontend/tests/conversational-narrative-agent.test.ts new file mode 100644 index 00000000..3eeaf178 --- /dev/null +++ b/frontend/tests/conversational-narrative-agent.test.ts @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + generateRectificationNarrative, + validateNarrativeAgainstPacket, + type RectificationNarrativeModelOutput, +} from "../src/lib/conversational-rectification/narrative-agent.ts"; +import { validationReceiptSchema } from "../src/lib/conversational-rectification/persistence-contracts.ts"; +import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts"; + +function syntheticTechnicalPacket(): RectificationTechnicalPacket { + return { + calculationVersion: "rectification-technical-v1", + candidate: { + status: "pending_validation", + representativeTime: "05:20", + range: { startTime: "05:16", endTime: "05:24" }, + }, + useBoundary: "该时间与范围仅是待验证候选,可用于比较稳定层和分钟敏感层,不能视为出生记录中的确定分钟。", + candidateModelRefs: ["synthetic-candidate-model-v1"], + candidateDifferenceRefs: ["difference-d9-relationship", "difference-d10-career"], + candidateWeights: { "05:10": 0.4, "05:30": 0.6 }, + partitionIds: ["private-partition-early", "private-partition-late"], + d1Stability: "stable", + boundaryDistanceMinutes: 4, + stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["consult-d1-ascendant"] }], + sensitiveLayers: [ + { layer: "D9", values: ["Leo", "Virgo"], referenceIds: ["consult-d9-candidate-difference"] }, + { layer: "D10", values: ["Libra", "Scorpio"], referenceIds: ["consult-d10-candidate-difference"] }, + ], + supportedSensitiveLayers: ["D9", "D10"], + scoredHistoricalEvidence: [], + suggestedDomains: [ + { domain: "relationship", layer: "D9", reason: "D9 在候选范围内变化" }, + { domain: "career", layer: "D10", reason: "D10 在候选范围内变化" }, + ], + referenceIds: [ + "difference-d9-relationship", + "difference-d10-career", + "consult-d1-ascendant", + "consult-d9-candidate-difference", + "consult-d10-candidate-difference", + ], + futureWindows: [{ + label: "2028 career context window", + startDate: "2028-03-01", + endDate: "2028-05-31", + scoreable: false, + }], + }; +} + +function richOutput(): RectificationNarrativeModelOutput { + const packet = syntheticTechnicalPacket(); + return { + narrative: [ + "05:20 是 05:16–05:24 范围内的待验证候选,不是已经确认的出生分钟。", + "D1 上升在范围内保持 Cancer,属于稳定层【consult-d1-ascendant】;D9 与 D10 分别出现 Leo/Virgo、Libra/Scorpio 的分钟敏感变化。", + "因此关系事件可区分 D9,事业事件可区分 D10。请提供已经发生的真实事件,尽量写明哪一年、哪一月以及发生了什么。", + "未来窗口只能作为背景,不能计入既成事件评分。", + ].join("\n"), + candidateStatus: "pending_validation", + representativeTime: "05:20", + rangeStart: "05:16", + rangeEnd: "05:24", + useBoundary: packet.useBoundary, + stableLayers: ["D1"], + sensitiveLayers: ["D9", "D10"], + referenceIds: ["consult-d1-ascendant"], + domainReasons: [ + { domain: "relationship", layer: "D9", reason: "D9 changes across the candidate minutes" }, + { domain: "career", layer: "D10", reason: "D10 changes across the candidate minutes" }, + ], + evidenceRequest: { + domains: ["relationship", "career"], + datePrecision: "month_preferred", + prompt: "请提供已经发生的真实关系或事业事件,并尽量说明哪一年、哪一月以及发生了什么。", + }, + }; +} + +function generator(outputs: readonly unknown[], prompts: string[] = []) { + let index = 0; + return { + modelId: "synthetic-narrative-model", + async generate(prompt: string) { + prompts.push(prompt); + const output = outputs[Math.min(index, outputs.length - 1)]; + index += 1; + return { text: JSON.stringify(output) }; + }, + }; +} + +test("validates a rich first-turn narrative against the technical packet", async () => { + const packet = syntheticTechnicalPacket(); + const output = richOutput(); + assert.deepEqual(validateNarrativeAgainstPacket(output, packet, "first"), { valid: true, issues: [] }); + + const result = await generateRectificationNarrative({ + phase: "first", + packet, + generator: generator([output]), + }); + + assert.equal(result.attempts, 1); + assert.equal(result.fallbackUsed, false); + assert.equal(result.allowEvidenceScoringAdvance, true); + assert.equal(validationReceiptSchema.safeParse(result.validationReceipt).success, true); + assert.equal(result.output.candidateStatus, "pending_validation"); + assert.match(result.narrative, /待验证候选/); + assert.match(result.narrative, /D1/); + assert.match(result.narrative, /D9[\s\S]*D10/); + assert.match(result.narrative, /关系[\s\S]*D9[\s\S]*事业[\s\S]*D10/); + assert.match(result.narrative, /已经发生[\s\S]*哪一年[\s\S]*哪一月/); + assert.doesNotMatch(result.narrative, /^哪一个时间段[\s\S]*\d{4}[–—-]\d{4}/); +}); + +test("rejects invented representative times, layers, and references", () => { + const packet = syntheticTechnicalPacket(); + const invalid = { + ...richOutput(), + representativeTime: "06:45", + sensitiveLayers: ["D9", "D60"], + referenceIds: ["invented-reference"], + } satisfies RectificationNarrativeModelOutput; + const result = validateNarrativeAgainstPacket(invalid, packet, "first"); + + assert.equal(result.valid, false); + assert.ok(result.issues.some((issue) => issue.includes("representativeTime"))); + assert.ok(result.issues.some((issue) => issue.includes("D60"))); + assert.ok(result.issues.some((issue) => issue.includes("invented-reference"))); +}); + +test("rejects an invented plain-text technical reference omitted from the reference list", () => { + const packet = syntheticTechnicalPacket(); + const invalid = { + ...richOutput(), + narrative: `${richOutput().narrative}\n另见 invented-reference。`, + }; + const result = validateNarrativeAgainstPacket(invalid, packet, "first"); + + assert.equal(result.valid, false); + assert.ok(result.issues.some((issue) => issue.includes("invented-reference"))); +}); + +test("retries expression exactly once with the same grounded packet", async () => { + const packet = syntheticTechnicalPacket(); + const prompts: string[] = []; + const result = await generateRectificationNarrative({ + phase: "first", + packet, + generator: generator([{ ...richOutput(), representativeTime: "06:45" }, richOutput()], prompts), + }); + + assert.equal(result.attempts, 2); + assert.equal(result.fallbackUsed, false); + assert.equal(result.allowEvidenceScoringAdvance, true); + assert.equal(prompts.length, 2); + assert.match(prompts[0] ?? "", /rectification-technical-v1/); + assert.match(prompts[1] ?? "", /rectification-technical-v1/); + assert.equal(prompts.some((prompt) => prompt.includes("candidateWeights")), false); + assert.equal(prompts.some((prompt) => prompt.includes("private-partition")), false); +}); + +test("uses a deterministic rich Chinese fallback after the second mismatch and holds scoring", async () => { + const packet = syntheticTechnicalPacket(); + const invalid = { ...richOutput(), sensitiveLayers: ["D60"] }; + const first = await generateRectificationNarrative({ + phase: "first", + packet, + generator: generator([invalid, invalid]), + }); + const second = await generateRectificationNarrative({ + phase: "first", + packet, + generator: generator([invalid, invalid]), + }); + + assert.equal(first.attempts, 2); + assert.equal(first.fallbackUsed, true); + assert.equal(first.allowEvidenceScoringAdvance, false); + assert.equal(first.narrative, second.narrative); + assert.match(first.narrative, /05:20[\s\S]*待验证/); + assert.match(first.narrative, /D1[\s\S]*稳定/); + assert.match(first.narrative, /D9[\s\S]*D10[\s\S]*敏感/); + assert.match(first.narrative, /已经发生[\s\S]*年[\s\S]*月/); + assert.match(first.narrative, /未来[\s\S]*不能[\s\S]*评分/); +}); + +test("bounds fallback validation issues for the durable receipt", async () => { + const inventedReference = `invented-${"x".repeat(500)}`; + const invalid = { + ...richOutput(), + narrative: `${richOutput().narrative}\n另见 ${inventedReference}。`, + }; + const result = await generateRectificationNarrative({ + phase: "first", + packet: syntheticTechnicalPacket(), + generator: generator([invalid, invalid]), + }); + + assert.equal(result.fallbackUsed, true); + assert.ok(result.validationReceipt.issues.length <= 20); + assert.ok(result.validationReceipt.issues.every((issue) => issue.length <= 240)); +}); + +test("builds distinct first, intermediate, and final grounded prompts", async () => { + for (const phase of ["first", "intermediate", "final"] as const) { + const prompts: string[] = []; + await generateRectificationNarrative({ + phase, + packet: syntheticTechnicalPacket(), + generator: generator([richOutput()], prompts), + }); + assert.match(prompts[0] ?? "", new RegExp(`\\"phase\\":\\"${phase}\\"`)); + } +}); diff --git a/frontend/tests/conversational-technical-packet.test.ts b/frontend/tests/conversational-technical-packet.test.ts new file mode 100644 index 00000000..95a0fef9 --- /dev/null +++ b/frontend/tests/conversational-technical-packet.test.ts @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildRectificationTechnicalPacket, + projectRectificationTechnicalPacket, +} from "../src/lib/conversational-rectification/technical-packet.ts"; +import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; +import type { CandidateDifferenceBuild } from "../src/lib/birth-time-dynamic-choice-internal.ts"; +import type { RectificationQuestionnaire } from "../src/lib/birth-time-journey-service.ts"; + +const scan = { + questions: [], + samples: [ + { ascendantSign: "Cancer", d4Sign: "Aries", d9Sign: "Leo", d10Sign: "Libra", d24Sign: "Gemini", d30Sign: "Pisces" }, + { ascendantSign: "Cancer", d4Sign: "Aries", d9Sign: "Virgo", d10Sign: "Scorpio", d24Sign: "Gemini", d30Sign: "Pisces" }, + ], + raw: { + schema_version: 3, + candidate_scan: { + samples: [ + { time: "2000-01-01 05:10", ascendant: { sign: "Cancer" } }, + { time: "2000-01-01 05:30", ascendant: { sign: "Cancer" } }, + ], + }, + }, +} satisfies RectificationQuestionnaire; + +const candidateDifferences = { + packet: { + caseId: "synthetic-case", + scoringVersion: "birth-time-choice-scoring-v2", + currentRange: { startTime: "05:10", endTime: "05:30" }, + opportunities: [{ + opportunityId: "difference-d9-relationship", + dimensionCode: "relationship", + neutralContext: "D9 changes across the candidate range", + estimatedInformationGain: 0.8, + candidatePartitionFingerprint: "private-fingerprint", + fallbackPrompt: "relationship event", + partitions: [ + { partitionId: "private-partition-early", descriptor: "early", fallbackLabel: "early" }, + { partitionId: "private-partition-late", descriptor: "late", fallbackLabel: "late" }, + ], + }, { + opportunityId: "difference-d10-career", + dimensionCode: "career", + neutralContext: "D10 changes across the candidate range", + estimatedInformationGain: 0.7, + candidatePartitionFingerprint: "private-career-fingerprint", + fallbackPrompt: "career event", + partitions: [ + { partitionId: "private-career-early", descriptor: "early", fallbackLabel: "early" }, + { partitionId: "private-career-late", descriptor: "late", fallbackLabel: "late" }, + ], + }], + askedQuestionFingerprints: [], + candidatePartitionFingerprints: ["private-fingerprint", "private-career-fingerprint"], + recentRangeHistory: [], + }, + candidateModel: { + version: "synthetic-candidate-model-v1", + candidateWeights: { "05:10": 0.4, "05:30": 0.6 }, + }, + scoringPartitions: { + "difference-d9-relationship": [ + { partitionId: "private-partition-early", descriptor: "early", fallbackLabel: "early", candidateScores: { "05:10": 1, "05:30": 0 } }, + { partitionId: "private-partition-late", descriptor: "late", fallbackLabel: "late", candidateScores: { "05:10": 0, "05:30": 1 } }, + ], + }, +} satisfies CandidateDifferenceBuild; + +const eventScore = { + resultId: "00000000-0000-4000-8000-000000000601", + confidence: "medium", + canApply: false, + winningSegment: { + startTime: "05:16", + endTime: "05:24", + representativeTime: "05:20", + widthMinutes: 9, + }, + eventCount: 1, + domainCount: 1, + topScore: 8, + secondScore: 7, + marginPercent: 12.5, + reasons: ["historical evidence narrows the middle segment"], + evidence: [{ + eventId: "00000000-0000-4000-8000-000000000602", + domain: "career", + candidateTime: "05:20", + ruleIds: ["vim-md-career"], + points: 3, + }], + algorithmVersion: "birth-time-event-scoring-v1", +} satisfies CandidateResult; + +export function syntheticTechnicalPacket() { + return buildRectificationTechnicalPacket({ + scan, + candidateDifferences, + eventScore, + consultation: { + source: "server_consultation_workflow", + calculationVersion: "rectification-technical-v1", + availableLayers: ["D1", "D9", "D10"], + layerReferences: { + D1: ["consult-d1-ascendant"], + D9: ["consult-d9-candidate-difference"], + D10: ["consult-d10-candidate-difference"], + }, + boundaryDistanceMinutes: 4, + futureWindows: [{ + label: "2028 career context window", + startDate: "2028-03-01", + endDate: "2028-05-31", + }], + }, + }); +} + +test("builds a deterministic private packet from server-computed engine receipts", () => { + const first = syntheticTechnicalPacket(); + const second = syntheticTechnicalPacket(); + + assert.deepEqual(first, second); + assert.equal(first.candidate.status, "pending_validation"); + assert.equal(first.candidate.representativeTime, "05:20"); + assert.deepEqual(first.candidate.range, { startTime: "05:16", endTime: "05:24" }); + assert.equal(first.d1Stability, "stable"); + assert.deepEqual(first.stableLayers.map((item) => item.layer), ["D1"]); + assert.deepEqual(first.supportedSensitiveLayers, ["D9", "D10"]); + assert.deepEqual(first.sensitiveLayers.map((item) => item.values), [["Leo", "Virgo"], ["Libra", "Scorpio"]]); + assert.equal(first.boundaryDistanceMinutes, 4); + assert.deepEqual(first.candidateWeights, { "05:10": 0.4, "05:30": 0.6 }); + assert.ok(first.partitionIds.includes("private-partition-early")); + assert.deepEqual(first.scoredHistoricalEvidence[0], { + evidenceId: "00000000-0000-4000-8000-000000000602", + domain: "career", + candidateTime: "05:20", + score: 3, + ruleRefs: ["vim-md-career"], + }); + assert.ok(first.suggestedDomains.length >= 2); + assert.deepEqual(first.suggestedDomains.map((item) => item.domain), ["relationship", "career"]); + assert.match(first.suggestedDomains[0]?.reason ?? "", /D9/); + assert.deepEqual(first.futureWindows, [{ + label: "2028 career context window", + startDate: "2028-03-01", + endDate: "2028-05-31", + scoreable: false, + }]); +}); + +test("public projection strips weights, partition identifiers, and private fingerprints", () => { + const projected = projectRectificationTechnicalPacket(syntheticTechnicalPacket()); + const serialized = JSON.stringify(projected); + + assert.equal(serialized.includes("candidateWeights"), false); + assert.equal(serialized.includes("partitionIds"), false); + assert.equal(serialized.includes("private-partition"), false); + assert.equal(serialized.includes("private-fingerprint"), false); + assert.deepEqual(projected.technicalReceipt.stableLayers, ["D1"]); + assert.deepEqual(projected.technicalReceipt.sensitiveLayers, ["D9", "D10"]); + assert.deepEqual(projected.evidenceRequest.domains, ["relationship", "career"]); + assert.equal(projected.futureWindows[0]?.scoreable, false); +}); + +test("public projection respects the existing bounded technical receipt", () => { + const packet = syntheticTechnicalPacket(); + const projected = projectRectificationTechnicalPacket({ + ...packet, + candidateDifferenceRefs: Array.from({ length: 50 }, (_, index) => `difference-${index}`), + }); + + assert.equal(projected.technicalReceipt.candidateDifferenceRefs.length, 40); + assert.deepEqual(projected.technicalReceipt.candidateDifferenceRefs.slice(0, 2), [ + "difference-0", + "difference-1", + ]); +});