feat: generate grounded rectification turns

This commit is contained in:
Jesse_Chen
2026-07-21 00:41:12 +08:00
parent 439338ae8a
commit e9328535a0
6 changed files with 1195 additions and 0 deletions
@@ -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;
}
@@ -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<typeof narrativeOutputSchema>;
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<RectificationNarrativeResult> {
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 };
@@ -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<Record<string, readonly string[]>>;
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<Record<string, number>>;
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<Record<string, RectificationEvidenceDomain>>;
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<Record<string, unknown>>): Readonly<Record<string, number>> {
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 })),
};
}