fix: harden grounded rectification narratives

This commit is contained in:
Jesse_Chen
2026-07-21 01:17:47 +08:00
parent e9328535a0
commit c6efe283c5
6 changed files with 509 additions and 34 deletions
@@ -26,6 +26,9 @@ type ParsedDate = {
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;
const unresolvedRelativeTimePattern = /(?:次年|第二年|后来|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)/;
const leadingRelativeTimePattern = /^\s*(?:(?:次年|第二年|后来(?:又)?|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)\s*)+/;
const missingEventSummary = "事件内容待补充";
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*(?:日|号))?)?$/);
@@ -66,10 +69,13 @@ function eventSummary(fragment: string): string {
.replace(chineseDatePattern, "")
.replace(isoDatePattern, "")
.replace(/^\s*(?:更正|纠正|修正)\s*[:]?\s*/, "")
.replace(/^\s*(?:后来|然后|同时|又)\s*/, "")
.replace(leadingRelativeTimePattern, "")
.replace(/^\s*(?:同时|又)\s*/, "")
.trim()
.replace(/^[,、:\s]+|[,、:\s]+$/g, "");
return withoutDates || fragment.trim();
return /[A-Za-z0-9\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/.test(withoutDates)
? withoutDates
: missingEventSummary;
}
function classifyDomain(summary: string): RectificationEvidenceDomain {
@@ -120,9 +126,12 @@ export function extractLifeEventEvidence(
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 unresolvedRelativeTime = ownDates.length === 0 && unresolvedRelativeTimePattern.test(fragment);
const date = ownDates.length === 1
? ownDates[0] ?? null
: ownDates.length === 0 && !unresolvedRelativeTime ? sharedDate : null;
const summary = eventSummary(fragment);
const complete = summary.length > 0 && date !== null;
const complete = summary !== missingEventSummary && date !== null && !unresolvedRelativeTime;
const extractionStatus = !complete
? "needs_clarification"
: corrections.length > 0 ? "corrected" : "clear";
@@ -9,8 +9,30 @@ 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 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"]),
@@ -95,6 +117,54 @@ function narrativeReferences(value: string): string[] {
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,
@@ -132,18 +202,32 @@ export function validateNarrativeAgainstPacket(
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)) {
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" && output.domainReasons.length < 2) {
issues.push("first turn requires two discriminating domain reasons");
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)) {
@@ -154,24 +238,48 @@ export function validateNarrativeAgainstPacket(
}
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`);
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");
}
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");
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)
@@ -182,7 +290,8 @@ export function validateNarrativeAgainstPacket(
issues.push("first narrative must state the candidate use boundary");
}
}
return { valid: issues.length === 0, issues };
const uniqueIssues = unique(issues);
return { valid: uniqueIssues.length === 0, issues: uniqueIssues };
}
function grounding(packet: RectificationTechnicalPacket) {
@@ -191,6 +300,7 @@ function grounding(packet: RectificationTechnicalPacket) {
calculationVersion: packet.calculationVersion,
candidate: projected.candidate,
useBoundary: packet.useBoundary,
sensitivityScope: projected.technicalReceipt.sensitivityScope,
stableLayers: packet.stableLayers,
sensitiveLayers: packet.sensitiveLayers,
scoredHistoricalEvidence: packet.scoredHistoricalEvidence,
@@ -218,6 +328,9 @@ function promptFor(
outputContract: {
candidateFactsMustMatch: true,
onlyListedLayersAndReferences: true,
everyAuthoredStringMustBeGrounded: true,
includeStableAndSensitiveLayerValues: phase === "first",
usePacketDomainReasonTextExactly: true,
requestRealPastEventsByYearAndMonth: phase !== "final",
futureWindowsAreContextOnly: true,
genericBroadYearRangeQuestionnaireForbidden: true,
@@ -232,10 +345,10 @@ function fallbackNarrative(packet: RectificationTechnicalPacket, phase: Rectific
.map((item) => `${item.layer}${item.values.join(" / ")})保持稳定`)
.join("");
const sensitive = packet.sensitiveLayers
.map((item) => `${item.layer}${item.values.join(" / ")}`)
.map((item) => `${item.layer}${item.values.join(" / ")}呈现分钟敏感差异`)
.join("");
const reasons = packet.suggestedDomains
.map((item) => `${item.domain}事件可区分 ${item.layer}`)
.map((item) => `${domainLabels[item.domain]}事件可区分 ${item.layer}`)
.join("");
const phaseLine = phase === "final"
? "当前证据已形成候选总结,但仍有残余不确定性;只有明确确认后才会替换当前排盘时间。"
@@ -15,6 +15,10 @@ export type ServerComputedRectificationConsultation = {
readonly calculationVersion: string;
readonly availableLayers: readonly string[];
readonly layerReferences: Readonly<Record<string, readonly string[]>>;
readonly timeLinkedScanSamples: readonly {
readonly sampleIndex: number;
readonly time: string;
}[];
readonly boundaryDistanceMinutes: number | null;
readonly futureWindows: readonly {
readonly label: string;
@@ -49,6 +53,12 @@ export type RectificationTechnicalPacket = {
readonly partitionIds: readonly string[];
readonly d1Stability: "stable" | "sensitive" | "unavailable";
readonly boundaryDistanceMinutes: number | null;
readonly sensitivityScope: {
readonly source: "time_linked_candidate_scan_samples";
readonly rangeStart: string;
readonly rangeEnd: string;
readonly sampleTimes: readonly string[];
};
readonly stableLayers: readonly RectificationLayerEvidence[];
readonly sensitiveLayers: readonly RectificationLayerEvidence[];
readonly supportedSensitiveLayers: readonly string[];
@@ -76,6 +86,11 @@ type PacketInput = {
readonly consultation: ServerComputedRectificationConsultation;
};
type TimeLinkedVargaSample = {
readonly time: string;
readonly sample: RectificationQuestionnaire["samples"][number];
};
const layerFields = [
["D1", "ascendantSign"],
["D4", "d4Sign"],
@@ -92,6 +107,15 @@ const domainByLayer = {
D4: "relocation",
} as const satisfies Readonly<Record<string, RectificationEvidenceDomain>>;
const domainLabels = {
career: "事业",
education: "教育",
relocation: "迁居",
relationship: "关系",
family: "家庭",
other: "其他",
} as const satisfies Readonly<Record<RectificationEvidenceDomain, string>>;
function unique(values: readonly string[]): string[] {
return [...new Set(values.filter((value) => value.trim().length > 0))];
}
@@ -106,6 +130,44 @@ function minuteToTime(value: number): string {
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
}
function sampleClockTime(value: unknown): string | null {
if (typeof value !== "string") return null;
const match = value.trim().match(
/(?:^|[T\s])(([01]\d|2[0-3]):[0-5]\d)(?::[0-5]\d)?(?:Z|[+-]\d{2}:?\d{2})?$/,
);
return match?.[1] ?? null;
}
function timeIsInsideRange(time: string, startTime: string, endTime: string): boolean {
const minute = timeToMinute(time);
const start = timeToMinute(startTime);
const end = timeToMinute(endTime);
return end >= start
? minute >= start && minute <= end
: minute >= start || minute <= end;
}
function timeLinkedSamples(
scan: RectificationQuestionnaire,
links: ServerComputedRectificationConsultation["timeLinkedScanSamples"],
): readonly TimeLinkedVargaSample[] {
const byTime = new Map<string, TimeLinkedVargaSample>();
const linkedIndexes = new Set<number>();
for (const link of links) {
const sample = scan.samples[link.sampleIndex];
const time = sampleClockTime(link.time);
if (!Number.isInteger(link.sampleIndex) || link.sampleIndex < 0 || !sample || !time) {
throw new TypeError("rectification packet received an invalid time-linked scan sample");
}
if (linkedIndexes.has(link.sampleIndex) || byTime.has(time)) {
throw new TypeError("rectification packet received duplicate time-linked scan samples");
}
linkedIndexes.add(link.sampleIndex);
byTime.set(time, { time, sample });
}
return [...byTime.values()];
}
function midpoint(startTime: string, endTime: string): string {
const start = timeToMinute(startTime);
let end = timeToMinute(endTime);
@@ -129,11 +191,14 @@ function eventDomain(domain: CandidateResult["evidence"][number]["domain"]): Rec
return domain === "finance" || domain === "health_pressure" ? "other" : domain;
}
function layerEvidence(input: PacketInput): RectificationLayerEvidence[] {
function layerEvidence(
samples: readonly RectificationQuestionnaire["samples"][number][],
consultation: ServerComputedRectificationConsultation,
): RectificationLayerEvidence[] {
return layerFields.map(([layer, field]) => ({
layer,
values: unique(input.scan.samples.map((sample) => sample[field] ?? "")),
referenceIds: unique(input.consultation.layerReferences[layer] ?? []),
values: unique(samples.map((sample) => sample[field] ?? "")),
referenceIds: unique(consultation.layerReferences[layer] ?? []),
})).filter((item) => item.values.length > 0);
}
@@ -144,7 +209,7 @@ function suggestedDomains(layers: readonly RectificationLayerEvidence[]): Sugges
return [{
domain,
layer: item.layer,
reason: `${item.layer} 在候选范围内呈现 ${item.values.join(" / ")} 差异,可用已发生的${domain}事件区分。`,
reason: `${item.layer} 在候选范围内呈现 ${item.values.join(" / ")} 差异,可用已发生的${domainLabels[domain]}事件区分。`,
}];
});
}
@@ -159,7 +224,14 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
: input.candidateDifferences.packet.currentRange;
const representativeTime = eventSegment?.representativeTime
?? midpoint(range.startTime, range.endTime);
const layers = layerEvidence(input);
const selectedSamples = timeLinkedSamples(input.scan, input.consultation.timeLinkedScanSamples)
.filter((item) => timeIsInsideRange(item.time, range.startTime, range.endTime));
if (selectedSamples.length < 2) {
throw new TypeError(
"rectification packet requires two time-linked scan samples inside the selected candidate range",
);
}
const layers = layerEvidence(selectedSamples.map((item) => item.sample), input.consultation);
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);
@@ -168,7 +240,9 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
&& available.has(item.layer));
const domains = suggestedDomains(sensitiveLayers);
if (domains.length < 2) {
throw new TypeError("rectification packet requires two server-computed discriminating domains");
throw new TypeError(
"rectification packet requires two time-linked discriminating domains inside the selected candidate range",
);
}
const scoredHistoricalEvidence = (input.eventScore?.evidence ?? []).map((item) => ({
evidenceId: item.eventId,
@@ -177,7 +251,12 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
score: item.points,
ruleRefs: [...item.ruleIds],
}));
const opportunityRefs = input.candidateDifferences.packet.opportunities.map((item) => item.opportunityId);
const scanRange = input.candidateDifferences.packet.currentRange;
const selectedRangeMatchesScan = range.startTime === scanRange.startTime
&& range.endTime === scanRange.endTime;
const opportunityRefs = selectedRangeMatchesScan
? 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;
@@ -205,6 +284,12 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
partitionIds,
d1Stability,
boundaryDistanceMinutes: input.consultation.boundaryDistanceMinutes,
sensitivityScope: {
source: "time_linked_candidate_scan_samples",
rangeStart: range.startTime,
rangeEnd: range.endTime,
sampleTimes: selectedSamples.map((item) => item.time),
},
stableLayers: d1Stability === "stable" && d1 ? [d1] : [],
sensitiveLayers,
supportedSensitiveLayers: sensitiveLayers.map((item) => item.layer),
@@ -228,6 +313,10 @@ export function projectRectificationTechnicalPacket(packet: RectificationTechnic
calculationVersion: packet.calculationVersion,
stableLayers: packet.stableLayers.map((item) => item.layer),
sensitiveLayers: [...packet.supportedSensitiveLayers],
sensitivityScope: {
...packet.sensitivityScope,
sampleTimes: [...packet.sensitivityScope.sampleTimes],
},
candidateDifferenceRefs: packet.candidateDifferenceRefs
.filter((reference) => reference.trim().length > 0 && reference.length <= 120)
.slice(0, 40),