Files
Jyotisha/frontend/src/lib/conversational-rectification/narrative-agent.ts
T
2026-07-21 01:17:47 +08:00

441 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };