441 lines
18 KiB
TypeScript
441 lines
18 KiB
TypeScript
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-v2";
|
||
const domainSchema = z.enum(["career", "education", "relocation", "relationship", "family", "other"]);
|
||
const stableSemanticsPattern = /(?:稳定|保持|不变|一致|stable|unchanged)/i;
|
||
const sensitiveSemanticsPattern = /(?:敏感|变化|差异|切换|不同|sensitive|changes?|differs?)/i;
|
||
const discriminationSemanticsPattern = /(?:区分|辨别|判别|验证|差异|变化|discriminat|distinguish)/i;
|
||
const broadYearRangePattern = /(?:19|20)\d{2}\s*年?\s*(?:[-–—~~至到\/]|\.\.)\s*(?:19|20)\d{2}\s*年?/i;
|
||
const explicitYearPattern = /(?:19|20)\d{2}\s*年?/g;
|
||
const choiceQuestionPattern = /(?:哪(?:一|个)?(?:年份|年代|时间段|区间|时期)|哪个时间段|选择|选项|更符合|更匹配|A\s*[.、::)]|B\s*[.、::)]|which\s+(?:year|period|range)|options?)/i;
|
||
const domainSemantics = {
|
||
career: /(?:事业|工作|职业|career)/i,
|
||
education: /(?:教育|学业|学校|education)/i,
|
||
relocation: /(?:搬迁|搬家|迁居|异地|居住|relocation)/i,
|
||
relationship: /(?:关系|婚恋|伴侣|relationship)/i,
|
||
family: /(?:家庭|家人|父母|孩子|family)/i,
|
||
other: /(?:其他|其它|other)/i,
|
||
} as const satisfies Readonly<Record<RectificationEvidenceDomain, RegExp>>;
|
||
const domainLabels = {
|
||
career: "事业",
|
||
education: "学业",
|
||
relocation: "迁居",
|
||
relationship: "关系",
|
||
family: "家庭",
|
||
other: "其他",
|
||
} as const satisfies Readonly<Record<RectificationEvidenceDomain, string>>;
|
||
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]);
|
||
}
|
||
|
||
function isGenericBroadYearChoiceQuestionnaire(value: string): boolean {
|
||
const distinctYears = unique((value.match(explicitYearPattern) ?? [])
|
||
.map((year) => year.replace(/\s*年$/, "")));
|
||
return choiceQuestionPattern.test(value)
|
||
&& (broadYearRangePattern.test(value) || distinctYears.length >= 2);
|
||
}
|
||
|
||
function proseFields(output: RectificationNarrativeModelOutput): readonly {
|
||
readonly path: string;
|
||
readonly value: string;
|
||
}[] {
|
||
return [
|
||
{ path: "narrative", value: output.narrative },
|
||
...output.domainReasons.map((item, index) => ({
|
||
path: `domainReasons[${index}].reason`,
|
||
value: item.reason,
|
||
})),
|
||
...(output.evidenceRequest
|
||
? [{ path: "evidenceRequest.prompt", value: output.evidenceRequest.prompt }]
|
||
: []),
|
||
];
|
||
}
|
||
|
||
function pairKey(value: { readonly domain: RectificationEvidenceDomain; readonly layer: string }): string {
|
||
return `${value.domain}\0${value.layer}`;
|
||
}
|
||
|
||
function narrativeHasDomainDiscrimination(
|
||
narrative: string,
|
||
domain: RectificationEvidenceDomain,
|
||
layer: string,
|
||
): boolean {
|
||
return narrative.split(/[。!?!?;;\n]/).some((clause) => domainSemantics[domain].test(clause)
|
||
&& clause.includes(layer)
|
||
&& discriminationSemanticsPattern.test(clause));
|
||
}
|
||
|
||
function narrativeHasLayerEvidence(
|
||
narrative: string,
|
||
item: RectificationTechnicalPacket["stableLayers"][number],
|
||
semantics: RegExp,
|
||
requiredValueCount: number,
|
||
): boolean {
|
||
return narrative.split(/[。!?!?;;\n]/).some((clause) => clause.includes(item.layer)
|
||
&& item.values.filter((value) => clause.includes(value)).length >= requiredValueCount
|
||
&& semantics.test(clause));
|
||
}
|
||
|
||
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]));
|
||
const packetReasons = new Map(packet.suggestedDomains.map((item) => [pairKey(item), item.reason]));
|
||
for (const [index, reason] of output.domainReasons.entries()) {
|
||
const expectedReason = packetReasons.get(pairKey(reason));
|
||
if (!expectedReason) {
|
||
issues.push(`domain reason ${reason.domain}/${reason.layer} is not packet-grounded`);
|
||
} else if (reason.reason !== expectedReason) {
|
||
issues.push(`domainReasons[${index}].reason must use the packet discrimination explanation`);
|
||
}
|
||
}
|
||
if (phase === "first") {
|
||
const expectedPairs = packet.suggestedDomains.map(pairKey);
|
||
const actualPairs = output.domainReasons.map(pairKey);
|
||
if (expectedPairs.length < 2 || !sameMembers(actualPairs, expectedPairs)) {
|
||
issues.push("first turn must carry at least two unique packet discrimination pairs");
|
||
}
|
||
}
|
||
if (output.evidenceRequest) {
|
||
for (const domain of output.evidenceRequest.domains) {
|
||
if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`);
|
||
}
|
||
if (phase === "first" && !sameMembers(
|
||
output.evidenceRequest.domains,
|
||
packet.suggestedDomains.map((item) => item.domain),
|
||
)) {
|
||
issues.push("first evidence request must match the packet discrimination domains");
|
||
}
|
||
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];
|
||
const allowedLayers = [...allowedStable, ...allowedSensitive];
|
||
for (const field of proseFields(output)) {
|
||
const layers = narrativeLayers(field.value);
|
||
for (const time of narrativeTimes(field.value)) {
|
||
if (!allowedTimes.includes(time)) issues.push(`${field.path} time ${time} is not packet-grounded`);
|
||
}
|
||
for (const layer of layers) {
|
||
if (!allowedLayers.includes(layer)) issues.push(`${field.path} layer ${layer} is not packet-grounded`);
|
||
}
|
||
for (const reference of narrativeReferences(field.value)) {
|
||
if (!layers.includes(reference) && !packet.referenceIds.includes(reference)) {
|
||
issues.push(`${field.path} reference ${reference} is not packet-grounded`);
|
||
}
|
||
}
|
||
if (isGenericBroadYearChoiceQuestionnaire(field.value)) {
|
||
issues.push(`${field.path} is a forbidden generic broad-year choice questionnaire`);
|
||
}
|
||
}
|
||
if (phase === "first") {
|
||
if (!output.narrative.includes(candidate.representativeTime)
|
||
|| !/(?:待验证|候选)/.test(output.narrative)) {
|
||
issues.push("first narrative must state the pending candidate time");
|
||
}
|
||
for (const item of packet.stableLayers) {
|
||
if (!narrativeHasLayerEvidence(output.narrative, item, stableSemanticsPattern, 1)) {
|
||
issues.push(`first narrative lacks stable evidence semantics for ${item.layer}`);
|
||
}
|
||
}
|
||
for (const item of packet.sensitiveLayers) {
|
||
if (!narrativeHasLayerEvidence(
|
||
output.narrative,
|
||
item,
|
||
sensitiveSemanticsPattern,
|
||
Math.min(2, item.values.length),
|
||
)) {
|
||
issues.push(`first narrative lacks sensitive evidence semantics for ${item.layer}`);
|
||
}
|
||
}
|
||
for (const item of packet.suggestedDomains) {
|
||
if (!narrativeHasDomainDiscrimination(output.narrative, item.domain, item.layer)) {
|
||
issues.push(`first narrative must explain how ${item.domain}/${item.layer} discriminates`);
|
||
}
|
||
}
|
||
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");
|
||
}
|
||
}
|
||
const uniqueIssues = unique(issues);
|
||
return { valid: uniqueIssues.length === 0, issues: uniqueIssues };
|
||
}
|
||
|
||
function grounding(packet: RectificationTechnicalPacket) {
|
||
const projected = projectRectificationTechnicalPacket(packet);
|
||
return {
|
||
calculationVersion: packet.calculationVersion,
|
||
candidate: projected.candidate,
|
||
useBoundary: packet.useBoundary,
|
||
sensitivityScope: projected.technicalReceipt.sensitivityScope,
|
||
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,
|
||
everyAuthoredStringMustBeGrounded: true,
|
||
includeStableAndSensitiveLayerValues: phase === "first",
|
||
usePacketDomainReasonTextExactly: 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) => `${domainLabels[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 };
|