fix(report): classify writer failures and size CJK section budgets
Independent Staging Quality Gate / validate (push) Successful in 9m41s
Independent Staging Quality Gate / publish (push) Successful in 1m49s

Staging personal_full died as report_schema_invalid after the first
chapter blocked. Keep assertWriterOutput, stop treating refs mismatch as
missing evidence, and surface section error codes on the report APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-03 01:17:46 +08:00
co-authored by Cursor
parent 838d9e7687
commit eda37c1523
16 changed files with 691 additions and 66 deletions
+122
View File
@@ -0,0 +1,122 @@
# PROGRESS · 个人报告写作阶段 report_schema_invalid(2026-09-02)
任务书:`TASK-report-section-writer-failure-20260902.md`(`838d9e76`)
分支:`codex/report-writer-failure-20260902`
基线:`origin/staging` @ `838d9e76`
开工前置:已读 `docs/research/pre_work_error_ledger.md`、`frontend/AGENTS.md`;`docs/BUG_HISTORY.md` 检索 BUG-352 / 451 / 486–489 / 495。按任务书从 `origin/staging` 建独立 worktree,不碰原仓脏树。
隐私:下文只有错误码、token 计数、`finishReason`、时间戳与 request UUID(任务书已公开)。无 prompt、bundle、模型原文、姓名、出生资料或账号标识。
---
## 任务 0 · Staging 实证(门控)
取证主机:staging `118.26.111.127`,路径 `/opt/jyotisha-staging`。Postgres 容器 `jyotisha-staging-postgres-1`。
### 日志窗口丢失
取证时公开 staging health 仍指向 `35688015`(`838d9e76` 的父提交)。Web 容器在 `2026-09-02T14:00:21Z`(22:00 +08)被 recreate,与任务书提交 `838d9e76`(`14:00:43Z`)几乎同时。事故窗口(13:19–13:21 UTC)的 `docker logs` 已不在。`[personal-report-agent]` 与 `[personal-report] generation_failed` 无法回收。`usage_ledger` 对该 requestId 为 0 行。
因此 **不能** 用本事故的 `finishReason` / `outputTokens` 分布来确认「主嫌 · 截断」。截断判定只能写 `blocked`。
### 事故行(requestId `77b79067-bd70-465e-ac23-adc4500d4738`)
| 面 | 结果 |
|---|---|
| `personal_reports` | `failed` / `report_schema_invalid` / created 13:19:23 UTC / updated 13:21:38 UTC |
| `personal_report_jobs` | `failed` / `last_error_code=report_schema_invalid` / phase `failed` / **progress 43%** / attempt **1 of 3** / finished 13:21:38 UTC |
| `theme-career` | **blocked**,attempts **2/2**,`last_error_code=section_evidence_insufficient`,updated 13:21:38.398 UTC |
| `theme-marriage` | pending,0 attempts |
| `theme-timing` | pending,0 attempts |
| `theme-wealth` | pending,0 attempts |
43% = `30 + floor((1/4)*55)`:career 被 block 后第一次 `onProgress`。Job 在 career block 后约 39ms 结束,其余三章从未 `start`。
历史 `report_schema_invalid`(08-30 起,与任务书 5 个小时一致):跨这些小时的 section 行里,本事故是唯一一条 blocked + `section_evidence_insufficient`。其余为 pending 或 ready。
### 定案
1. **主嫌(CJK `/2` 截断)未证实。** 落库码不是 `section_output_truncated`。截断在 writer 路径通常会变成 `PersonalReportAgentOutputError("report_schema_invalid")` → `section_output_invalid`。本事故没有 telemetry,不得写成 `finishReason=length`。
2. **次嫌(evidenceRefs 回显)是 career 章的最佳解释。** 旧 `classifySectionErrorCode` 把任何含 `"evidence"` 的消息打成 `section_evidence_insufficient`。`report_writer_evidence_refs_mismatch` 含子串 `evidence`,会被误分类。career 2/2 次、约 29s,符合 assert 失败 + 一次 repair,而不是四章长输出截断。
3. **第三层:首章 blocked 后没有继续写其余章。** 产品测试已要求其他章仍应交付。Job 以不可重试的 `report_schema_invalid` 失败(attempt 1/3),说明 worker 收到了 `{status:"failed"}` 或 `invalid_request`/`storage_invalid`,而不是 retryable 抛错。无日志无法闭合「是 filter / onProgress / 其它未捕获抛错」。本轮仍把每章失败隔离开,避免一章 throw 中止整份。
`all_sections_blocked` 原先裸 `return`、不走 `failSchema`,因此即使日志还在,也可能看不到 `generation_failed`。本轮改为走 `failSchema`。
任务 1 仍按任务书授权落地中文口径公式,校准用的是 **08-30 任务 0** 的整份调用观测(`outputTokens=3069`,`finishReason=stop`),**不是** 本事故。research / deep 的 3072 cap 保持不变;若以后实测需要更高,另开任务。
---
## 任务 1 · CJK 输出预算
禁止 `targetCharacters.max / 2`。
口径:`1` 中文字符 ≈ `1.3` token(`CJK_OUTPUT_TOKENS_PER_CHAR`)。
章节结构余量 `568`(narrative 之外的 title / actions / caveats / refs / JSON 外壳):
| 部分 | 估算 |
|---|---|
| title ≈ 80 字 | `ceil(80 × 1.3) = 104` |
| 3 条 actions ≈ 120 字 | `156` |
| caveats ≈ 80 字 | `104` |
| evidenceRefs JSON | `80` |
| JSON 键与括号 | `124` |
| 合计 | `568` |
摘要结构余量 `580` **已经包含** headline / priorities / JSON,不再把 headline 算第二次:
`ceil(2000 × 1.3) + 580 = 2600 + 580 = 3180`
章节:`min(3072, max(1024, ceil(maxChars × 1.3) + 568))`
| 深度 | max 字 | 未封顶 | 落地预算 |
|---|---|---|---|
| concise | 500 | 1218 | **1218** |
| standard | 1200 | 2128 | **2128** |
| deep | 2000 | 3168 | **3072**(cap,缺口 96 token) |
| research | 2800 | 4208 | **3072**(cap;约合 narrative 2504 字,低于 2800 上限) |
| summary | 2000 字段 | 3180 | **3180**(cap 4096) |
08-30 整份 writer 一次调用 `outputTokens=3069` 且 `stop`,说明 3072 能装下一份大 JSON。本事故未证实截断,因此不把 cap 自行抬高。length 修复重试:同一 `outputBudget`(不得低于首次),提示词要求压到 `targetCharacters.min` 并闭合 JSON。
---
## 任务 2 · evidenceRefs 回显
- `sectionPrompt` 增加:`evidenceRefs 必须逐字复制 plan.evidenceRefs 数组,不得增删`。
- `assertWriterOutput` 仍是 id/theme 全等 + refs **集合相等**,语义未放宽。
- Repair 只拼类别词:`identity mismatch` / `refs mismatch` / `schema invalid` / `output truncated`。refs 类再次重申逐字复制。不把 ref id、正文或 bundle 拼进 repair。
- `classifySectionErrorCode` 先匹配 identity / refs,再匹配泛化 `"evidence"`,避免再把 refs mismatch 写成 `section_evidence_insufficient`。
---
## 任务 3 · 失败可观测
不改表。应用层聚合已有 `personal_report_sections` 行。
- 详情 API:`failureSummary`、`innerReason`(`all_sections_blocked` 或 `section_generation_incomplete`)、`sectionErrorCodes`。
- 列表 API:失败行多查 `request_id, status, last_error_code`(无正文),写入 `failureSummary`。
- 报告中心:`failureSummary ?? failureCode`。详情页摘要为主文案,等宽错误码为辅。
本事故若用新摘要,应接近:「4 个主题中 1 个写作失败:证据引用未对齐,3 个未开始」。旧码仍会显示「证据不足」,因为历史行的 `last_error_code` 不会回写。
---
## 任务 4 · 真实验证
**未做。** 等任务 1–3 进入 staging 且 `GET https://staging.jyotisha.chat/api/health` 的 `.deployment.gitCommit` 对齐后再跑:standard `personal_full` 四章正文、telemetry 无 `length`、≥3 处 writer→bundle 回溯、每章 token/墙钟对照 `PROGRESS-report-skill-parity-20260901.md` 的 2 倍线。不提升 main。
---
## 质量门
| 门 | 结果 |
|---|---|
| `frontend/node_modules/.bin/tsc --noEmit` | 通过(exit 0,无输出) |
| `eslint`(改动的 TS/TSX 文件) | exit 0,无 error 输出 |
| 聚焦 `tsx --test`:plan / writer-failure / generation-v2 / api / view / agent-telemetry | 104 pass / 0 fail |
| 复跑 api + writer-failure | 53 pass / 0 fail |
| `personal-report-generation.test.ts` + `personal-report-worker.test.ts` | 51 pass / 0 fail |
未跑全量 `tests/*.test.ts`,不声称全套绿。未改 `.gitea/workflows/**`,未提升 main。
+16
View File
@@ -7633,3 +7633,19 @@
- 复发自:无
- 修复版本:待发布
## BUG-496 | 个人报告写作失败被压成 report_schema_invalid,首章失败后其余主题未开始
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`generateSectionedPersonalReport`、`createPersonalReportAgent`、`classifySectionErrorCode`、`GET /api/reports`、`GET /api/reports/[reportId]`、报告中心失败卡片
- 用户现象:blocked-repairs 部署后,真实 `personal_full` 仍显示生成失败,可见码只有 `report_schema_invalid`。四主题里只有事业章留下失败记录,婚恋/财富/应期未开始。
- 触发条件:章节 writer 输出未通过 `assertWriterOutput`(常见为 `evidenceRefs` 与 plan 集合不等),或输出在 token 预算内被截断;随后整份报告失败。
- 根因:三层叠加。旧错误分类把任何含 `evidence` 的消息打成 `section_evidence_insufficient`,`report_writer_evidence_refs_mismatch` 被误标;章节输出预算用英文「字符 ÷ 2」,standard 上限 1200 字中文只分到 1024 token;一章 blocked 或未捕获抛错可以中止整份,job 停在约 43%。失败详情只有聚合码,页面无法区分截断、引用不齐或未开始。staging 事故窗口的 writer telemetry 在容器 recreate 后丢失,本事故不能写成 `finishReason=length`。
- 修复:中文口径重算每章/摘要 `maxOutputTokens`,去掉 ÷2;length 修复要求压到字数下限且预算不低于首次。Prompt 要求逐字复制 `plan.evidenceRefs`;repair 只加失败类别。refs/identity 先于泛化 `evidence` 分类。单章失败后继续其余主题;`all_sections_blocked` 走统一失败日志。详情与列表聚合已有 section 行,展示可读摘要与错误码,不改表、不放宽 writer schema。
- 验证:预算表锁定 concise 1218 / standard 2128 / deep 3072 / research 3072 / summary 3180。refs mismatch 分类为 `section_refs_mismatch` 且其余主题仍交付。repair 提示含类别词、不含内容。失败详情从 section 行汇总。`tsc --noEmit`、改动文件 ESLint、个人报告聚焦测试。
- 防复发:输出预算不得再用字符 ÷ 2。`assertWriterOutput` 保持 id/theme 全等与 refs 集合相等。section 错误码不得把 refs mismatch 归进 evidence insufficient。一章失败不得中止其余 write 主题。用户可见失败必须有错误码级摘要,不得只展示 `report_schema_invalid`。日志与 PROGRESS 不得写入 prompt、bundle、模型原文或用户资料。
- 相关记录:BUG-352、BUG-451、BUG-486、BUG-489
- 复发自:BUG-451(分章后仍把写作失败压成整份 schema 码,截断预算与失败分类未按中文口径收口)
- 修复版本:待发布
@@ -11,6 +11,7 @@ import {
type PersonalReportService,
} from "@/lib/personal-report-service";
import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service";
import { createPersonalReportSectionService } from "@/lib/personal-report-section-service";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
@@ -38,15 +39,24 @@ async function resolvePersistenceForUser() {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return { userId: null as string | null, persistence: null as PersonalReportService | null, jobs: null };
return { userId: null as string | null, persistence: null as PersonalReportService | null, jobs: null, listSections: undefined };
}
const persistence = createSupabasePersonalReportService(supabase);
return { userId: user.id, persistence, jobs: createSupabasePersonalReportJobService(supabase) };
const sections = createPersonalReportSectionService(supabase as never);
return {
userId: user.id,
persistence,
jobs: createSupabasePersonalReportJobService(supabase),
listSections: async (ownerId: string, requestId: string) => {
const rows = await sections.list(ownerId, requestId);
return rows.map((row) => ({ status: row.status, lastErrorCode: row.lastErrorCode }));
},
};
}
export async function GET(request: Request, context: RouteContext) {
try {
const { userId, persistence, jobs } = await resolvePersistenceForUser();
const { userId, persistence, jobs, listSections } = await resolvePersistenceForUser();
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json(
@@ -71,6 +81,7 @@ export async function GET(request: Request, context: RouteContext) {
// before it is returned to the browser. Client-side validation is never
// a substitute.
jobs: jobs ?? undefined,
listSections,
validateReadyDocument: (document) => {
const parsed = safeParseServerReportDocument(document);
return parsed.ok
+39 -4
View File
@@ -6,6 +6,7 @@ import {
resolveSkillSnapshot,
} from "@/lib/personal-report-generation";
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
import { summarizePersonalReportFailure } from "@/lib/personal-report-failure-summary";
import { isProductEnabled } from "@/lib/product-access";
import {
isPersonalReportFeatureEnabled,
@@ -54,7 +55,10 @@ function toNextResponse(response: { status: number; body: Record<string, unknown
return NextResponse.json(response.body, { status: response.status });
}
function listReportView(value: unknown) {
function listReportView(
value: unknown,
failure?: ReturnType<typeof summarizePersonalReportFailure> | null,
) {
const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
return {
id: typeof row.id === "string" ? row.id : "",
@@ -71,6 +75,7 @@ function listReportView(value: unknown) {
completedAt: row.completed_at == null || row.completed_at === ""
? null
: reportListTimestamp(row.completed_at) || null,
...(failure?.summary ? { failureSummary: failure.summary } : {}),
};
}
@@ -89,9 +94,39 @@ export async function GET() {
.order("created_at", { ascending: false })
.limit(20);
if (error) throw error;
return NextResponse.json({
reports: Array.isArray(data) ? data.map(listReportView).filter((report) => report.id) : [],
});
const reports = Array.isArray(data) ? data.map((row) => listReportView(row)).filter((report) => report.id) : [];
const failed = reports.filter((report) => report.status === "failed" && report.requestId);
if (failed.length > 0) {
const { data: sections, error: sectionError } = await supabase
.from("personal_report_sections")
.select("request_id, status, last_error_code")
.eq("user_id", user.id)
.in("request_id", failed.map((report) => report.requestId));
if (sectionError) throw sectionError;
const grouped = new Map<string, Array<{ status: string; lastErrorCode: string | null }>>();
for (const row of Array.isArray(sections) ? sections : []) {
const requestId = typeof row.request_id === "string" ? row.request_id : "";
if (!requestId) continue;
const list = grouped.get(requestId) ?? [];
list.push({
status: typeof row.status === "string" ? row.status : "pending",
lastErrorCode: typeof row.last_error_code === "string" ? row.last_error_code : null,
});
grouped.set(requestId, list);
}
for (const [index, report] of reports.entries()) {
if (report.status !== "failed") continue;
const source = Array.isArray(data) ? data.find((row) => (
row && typeof row === "object" && (row as { id?: unknown }).id === report.id
)) : null;
reports[index] = listReportView(source, summarizePersonalReportFailure({
themeCount: report.themes.length,
sections: grouped.get(report.requestId) ?? [],
failureCode: report.failureCode,
}));
}
}
return NextResponse.json({ reports });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json(
@@ -19,6 +19,7 @@ type ReportListItem = Readonly<{
themes: readonly string[];
status: "generating" | "ready" | "failed";
failureCode: string | null;
failureSummary?: string | null;
createdAt: string;
completedAt: string | null;
}>;
@@ -57,6 +58,7 @@ function readReports(value: unknown): ReportListItem[] {
: [],
status: row.status as ReportListItem["status"],
failureCode: typeof row.failureCode === "string" ? row.failureCode : null,
failureSummary: typeof row.failureSummary === "string" ? row.failureSummary : null,
createdAt: typeof row.createdAt === "string" ? row.createdAt : "",
completedAt: typeof row.completedAt === "string" ? row.completedAt : null,
}];
@@ -207,7 +209,7 @@ export function PersonalReportCenter() {
查看进度
</Button>
) : (
<span className="report-center-failure">{report.failureCode ?? "生成失败"}</span>
<span className="report-center-failure">{report.failureSummary ?? report.failureCode ?? "生成失败"}</span>
)}
</article>
);
@@ -35,7 +35,7 @@ export type ReportLoadState =
| { phase: "not-found" }
| { phase: "generating"; progressPercent?: number; progressPhase?: string }
| { phase: "timed-out" }
| { phase: "failed"; failureCode: string | null }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| { phase: "ready"; document: ReportDocument };
@@ -48,6 +48,7 @@ export interface ReportEnvelopeView {
presentationMode: string;
status: string;
failureCode: string | null;
failureSummary?: string | null;
createdAt: string;
completedAt: string | null;
progressPercent?: number;
@@ -104,7 +105,14 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const code = typeof view.failureCode === "string" && view.failureCode.length > 0
? view.failureCode
: null;
return { phase: "failed", failureCode: code };
const summary = typeof view.failureSummary === "string" && view.failureSummary.length > 0
? view.failureSummary
: null;
return {
phase: "failed",
failureCode: code,
...(summary ? { failureSummary: summary } : {}),
};
}
default:
return { phase: "invalid", message: "报告状态无法识别。" };
@@ -293,12 +301,12 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
<h1 className="text-xl font-semibold text-ink">报告生成失败</h1>
<p className="max-w-md text-sm text-ink-secondary">
{state.failureSummary ?? "生成过程中出现问题,未产出可用报告。请稍后重试。"}
</p>
{state.failureCode && (
<p className="font-mono text-xs text-ink-tertiary">错误码:{state.failureCode}</p>
)}
<p className="max-w-md text-sm text-ink-secondary">
生成过程中出现问题,未产出可用报告。请稍后重试。
</p>
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline">
返回报告中心重新生成
</Button>
@@ -0,0 +1,73 @@
/**
* User-visible personal-report failure copy, derived only from allowlisted
* error codes and section row status. Never include prompts, model text,
* bundle contents, or user profile fields.
*/
export type PersonalReportSectionFailureRow = Readonly<{
status: string;
lastErrorCode: string | null;
}>;
export type PersonalReportFailureSummary = Readonly<{
summary: string;
innerReason: string | null;
lastErrorCodes: readonly string[];
}>;
const SECTION_ERROR_LABELS: Readonly<Record<string, string>> = {
section_output_truncated: "输出被截断",
section_refs_mismatch: "证据引用未对齐",
section_identity_mismatch: "章节身份未对齐",
section_evidence_insufficient: "证据不足",
section_output_invalid: "输出未通过校验",
};
function uniqueLabels(codes: readonly string[]): string[] {
const labels: string[] = [];
for (const code of codes) {
const label = SECTION_ERROR_LABELS[code] ?? "输出未通过校验";
if (!labels.includes(label)) labels.push(label);
}
return labels;
}
export function summarizePersonalReportFailure(input: Readonly<{
themeCount?: number;
sections: readonly PersonalReportSectionFailureRow[];
failureCode?: string | null;
}>): PersonalReportFailureSummary {
const sections = input.sections;
const blocked = sections.filter((row) => row.status === "blocked");
const pending = sections.filter((row) => row.status === "pending");
const lastErrorCodes = blocked
.map((row) => row.lastErrorCode)
.filter((code): code is string => typeof code === "string" && code.length > 0);
const total = input.themeCount && input.themeCount > 0 ? input.themeCount : sections.length;
const labels = uniqueLabels(lastErrorCodes);
const labelText = labels.length > 0
? labels.join("、")
: input.failureCode === "report_schema_invalid"
? "结构校验失败"
: "生成失败";
let summary: string;
if (total > 0 && blocked.length > 0 && pending.length > 0) {
summary = `${total} 个主题中 ${blocked.length} 个写作失败:${labelText},${pending.length} 个未开始`;
} else if (total > 0 && blocked.length > 0) {
summary = `${total} 个主题中 ${blocked.length} 个写作失败:${labelText}`;
} else if (input.failureCode === "report_schema_invalid") {
summary = "报告未通过结构校验";
} else {
summary = "本次生成没有产出可用报告";
}
let innerReason: string | null = null;
if (blocked.length > 0 && pending.length === 0 && (total === 0 || blocked.length === total)) {
innerReason = "all_sections_blocked";
} else if (blocked.length > 0 && pending.length > 0) {
innerReason = "section_generation_incomplete";
}
return { summary, innerReason, lastErrorCodes };
}
+97 -43
View File
@@ -43,7 +43,14 @@ import {
} from "./report-evidence-bundle-v2.ts";
import { buildReportThemePlan, normalizeReportTheme } from "./report-theme-evidence-plan.ts";
import { resolveActiveSkillPackage } from "./skill-package-registry.ts";
import { buildPersonalReportSectionPlan, validatePersonalReportSectionPlan, type PersonalReportSectionPlan, type ReportSectionPlanEntry } from "./personal-report-plan.ts";
import {
buildPersonalReportSectionPlan,
sectionOutputTokenBudget,
summaryOutputTokenBudget,
validatePersonalReportSectionPlan,
type PersonalReportSectionPlan,
type ReportSectionPlanEntry,
} from "./personal-report-plan.ts";
import type { PersonalReportSectionRecord, PersonalReportSectionService } from "./personal-report-section-service-core.ts";
// Compatibility re-export: prefer importing from ./personal-report-codes.ts
// directly (the pure, dependency-free codes module).
@@ -3038,8 +3045,22 @@ function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
if (isPersonalReportGenerationAbort(error, signal)) throw error;
}
const SECTION_OUTPUT_TOKEN_BUDGET = 3072;
const SUMMARY_OUTPUT_TOKEN_BUDGET = 1536;
export function classifySectionErrorCode(error: unknown): string {
const finishReason = error instanceof PersonalReportAgentOutputError ? error.finishReason : null;
if (finishReason === "length") return "section_output_truncated";
const message = error instanceof Error ? error.message : "";
if (message.includes("length") || message.includes("truncat")) return "section_output_truncated";
if (
message.includes("report_writer_section_identity_mismatch")
|| message.includes("report_writer_unplanned_theme")
|| message.includes("report_writer_section_id_mismatch")
) {
return "section_identity_mismatch";
}
if (message.includes("report_writer_evidence_refs_mismatch")) return "section_refs_mismatch";
if (message.includes("evidence")) return "section_evidence_insufficient";
return "section_output_invalid";
}
/** Keep each section prompt bounded without weakening the evidence contract. */
export function filterReportEvidenceBundleForSection(
@@ -3109,18 +3130,19 @@ export function filterReportEvidenceBundleForSection(
}
function sectionFailureReason(errorCode: string | null): string {
if (errorCode?.includes("length") || errorCode?.includes("truncat")) return "本节未能生成:输出被截断。";
if (errorCode?.includes("evidence")) return "本节未能生成:证据不足。";
if (errorCode === "section_output_truncated" || errorCode?.includes("length") || errorCode?.includes("truncat")) {
return "本节未能生成:输出被截断。";
}
if (errorCode === "section_refs_mismatch") return "本节未能生成:证据引用未对齐。";
if (errorCode === "section_identity_mismatch") return "本节未能生成:章节身份未对齐。";
if (errorCode === "section_evidence_insufficient" || errorCode?.includes("evidence")) {
return "本节未能生成:证据不足。";
}
return "本节未能生成:输出未通过校验。";
}
function sectionErrorCode(error: unknown): string {
const message = error instanceof Error ? error.message : "";
return message.includes("length") || message.includes("truncat")
? "section_output_truncated"
: message.includes("evidence")
? "section_evidence_insufficient"
: "section_output_invalid";
return classifySectionErrorCode(error);
}
async function generateSectionedPersonalReport(
@@ -3145,51 +3167,83 @@ async function generateSectionedPersonalReport(
let row = existing.get(entry.id) ?? null;
if (row?.status === "ready" && row.payload) { ready.push(row); continue; }
if (row?.status === "blocked") { blocked.push(row); continue; }
const sectionBundle = filterReportEvidenceBundleForSection(bundle, entry);
while (true) {
row = await sectionService.start({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id });
if (!row) {
const current = (await sectionService.list(deps.userId, deps.requestId)).find((item) => item.sectionId === entry.id);
if (current?.status === "ready" && current.payload) { ready.push(current); break; }
if (current?.status === "blocked") { blocked.push(current); break; }
throw new Error("section_start_failed");
}
try {
const titles = ready.map((item) => item.payload?.title).filter((title): title is string => Boolean(title));
const output = await deps.agent.generateSection(sectionBundle, entry, titles, {
signal: deps.signal,
maxOutputTokens: Math.min(SECTION_OUTPUT_TOKEN_BUDGET, Math.max(1024, Math.ceil(entry.targetCharacters.max / 2))),
assertWriterOutput: (candidate) => {
if (candidate.id !== entry.id || candidate.theme !== entry.theme) throw new Error("report_writer_section_identity_mismatch");
if (!equalStringSets(candidate.evidenceRefs, entry.evidenceRefs)) throw new Error("report_writer_evidence_refs_mismatch");
},
});
const completed = await sectionService.complete({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, payload: output });
if (!completed) throw new Error("section_complete_failed");
ready.push(completed);
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
break;
} catch (error) {
rethrowIfAborted(error, deps.signal);
if (row.attemptCount >= row.maxAttempts) {
const blockedRow = await sectionService.block({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error) });
if (!blockedRow) throw new Error("section_block_failed");
blocked.push(blockedRow);
let sectionBundle: ReportEvidenceBundleV2;
try {
sectionBundle = filterReportEvidenceBundleForSection(bundle, entry);
} catch (error) {
rethrowIfAborted(error, deps.signal);
const blockedRow = await sectionService.block({
userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error),
});
if (!blockedRow) throw new Error("section_block_failed");
blocked.push(blockedRow);
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
continue;
}
const outputBudget = sectionOutputTokenBudget(entry.targetCharacters.max);
try {
while (true) {
row = await sectionService.start({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id });
if (!row) {
const current = (await sectionService.list(deps.userId, deps.requestId)).find((item) => item.sectionId === entry.id);
if (current?.status === "ready" && current.payload) { ready.push(current); break; }
if (current?.status === "blocked") { blocked.push(current); break; }
throw new Error("section_start_failed");
}
try {
const titles = ready.map((item) => item.payload?.title).filter((title): title is string => Boolean(title));
const output = await deps.agent.generateSection(sectionBundle, entry, titles, {
signal: deps.signal,
maxOutputTokens: outputBudget,
targetCharactersMin: entry.targetCharacters.min,
assertWriterOutput: (candidate) => {
if (candidate.id !== entry.id || candidate.theme !== entry.theme) throw new Error("report_writer_section_identity_mismatch");
if (!equalStringSets(candidate.evidenceRefs, entry.evidenceRefs)) throw new Error("report_writer_evidence_refs_mismatch");
},
});
const completed = await sectionService.complete({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, payload: output });
if (!completed) throw new Error("section_complete_failed");
ready.push(completed);
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
break;
} catch (error) {
rethrowIfAborted(error, deps.signal);
if (row.attemptCount >= row.maxAttempts) {
const blockedRow = await sectionService.block({
userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error),
});
if (!blockedRow) throw new Error("section_block_failed");
blocked.push(blockedRow);
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
break;
}
}
}
} catch (error) {
rethrowIfAborted(error, deps.signal);
const current = (await sectionService.list(deps.userId, deps.requestId)).find((item) => item.sectionId === entry.id);
if (current?.status === "ready" && current.payload) { ready.push(current); continue; }
if (current?.status === "blocked") {
if (!blocked.some((item) => item.sectionId === current.sectionId)) blocked.push(current);
continue;
}
const blockedRow = await sectionService.block({
userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error),
});
if (!blockedRow) throw new Error("section_block_failed");
blocked.push(blockedRow);
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
}
}
if (writePlans.length > 0 && ready.length === 0) {
return { status: "failed", failureCode: "report_schema_invalid", innerReason: "all_sections_blocked" };
return failSchema("all_sections_blocked");
}
await deps.onProgress?.({ phase: "summary", completed: writePlans.length, total: writePlans.length });
let summary;
try {
summary = await deps.agent.generateSummary(ready.map((item) => ({
title: item.payload!.title, claimStatus: item.payload!.claimStatus,
})), { signal: deps.signal, maxOutputTokens: SUMMARY_OUTPUT_TOKEN_BUDGET });
})), { signal: deps.signal, maxOutputTokens: summaryOutputTokenBudget() });
} catch (error) {
rethrowIfAborted(error, deps.signal);
return failSchema(classifyReportSchemaInnerReason(error));
+33
View File
@@ -40,6 +40,39 @@ const characterTargets: Record<ReportDepth, Readonly<{ min: number; max: number
research: { min: 600, max: 2_800 },
};
/** CJK writer output: ~1 character ≈ 1.3 tokens. Never use English "chars / 2". */
export const CJK_OUTPUT_TOKENS_PER_CHAR = 1.3;
export const SECTION_OUTPUT_TOKEN_BUDGET_CAP = 3072;
export const SECTION_OUTPUT_TOKEN_BUDGET_FLOOR = 1024;
/** title/actions/caveats/refs JSON envelope on top of narrative. */
export const SECTION_STRUCTURE_TOKEN_ALLOWANCE = 568;
export const SUMMARY_OUTPUT_TOKEN_BUDGET_CAP = 4096;
export const SUMMARY_OUTPUT_TOKEN_BUDGET_FLOOR = 1536;
/** headline + priorities + JSON envelope on top of the 2000-char summary field. */
export const SUMMARY_STRUCTURE_TOKEN_ALLOWANCE = 580;
export function sectionOutputTokenBudget(maxCharacters: number): number {
const narrativeTokens = Math.ceil(Math.max(0, maxCharacters) * CJK_OUTPUT_TOKENS_PER_CHAR);
return Math.min(
SECTION_OUTPUT_TOKEN_BUDGET_CAP,
Math.max(SECTION_OUTPUT_TOKEN_BUDGET_FLOOR, narrativeTokens + SECTION_STRUCTURE_TOKEN_ALLOWANCE),
);
}
export function summaryOutputTokenBudget(): number {
// SUMMARY_STRUCTURE_TOKEN_ALLOWANCE already covers headline, priorities, and
// the JSON envelope; do not add a second headline term.
const summaryTokens = Math.ceil(2000 * CJK_OUTPUT_TOKENS_PER_CHAR);
return Math.min(
SUMMARY_OUTPUT_TOKEN_BUDGET_CAP,
Math.max(SUMMARY_OUTPUT_TOKEN_BUDGET_FLOOR, summaryTokens + SUMMARY_STRUCTURE_TOKEN_ALLOWANCE),
);
}
export function sectionOutputTokenBudgetForDepth(depth: ReportDepth): number {
return sectionOutputTokenBudget(characterTargets[depth].max);
}
const reportSectionPlanEntrySchema = z.object({
id: z.string().regex(/^[a-z][a-z0-9_-]{0,95}$/),
kind: z.enum(REPORT_SECTION_KINDS),
+21 -2
View File
@@ -24,6 +24,7 @@ import {
type SkillSnapshot,
} from "./personal-report-generation";
import { REPORT_STABLE_CODES } from "./personal-report-codes";
import { summarizePersonalReportFailure } from "./personal-report-failure-summary";
import type { ReportBillingPort } from "./personal-report-billing";
import { checkSameOrigin } from "./personal-report-entitlement";
import type { PersonalReportJobRecord, PersonalReportJobService } from "./personal-report-job-service-core";
@@ -132,7 +133,11 @@ export function reportListTimestamp(value: unknown): string {
return "";
}
export function reportView(row: PersonalReportRecord, job?: PersonalReportJobRecord | null) {
export function reportView(
row: PersonalReportRecord,
job?: PersonalReportJobRecord | null,
failure?: ReturnType<typeof summarizePersonalReportFailure> | null,
) {
return {
id: row.id,
requestId: row.requestId,
@@ -144,6 +149,9 @@ export function reportView(row: PersonalReportRecord, job?: PersonalReportJobRec
createdAt: row.createdAt,
completedAt: row.completedAt,
...(job ? { progressPercent: job.progressPercent, progressPhase: job.progressPhase } : {}),
...(failure?.summary ? { failureSummary: failure.summary } : {}),
...(failure?.innerReason ? { innerReason: failure.innerReason } : {}),
...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}),
};
}
@@ -580,6 +588,10 @@ export type ReportReadCoreDeps = Readonly<{
document: unknown,
) => { ok: true; document: unknown } | { ok: false };
jobs?: Pick<PersonalReportJobService, "getOwnedByRequestId">;
listSections?: (userId: string, requestId: string) => Promise<readonly Readonly<{
status: string;
lastErrorCode: string | null;
}>[]>;
}>;
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
@@ -601,6 +613,13 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
};
}
const job = deps.jobs ? await deps.jobs.getOwnedByRequestId(deps.userId, row.requestId) : null;
const failure = row.status === "failed" && deps.listSections
? summarizePersonalReportFailure({
themeCount: row.requestedThemes.length,
sections: await deps.listSections(deps.userId, row.requestId),
failureCode: row.failureCode,
})
: null;
if (row.status === "ready") {
// Re-validate the stored document through the canonical server parse
// before it is allowed to leave the server; an invalid stored document is
@@ -621,7 +640,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
body: { report: reportView(row, job), reportDocument: validated.document },
};
}
return { status: 200, body: { report: reportView(row, job) } };
return { status: 200, body: { report: reportView(row, job, failure) } };
}
export type ReportDeleteCoreDeps = Readonly<{
+58 -5
View File
@@ -224,7 +224,7 @@ async function readFinishReason(value: unknown): Promise<string | null> {
export class PersonalReportAgentOutputError extends Error {
readonly code = "report_schema_invalid";
constructor() {
constructor(readonly finishReason: string | null = null) {
super("report_schema_invalid");
this.name = "PersonalReportAgentOutputError";
}
@@ -254,6 +254,7 @@ export type ReportAgentSectionOptions = Readonly<{
signal?: AbortSignal;
assertWriterOutput?: (output: z.output<typeof personalReportThematicNarrativeSchema>) => void;
maxOutputTokens?: number;
targetCharactersMin?: number;
}>;
export type ReportAgentSummaryOptions = Readonly<{
@@ -283,7 +284,51 @@ export type ReportAgentPort = Readonly<{
) => Promise<z.output<typeof personalReportExecutiveSummarySchema>>;
}>;
const REPAIR_PROMPT_SUFFIX = "\\n\\n上次输出未通过结构或章节计划校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。";
const REPAIR_PROMPT_SUFFIX = "\n\n上次输出未通过结构或章节计划校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。";
export type WriterRepairCategory =
| "identity mismatch"
| "refs mismatch"
| "schema invalid"
| "output truncated";
export function classifyWriterRepairCategory(input: Readonly<{
finishReason?: string | null;
error?: unknown;
}>): WriterRepairCategory {
if (input.finishReason === "length") return "output truncated";
const message = input.error instanceof Error ? input.error.message : "";
if (
message.includes("report_writer_section_identity_mismatch")
|| message.includes("report_writer_unplanned_theme")
|| message.includes("report_writer_section_id_mismatch")
) {
return "identity mismatch";
}
if (message.includes("report_writer_evidence_refs_mismatch")) return "refs mismatch";
return "schema invalid";
}
export function writerRepairPromptSuffix(input: Readonly<{
finishReason?: string | null;
error?: unknown;
targetCharactersMin?: number;
}>): string {
const category = classifyWriterRepairCategory(input);
const extras: string[] = [`失败类别:${category}。`];
if (category === "output truncated") {
const min = input.targetCharactersMin;
extras.push(
typeof min === "number" && Number.isFinite(min)
? `请把 narrative 压缩到约 ${min} 字,并保证 JSON 完整闭合。`
: "请压缩篇幅并保证 JSON 完整闭合。",
);
}
if (category === "refs mismatch") {
extras.push("evidenceRefs 必须逐字复制 plan.evidenceRefs 数组,不得增删。");
}
return `${REPAIR_PROMPT_SUFFIX}${extras.join("")}`;
}
type GenerationResult = { object?: unknown; usage?: unknown; finishReason?: unknown };
@@ -297,7 +342,7 @@ function sectionPrompt(
section: ReportSectionPlanEntry,
completedTitles: readonly string[],
): string {
return `请只生成以下一个个人报告 thematicNarrative 条目。严格输出单个 JSON 对象,不要数组、Markdown 或额外文字。只使用给定证据;section id、theme、evidenceRefs 必须与 plan 完全一致。已完成章节标题仅用于避免重复,不要复述正文。\n${JSON.stringify({ bundle, plan: section, completedSectionTitles: completedTitles })}`;
return `请只生成以下一个个人报告 thematicNarrative 条目。严格输出单个 JSON 对象,不要数组、Markdown 或额外文字。只使用给定证据;section id、theme、evidenceRefs 必须与 plan 完全一致。evidenceRefs 必须逐字复制 plan.evidenceRefs 数组,不得增删。已完成章节标题仅用于避免重复,不要复述正文。\n${JSON.stringify({ bundle, plan: section, completedSectionTitles: completedTitles })}`;
}
/**
@@ -346,6 +391,7 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
themePack?: string | null;
/** Metric only: interpretive rows in the bundle this call received. */
interpretiveFactCount?: number;
targetCharactersMin?: number;
accept: (value: T) => void;
}>): Promise<T> => {
const startedAt = Date.now();
@@ -402,9 +448,15 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
}
await recordUsage(first.usage);
await logTelemetry(model.id, startedAt, false, "failed", first.usage, first.finishReason, metrics);
const firstFinishReason = await readFinishReason(first.finishReason);
repairAttempted = true;
attemptReturned = false;
const repaired = await runOnce(`${prompt}${REPAIR_PROMPT_SUFFIX}`);
const repairSuffix = writerRepairPromptSuffix({
finishReason: firstFinishReason,
error: accepted.error,
targetCharactersMin: input.targetCharactersMin,
});
const repaired = await runOnce(`${prompt}${repairSuffix}`);
attemptReturned = true;
const repairedAccepted = accept(repaired);
if (repairedAccepted.ok) {
@@ -415,7 +467,7 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
await recordUsage(repaired.usage);
await logTelemetry(model.id, startedAt, true, "failed", repaired.usage, repaired.finishReason, metrics);
if (repairedAccepted.error) throw repairedAccepted.error;
throw new PersonalReportAgentOutputError();
throw new PersonalReportAgentOutputError(await readFinishReason(repaired.finishReason) ?? firstFinishReason);
} catch (error) {
if (error instanceof PersonalReportAgentOutputError) throw error;
if (isAbortError(error, input.signal)) throw error;
@@ -443,6 +495,7 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
schema: personalReportThematicNarrativeSchema,
signal: options?.signal,
maxOutputTokens: options?.maxOutputTokens,
targetCharactersMin: options?.targetCharactersMin ?? section.targetCharacters?.min,
themePack: section.theme,
interpretiveFactCount: countInterpretiveFacts(bundle),
accept: (output) => options?.assertWriterOutput?.(output),
@@ -973,6 +973,40 @@ test("core read: ready returns the document, generating returns status only", as
assert.equal((generating.body.report as { status: string }).status, "generating");
});
test("core read: failed reports include a readable summary from existing section rows", async () => {
const persistence = new MemoryPersistence();
persistence.rows.set(REPORT_ID, {
...seedRecord(),
status: "failed",
failureCode: "report_schema_invalid",
requestedThemes: ["career", "marriage", "wealth", "timing"],
});
const response = await resolveReportRead({
requestUrl: "https://jyotisha.chat/api/reports/x",
origin: null,
allowedOrigins: [],
userId: UUID_A,
reportId: REPORT_ID,
persistence,
listSections: async () => [
{ status: "blocked", lastErrorCode: "section_refs_mismatch" },
{ status: "pending", lastErrorCode: null },
{ status: "pending", lastErrorCode: null },
{ status: "pending", lastErrorCode: null },
],
validateReadyDocument: () => ({ ok: false }),
});
assert.equal(response.status, 200);
const report = response.body.report as {
failureSummary?: string;
innerReason?: string;
sectionErrorCodes?: string[];
};
assert.equal(report.failureSummary, "4 个主题中 1 个写作失败:证据引用未对齐,3 个未开始");
assert.equal(report.innerReason, "section_generation_incomplete");
assert.deepEqual(report.sectionErrorCodes, ["section_refs_mismatch"]);
});
test("core read: rejects a polluted stored ready document via canonical re-validation", async () => {
const persistence = new MemoryPersistence();
persistence.rows.set(REPORT_ID, {
@@ -1225,3 +1259,11 @@ test("generation delegates package verification to the immutable registry", () =
assert.doesNotMatch(generationSource, /\bskillDirectory\b|\brepoRoot\b|turbopackIgnore/);
assert.match(generationSource, /resolveActiveSkillPackage\("jyotish-personal-report"\)/);
});
test("failed report reads aggregate existing section error codes without importing generation", () => {
assert.match(itemRoute, /listSections/);
assert.match(itemRoute, /createPersonalReportSectionService\(supabase as never\)/);
assert.match(createRoute, /from\("personal_report_sections"\)/);
assert.match(createRoute, /select\("request_id, status, last_error_code"\)/);
assert.doesNotMatch(itemRoute, /personal-report-generation/);
});
@@ -416,7 +416,8 @@ test("production writer and worker keep the same signal on initial and repair mo
assert.match(agentSource, /abortSignal: signal/);
assert.match(agentSource, /assertWriterOutput/);
assert.match(agentSource, /runOnce\(`\$\{prompt\}\$\{REPAIR_PROMPT_SUFFIX\}`\)/);
assert.match(agentSource, /writerRepairPromptSuffix/);
assert.match(agentSource, /runOnce\(`\$\{prompt\}\$\{repairSuffix\}`\)/);
assert.match(agentSource, /const signal = options\?\.signal/);
assert.match(workerSource, /generatePersonalReport\(\{[\s\S]*signal: context\.signal,[\s\S]*\}\)/);
});
@@ -737,6 +738,7 @@ function sectionedAgent(input: Readonly<{
titles?: string[][];
sectionCalls?: string[];
failThemes?: ReadonlySet<string>;
mismatchRefsThemes?: ReadonlySet<string>;
abortAfterTheme?: string;
signalToAbort?: AbortController;
}>): ReportAgentPort {
@@ -759,8 +761,11 @@ function sectionedAgent(input: Readonly<{
throw new Error("section_output_invalid");
}
const output = sectionPayloadFor(bundle, section);
options?.assertWriterOutput?.(output);
return output;
const candidate = input.mismatchRefsThemes?.has(section.theme ?? "")
? { ...output, evidenceRefs: [...output.evidenceRefs, "ev-tech-extra"] }
: output;
options?.assertWriterOutput?.(candidate);
return candidate;
},
async generateSummary(sections) {
return {
@@ -869,3 +874,27 @@ test("all blocked sections fail before summary", async () => {
assert.deepEqual(result, { status: "failed", failureCode: "report_schema_invalid", innerReason: "all_sections_blocked" });
assert.equal(summaryCalls, 0);
});
test("evidenceRefs mismatch is classified separately from evidence insufficient and other sections continue", async () => {
const bundle = makeBundle({ themes: fullThemes.slice(0, 3), charts: [chart("D1"), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4)] });
const service = inMemorySectionService();
const result = await runSectioned(
bundle,
sectionedAgent({ mismatchRefsThemes: new Set(["career"]) }),
service,
);
const document = readyV2(result);
const rows = await service.list("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
assert.equal(rows.find((row) => row.sectionId === "theme-career")?.lastErrorCode, "section_refs_mismatch");
assert.equal(rows.find((row) => row.sectionId === "theme-career")?.status, "blocked");
assert.deepEqual(document.thematicNarrative.map((section) => section.theme), ["marriage", "wealth"]);
});
test("sectioned generation never uses English character-halving for output token budgets", () => {
const generationSource = readFileSync(
new URL("../src/lib/personal-report-generation.ts", import.meta.url),
"utf8",
);
assert.match(generationSource, /sectionOutputTokenBudget\(entry\.targetCharacters\.max\)/);
assert.doesNotMatch(generationSource, /targetCharacters\.max\s*\/\s*2/);
});
@@ -2,6 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
buildPersonalReportSectionPlan,
sectionOutputTokenBudget,
sectionOutputTokenBudgetForDepth,
summaryOutputTokenBudget,
validatePersonalReportSectionPlan,
} from "../src/lib/personal-report-plan.ts";
import {
@@ -83,6 +86,17 @@ test("four report depths use bounded configurable ranges instead of one-characte
assert.ok(minimums.every((minimum) => minimum > 1));
});
test("CJK section and summary output token budgets are derived from character targets, never chars/2", () => {
assert.equal(sectionOutputTokenBudget(1_200), 2_128);
assert.deepEqual(
(["concise", "standard", "deep", "research"] as const).map(sectionOutputTokenBudgetForDepth),
[1_218, 2_128, 3_072, 3_072],
);
assert.equal(summaryOutputTokenBudget(), 3_180);
assert.ok(sectionOutputTokenBudget(1_200) > Math.ceil(1_200 / 2));
assert.ok(summaryOutputTokenBudget() > 1_536);
});
test("plan validation rejects omitted themes, wrong blocked disposition and dangling refs", () => {
const bundle = bundleFixture();
const plan = buildPersonalReportSectionPlan(bundle, "standard");
@@ -307,6 +307,16 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
phase: "failed",
failureCode: "model_unavailable",
});
assert.deepEqual(
classifyReportEnvelope(200, {
report: { ...view("failed", "report_schema_invalid"), failureSummary: "4 个主题中 4 个写作失败:输出被截断" },
}),
{
phase: "failed",
failureCode: "report_schema_invalid",
failureSummary: "4 个主题中 4 个写作失败:输出被截断",
},
);
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), { phase: "failed", failureCode: null });
// Server-side error envelopes carry a stable code at the top level.
@@ -332,6 +342,20 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
assert.equal(classifyReportEnvelope(200, { error: "oops" }).phase, "invalid");
});
test("report center and detail surfaces prefer a readable failure summary over the bare schema code", () => {
const center = readFileSync(
new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url),
"utf8",
);
const page = readFileSync(
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
"utf8",
);
assert.match(center, /report\.failureSummary \?\? report\.failureCode/);
assert.match(page, /state\.failureSummary \?\? "生成过程中出现问题/);
assert.match(page, /错误码:\{state\.failureCode\}/);
});
test("canonical contract rejects malformed documents", () => {
assert.equal(safeParseReportDocument({}).ok, false);
const noCharts = { ...structuredClone(canonicalFixture) };
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { classifySectionErrorCode } from "../src/lib/personal-report-generation.ts";
import { summarizePersonalReportFailure } from "../src/lib/personal-report-failure-summary.ts";
import {
PersonalReportAgentOutputError,
classifyWriterRepairCategory,
writerRepairPromptSuffix,
} from "../src/mastra/personal-report.ts";
test("refs mismatch is not classified as evidence insufficient", () => {
assert.equal(
classifySectionErrorCode(new Error("report_writer_evidence_refs_mismatch")),
"section_refs_mismatch",
);
assert.equal(
classifySectionErrorCode(new Error("report_writer_section_identity_mismatch")),
"section_identity_mismatch",
);
assert.equal(
classifySectionErrorCode(new Error("Report evidence insufficient: planets_missing")),
"section_evidence_insufficient",
);
assert.equal(
classifySectionErrorCode(new PersonalReportAgentOutputError("length")),
"section_output_truncated",
);
assert.equal(classifySectionErrorCode(new Error("section_output_invalid")), "section_output_invalid");
});
test("repair prompt names the failure category without carrying content", () => {
const refs = writerRepairPromptSuffix({ error: new Error("report_writer_evidence_refs_mismatch") });
assert.match(refs, /失败类别:refs mismatch/);
assert.match(refs, /evidenceRefs 必须逐字复制 plan\.evidenceRefs 数组,不得增删/);
assert.doesNotMatch(refs, /ev-|Sun|Moon|宫/);
const truncated = writerRepairPromptSuffix({ finishReason: "length", targetCharactersMin: 260 });
assert.equal(classifyWriterRepairCategory({ finishReason: "length" }), "output truncated");
assert.match(truncated, /失败类别:output truncated/);
assert.match(truncated, /压缩到约 260 字/);
const schema = writerRepairPromptSuffix({});
assert.match(schema, /失败类别:schema invalid/);
});
test("failure summary is readable and stays at error-code granularity", () => {
const truncated = summarizePersonalReportFailure({
themeCount: 4,
failureCode: "report_schema_invalid",
sections: [
{ status: "blocked", lastErrorCode: "section_output_truncated" },
{ status: "blocked", lastErrorCode: "section_output_truncated" },
{ status: "blocked", lastErrorCode: "section_output_truncated" },
{ status: "blocked", lastErrorCode: "section_output_truncated" },
],
});
assert.equal(truncated.summary, "4 个主题中 4 个写作失败:输出被截断");
assert.equal(truncated.innerReason, "all_sections_blocked");
const incomplete = summarizePersonalReportFailure({
themeCount: 4,
failureCode: "report_schema_invalid",
sections: [
{ status: "blocked", lastErrorCode: "section_refs_mismatch" },
{ status: "pending", lastErrorCode: null },
{ status: "pending", lastErrorCode: null },
{ status: "pending", lastErrorCode: null },
],
});
assert.equal(incomplete.summary, "4 个主题中 1 个写作失败:证据引用未对齐,3 个未开始");
assert.equal(incomplete.innerReason, "section_generation_incomplete");
assert.deepEqual(incomplete.lastErrorCodes, ["section_refs_mismatch"]);
});
test("writer identity and evidenceRefs assertions keep set-equality semantics", () => {
const generationSource = readFileSync(
new URL("../src/lib/personal-report-generation.ts", import.meta.url),
"utf8",
);
const agentSource = readFileSync(
new URL("../src/mastra/personal-report.ts", import.meta.url),
"utf8",
);
assert.match(generationSource, /if \(candidate\.id !== entry\.id \|\| candidate\.theme !== entry\.theme\) throw new Error\("report_writer_section_identity_mismatch"\)/);
assert.match(generationSource, /equalStringSets\(candidate\.evidenceRefs, entry\.evidenceRefs\)/);
assert.match(generationSource, /throw new Error\("report_writer_evidence_refs_mismatch"\)/);
assert.match(agentSource, /evidenceRefs 必须逐字复制 plan\.evidenceRefs 数组,不得增删/);
assert.doesNotMatch(generationSource, /targetCharacters\.max\s*\/\s*2/);
});