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
@@ -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:1605: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:1605:24 范围内的待验证候选,不能视为已经确认的出生分钟。",
"D1 的 Cancer 保持稳定;D9 的 Leo / Virgo 与 D10 的 Libra / Scorpio 都有分钟敏感差异。",
"关系事件可区分 D9,事业事件可区分 D10。请按已经发生的经历选择哪一年、哪一月:A. 20182020B. 20212023,哪个时间段更符合?",
].join("\n"),
evidenceRequest: {
domains: ["relationship", "career"],
datePrecision: "month_preferred",
prompt: "请按过去经历选择哪一年、哪一月:A. 20182020B. 20212023,哪个时间段更符合?",
},
} 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);
});