fix(report): name final-parse guards and scrub blocked wording
Independent Staging Quality Gate / validate (push) Successful in 12m11s
Independent Staging Quality Gate / publish (push) Successful in 1m57s

Five-theme assemble cleared the chart and actionNotes caps, then failed
three anonymous guards. Log field path plus kind only, and strip
deterministic phrases from sections the producer already marked blocked.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-04 23:09:21 +08:00
parent a68fd01f68
commit 4ef4c4064f
7 changed files with 142 additions and 8 deletions
+58 -1
View File
@@ -314,6 +314,63 @@ export type ReportDocumentParseError = Readonly<{
code: string;
}>;
/**
* Map a guard message to a field path + kind. The message itself is kept on
* the typed error for tests; logs must use only path and code (no values).
*/
export function classifyReportDocumentGuardError(message: string): ReportDocumentParseError {
const forbidden = /^(.*?): forbidden content ([a-z_,]+)$/.exec(message);
if (forbidden) {
const kinds = forbidden[2].split(",").filter((kind) => /^[a-z_]+$/.test(kind));
return {
path: forbidden[1] || "(guard)",
message,
code: kinds[0] ? `forbidden_${kinds[0]}` : "forbidden_content",
};
}
const dateClaim = /^(.*?): date claim requires evidenceRefs$/.exec(message);
if (dateClaim) {
return { path: dateClaim[1] || "(guard)", message, code: "unsupported_date" };
}
const blocked = /^(.*?): blocked section contains deterministic prediction$/.exec(message);
if (blocked) {
return { path: blocked[1] || "(guard)", message, code: "blocked_deterministic" };
}
if (
message.startsWith("requestedThemes must contain ")
|| message.startsWith("requested theme ")
|| message.startsWith("thematic section covers unrequested theme ")
|| message.startsWith("blocked disclosure covers unrequested theme ")
) {
return { path: "requestedThemes", message, code: "theme_coverage" };
}
if (message.includes(" requires structured ") && message.includes(" chart data")) {
return { path: "thematicNarrative", message, code: "missing_required_chart" };
}
if (
message.startsWith("charts must ")
|| message.startsWith("duplicate chart id ")
|| message.startsWith("D1 chart ")
|| / chart contains duplicate house numbers$/.test(message)
) {
return { path: "charts", message, code: "chart_set" };
}
if (message.startsWith("evidence id ")) {
return { path: "evidenceAppendix", message, code: "duplicate_evidence_id" };
}
const dangling = /^evidenceRefs: unknown evidence id (.+):([^:]+)$/.exec(message);
if (dangling) {
return { path: dangling[1], message, code: "dangling_ref" };
}
if (message.startsWith("evidenceRefs:")) {
return { path: "evidenceRefs", message, code: "dangling_ref" };
}
if (message.startsWith("serialized document")) {
return { path: "(root)", message, code: "document_too_large" };
}
return { path: "(guard)", message, code: "guard" };
}
export class ReportDocumentValidationError extends Error {
readonly errors: readonly ReportDocumentParseError[];
@@ -603,7 +660,7 @@ export function safeParseReportDocument(input: unknown): ReportDocumentParseResu
if (guardErrors.length > 0) {
return {
ok: false,
errors: guardErrors.map((message) => ({ path: "(guard)", message, code: "guard" })),
errors: guardErrors.map((message) => classifyReportDocumentGuardError(message)),
};
}
return { ok: true, document };
@@ -4,6 +4,7 @@ import {
safeParseServerReportDocument,
} from "./personal-report-contract.server-core.ts";
import {
BLOCKED_DETERMINISTIC_PHRASES,
CHART_IDS,
REPORT_DOCUMENT_V2_ACTION_NOTES_MAX,
REQUIRED_THEME_CHARTS,
@@ -2922,6 +2923,28 @@ export function redactDeterministicSentences(
const BLOCKED_SECTION_CAVEAT = "该部分证据受限,已按确定性边界降级,仅保留方向性描述。";
const BLOCKED_DETERMINISTIC_PATTERNS = BLOCKED_DETERMINISTIC_PHRASES.map(
(phrase) => new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"),
);
function scrubBlockedDeterministicStrings(target: JsonRecord, keys: readonly string[]): void {
for (const key of keys) {
const value = target[key];
if (typeof value === "string") {
const redacted = redactDeterministicSentences(value, BLOCKED_DETERMINISTIC_PATTERNS);
target[key] = redacted.text.length > 0 ? redacted.text : BLOCKED_SECTION_CAVEAT;
} else if (Array.isArray(value)) {
target[key] = value
.map((item) => (
typeof item === "string"
? redactDeterministicSentences(item, BLOCKED_DETERMINISTIC_PATTERNS).text
: item
))
.filter((item) => typeof item !== "string" || item.length > 0);
}
}
}
type GuardSection = {
id: string;
narrative: string;
@@ -3106,6 +3129,7 @@ export function applyReportGuard<D>(
const caveats = stringArray(target.caveats);
if (!caveats.includes(BLOCKED_SECTION_CAVEAT)) caveats.push(BLOCKED_SECTION_CAVEAT);
target.caveats = caveats;
scrubBlockedDeterministicStrings(target, ["narrative", "actions", "caveats", "title"]);
}
}
const summaryRedacted = redactDeterministicSentences(
@@ -3116,6 +3140,7 @@ export function applyReportGuard<D>(
if (summaryRedacted.removedCount > 0) {
summary.summary = summaryRedacted.text;
summary.overallClaimStatus = "blocked";
scrubBlockedDeterministicStrings(summary, ["headline", "summary", "priorities"]);
}
if (Array.isArray(summary.priorities)) {
const keptPriorities = summary.priorities
@@ -3148,6 +3173,7 @@ export function applyReportGuard<D>(
if (target.claimStatus === "blocked") blockedSectionCount += 1;
const finalStatus = target.claimStatus as string;
if (finalStatus === "blocked") {
scrubBlockedDeterministicStrings(target, ["narrative", "actions", "caveats", "title"]);
const narrativeText = typeof target.narrative === "string" ? target.narrative : "";
const blockedClaims = findForbiddenDeterministicClaims(narrativeText);
const hardDomain = blockedClaims.find((claim) => claim.domain !== "timing");
@@ -3166,6 +3192,24 @@ export function applyReportGuard<D>(
}
if (summary && (readModel.sections.length === 0 || blockedSectionCount === readModel.sections.length)) {
summary.overallClaimStatus = "blocked";
scrubBlockedDeterministicStrings(summary, ["headline", "summary", "priorities"]);
}
const currentPhase = record(next.currentPhase);
if (currentPhase) {
const timing = narrative.find((item) => {
const row = record(item);
return text(row?.theme) === "timing" || text(row?.id) === "theme-timing";
});
const timingRow = record(timing);
if (timingRow) {
if (typeof timingRow.narrative === "string") currentPhase.narrative = timingRow.narrative;
if (typeof timingRow.claimStatus === "string") currentPhase.claimStatus = timingRow.claimStatus;
if (Array.isArray(timingRow.caveats)) currentPhase.caveats = [...timingRow.caveats];
if (currentPhase.claimStatus === "blocked") {
scrubBlockedDeterministicStrings(currentPhase, ["narrative", "timingNotes", "caveats", "title", "phaseLabel"]);
}
}
}
return { ok: true, document: next as D };
@@ -12,6 +12,7 @@ import {
findChartSetViolations,
findDanglingEvidenceRefs,
findDuplicateEvidenceIds,
classifyReportDocumentGuardError,
CURRENT_REPORT_DOCUMENT_SCHEMA_VERSION,
LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION,
REPORT_DOCUMENT_SCHEMA_VERSION,
@@ -404,3 +405,28 @@ test("v2 guard rejects HTML and CSS while allowing ordinary Chinese prose", () =
assert.equal(findForbiddenContent(ordinary.actionNotes[0].note).length, 0);
assert.equal(safeParseReportDocument(ordinary).ok, true);
});
test("guard parse errors expose field path and kind without the offending value", () => {
const missing = cloneV2();
missing.blockedConflictDisclosure = missing.blockedConflictDisclosure.filter(
(section) => section.theme !== "career",
);
const coverage = safeParseReportDocument(missing);
assert.equal(coverage.ok, false);
assert.ok(coverage.errors.some((error) => (
error.path === "requestedThemes" && error.code === "theme_coverage"
)));
const medical = cloneV2();
medical.actionNotes[0].note = "你已经患有糖尿病。";
const forbidden = safeParseReportDocument(medical);
assert.equal(forbidden.ok, false);
assert.ok(forbidden.errors.some((error) => (
error.path === "actionNotes[0].note" && error.code === "forbidden_medical_diagnosis"
)));
assert.ok(forbidden.errors.every((error) => !error.path.includes("糖尿病")));
assert.equal(
classifyReportDocumentGuardError("thematicNarrative[1]: blocked section contains deterministic prediction").code,
"blocked_deterministic",
);
});
@@ -572,7 +572,7 @@ test("guard redacts precise timing and downgrades the section when timing is blo
id: "career",
theme: "career",
title: "事业",
narrative: "方向稳定。2027年3月将迎来事业转折,届时务必把握机会。",
narrative: "方向稳定。2027年3月将迎来事业转折,届时务必把握机会。命主一定会升职。",
actions: ["2027年3月跳槽"],
caveats: [],
claimStatus: "single_system_inference",
@@ -587,6 +587,7 @@ test("guard redacts precise timing and downgrades the section when timing is blo
const section = (guarded.document as unknown as { thematicNarrative: { narrative: string; claimStatus: string; caveats: string[] }[] })
.thematicNarrative[0];
assert.doesNotMatch(section.narrative, /2027年3月/);
assert.doesNotMatch(section.narrative, /一定会/);
assert.equal(section.claimStatus, "blocked");
assert.ok(section.caveats.some((caveat) => caveat.includes("确定性边界")));
});