fix: harden grounded rectification narratives
This commit is contained in:
@@ -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