fix(report): bind v2 chart cap to CHART_IDS.length
Five-theme personal_full assembled nine charts after health vargas landed, but the document schema still capped at six. Bind the count to the enum, align JSON/Python contracts, and log final-parse paths without values. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# 印度占星 Skill 更新日志
|
# 印度占星 Skill 更新日志
|
||||||
|
|
||||||
|
## 2026-09-04 — 五主题完整报告不再因分盘张数上限装不出来
|
||||||
|
|
||||||
|
默认五章(含健康)会带上全部正式分盘。以前文档最多只许 6 张图,五章写完后整份报告仍会被判失败。现在张数上限跟着分盘枚举走。Skill 版本未变。
|
||||||
|
|
||||||
## 2026-09-04 — 开场打招呼不再被 set-focus 清掉
|
## 2026-09-04 — 开场打招呼不再被 set-focus 清掉
|
||||||
|
|
||||||
生时校正开场先播出的打招呼会留在同一条消息里。`rectification-set-focus` 只负责题干,不再发空的 `replace` 把正文抹掉。Skill 版本未变。
|
生时校正开场先播出的打招呼会留在同一条消息里。`rectification-set-focus` 只负责题干,不再发空的 `replace` 把正文抹掉。Skill 版本未变。
|
||||||
|
|||||||
@@ -109,10 +109,13 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"D1",
|
"D1",
|
||||||
"D2",
|
"D2",
|
||||||
|
"D6",
|
||||||
|
"D8",
|
||||||
"D9",
|
"D9",
|
||||||
"D10",
|
"D10",
|
||||||
"D11",
|
"D11",
|
||||||
"D24"
|
"D24",
|
||||||
|
"D30"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
@@ -736,7 +739,8 @@
|
|||||||
"$ref": "#/definitions/chart"
|
"$ref": "#/definitions/chart"
|
||||||
},
|
},
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"maxItems": 6
|
"maxItems": 9,
|
||||||
|
"description": "maxItems must equal CHART_IDS.length in frontend/src/lib/personal-report-contract.ts"
|
||||||
},
|
},
|
||||||
"thematicNarrative": {
|
"thematicNarrative": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
+7
-7
@@ -8263,16 +8263,16 @@
|
|||||||
|
|
||||||
## BUG-535 | 五章写完后终稿 final_parse_rejected,accepted 报告仍无 ready 文档
|
## BUG-535 | 五章写完后终稿 final_parse_rejected,accepted 报告仍无 ready 文档
|
||||||
|
|
||||||
- 状态:investigating
|
- 状态:mitigated
|
||||||
- 首次发现:2026-09-04
|
- 首次发现:2026-09-04
|
||||||
- 最近更新:2026-09-04
|
- 最近更新:2026-09-04
|
||||||
- 影响面:`assembleReportDocumentV2` 之后的 `safeParseServerReportDocument`;standard `personal_full` 终装
|
- 影响面:`assembleReportDocumentV2` 之后的 `safeParseServerReportDocument`;standard `personal_full` 终装
|
||||||
- 用户现象:报告中心显示生成失败。错误码 `report_schema_invalid`,日志 `innerReason=final_parse_rejected`。五章其实都已写完。
|
- 用户现象:报告中心显示生成失败。错误码 `report_schema_invalid`,日志 `innerReason=final_parse_rejected`。五章其实都已写完。
|
||||||
- 触发条件:accepted 生时、默认五主题、staging @ `e27d5dc5`。干净 attempt 1 与此前被租约打断后恢复的 attempt 都复现。
|
- 触发条件:accepted 生时、默认五主题、staging @ `e27d5dc5`。干净 attempt 1 与此前被租约打断后恢复的 attempt 都复现。
|
||||||
- 根因:未知。已排除:引擎未调用(BUG-526)、心跳死在 `section:`(BUG-534)、`applyReportGuard` 拒绝(会是 `report_guard_rejected`)、evidenceHash 不匹配(会是 `final_parse_evidence_hash`)。日志没有逐条 parse error。
|
- 根因:`c2f23131` 把 `CHART_IDS` 扩到 9 个(加 D6/D8/D30),文档 v2 仍 `charts.max(6)`。分盘提取修好后,五主题装配 9 张图,终稿 parse 拒绝。四主题一直 ≤6 所以从未踩中。
|
||||||
- 修复:未修。终稿失败码应带上 parse path,才能定案。
|
- 修复:v2 `charts.max(CHART_IDS.length)`;JSON Schema / Python 合同同步;装配允许集改为 `CHART_IDS`。`final_parse_rejected` 日志增加 `parsePaths`(仅 path + code)。
|
||||||
- 验证:request `20c94bf4`:5× workflow 200;五章 ready、narrative 非空、refs 3–7 条;摘要 writer `stop`;随后 `generation_failed`。
|
- 验证:合同测试 9 图过 parse;五主题分章夹具 READY;四主题不回归;超限仍 `too_big`。staging 真实 standard personal_full 待任务 3。
|
||||||
- 防复发:待定案后补。
|
- 防复发:上限必须绑定 `CHART_IDS.length`,不得再写裸 6/9。扩枚举必须同时改 Zod / JSON Schema enum / Python `CHART_IDS`。终稿失败必须带 parse path。
|
||||||
- 相关记录:BUG-526、BUG-534
|
- 相关记录:BUG-526、BUG-534;引入半拉子改动的提交 `c2f23131`
|
||||||
- 复发自:无
|
- 复发自:无
|
||||||
- 修复版本:待修复
|
- 修复版本:待本轮代码提交 SHA
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# PROGRESS · BUG-535 文档 charts 上限绑定 CHART_IDS(2026-09-04)
|
||||||
|
|
||||||
|
工作树:`/Users/jesse/Downloads/Copse/astrology/.worktrees/report-chart-cap-20260904`
|
||||||
|
分支:`codex/report-chart-cap-20260904`
|
||||||
|
基线:任务书写 `e674cd38`;开工 `origin/staging` HEAD 为 `d2955c62`(含本任务书 `40387ff9`)。
|
||||||
|
任务书:仓库根 `TASK-report-chart-cap-20260904.md`
|
||||||
|
未改 `.gitea/workflows/**`,不提升 main,未放宽字段级校验,未改 v1 `charts.max(3)`。
|
||||||
|
|
||||||
|
| 任务 | 状态 | BUG |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 上限绑定 + 消费点核对 + 回归 | 完成 | BUG-535 |
|
||||||
|
| 2 终稿 parse path 日志 | 完成 | BUG-535 |
|
||||||
|
| 3 部署后真实 personal_full | 待 health SHA 对齐后做 | BUG-535 |
|
||||||
|
|
||||||
|
## 实现要点
|
||||||
|
|
||||||
|
- v2 文档 `charts.max(CHART_IDS.length)`,单一真源,不写裸数字 9。
|
||||||
|
- 装配侧 `assembleReportDocumentV2` 的允许分盘集合改为 `new Set(CHART_IDS)`;`DOCUMENT_VARGA_CHART_IDS` 从 `CHART_IDS` 去掉 `D1` 派生。
|
||||||
|
- 终稿 `safeParseServerReportDocument` 失败时,`generation_failed` 增加 `parsePaths: [{path, code}]`。只记路径与 zod/guard 代号,不记 `message`、不记正文或 hash 值。
|
||||||
|
- JSON Schema `maxItems` / chart `enum`、Python `CHART_IDS` 与 `len(CHART_IDS)` 与 TS 对齐(合同三份真源)。冻结 skill 包 `6.9.14` / `6.9.15` 未动。
|
||||||
|
|
||||||
|
## 消费点核对(红线 3)
|
||||||
|
|
||||||
|
| 点 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| `personal-report-contract.ts` `z.enum(CHART_IDS)` + `charts.max` | 上限已绑定 `CHART_IDS.length`;枚举本来就是 9 个 |
|
||||||
|
| `REQUIRED_THEME_CHARTS` | 按主题要哪些分盘,不是张数上限;未改 |
|
||||||
|
| `personal-report-generation.ts` `DOCUMENT_VARGA_CHART_IDS` | 已改为从 `CHART_IDS` 去掉 D1 |
|
||||||
|
| 同文件装配 `Set([...])` | 已改为 `Set(CHART_IDS)` |
|
||||||
|
| `canonicalDocumentVargaChartId` 正则 | 引擎别名提取,不是计数上限;当前覆盖 D2/D6/D8/D9/D10/D11/D24/D30 |
|
||||||
|
| `report-evidence-bundle-v2.ts` `charts.max(24)` | bundle 上限 24,大于文档 9,无脱节 |
|
||||||
|
| `personal-report-document-view.tsx` | 按数组渲染,无硬编码 6 |
|
||||||
|
| `personal-report-route-core.ts` `themes.max(6)` | 主题数不是分盘数 |
|
||||||
|
| `report-theme-evidence-plan.ts` health `chartIds` | 证据计划,不是文档张数 |
|
||||||
|
| `contracts/personal-report/report-document.v2.schema.json` | 原 `maxItems: 6` 且 enum 缺 D6/D8/D30;已对齐,测试锁 `maxItems === CHART_IDS.length` |
|
||||||
|
| `scripts/personal_report_contract.py` | 原 enum 6 个、硬编码 `else 6`;已 `len(CHART_IDS)` 且补 D6/D8/D30 |
|
||||||
|
| `skills/.../6.9.14` 与 `6.9.15` 的合同副本 | 冻结包,仍是历史 v1 三盘;未改 |
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- 9 张全枚举文档过 `safeParseServerReportDocument`
|
||||||
|
- 超过 `CHART_IDS.length` 仍 `too_big`
|
||||||
|
- 四主题既有用例不回归
|
||||||
|
- 五主题(含 health)分章管线 READY,文档 charts 正好 9 张、blocked 为空
|
||||||
|
- `final_parse_rejected` 日志含 `thematicNarrative.0.title` / `too_big`,不含超长标题正文
|
||||||
|
- Python `test_v2_accepts_full_chart_ids_set`
|
||||||
|
|
||||||
|
## 门禁输出
|
||||||
|
|
||||||
|
- `./node_modules/.bin/tsc --noEmit`:0
|
||||||
|
- `npm run lint`:0 error / 74 warning(既有,未动)
|
||||||
|
- 定向:`personal-report-contract` + `personal-report-generation-v2` + `personal-report-api`:**110 pass / 0 fail**(含新增 9 图 parse、五主题 READY、超限 too_big、parsePaths 日志)
|
||||||
|
- `.venv/bin/python -m pytest tests/test_personal_report_contract.py`:**40 pass**(含 `test_v2_accepts_full_chart_ids_set`)
|
||||||
|
- 全量 `npm test`:Docker 库测争用超时(`database-*` 与部分会起 Postgres 的套件),与既有环境缺口同类;与本单无关
|
||||||
|
- `next build`:本单未改 UI/路由,未跑;任务 3 部署后用 health SHA 验收
|
||||||
|
|
||||||
|
## 让步
|
||||||
|
|
||||||
|
无。JSON Schema 的 `maxItems` 只能写数字,用合同测试把它锁到 `CHART_IDS.length`。
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
| `TASK-report-section-writer-failure-20260902.md` | `PROGRESS-report-writer-failure-20260902.md` | 写作阶段 report_schema_invalid | 已验收 | `eda37c15`(后续 `43294265`、`5c0bec0c`、`fbd6e480`、`cf6405ed`) |
|
| `TASK-report-section-writer-failure-20260902.md` | `PROGRESS-report-writer-failure-20260902.md` | 写作阶段 report_schema_invalid | 已验收 | `eda37c15`(后续 `43294265`、`5c0bec0c`、`fbd6e480`、`cf6405ed`) |
|
||||||
| `TASK-report-sensitivity-crash-20260904.md`(仓库根) | `PROGRESS-report-sensitivity-crash-20260904.md` | consultation_workflow float 时辰崩溃,全量 500 | 执行中 | `codex/report-sensitivity-crash-20260904`(BUG-524) |
|
| `TASK-report-sensitivity-crash-20260904.md`(仓库根) | `PROGRESS-report-sensitivity-crash-20260904.md` | consultation_workflow float 时辰崩溃,全量 500 | 执行中 | `codex/report-sensitivity-crash-20260904`(BUG-524) |
|
||||||
| `TASK-report-candidate-range-read-20260904.md`(仓库根) | `PROGRESS-report-candidate-range-20260904.md` | accepted 生时报告直读已收权校正表 | 待验收 | `e27d5dc5`(BUG-526/534;终稿见 BUG-535) |
|
| `TASK-report-candidate-range-read-20260904.md`(仓库根) | `PROGRESS-report-candidate-range-20260904.md` | accepted 生时报告直读已收权校正表 | 待验收 | `e27d5dc5`(BUG-526/534;终稿见 BUG-535) |
|
||||||
|
| `TASK-report-chart-cap-20260904.md`(仓库根) | `PROGRESS-report-chart-cap-20260904.md` | 文档 charts 上限绑定 CHART_IDS | 执行中 | `codex/report-chart-cap-20260904`(BUG-535) |
|
||||||
|
|
||||||
### 前端基础与工程
|
### 前端基础与工程
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function safeParseServerReportDocument(input: unknown): ReportDocumentPar
|
|||||||
{
|
{
|
||||||
path: "provenance.evidenceHash",
|
path: "provenance.evidenceHash",
|
||||||
message: `does not match recomputed evidence hash ${recomputed}`,
|
message: `does not match recomputed evidence hash ${recomputed}`,
|
||||||
|
code: "evidence_hash",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export const reportDocumentV2Schema = z.strictObject({
|
|||||||
natalFoundation: natalFoundationSchema,
|
natalFoundation: natalFoundationSchema,
|
||||||
currentPhase: currentPhaseSchema.nullable(),
|
currentPhase: currentPhaseSchema.nullable(),
|
||||||
actionNotes: z.array(actionNoteSchema).min(1).max(24),
|
actionNotes: z.array(actionNoteSchema).min(1).max(24),
|
||||||
charts: z.array(reportDocumentV2ChartSchema).min(1).max(6),
|
charts: z.array(reportDocumentV2ChartSchema).min(1).max(CHART_IDS.length),
|
||||||
thematicNarrative: z.array(thematicSectionV2Schema).max(12),
|
thematicNarrative: z.array(thematicSectionV2Schema).max(12),
|
||||||
blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12),
|
blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12),
|
||||||
evidenceAppendix: evidenceAppendixSchema,
|
evidenceAppendix: evidenceAppendixSchema,
|
||||||
@@ -310,6 +310,7 @@ export type BlockedConflictDisclosureV2 = ReportDocumentV2["blockedConflictDiscl
|
|||||||
export type ReportDocumentParseError = Readonly<{
|
export type ReportDocumentParseError = Readonly<{
|
||||||
path: string;
|
path: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
code: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export class ReportDocumentValidationError extends Error {
|
export class ReportDocumentValidationError extends Error {
|
||||||
@@ -592,6 +593,7 @@ export function safeParseReportDocument(input: unknown): ReportDocumentParseResu
|
|||||||
errors: parsed.error.issues.map((issue) => ({
|
errors: parsed.error.issues.map((issue) => ({
|
||||||
path: issue.path.join(".") || "(root)",
|
path: issue.path.join(".") || "(root)",
|
||||||
message: issue.message,
|
message: issue.message,
|
||||||
|
code: issue.code,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -600,7 +602,7 @@ export function safeParseReportDocument(input: unknown): ReportDocumentParseResu
|
|||||||
if (guardErrors.length > 0) {
|
if (guardErrors.length > 0) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
errors: guardErrors.map((message) => ({ path: "(guard)", message })),
|
errors: guardErrors.map((message) => ({ path: "(guard)", message, code: "guard" })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true, document };
|
return { ok: true, document };
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
safeParseServerReportDocument,
|
safeParseServerReportDocument,
|
||||||
} from "./personal-report-contract.server-core.ts";
|
} from "./personal-report-contract.server-core.ts";
|
||||||
import {
|
import {
|
||||||
|
CHART_IDS,
|
||||||
REQUIRED_THEME_CHARTS,
|
REQUIRED_THEME_CHARTS,
|
||||||
type ClaimStatus,
|
type ClaimStatus,
|
||||||
type EvidenceAppendix,
|
type EvidenceAppendix,
|
||||||
type ReportDepth,
|
type ReportDepth,
|
||||||
|
type ReportDocumentParseError,
|
||||||
type ReportDocumentV1,
|
type ReportDocumentV1,
|
||||||
type ReportDocumentV2,
|
type ReportDocumentV2,
|
||||||
} from "./personal-report-contract.ts";
|
} from "./personal-report-contract.ts";
|
||||||
@@ -318,8 +320,10 @@ function readHouses(
|
|||||||
return houses;
|
return houses;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOCUMENT_VARGA_CHART_IDS = ["D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"] as const;
|
type DocumentVargaChartId = Exclude<(typeof CHART_IDS)[number], "D1">;
|
||||||
type DocumentVargaChartId = (typeof DOCUMENT_VARGA_CHART_IDS)[number];
|
const DOCUMENT_VARGA_CHART_IDS = CHART_IDS.filter(
|
||||||
|
(id): id is DocumentVargaChartId => id !== "D1",
|
||||||
|
);
|
||||||
|
|
||||||
const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]>> = {
|
const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]>> = {
|
||||||
D2: ["D2_Hora", "D2"],
|
D2: ["D2_Hora", "D2"],
|
||||||
@@ -2691,7 +2695,7 @@ export function assembleReportDocumentV2(
|
|||||||
const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs));
|
const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs));
|
||||||
const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs;
|
const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs;
|
||||||
|
|
||||||
const chartIds = new Set(["D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"]);
|
const chartIds = new Set<string>(CHART_IDS);
|
||||||
const charts: ReportDocumentV2["charts"] = [];
|
const charts: ReportDocumentV2["charts"] = [];
|
||||||
for (const chart of bundle.charts) {
|
for (const chart of bundle.charts) {
|
||||||
if (!chartIds.has(chart.id)) continue;
|
if (!chartIds.has(chart.id)) continue;
|
||||||
@@ -3220,15 +3224,39 @@ export function isPersonalReportGenerationAbort(error: unknown, signal?: AbortSi
|
|||||||
return error instanceof Error && error.name === "AbortError";
|
return error instanceof Error && error.name === "AbortError";
|
||||||
}
|
}
|
||||||
|
|
||||||
function failSchema(innerReason: ReportSchemaInnerReason): GeneratePersonalReportResult {
|
function failSchema(
|
||||||
console.info("[personal-report]", JSON.stringify({
|
innerReason: ReportSchemaInnerReason,
|
||||||
|
parseErrors?: readonly ReportDocumentParseError[],
|
||||||
|
): GeneratePersonalReportResult {
|
||||||
|
const payload: {
|
||||||
|
event: "generation_failed";
|
||||||
|
failureCode: "report_schema_invalid";
|
||||||
|
innerReason: ReportSchemaInnerReason;
|
||||||
|
parsePaths?: readonly Readonly<{ path: string; code: string }>[];
|
||||||
|
} = {
|
||||||
event: "generation_failed",
|
event: "generation_failed",
|
||||||
failureCode: "report_schema_invalid",
|
failureCode: "report_schema_invalid",
|
||||||
innerReason,
|
innerReason,
|
||||||
}));
|
};
|
||||||
|
if (parseErrors && parseErrors.length > 0) {
|
||||||
|
payload.parsePaths = parseErrors.map((error) => ({
|
||||||
|
path: error.path,
|
||||||
|
code: error.code,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
console.info("[personal-report]", JSON.stringify(payload));
|
||||||
return { status: "failed", failureCode: "report_schema_invalid", innerReason };
|
return { status: "failed", failureCode: "report_schema_invalid", innerReason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function failFinalParse(
|
||||||
|
parsed: ReturnType<typeof safeParseServerReportDocument>,
|
||||||
|
): GeneratePersonalReportResult {
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return failSchema(classifyFinalParseInnerReason(parsed.errors), parsed.errors);
|
||||||
|
}
|
||||||
|
return failSchema("final_parse_rejected");
|
||||||
|
}
|
||||||
|
|
||||||
function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
|
function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
|
||||||
if (isPersonalReportGenerationAbort(error, signal)) throw error;
|
if (isPersonalReportGenerationAbort(error, signal)) throw error;
|
||||||
}
|
}
|
||||||
@@ -3473,7 +3501,7 @@ async function generateSectionedPersonalReport(
|
|||||||
const guarded = applyReportGuard(candidate, buildLegacyPacketFromBundle(bundle));
|
const guarded = applyReportGuard(candidate, buildLegacyPacketFromBundle(bundle));
|
||||||
if (!guarded.ok) return { status: "failed", failureCode: "report_guard_rejected" };
|
if (!guarded.ok) return { status: "failed", failureCode: "report_guard_rejected" };
|
||||||
const parsed = safeParseServerReportDocument(guarded.document);
|
const parsed = safeParseServerReportDocument(guarded.document);
|
||||||
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors));
|
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failFinalParse(parsed);
|
||||||
return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), usage: deps.agent.getUsage?.() };
|
return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), usage: deps.agent.getUsage?.() };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rethrowIfAborted(error, deps.signal);
|
rethrowIfAborted(error, deps.signal);
|
||||||
@@ -3575,7 +3603,7 @@ export async function generatePersonalReport(
|
|||||||
}
|
}
|
||||||
const parsed = safeParseServerReportDocument(guarded.document);
|
const parsed = safeParseServerReportDocument(guarded.document);
|
||||||
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") {
|
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") {
|
||||||
return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors));
|
return failFinalParse(parsed);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status: "ready",
|
status: "ready",
|
||||||
|
|||||||
@@ -1294,6 +1294,7 @@ test("list timestamps accept Date objects and ISO strings from self-hosted postg
|
|||||||
assert.match(coreSource, /export function reportListTimestamp/);
|
assert.match(coreSource, /export function reportListTimestamp/);
|
||||||
assert.match(generationSource, /innerReason/);
|
assert.match(generationSource, /innerReason/);
|
||||||
assert.match(generationSource, /isPersonalReportGenerationAbort/);
|
assert.match(generationSource, /isPersonalReportGenerationAbort/);
|
||||||
|
assert.match(generationSource, /parsePaths/);
|
||||||
assert.doesNotMatch(generationSource, /failSchema\([^)]*error\.message/);
|
assert.doesNotMatch(generationSource, /failSchema\([^)]*error\.message/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
parseServerReportDocument,
|
parseServerReportDocument,
|
||||||
} from "../src/lib/personal-report-contract.server-core.ts";
|
} from "../src/lib/personal-report-contract.server-core.ts";
|
||||||
import {
|
import {
|
||||||
|
CHART_IDS,
|
||||||
findBlockedDeterministicClaims,
|
findBlockedDeterministicClaims,
|
||||||
findChartSetViolations,
|
findChartSetViolations,
|
||||||
findDanglingEvidenceRefs,
|
findDanglingEvidenceRefs,
|
||||||
@@ -336,6 +337,60 @@ test("v2 guard rejects unsupported dates, medical diagnoses, and deterministic f
|
|||||||
assert.equal(safeParseReportDocument(financial).ok, false);
|
assert.equal(safeParseReportDocument(financial).ok, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("v2 chart cap and enum stay bound to CHART_IDS", () => {
|
||||||
|
const contractSource = readFileSync(
|
||||||
|
new URL("../src/lib/personal-report-contract.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
contractSource,
|
||||||
|
/charts: z\.array\(reportDocumentV2ChartSchema\)\.min\(1\)\.max\(CHART_IDS\.length\)/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(contractSource, /charts: z\.array\(reportDocumentV1ChartSchema\)\.min\(1\)\.max\(CHART_IDS/);
|
||||||
|
|
||||||
|
const schema = JSON.parse(readFileSync(
|
||||||
|
new URL("../../contracts/personal-report/report-document.v2.schema.json", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
)) as {
|
||||||
|
properties: { charts: { maxItems: number } };
|
||||||
|
definitions: { chart: { properties: { id: { enum: string[] } } } };
|
||||||
|
};
|
||||||
|
assert.equal(schema.properties.charts.maxItems, CHART_IDS.length);
|
||||||
|
assert.deepEqual(schema.definitions.chart.properties.id.enum, [...CHART_IDS]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a document with every CHART_IDS entry passes the server parse", () => {
|
||||||
|
const document = cloneV2();
|
||||||
|
const template = document.charts[0];
|
||||||
|
const present = new Set(document.charts.map((chart) => chart.id));
|
||||||
|
for (const id of CHART_IDS) {
|
||||||
|
if (present.has(id)) continue;
|
||||||
|
document.charts.push({
|
||||||
|
...structuredClone(template),
|
||||||
|
id,
|
||||||
|
title: `${id} 分盘`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(document.charts.length, CHART_IDS.length);
|
||||||
|
const parsed = safeParseServerReportDocument(document);
|
||||||
|
assert.equal(parsed.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one chart beyond CHART_IDS.length is still rejected", () => {
|
||||||
|
const document = cloneV2();
|
||||||
|
const template = document.charts[0];
|
||||||
|
while (document.charts.length < CHART_IDS.length + 1) {
|
||||||
|
document.charts.push({
|
||||||
|
...structuredClone(template),
|
||||||
|
id: template.id,
|
||||||
|
title: `${template.id} extra ${document.charts.length}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = safeParseReportDocument(document);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.ok(result.errors.some((error) => error.path === "charts" && error.code === "too_big"));
|
||||||
|
});
|
||||||
|
|
||||||
test("v2 guard rejects HTML and CSS while allowing ordinary Chinese prose", () => {
|
test("v2 guard rejects HTML and CSS while allowing ordinary Chinese prose", () => {
|
||||||
for (const poison of ["<div>报告</div>", "body { color: red; }", 'style="color:red"']) {
|
for (const poison of ["<div>报告</div>", "body { color: red; }", 'style="color:red"']) {
|
||||||
const document = cloneV2();
|
const document = cloneV2();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type ReportEvidenceBundleV2,
|
type ReportEvidenceBundleV2,
|
||||||
type TechniqueExecutionReceipt,
|
type TechniqueExecutionReceipt,
|
||||||
} from "../src/lib/report-evidence-bundle-v2.ts";
|
} from "../src/lib/report-evidence-bundle-v2.ts";
|
||||||
|
import { CHART_IDS } from "../src/lib/personal-report-contract.ts";
|
||||||
import {
|
import {
|
||||||
classifyFinalParseInnerReason,
|
classifyFinalParseInnerReason,
|
||||||
classifyReportSchemaInnerReason,
|
classifyReportSchemaInnerReason,
|
||||||
@@ -320,6 +321,14 @@ const fullThemes: readonly ThemeSpec[] = [
|
|||||||
{ theme: "education", section: "学习与成长", refs: ["ev-tech-d1", "ev-tech-d24"] },
|
{ theme: "education", section: "学习与成长", refs: ["ev-tech-d1", "ev-tech-d24"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const standardFiveThemes: readonly ThemeSpec[] = [
|
||||||
|
{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"] },
|
||||||
|
{ theme: "marriage", section: "关系与婚恋", refs: ["ev-tech-d1", "ev-tech-d9"] },
|
||||||
|
{ theme: "wealth", section: "财富结构", refs: ["ev-tech-d2", "ev-tech-d11"] },
|
||||||
|
{ theme: "timing", section: "当前阶段", refs: ["ev-tech-vimshottari", "ev-tech-narayana"] },
|
||||||
|
{ theme: "health", section: "压力与恢复", refs: ["ev-tech-d6", "ev-tech-d8", "ev-tech-d24", "ev-tech-d30"] },
|
||||||
|
];
|
||||||
|
|
||||||
test("planner -> writer -> ReportDocument v2 preserves four-theme coverage, real charts, depth and report-skill provenance", async () => {
|
test("planner -> writer -> ReportDocument v2 preserves four-theme coverage, real charts, depth and report-skill provenance", async () => {
|
||||||
const bundle = makeBundle({
|
const bundle = makeBundle({
|
||||||
themes: fullThemes,
|
themes: fullThemes,
|
||||||
@@ -358,6 +367,31 @@ test("planner -> writer -> ReportDocument v2 preserves four-theme coverage, real
|
|||||||
assert.equal(document.schemaVersion, "report_document.v2");
|
assert.equal(document.schemaVersion, "report_document.v2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("five-theme sectioned pipeline with the full CHART_IDS set finishes READY", async () => {
|
||||||
|
const bundle = makeBundle({
|
||||||
|
themes: standardFiveThemes,
|
||||||
|
charts: CHART_IDS.map((id, offset) => chart(id, offset)),
|
||||||
|
});
|
||||||
|
const sectionCalls: string[] = [];
|
||||||
|
const result = await runSectioned(
|
||||||
|
bundle,
|
||||||
|
sectionedAgent({ sectionCalls }),
|
||||||
|
inMemorySectionService(),
|
||||||
|
);
|
||||||
|
const document = readyV2(result);
|
||||||
|
assert.deepEqual(
|
||||||
|
document.thematicNarrative.map((section) => section.theme),
|
||||||
|
["career", "health", "marriage", "timing", "wealth"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(document.blockedConflictDisclosure, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...document.charts.map((entry) => entry.id)].sort(),
|
||||||
|
[...CHART_IDS].sort(),
|
||||||
|
);
|
||||||
|
assert.equal(document.charts.length, CHART_IDS.length);
|
||||||
|
assert.deepEqual(sectionCalls, ["career", "health", "marriage", "timing", "wealth"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("generatePersonalReport passes the worker lease signal to the writer and settles when aborted", async () => {
|
test("generatePersonalReport passes the worker lease signal to the writer and settles when aborted", async () => {
|
||||||
const bundle = makeBundle({
|
const bundle = makeBundle({
|
||||||
themes: [{
|
themes: [{
|
||||||
@@ -728,6 +762,50 @@ test("deterministic guard rejection and final schema rejection remain distinct t
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("final parse rejection logs issue paths without document body", async () => {
|
||||||
|
const bundle = makeBundle({
|
||||||
|
themes: [{ theme: "career", section: "事业与方向", refs: ["ev-tech-d1", "ev-tech-d10"] }],
|
||||||
|
charts: [chart("D1"), chart("D10", 3)],
|
||||||
|
});
|
||||||
|
const poison = "过".repeat(161);
|
||||||
|
const logs: unknown[][] = [];
|
||||||
|
const originalInfo = console.info;
|
||||||
|
console.info = (...args: unknown[]) => logs.push(args);
|
||||||
|
let result: GeneratePersonalReportResult;
|
||||||
|
try {
|
||||||
|
result = await run(bundle, fakeWriter((inputBundle, plan) => {
|
||||||
|
const output = writerOutputFor(inputBundle, plan);
|
||||||
|
return {
|
||||||
|
...output,
|
||||||
|
thematicNarrative: [{
|
||||||
|
...output.thematicNarrative[0],
|
||||||
|
title: poison,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
console.info = originalInfo;
|
||||||
|
}
|
||||||
|
expectSchemaRejected(result, "final_parse_rejected");
|
||||||
|
const payload = logs
|
||||||
|
.filter((args) => args[0] === "[personal-report]")
|
||||||
|
.map((args) => JSON.parse(String(args[1])) as {
|
||||||
|
event?: string;
|
||||||
|
innerReason?: string;
|
||||||
|
parsePaths?: readonly Readonly<{ path: string; code: string }>[];
|
||||||
|
})
|
||||||
|
.find((entry) => entry.event === "generation_failed");
|
||||||
|
assert.ok(payload);
|
||||||
|
assert.equal(payload.innerReason, "final_parse_rejected");
|
||||||
|
assert.ok(payload.parsePaths?.some((entry) => (
|
||||||
|
entry.path === "thematicNarrative.0.title" && entry.code === "too_big"
|
||||||
|
)));
|
||||||
|
const serialized = JSON.stringify(payload);
|
||||||
|
assert.equal("message" in (payload.parsePaths?.[0] ?? {}), false);
|
||||||
|
assert.doesNotMatch(serialized, new RegExp(poison));
|
||||||
|
assert.doesNotMatch(serialized, /正式证据支持多个主题/);
|
||||||
|
});
|
||||||
|
|
||||||
function sectionPayloadFor(
|
function sectionPayloadFor(
|
||||||
bundle: ReportEvidenceBundleV2,
|
bundle: ReportEvidenceBundleV2,
|
||||||
section: Readonly<{ id: string; theme: string | null; evidenceRefs: readonly string[] }>,
|
section: Readonly<{ id: string; theme: string | null; evidenceRefs: readonly string[] }>,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ TECHNIQUE_STATUSES = ("verified", "partial", "blocked")
|
|||||||
CONFLICT_STATUSES = ("unresolved", "partial", "resolved")
|
CONFLICT_STATUSES = ("unresolved", "partial", "resolved")
|
||||||
REPORT_ACTION_PRIORITIES = ("now", "next", "watch")
|
REPORT_ACTION_PRIORITIES = ("now", "next", "watch")
|
||||||
V1_CHART_IDS = ("D1", "D9", "D10")
|
V1_CHART_IDS = ("D1", "D9", "D10")
|
||||||
CHART_IDS = ("D1", "D2", "D9", "D10", "D11", "D24")
|
CHART_IDS = ("D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30")
|
||||||
HOUSE_NUMBERS = tuple(range(1, 13))
|
HOUSE_NUMBERS = tuple(range(1, 13))
|
||||||
|
|
||||||
FAILURE_CODES = (
|
FAILURE_CODES = (
|
||||||
@@ -797,7 +797,13 @@ def validate_report_document(document: Any) -> ValidationResult:
|
|||||||
_validate_current_phase(result, document.get("currentPhase"))
|
_validate_current_phase(result, document.get("currentPhase"))
|
||||||
_validate_action_notes(result, document.get("actionNotes"))
|
_validate_action_notes(result, document.get("actionNotes"))
|
||||||
|
|
||||||
charts = _check_array(result, "charts", document.get("charts"), 3 if version == V1_SCHEMA_VERSION else 6, 1)
|
charts = _check_array(
|
||||||
|
result,
|
||||||
|
"charts",
|
||||||
|
document.get("charts"),
|
||||||
|
3 if version == V1_SCHEMA_VERSION else len(CHART_IDS),
|
||||||
|
1,
|
||||||
|
)
|
||||||
if charts is not None:
|
if charts is not None:
|
||||||
for index, chart in enumerate(charts):
|
for index, chart in enumerate(charts):
|
||||||
_validate_chart(result, f"charts[{index}]", chart, version)
|
_validate_chart(result, f"charts[{index}]", chart, version)
|
||||||
|
|||||||
@@ -316,3 +316,22 @@ def test_validator_accepts_synthetic_producer_output() -> None:
|
|||||||
}
|
}
|
||||||
document["provenance"]["evidenceHash"] = compute_evidence_hash(document)
|
document["provenance"]["evidenceHash"] = compute_evidence_hash(document)
|
||||||
assert validate_report_document(document).valid
|
assert validate_report_document(document).valid
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_accepts_full_chart_ids_set() -> None:
|
||||||
|
from scripts.personal_report_contract import CHART_IDS
|
||||||
|
|
||||||
|
document = load_report_document(str(ROOT / "tests" / "fixtures" / "personal_report_document.v2.json"))
|
||||||
|
template = document["charts"][0]
|
||||||
|
present = {chart["id"] for chart in document["charts"]}
|
||||||
|
for chart_id in CHART_IDS:
|
||||||
|
if chart_id in present:
|
||||||
|
continue
|
||||||
|
extra = json.loads(json.dumps(template))
|
||||||
|
extra["id"] = chart_id
|
||||||
|
extra["title"] = f"{chart_id} 分盘"
|
||||||
|
document["charts"].append(extra)
|
||||||
|
assert len(document["charts"]) == len(CHART_IDS)
|
||||||
|
document["provenance"]["evidenceHash"] = compute_evidence_hash(document)
|
||||||
|
result = validate_report_document(document)
|
||||||
|
assert result.valid, result.errors
|
||||||
|
|||||||
Reference in New Issue
Block a user