fix(report): classify writer failures and size CJK section budgets
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:
@@ -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。
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user