From c6efe283c5fea7cdfde54c2504c1d08050238ed2 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 01:17:47 +0800 Subject: [PATCH] fix: harden grounded rectification narratives --- .../evidence-extractor.ts | 17 +- .../narrative-agent.ts | 151 +++++++++++++++--- .../technical-packet.ts | 103 +++++++++++- .../conversational-evidence-extractor.test.ts | 38 +++++ .../conversational-narrative-agent.test.ts | 129 ++++++++++++++- .../conversational-technical-packet.test.ts | 105 ++++++++++++ 6 files changed, 509 insertions(+), 34 deletions(-) diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts index f20a0be3..5a662a54 100644 --- a/frontend/src/lib/conversational-rectification/evidence-extractor.ts +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -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"; diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts index c2d94a6c..bb3a76f8 100644 --- a/frontend/src/lib/conversational-rectification/narrative-agent.ts +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -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>; +const domainLabels = { + career: "事业", + education: "学业", + relocation: "迁居", + relationship: "关系", + family: "家庭", + other: "其他", +} as const satisfies Readonly>; 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" ? "当前证据已形成候选总结,但仍有残余不确定性;只有明确确认后才会替换当前排盘时间。" diff --git a/frontend/src/lib/conversational-rectification/technical-packet.ts b/frontend/src/lib/conversational-rectification/technical-packet.ts index 60acbf7c..fe8cd6da 100644 --- a/frontend/src/lib/conversational-rectification/technical-packet.ts +++ b/frontend/src/lib/conversational-rectification/technical-packet.ts @@ -15,6 +15,10 @@ export type ServerComputedRectificationConsultation = { readonly calculationVersion: string; readonly availableLayers: readonly string[]; readonly layerReferences: Readonly>; + 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>; +const domainLabels = { + career: "事业", + education: "教育", + relocation: "迁居", + relationship: "关系", + family: "家庭", + other: "其他", +} as const satisfies Readonly>; + 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(); + const linkedIndexes = new Set(); + 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), diff --git a/frontend/tests/conversational-evidence-extractor.test.ts b/frontend/tests/conversational-evidence-extractor.test.ts index f13dfb8d..a08bf657 100644 --- a/frontend/tests/conversational-evidence-extractor.test.ts +++ b/frontend/tests/conversational-evidence-extractor.test.ts @@ -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月计划结婚", diff --git a/frontend/tests/conversational-narrative-agent.test.ts b/frontend/tests/conversational-narrative-agent.test.ts index 3eeaf178..3a2eec23 100644 --- a/frontend/tests/conversational-narrative-agent.test.ts +++ b/frontend/tests/conversational-narrative-agent.test.ts @@ -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); }); diff --git a/frontend/tests/conversational-technical-packet.test.ts b/frontend/tests/conversational-technical-packet.test.ts index 95a0fef9..0de1e2da 100644 --- a/frontend/tests/conversational-technical-packet.test.ts +++ b/frontend/tests/conversational-technical-packet.test.ts @@ -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); });