feat: generate grounded rectification turns
This commit is contained in:
@@ -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 })),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user