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 })),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}\\"`));
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user