fix: harden grounded rectification narratives
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -58,6 +58,44 @@ test("never invents a missing month or day", () => {
|
||||
assert.equal(evidence?.eventSummary, "毕业");
|
||||
});
|
||||
|
||||
test("keeps a bare year as non-scoreable clarification instead of an event summary", () => {
|
||||
const rawText = "2021年";
|
||||
const [evidence] = extractLifeEventEvidence({
|
||||
rawText,
|
||||
sourceTurnId,
|
||||
asOfDate: "2026-07-20",
|
||||
});
|
||||
|
||||
assert.equal(evidence?.rawText, rawText);
|
||||
assert.equal(evidence?.dateValue, "2021");
|
||||
assert.equal(evidence?.datePrecision, "year");
|
||||
assert.notEqual(evidence?.eventSummary, rawText);
|
||||
assert.equal(evidence?.extractionStatus, "needs_clarification");
|
||||
assert.equal(evidence?.scoreable, false);
|
||||
assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true);
|
||||
});
|
||||
|
||||
for (const rawText of [
|
||||
"2021年7月毕业并次年工作",
|
||||
"2021年7月毕业并后来工作",
|
||||
"2021年7月毕业并第二年工作",
|
||||
"2021年7月毕业并此前工作",
|
||||
]) {
|
||||
test(`does not propagate a shared date through an unresolved relative clause: ${rawText}`, () => {
|
||||
const evidence = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" });
|
||||
|
||||
assert.equal(evidence.length, 2);
|
||||
assert.deepEqual(evidence.map((item) => item.eventSummary), ["毕业", "工作"]);
|
||||
assert.equal(evidence[0]?.dateValue, "2021-07");
|
||||
assert.equal(evidence[0]?.extractionStatus, "clear");
|
||||
assert.equal(evidence[0]?.scoreable, true);
|
||||
assert.equal(evidence[1]?.dateValue, null);
|
||||
assert.equal(evidence[1]?.datePrecision, "unknown");
|
||||
assert.equal(evidence[1]?.extractionStatus, "needs_clarification");
|
||||
assert.equal(evidence[1]?.scoreable, false);
|
||||
});
|
||||
}
|
||||
|
||||
test("marks a future event as context-only and non-scoreable", () => {
|
||||
const [evidence] = extractLifeEventEvidence({
|
||||
rawText: "2030年3月计划结婚",
|
||||
|
||||
@@ -23,6 +23,12 @@ function syntheticTechnicalPacket(): RectificationTechnicalPacket {
|
||||
partitionIds: ["private-partition-early", "private-partition-late"],
|
||||
d1Stability: "stable",
|
||||
boundaryDistanceMinutes: 4,
|
||||
sensitivityScope: {
|
||||
source: "time_linked_candidate_scan_samples",
|
||||
rangeStart: "05:16",
|
||||
rangeEnd: "05:24",
|
||||
sampleTimes: ["05:16", "05:24"],
|
||||
},
|
||||
stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["consult-d1-ascendant"] }],
|
||||
sensitiveLayers: [
|
||||
{ layer: "D9", values: ["Leo", "Virgo"], referenceIds: ["consult-d9-candidate-difference"] },
|
||||
@@ -31,8 +37,8 @@ function syntheticTechnicalPacket(): RectificationTechnicalPacket {
|
||||
supportedSensitiveLayers: ["D9", "D10"],
|
||||
scoredHistoricalEvidence: [],
|
||||
suggestedDomains: [
|
||||
{ domain: "relationship", layer: "D9", reason: "D9 在候选范围内变化" },
|
||||
{ domain: "career", layer: "D10", reason: "D10 在候选范围内变化" },
|
||||
{ domain: "relationship", layer: "D9", reason: "D9 在候选范围内呈现 Leo / Virgo 差异,可用已发生的关系事件区分。" },
|
||||
{ domain: "career", layer: "D10", reason: "D10 在候选范围内呈现 Libra / Scorpio 差异,可用已发生的事业事件区分。" },
|
||||
],
|
||||
referenceIds: [
|
||||
"difference-d9-relationship",
|
||||
@@ -68,8 +74,8 @@ function richOutput(): RectificationNarrativeModelOutput {
|
||||
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" },
|
||||
{ domain: "relationship", layer: "D9", reason: packet.suggestedDomains[0]?.reason ?? "" },
|
||||
{ domain: "career", layer: "D10", reason: packet.suggestedDomains[1]?.reason ?? "" },
|
||||
],
|
||||
evidenceRequest: {
|
||||
domains: ["relationship", "career"],
|
||||
@@ -132,6 +138,120 @@ test("rejects invented representative times, layers, and references", () => {
|
||||
assert.ok(result.issues.some((issue) => issue.includes("invented-reference")));
|
||||
});
|
||||
|
||||
test("rejects a first turn that names layer tokens without their stable and sensitive values", () => {
|
||||
const packet = syntheticTechnicalPacket();
|
||||
const invalid = {
|
||||
...richOutput(),
|
||||
narrative: [
|
||||
"05:20 是 05:16–05:24 范围内的待验证候选,不能视为已经确认的出生分钟。",
|
||||
"D1 是稳定层,D9 和 D10 是分钟敏感层。",
|
||||
"关系事件可区分 D9,事业事件可区分 D10。请提供已经发生的真实事件,写明哪一年、哪一月。",
|
||||
].join("\n"),
|
||||
} satisfies RectificationNarrativeModelOutput;
|
||||
const result = validateNarrativeAgainstPacket(invalid, packet, "first");
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.issues.some((issue) => issue.includes("stable evidence semantics")));
|
||||
assert.ok(result.issues.some((issue) => issue.includes("sensitive evidence semantics")));
|
||||
});
|
||||
|
||||
test("rejects duplicated generic domain reasons that omit a packet discrimination pair", () => {
|
||||
const packet = syntheticTechnicalPacket();
|
||||
const relationshipReason = richOutput().domainReasons[0];
|
||||
assert.ok(relationshipReason);
|
||||
const invalid = {
|
||||
...richOutput(),
|
||||
domainReasons: [
|
||||
{ ...relationshipReason, reason: "D9 may matter for this question" },
|
||||
{ ...relationshipReason, reason: "D9 may matter for another question" },
|
||||
],
|
||||
} satisfies RectificationNarrativeModelOutput;
|
||||
const result = validateNarrativeAgainstPacket(invalid, packet, "first");
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.issues.some((issue) => issue.includes("packet discrimination pairs")));
|
||||
});
|
||||
|
||||
test("rejects a generic broad-year choice questionnaire and falls back without scoring", async () => {
|
||||
const invalid = {
|
||||
...richOutput(),
|
||||
narrative: [
|
||||
"05:20 是 05:16–05:24 范围内的待验证候选,不能视为已经确认的出生分钟。",
|
||||
"D1 的 Cancer 保持稳定;D9 的 Leo / Virgo 与 D10 的 Libra / Scorpio 都有分钟敏感差异。",
|
||||
"关系事件可区分 D9,事业事件可区分 D10。请按已经发生的经历选择哪一年、哪一月:A. 2018–2020;B. 2021–2023,哪个时间段更符合?",
|
||||
].join("\n"),
|
||||
evidenceRequest: {
|
||||
domains: ["relationship", "career"],
|
||||
datePrecision: "month_preferred",
|
||||
prompt: "请按过去经历选择哪一年、哪一月:A. 2018–2020;B. 2021–2023,哪个时间段更符合?",
|
||||
},
|
||||
} satisfies RectificationNarrativeModelOutput;
|
||||
const direct = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first");
|
||||
|
||||
assert.equal(direct.valid, false);
|
||||
assert.ok(direct.issues.some((issue) => issue.includes("broad-year choice questionnaire")));
|
||||
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "first",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
generator: generator([invalid, invalid]),
|
||||
});
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(result.fallbackUsed, true);
|
||||
assert.equal(result.allowEvidenceScoringAdvance, false);
|
||||
});
|
||||
|
||||
test("rejects generic individual-year options even without a written range", () => {
|
||||
const output = richOutput();
|
||||
assert.ok(output.evidenceRequest);
|
||||
const invalid = {
|
||||
...output,
|
||||
evidenceRequest: {
|
||||
...output.evidenceRequest,
|
||||
prompt: "请按过去已经发生的事件选择哪一年、哪一月更符合:A. 2018年;B. 2021年。",
|
||||
},
|
||||
} satisfies RectificationNarrativeModelOutput;
|
||||
const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first");
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.issues.some((issue) => issue.includes("broad-year choice questionnaire")));
|
||||
});
|
||||
|
||||
test("rejects ungrounded layers and references nested in a domain reason", () => {
|
||||
const output = richOutput();
|
||||
const firstReason = output.domainReasons[0];
|
||||
assert.ok(firstReason);
|
||||
const invalid = {
|
||||
...output,
|
||||
domainReasons: [
|
||||
{ ...firstReason, reason: `${firstReason.reason} D60 另见 invented-reference。` },
|
||||
...output.domainReasons.slice(1),
|
||||
],
|
||||
} satisfies RectificationNarrativeModelOutput;
|
||||
const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first");
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.issues.some((issue) => issue.includes("domainReasons[0].reason") && issue.includes("D60")));
|
||||
assert.ok(result.issues.some((issue) => issue.includes("domainReasons[0].reason") && issue.includes("invented-reference")));
|
||||
});
|
||||
|
||||
test("rejects ungrounded times and references nested in the evidence request prompt", () => {
|
||||
const output = richOutput();
|
||||
assert.ok(output.evidenceRequest);
|
||||
const invalid = {
|
||||
...output,
|
||||
evidenceRequest: {
|
||||
...output.evidenceRequest,
|
||||
prompt: `${output.evidenceRequest.prompt} 请以 06:45 和 invented-reference 为准。`,
|
||||
},
|
||||
} satisfies RectificationNarrativeModelOutput;
|
||||
const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first");
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.issues.some((issue) => issue.includes("evidenceRequest.prompt") && issue.includes("06:45")));
|
||||
assert.ok(result.issues.some((issue) => issue.includes("evidenceRequest.prompt") && issue.includes("invented-reference")));
|
||||
});
|
||||
|
||||
test("rejects an invented plain-text technical reference omitted from the reference list", () => {
|
||||
const packet = syntheticTechnicalPacket();
|
||||
const invalid = {
|
||||
@@ -159,6 +279,7 @@ test("retries expression exactly once with the same grounded packet", async () =
|
||||
assert.equal(prompts.length, 2);
|
||||
assert.match(prompts[0] ?? "", /rectification-technical-v1/);
|
||||
assert.match(prompts[1] ?? "", /rectification-technical-v1/);
|
||||
assert.match(prompts[0] ?? "", /time_linked_candidate_scan_samples/);
|
||||
assert.equal(prompts.some((prompt) => prompt.includes("candidateWeights")), false);
|
||||
assert.equal(prompts.some((prompt) => prompt.includes("private-partition")), false);
|
||||
});
|
||||
|
||||
@@ -11,14 +11,18 @@ import type { RectificationQuestionnaire } from "../src/lib/birth-time-journey-s
|
||||
const scan = {
|
||||
questions: [],
|
||||
samples: [
|
||||
{ ascendantSign: "Cancer", d4Sign: "Aries", d9Sign: "Aries", d10Sign: "Taurus", d24Sign: "Gemini", d30Sign: "Pisces" },
|
||||
{ ascendantSign: "Cancer", d4Sign: "Aries", d9Sign: "Leo", d10Sign: "Libra", d24Sign: "Gemini", d30Sign: "Pisces" },
|
||||
{ ascendantSign: "Cancer", d4Sign: "Aries", d9Sign: "Virgo", d10Sign: "Scorpio", d24Sign: "Gemini", d30Sign: "Pisces" },
|
||||
{ ascendantSign: "Cancer", d4Sign: "Aries", d9Sign: "Sagittarius", d10Sign: "Capricorn", 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:16", ascendant: { sign: "Cancer" } },
|
||||
{ time: "2000-01-01 05:24", ascendant: { sign: "Cancer" } },
|
||||
{ time: "2000-01-01 05:30", ascendant: { sign: "Cancer" } },
|
||||
],
|
||||
},
|
||||
@@ -109,6 +113,12 @@ export function syntheticTechnicalPacket() {
|
||||
D9: ["consult-d9-candidate-difference"],
|
||||
D10: ["consult-d10-candidate-difference"],
|
||||
},
|
||||
timeLinkedScanSamples: [
|
||||
{ sampleIndex: 0, time: "05:10" },
|
||||
{ sampleIndex: 1, time: "05:16" },
|
||||
{ sampleIndex: 2, time: "05:24" },
|
||||
{ sampleIndex: 3, time: "05:30" },
|
||||
],
|
||||
boundaryDistanceMinutes: 4,
|
||||
futureWindows: [{
|
||||
label: "2028 career context window",
|
||||
@@ -131,6 +141,14 @@ test("builds a deterministic private packet from server-computed engine receipts
|
||||
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.deepEqual(first.sensitivityScope, {
|
||||
source: "time_linked_candidate_scan_samples",
|
||||
rangeStart: "05:16",
|
||||
rangeEnd: "05:24",
|
||||
sampleTimes: ["05:16", "05:24"],
|
||||
});
|
||||
assert.equal(first.candidateDifferenceRefs.includes("difference-d9-relationship"), false);
|
||||
assert.ok(first.candidateDifferenceRefs.includes("consult-d9-candidate-difference"));
|
||||
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"));
|
||||
@@ -144,6 +162,10 @@ test("builds a deterministic private packet from server-computed engine receipts
|
||||
assert.ok(first.suggestedDomains.length >= 2);
|
||||
assert.deepEqual(first.suggestedDomains.map((item) => item.domain), ["relationship", "career"]);
|
||||
assert.match(first.suggestedDomains[0]?.reason ?? "", /D9/);
|
||||
assert.match(first.suggestedDomains[0]?.reason ?? "", /关系/);
|
||||
assert.doesNotMatch(first.suggestedDomains[0]?.reason ?? "", /relationship/);
|
||||
assert.match(first.suggestedDomains[1]?.reason ?? "", /事业/);
|
||||
assert.doesNotMatch(first.suggestedDomains[1]?.reason ?? "", /career/);
|
||||
assert.deepEqual(first.futureWindows, [{
|
||||
label: "2028 career context window",
|
||||
startDate: "2028-03-01",
|
||||
@@ -152,6 +174,83 @@ test("builds a deterministic private packet from server-computed engine receipts
|
||||
}]);
|
||||
});
|
||||
|
||||
test("does not claim scan-wide 05:10-05:30 differences inside a 05:16-05:24 candidate", () => {
|
||||
const scanWideOnly = {
|
||||
...scan,
|
||||
samples: [scan.samples[0], scan.samples[3]],
|
||||
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;
|
||||
|
||||
assert.throws(() => buildRectificationTechnicalPacket({
|
||||
scan: scanWideOnly,
|
||||
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"],
|
||||
},
|
||||
timeLinkedScanSamples: [
|
||||
{ sampleIndex: 0, time: "05:10" },
|
||||
{ sampleIndex: 1, time: "05:30" },
|
||||
],
|
||||
boundaryDistanceMinutes: 4,
|
||||
futureWindows: [],
|
||||
},
|
||||
}), /two time-linked scan samples inside the selected candidate range/);
|
||||
});
|
||||
|
||||
test("uses typed server time links when normalized scan raw metadata omits sample times", () => {
|
||||
const normalizedScan = {
|
||||
...scan,
|
||||
raw: {
|
||||
questions: [],
|
||||
candidate_scan: { samples: scan.samples.map(() => ({})) },
|
||||
},
|
||||
} satisfies RectificationQuestionnaire;
|
||||
const packet = buildRectificationTechnicalPacket({
|
||||
scan: normalizedScan,
|
||||
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"],
|
||||
},
|
||||
timeLinkedScanSamples: [
|
||||
{ sampleIndex: 0, time: "05:10" },
|
||||
{ sampleIndex: 1, time: "05:16" },
|
||||
{ sampleIndex: 2, time: "05:24" },
|
||||
{ sampleIndex: 3, time: "05:30" },
|
||||
],
|
||||
boundaryDistanceMinutes: 4,
|
||||
futureWindows: [],
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(packet.sensitivityScope.sampleTimes, ["05:16", "05:24"]);
|
||||
assert.deepEqual(packet.sensitiveLayers.map((item) => item.values), [
|
||||
["Leo", "Virgo"],
|
||||
["Libra", "Scorpio"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("public projection strips weights, partition identifiers, and private fingerprints", () => {
|
||||
const projected = projectRectificationTechnicalPacket(syntheticTechnicalPacket());
|
||||
const serialized = JSON.stringify(projected);
|
||||
@@ -162,6 +261,12 @@ test("public projection strips weights, partition identifiers, and private finge
|
||||
assert.equal(serialized.includes("private-fingerprint"), false);
|
||||
assert.deepEqual(projected.technicalReceipt.stableLayers, ["D1"]);
|
||||
assert.deepEqual(projected.technicalReceipt.sensitiveLayers, ["D9", "D10"]);
|
||||
assert.deepEqual(projected.technicalReceipt.sensitivityScope, {
|
||||
source: "time_linked_candidate_scan_samples",
|
||||
rangeStart: "05:16",
|
||||
rangeEnd: "05:24",
|
||||
sampleTimes: ["05:16", "05:24"],
|
||||
});
|
||||
assert.deepEqual(projected.evidenceRequest.domains, ["relationship", "career"]);
|
||||
assert.equal(projected.futureWindows[0]?.scoreable, false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user