feat(report): add dense personal report appendix

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
jesse-ux
2026-09-23 10:42:17 +08:00
co-authored by Claude Code
parent a7f82dc2a6
commit bbd96d3b6b
44 changed files with 48833 additions and 50 deletions
+10
View File
@@ -1,5 +1,15 @@
# BLOCKED
## 报告密度 BUG-1003~1007 验收缺口(2026-09-23)
- 本机 Windows build 编译/类型通过,但 page-data 的 SKILL.md 符号链接 EPERM 与基线一致;Static 与首屏 gzip 未验收。
- Docker 存在但地址池耗尽;没有删除他会话网络。前端全部失败名按基线逐项比对,见 PROGRESS 和失败清单。
- 预检缺 worktree 镜像目录;未复制镜像、未修改系统权限。quick 初次受 GBK 输出编码阻塞,UTF-8 重跑结果见 PROGRESS。
- 缺本轮受控登录态与浏览器验收,未借用账号。清单:`docs/testing/report-density-20260922.md`。
- 源 packet 只有 Sade Sati 三阶段框架,没有三轮日期;该验收项未满足。新计算到附录缓存成功、最终报告落库失败后的重试只有 Markdown 缓存,不补算事实层;恢复结构化层的持久重试尚未实现。
- Task 5 适用性说明仅随新报告产生;旧缓存不升级,因此旧附录没有新增说明。未提交、推送、部署。
## BUG-1000:聊天人物选择缺少受控登录态(2026-09-22)
- 本轮没有登录态,也没有 Chrome。顶栏点击、焦点回收和跨设备刷新不能写成通过。
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-09-23 — 报告增加独立原始附录与计算核对层(未部署)
- 原始附录独立下载,保留原始表格,清理工程用语;普通 Markdown 下载不变。
- 新生成长报告附带服务端事实表与最多 16 张真实分盘;旧缓存不重算、不升级。
- 辅助大运说明仅供交叉核对;Sade Sati 三轮日期仍缺源数据,未宣称完成。
- Skill 版本不变。尚未提交、推送或部署;完整限制见本轮 PROGRESS。
## 2026-09-22 — 今日星语和生时校正入口边框变轻
首页两枚入口仍是 36px 的按钮,点按区域保持 44px。默认边框改成更淡的 1px,悬停不再换成强调色,键盘焦点仍是更清楚的 3px 描边。Skill 版本不变。
@@ -109,13 +109,20 @@
"enum": [
"D1",
"D2",
"D6",
"D8",
"D3",
"D4",
"D7",
"D9",
"D10",
"D11",
"D12",
"D16",
"D20",
"D24",
"D30"
"D27",
"D30",
"D40",
"D60"
]
},
"title": {
@@ -434,6 +441,103 @@
"evidenceRefs",
"claimStatus"
]
},
"factTable": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"title",
"claimStatus",
"sourcePath",
"note",
"columns",
"rows"
],
"properties": {
"id": {
"enum": [
"vimshottari",
"ashtakavarga",
"shadbala",
"functional_roles",
"avasthas",
"special_points",
"sade_sati",
"annual"
]
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"claimStatus": {
"$ref": "#/definitions/claimStatus"
},
"sourcePath": {
"type": "string",
"minLength": 1,
"maxLength": 400,
"pattern": "^[A-Za-z0-9_.\\[\\]-]+$"
},
"note": {
"type": "string",
"maxLength": 500
},
"columns": {
"type": "array",
"minItems": 1,
"maxItems": 16,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 80
}
},
"rows": {
"type": "array",
"maxItems": 2000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"sourcePath",
"cells"
],
"properties": {
"sourcePath": {
"type": "string",
"minLength": 1,
"maxLength": 400,
"pattern": "^[A-Za-z0-9_.\\[\\]-]+$"
},
"cells": {
"type": "array",
"minItems": 1,
"maxItems": 16,
"items": {
"anyOf": [
{
"type": "string",
"maxLength": 300
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
}
}
}
}
}
}
}
},
"additionalProperties": false,
@@ -739,7 +843,7 @@
"$ref": "#/definitions/chart"
},
"minItems": 1,
"maxItems": 9,
"maxItems": 16,
"description": "maxItems must equal CHART_IDS.length in frontend/src/lib/personal-report-contract.ts"
},
"thematicNarrative": {
@@ -806,6 +910,13 @@
"type": "string",
"minLength": 1,
"maxLength": 2000
},
"factTables": {
"type": "array",
"maxItems": 8,
"items": {
"$ref": "#/definitions/factTable"
}
}
},
"required": [
+1 -1
View File
@@ -169,7 +169,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-report-density-20260922.md` | — | **报告信息密度与原始附录(BUG-1003~1007)**:与源头仓 PL9 全量报告同资料实测:对照 847,632 字符 / 8,044 表格行,我方 309,357 / 1,478。缺口不在引擎——20 张分盘全表、KP 四表、Ashtakavarga 四类、Shadbala 六分量、Avastha、Sahams、年度都已算得出,但 `personal-report-contract.ts` 的 `REPORT_SECTION_KINDS` **没有表格类型**、`CHART_IDS` 只有 9 种,这些表在产品页报告里没有落脚字段。另测出对外原始附录泄漏 925 种 / 3,797 处工程标识符(`parameter_sensitive` 697、`cmd_full_reading` 68、`PyJHora`/`JHora` 35、`PL9` 页码 59),对照物同项为 0。**产品 2026-09-22 三点拍板**:层 1 按 8 组表 + 分盘扩到 16 张;层 2 原始附录直接给 C 端且先全显示;B 类大运族要显示。**决策记录已写明推翻 BUG-999 的两条红线**(专业参考不得作普通下载 fallback;`parameter_sensitive`/「参数敏感」命中整行剔除)——**仅限附录通道**,普通正文投影不动。硬红线:表格层服务端装配、writer 不得写表;对照物的替代大运族日期与 Shadbala 分量在源头仍未闭环(506 行 date mismatch、`production_tuning_allowed=false`),不得照搬升级。让步顺序与串行依赖(任务 3 → 任务 4 同改契约)见任务书 | **待领取** | 分支 `codex/report-density-20260922`;任务 1 参考实现已在该分支本地提交 `cf7e26e4`(未推送,29 passed) |
| `TASK-report-density-20260922.md` | `PROGRESS-report-density-20260922.md` | **报告信息密度与原始附录(BUG-1003~1007)**:与源头仓 PL9 全量报告同资料实测:对照 847,632 字符 / 8,044 表格行,我方 309,357 / 1,478。缺口不在引擎——20 张分盘全表、KP 四表、Ashtakavarga 四类、Shadbala 六分量、Avastha、Sahams、年度都已算得出,但 `personal-report-contract.ts` 的 `REPORT_SECTION_KINDS` **没有表格类型**、`CHART_IDS` 只有 9 种,这些表在产品页报告里没有落脚字段。另测出对外原始附录泄漏 925 种 / 3,797 处工程标识符(`parameter_sensitive` 697、`cmd_full_reading` 68、`PyJHora`/`JHora` 35、`PL9` 页码 59),对照物同项为 0。**产品 2026-09-22 三点拍板**:层 1 按 8 组表 + 分盘扩到 16 张;层 2 原始附录直接给 C 端且先全显示;B 类大运族要显示。**决策记录已写明推翻 BUG-999 的两条红线**(专业参考不得作普通下载 fallback;`parameter_sensitive`/「参数敏感」命中整行剔除)——**仅限附录通道**,普通正文投影不动。硬红线:表格层服务端装配、writer 不得写表;对照物的替代大运族日期与 Shadbala 分量在源头仍未闭环(506 行 date mismatch、`production_tuning_allowed=false`),不得照搬升级。让步顺序与串行依赖(任务 3 → 任务 4 同改契约)见任务书 | **待领取** | 分支 `codex/report-density-20260922`;任务 1 参考实现已在该分支本地提交 `cf7e26e4`(未推送,29 passed) |
| `TASK-report-sectioned-generation-20260830.md` | `PROGRESS-report-sectioned-20260830.md` | 分章节生成 | 已合入 | 见 PROGRESS |
| `TASK-report-skill-parity-20260901.md` | `PROGRESS-report-skill-parity-20260901.md` | 内容对齐 skill 解读深度 | 已验收 | `90bad10d`、`ef1bd6df` |
| `TASK-report-blocked-repairs-20260902.md` | `PROGRESS-report-blocked-repairs-20260902.md` | 全主题 blocked 修复 | 已验收 | `7faf8555` |
+4
View File
@@ -2,6 +2,10 @@
This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped Jyotisha application. `CLAUDE_DESIGN.md` remains the upstream reference; this file is the implementation contract.
## 报告核对层与独立附录(2026-09-23)
个人报告保留原 Markdown 阅读、目录隔离与 memo 边界;文后独立渲染服务端分盘和事实表。分盘复用 VedicChartSvg 与 chart-grid/chart-card,不使用 float 或负 margin。事实表默认折叠、可键盘展开,横向溢出仅在表格区域滚动,缺数据不补写。Sade Sati 明确缺三轮日期。操作区增加「下载原始附录」,有独立忙态和简短错误提示,不接普通下载 fallback、不增加 spinner。旧缓存不升级、不重算;没有结构化层就不显示占位盘。文案遵守 VOICE:不把可计算写成已验证。
## 校正跨午夜日期与采用(2026-09-20)
复用现有时段选择卡追问一次午夜前/后;不知道或跳过保留填报当天凌晨与深夜两段,不补白昼或次日。选项用中文钟点,继续遵守题卡禁止暴露数字钟点的现有校验,不新增入口、状态或等待动画。
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { LONGFORM_APPENDIX_TABLE, parseLongformAppendixRow } from "@/lib/personal-report-longform-appendix";
import { checkSameOrigin, resolveAllowedReportOrigins } from "@/lib/personal-report-entitlement";
import { createSupabasePersonalReportService } from "@/lib/personal-report-service";
import { releaseRawReportAppendix } from "@/lib/personal-report-raw-appendix";
import { readLongformSnapshot, type LongformSnapshotReader } from "@/lib/personal-report-longform-snapshot";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const privateHeaders = { "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff" };
const json = (body: unknown, status = 200) => NextResponse.json(body, { status, headers: privateHeaders });
type RouteContext = { params: Promise<{ reportId: string }> };
/** Cache-only attachment: no birth input, recalculation, or alternate subject selection. */
export async function POST(request: Request, context: RouteContext) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return json({ error: "请先登录" }, 401);
const origin = checkSameOrigin(request.url, request.headers.get("origin"), resolveAllowedReportOrigins(process.env), request.headers);
if (!origin.ok) return json({ error: "此处无法下载,请回到报告页重试。" }, 403);
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) return json({ error: "报告不存在" }, 404);
const report = await createSupabasePersonalReportService(supabase).getOwnedById(user.id, reportId);
if (!report) return json({ error: "报告不存在" }, 404);
if (report.status !== "ready") return json({ error: "报告尚未完成" }, 409);
const result = await supabase.from(LONGFORM_APPENDIX_TABLE)
.select("report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code")
.eq("report_id", reportId).eq("user_id", user.id).eq("request_id", report.requestId)
.maybeSingle();
const cached = result.error ? null : parseLongformAppendixRow(result.data);
if (!cached || cached.reportId !== reportId || cached.userId !== user.id || cached.requestId !== report.requestId
|| cached.status !== "ready" || !cached.markdown) {
return json({ error: "这份报告暂无原始附录。" }, 410);
}
const stored = await readLongformSnapshot(supabase as unknown as LongformSnapshotReader, { reportId, userId: user.id, requestId: report.requestId });
if (stored && (stored.status !== "ready" || stored.snapshot.markdown !== cached.markdown)) {
return json({ error: "原始附录暂时无法下载,请稍后重试。" }, 503);
}
return json(releaseRawReportAppendix(cached.markdown, stored?.status === "ready" ? stored.snapshot.readerDashaApplicability : undefined));
} catch (error) {
return json({ error: "原始附录暂时无法下载,请稍后重试。" }, isSupabaseConfigurationError(error) ? 503 : 500);
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { LONGFORM_SNAPSHOT_SECTION_ID } from "@/lib/personal-report-longform-snapshot";
import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone";
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
@@ -108,14 +109,14 @@ export async function GET() {
if (failed.length > 0) {
const { data: sections, error: sectionError } = await supabase
.from("personal_report_sections")
.select("request_id, status, last_error_code")
.select("request_id, section_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;
if (!requestId || row.section_id === LONGFORM_SNAPSHOT_SECTION_ID) continue;
const list = grouped.get(requestId) ?? [];
list.push({
status: typeof row.status === "string" ? row.status : "pending",
+8
View File
@@ -1,6 +1,14 @@
@import "tailwindcss";
@import "tw-animate-css";
.personal-report-fact-tables { margin-block: 2rem; min-width: 0; }
.personal-report-fact-tables details { margin-block: 1rem; padding: 1rem; border: 1px solid var(--color-border); border-radius: 0.5rem; }
.personal-report-fact-tables summary { cursor: pointer; min-height: 2.75rem; }
.personal-report-fact-tables table { width: 100%; border-collapse: collapse; }
.personal-report-fact-tables th, .personal-report-fact-tables td { padding: 0.5rem; text-align: start; border-bottom: 1px solid var(--color-border); overflow-wrap: anywhere; }
@media print { .personal-report-fact-tables details { break-inside: auto; } }
@theme inline {
--color-background: var(--color-canvas);
--color-foreground: var(--color-ink);
@@ -19,6 +19,10 @@ import { InlineSpinner } from "@/components/inline-spinner";
import { SecondaryHeader } from "@/components/secondary-header";
import { ReportActions } from "./report-actions";
import { ReportFactTables } from "./report-fact-tables";
import { ReportCalculationCharts } from "./report-calculation-charts";
import { reportDocumentV2Schema, type ReportDocumentV2 } from "@/lib/personal-report-contract";
import { reportFactTablesSchema, type ReportFactTable } from "@/lib/report-fact-table-schema";
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
import {
@@ -50,7 +54,7 @@ export type ReportLoadState =
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string }
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"] }
| { phase: "legacy-unavailable" };
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
@@ -125,7 +129,11 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const stored = typeof json.longformMarkdown === "string" ? json.longformMarkdown : "";
const markdown = projectOrdinaryReportMarkdown(stored).trim();
if (markdown.length > 0) {
const facts = reportFactTablesSchema.safeParse(json.factTables);
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
return {
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
...(charts.success ? { calculationCharts: charts.data } : {}),
phase: "markdown-ready",
markdown,
reportId: typeof view.id === "string" ? view.id : "",
@@ -475,6 +483,8 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
/>
<div className="personal-report-reader-body">
<PersonalReportMarkdownView markdown={state.markdown} />
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} />}
{state.factTables && <ReportFactTables tables={state.factTables} />}
</div>
</div>
</>
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
export function RawAppendixDownload({ reportId }: { reportId: string }) {
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function download() {
if (busy) return;
setBusy(true);
setNotice(null);
try {
const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/raw-appendix`, {
method: "POST", credentials: "same-origin", headers: { Accept: "application/json" },
});
const result = await response.json();
if (!response.ok || result.documentKind !== "raw_appendix" || result.format !== "markdown"
|| typeof result.markdown !== "string" || !result.markdown.trim()) throw new Error("unavailable");
downloadMarkdownReport("个人报告-原始附录", result.markdown);
} catch {
setNotice("原始附录暂时无法下载,请稍后重试。");
} finally {
setBusy(false);
}
}
return <>
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => void download()}>
<FileText aria-hidden="true" />{busy ? "正在准备…" : "下载原始附录"}
</Button>
{notice && <span role="status">{notice}</span>}
</>;
}
@@ -20,6 +20,7 @@ import {
PERSONAL_REPORT_PRINT_LABEL,
} from "@/lib/personal-report-longform-copy";
import { Button } from "@/components/ui/button";
import { RawAppendixDownload } from "./raw-appendix-download";
interface ReportActionsProps {
reportId: string;
@@ -83,6 +84,7 @@ export function ReportActions({
<ArrowLeft aria-hidden="true" />返回报告中心
</Link>
<div className="personal-report-action-end">
<RawAppendixDownload reportId={reportId} />
{notice && <span role="status"><TriangleAlert aria-hidden="true" />{notice}</span>}
<Button type="button" size="sm" variant="ghost" onClick={() => void handlePrint()} disabled={printBusy || exportBusy || !printSupported}>
<Printer aria-hidden="true" />{printBusy ? "正在准备…" : PERSONAL_REPORT_PRINT_LABEL}
@@ -0,0 +1,20 @@
"use client";
import { memo } from "react";
import type { ChartV1, ReportDocumentV2 } from "@/lib/personal-report-contract";
import { hasRealChartData, VedicChartSvg } from "./vedic-chart-svg";
export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts }: { charts: ReportDocumentV2["charts"] }) {
const actual = charts.filter(chart => hasRealChartData(chart as ChartV1));
if (!actual.length) return null;
return <section className="personal-report-section" aria-label="分盘核对">
<h2>分盘核对</h2>
<p>原始计算供核对,分盘对出生时间敏感,不单独增加结论的确定性。</p>
<div className="personal-report-chart-grid">
{actual.map(chart => <article className="personal-report-chart-card" key={chart.id}>
<h3>{chart.title}</h3>
<VedicChartSvg chart={chart as ChartV1} ariaLabel={`${chart.title},十二宫数据`} />
</article>)}
</div>
</section>;
});
@@ -0,0 +1,41 @@
"use client";
import { memo } from "react";
import type { ReportFactTable } from "@/lib/report-fact-table-schema";
const STATUS_LABELS = {
multi_system_consensus: "多系统共识",
single_system_inference: "单一系统推断",
parameter_sensitive: "参数敏感",
unclosed_divisional_chart: "分盘尚未核验完整",
user_history_verification_required: "需要过往经历核对",
blocked: "暂不可用",
} as const;
/** Server-owned facts, separate from the ordinary Markdown and its TOC state. */
export const ReportFactTables = memo(function ReportFactTables({ tables }: { tables: readonly ReportFactTable[] }) {
return (
<section className="personal-report-fact-tables" aria-label="计算数据核对表">
<h2>计算数据核对表</h2>
{tables.map((table) => (
<details key={table.id}>
<summary>{table.title} · {STATUS_LABELS[table.claimStatus]}</summary>
<p>{table.note}</p>
{table.rows.length > 0 && (
<div className="overflow-x-auto" role="region" aria-label={table.title} tabIndex={0}>
<table>
<caption className="sr-only">{table.title}</caption>
<thead><tr>{table.columns.map((column) => <th scope="col" key={column}>{column}</th>)}</tr></thead>
<tbody>{table.rows.map((row) => (
<tr key={row.sourcePath}>{row.cells.map((cell, index) => (
<td key={index}>{cell === null ? "—" : typeof cell === "boolean" ? (cell ? "是" : "否") : String(cell)}</td>
))}</tr>
))}</tbody>
</table>
</div>
)}
</details>
))}
</section>
);
});
+3 -1
View File
@@ -14,6 +14,7 @@
*/
import { z } from "zod";
import { reportFactTablesSchema } from "./report-fact-table-schema";
export const LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const;
export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v2" as const;
@@ -45,7 +46,7 @@ export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const;
export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const;
export const REPORT_ACTION_PRIORITIES = ["now", "next", "watch"] as const;
export const REPORT_DOCUMENT_V1_CHART_IDS = ["D1", "D9", "D10"] as const;
export const CHART_IDS = ["D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"] as const;
export const CHART_IDS = ["D1", "D2", "D3", "D4", "D7", "D9", "D10", "D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D60"] as const;
export const REPORT_DOCUMENT_V2_ACTION_NOTES_MAX = 24;
const claimStatusSchema = z.enum(CLAIM_STATUSES);
@@ -284,6 +285,7 @@ export const reportDocumentV2Schema = z.strictObject({
currentPhase: currentPhaseSchema.nullable(),
actionNotes: z.array(actionNoteSchema).min(1).max(REPORT_DOCUMENT_V2_ACTION_NOTES_MAX),
charts: z.array(reportDocumentV2ChartSchema).min(1).max(CHART_IDS.length),
factTables: reportFactTablesSchema.optional(),
thematicNarrative: z.array(thematicSectionV2Schema).max(12),
blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12),
evidenceAppendix: evidenceAppendixSchema,
+42 -3
View File
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { assembleReportFactTables } from "./report-fact-tables";
import {
computeEvidenceHash,
safeParseServerReportDocument,
@@ -330,8 +331,15 @@ const DOCUMENT_VARGA_CHART_IDS = CHART_IDS.filter(
const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]>> = {
D2: ["D2_Hora", "D2"],
D6: ["D6_Shashthamsa", "D6"],
D8: ["D8_Ashtamsa", "D8"],
D3: ["D3_Drekkana", "D3"],
D4: ["D4_Chaturthamsa", "D4"],
D7: ["D7_Saptamsa", "D7"],
D12: ["D12_Dvadasamsa", "D12"],
D16: ["D16_Shodasamsa", "D16"],
D20: ["D20_Vimsamsa", "D20"],
D27: ["D27_Bhamsa", "D27"],
D40: ["D40_Khavedamsa", "D40"],
D60: ["D60_Shashtiamsa", "D60"],
D9: ["D9_Navamsa", "D9"],
D10: ["D10_Dasamsa", "D10"],
D11: ["D11_Rudramsa", "D11"],
@@ -340,7 +348,7 @@ const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]
};
function canonicalDocumentVargaChartId(rawId: string): DocumentVargaChartId | null {
const match = /^(D(?:30|24|11|10|9|8|6|2))(?:[_\s-].*)?$/.exec(rawId.trim().toUpperCase());
const match = /^(D(?:60|40|30|27|24|20|16|12|11|10|9|7|4|3|2))(?:[_\s-].*)?$/.exec(rawId.trim().toUpperCase());
return match ? match[1] as DocumentVargaChartId : null;
}
@@ -450,6 +458,26 @@ function readVargaHouses(
return result;
}
/** Actual packet charts for the default longform path; no placeholder promotion. */
export function assembleLongformCharts(packet: unknown, evidenceId: string): ReportDocumentV2["charts"] {
const root = record(packet);
const worksheets = record(root?.worksheets);
const divisional = record(worksheets?.divisional_and_special_charts);
const vargas = record(divisional?.varga_full);
const core = record(root?.core_chart);
const charts: ReportDocumentV2["charts"] = [];
for (const id of CHART_IDS) {
const entry = id === "D1" ? core : record(Object.entries(vargas ?? {}).find(([key]) => canonicalDocumentVargaChartId(key) === id)?.[1]);
if (!entry) continue;
const derived = deriveVargaHousesFromEngine(entry);
if (!derived) continue;
charts.push({ id, title: `${id} 分盘`, houses: derived.houses.map(house => ({
houseNumber: house.number, sign: house.sign, occupants: [...house.occupants],
})), claimStatus: "parameter_sensitive", evidenceRefs: [evidenceId] });
}
return charts;
}
function readDashaPeriods(rows: unknown): ReportEvidencePacket["chart"]["vimshottari"] {
if (!Array.isArray(rows)) return null;
const periods: { lord: string; start: string; end: string }[] = [];
@@ -810,6 +838,7 @@ const SAFE_CELESTIAL_NAMES = new Map([
const SAFE_WORKFLOW_TECHNIQUES = new Map<string, string>([
["d1", "D1"], ["d2", "D2"], ["d4", "D4"], ["d6", "D6"], ["d7", "D7"], ["d8", "D8"],
["d9", "D9"], ["d10", "D10"], ["d11", "D11"], ["d12", "D12"],
["d3", "D3"], ["d16", "D16"], ["d20", "D20"], ["d27", "D27"], ["d40", "D40"], ["d60", "D60"],
["d24", "D24"], ["d30", "D30"], ["a7", "A7"], ["a10", "A10"],
["ul", "UL"], ["upapada", "UL"], ["dk", "DK"], ["darakaraka", "DK"],
["amk", "AmK"], ["amatyakaraka", "AmK"], ["karma_pada", "A10"],
@@ -2170,6 +2199,7 @@ export function buildReportEvidenceBundleV2(
},
skill: input.skillSnapshot,
charts: [...chartsById.values()],
factTables: assembleReportFactTables(base.workflow),
claimCards,
interpretiveFacts,
themeNarrativeSeeds,
@@ -2504,6 +2534,14 @@ export function validatePersonalReportAgentOutputAgainstPlan(
bundle: ReportEvidenceBundleV2,
options: Readonly<{ allowIncompleteThematic?: boolean }> = {},
): PersonalReportAgentOutput {
const hasWriterTable = (value: unknown): boolean => {
if (typeof value === "string") return /(?:^|\n)\s*\|[^\n]+\||<table\b/i.test(value);
if (!value || typeof value !== "object") return false;
return Object.entries(value).some(([key, entry]) => key === "factTables" || hasWriterTable(entry));
};
if (hasWriterTable(output)) {
throw new Error("report_writer_fact_tables_forbidden");
}
const writePlans = plan.sections.filter((section) => (
section.kind === "thematic" && section.disposition === "write"
));
@@ -2839,6 +2877,7 @@ export function assembleReportDocumentV2(
currentPhase,
actionNotes,
charts,
...(bundle.factTables ? { factTables: structuredClone(bundle.factTables) } : {}),
thematicNarrative,
blockedConflictDisclosure,
evidenceAppendix: appendix,
@@ -42,6 +42,8 @@ export type LongformCoverInput = Readonly<{
birthPlaceLabel: string;
}>;
markdown: string;
factTables?: ReportDocumentV2["factTables"];
charts?: ReportDocumentV2["charts"];
generatedAt?: string;
skillSnapshot?: CoverSkillSnapshot;
}>;
@@ -121,13 +123,14 @@ export function buildLongformCoverDocument(input: LongformCoverInput): ReportDoc
priority: "now" as const,
evidenceRefs: [COVER_EVIDENCE_ID],
}],
charts: [{
charts: input.charts?.some(chart => chart.id === "D1") ? input.charts : [{
id: "D1" as const,
title: "D1 占位",
houses: coverHouses(),
claimStatus: "parameter_sensitive" as const,
evidenceRefs: [COVER_EVIDENCE_ID],
}],
...(input.factTables?.length ? { factTables: input.factTables } : {}),
thematicNarrative: [],
blockedConflictDisclosure: input.report.requestedThemes.map((theme) => ({
theme,
@@ -1,5 +1,9 @@
import type { ReportCandidateClockRange } from "./report-candidate-range";
import "server-only";
import { assembleReportFactTables } from "./report-fact-tables";
import type { ReportFactTable } from "./report-fact-table-schema";
import { persistLongformSnapshot, readLongformSnapshot, validateLongformSnapshot, type LongformSnapshot } from "./personal-report-longform-snapshot";
import { hashLongformMarkdown } from "./personal-report-longform-appendix";
import {
LONGFORM_APPENDIX_TABLE,
@@ -9,7 +13,7 @@ import {
} from "./personal-report-longform-appendix";
import { buildLongformBirthPayload, utcToday } from "./personal-report-longform-birth";
import { buildLongformCoverDocument } from "./personal-report-longform-cover";
import type { GeneratePersonalReportResult } from "./personal-report-generation";
import { assembleLongformCharts, type GeneratePersonalReportResult } from "./personal-report-generation";
import type { PersonalReportRecord } from "./personal-report-service-core";
import type { ReportDocumentV2 } from "./personal-report-contract";
@@ -33,6 +37,7 @@ export class LongformGenerateError extends Error {
type JsonRecord = Record<string, unknown>;
export type AppendixClient = {
rpc(name: string, args: Readonly<Record<string, unknown>>): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
from(table: string): {
select(columns: string): {
eq(column: string, value: unknown): AppendixFilter;
@@ -85,8 +90,10 @@ export async function readOwnedLongformAppendix(
.eq("report_id", reportId)
.eq("user_id", userId)
.maybeSingle();
if (result.error) return null;
return parseLongformAppendixRow(result.data);
if (result.error) throw new LongformGenerateError("calculation_unavailable", true, "appendix_read_failed");
const parsed = parseLongformAppendixRow(result.data);
if (result.data !== null && !parsed) throw new LongformGenerateError("report_schema_invalid", false, "appendix_invalid");
return parsed;
}
export async function persistLongformAppendix(input: Readonly<{
@@ -130,7 +137,7 @@ async function fetchLongformMarkdown(input: Readonly<{
apiBase: string;
fetchImpl: typeof fetch;
signal?: AbortSignal;
}>): Promise<string> {
}>): Promise<{ markdown: string; factTables: ReportFactTable[]; charts: ReportDocumentV2["charts"]; readerDashaApplicability: string }> {
const timeout = AbortSignal.timeout(ENGINE_TIMEOUT_MS);
const signal = input.signal
? AbortSignal.any([input.signal, timeout])
@@ -141,7 +148,7 @@ async function fetchLongformMarkdown(input: Readonly<{
const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(input.payload),
body: JSON.stringify({ ...input.payload, include_fact_tables: true }),
cache: "no-store",
signal,
});
@@ -153,11 +160,13 @@ async function fetchLongformMarkdown(input: Readonly<{
upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
);
}
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown } | null;
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown; fact_table_packet?: unknown; reader_dasha_applicability?: unknown } | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown");
}
return result.markdown;
return { markdown: result.markdown, factTables: assembleReportFactTables(result.fact_table_packet),
charts: assembleLongformCharts(result.fact_table_packet, "ev-cover-longform"),
readerDashaApplicability: typeof result.reader_dasha_applicability === "string" ? result.reader_dasha_applicability : "" };
} catch (error) {
throw error;
} finally {
@@ -191,38 +200,58 @@ export async function generatePersonalReportLongform(
throw new LongformGenerateError("birth_time_not_usable", false);
}
const current = await readOwnedLongformAppendix(deps.admin, deps.report.id, deps.report.userId);
const identity = { reportId: deps.report.id, userId: deps.report.userId, requestId: deps.report.requestId };
const current = await readOwnedLongformAppendix(deps.admin, identity.reportId, identity.userId);
if (current && (current.reportId !== identity.reportId || current.userId !== identity.userId || current.requestId !== identity.requestId)) {
throw new LongformGenerateError("report_schema_invalid", false, "appendix_binding_invalid");
}
let markdown = current?.status === "ready" && current.markdown ? current.markdown : null;
if (!markdown) {
try {
markdown = await fetchLongformMarkdown({
let snapshot: LongformSnapshot | null = null;
try {
const stored = await readLongformSnapshot(deps.admin, identity);
if (stored?.status === "ready") snapshot = stored.snapshot;
// A pending marker proves this is not a historical Markdown-only report.
if (stored?.status === "pending" && markdown) throw new Error("longform_snapshot_missing");
if (snapshot && markdown !== null && markdown !== snapshot.markdown) throw new Error("longform_snapshot_markdown_mismatch");
} catch {
throw new LongformGenerateError("report_schema_invalid", false, "longform_snapshot_invalid");
}
if (!markdown && !snapshot) {
// Mark new-format work before calculation; never confuse a torn new cache with legacy data.
const ensured = await persistLongformSnapshot(deps.admin, identity, null);
if (ensured.status === "ready") snapshot = ensured.snapshot;
deps.signal?.throwIfAborted();
if (!snapshot) try {
const generated = await fetchLongformMarkdown({
payload,
apiBase: deps.apiBase ?? process.env.JYOTISH_API_BASE ?? DEFAULT_API_BASE,
fetchImpl: deps.fetchImpl ?? fetch,
signal: deps.signal,
});
snapshot = validateLongformSnapshot({
...generated, ...identity, kind: "longform_calculation_snapshot", version: 1,
contentSha256: hashLongformMarkdown(generated.markdown),
}, identity);
// Durable complete snapshot precedes the shared Markdown cache and final report.
deps.signal?.throwIfAborted();
const completed = await persistLongformSnapshot(deps.admin, identity, snapshot);
if (completed.status !== "ready") throw new Error("longform_snapshot_persist_failed");
snapshot = completed.snapshot;
} catch (error) {
if (deps.signal?.aborted) throw error;
await persistLongformAppendix({
admin: deps.admin,
reportId: deps.report.id,
userId: deps.report.userId,
requestId: deps.report.requestId,
current,
errorCode: error instanceof LongformGenerateError ? error.message.slice(0, 80) : "generation_failed",
}).catch(() => undefined);
// Do not write a failed appendix here: another worker may already have
// committed its successful snapshot and appendix while this calculation failed.
if (error instanceof LongformGenerateError) throw error;
throw new LongformGenerateError("calculation_unavailable", true);
throw new LongformGenerateError("calculation_unavailable", true, "longform_snapshot_generation_failed");
}
await persistLongformAppendix({
admin: deps.admin,
reportId: deps.report.id,
userId: deps.report.userId,
requestId: deps.report.requestId,
current,
successMarkdown: markdown,
});
}
if (!markdown && snapshot) {
markdown = snapshot.markdown;
await persistLongformAppendix({ admin: deps.admin, ...identity, current, successMarkdown: markdown });
}
if (!markdown) throw new LongformGenerateError("report_schema_invalid", false);
const factTables = snapshot?.factTables ?? [];
const charts = snapshot?.charts ?? [];
try {
const document = buildLongformCoverDocument({
@@ -233,6 +262,8 @@ export async function generatePersonalReportLongform(
birthPlaceLabel: text(profile.birth_place_label) ?? "未知出生地",
},
markdown,
factTables,
charts,
generatedAt: (deps.now?.() ?? new Date()).toISOString(),
});
return {
@@ -0,0 +1,67 @@
import { z } from "zod";
import { hashLongformMarkdown } from "./personal-report-longform-appendix";
import { CHART_IDS, reportDocumentV2Schema } from "./personal-report-contract";
import { reportFactTablesSchema } from "./report-fact-table-schema";
import type { AppendixClient } from "./personal-report-longform-generate";
/** Reserved server-owned cache, never a writer chapter or reader progress row. */
export const LONGFORM_SNAPSHOT_SECTION_ID = "longform-calculation-snapshot-v1";
export const LONGFORM_SNAPSHOT_TABLE = "personal_report_sections";
export type LongformSnapshotIdentity = { reportId: string; userId: string; requestId: string };
const snapshotSchema = z.strictObject({
kind: z.literal("longform_calculation_snapshot"),
version: z.literal(1),
reportId: z.string().uuid(), userId: z.string().uuid(), requestId: z.string().uuid(),
markdown: z.string().min(1), contentSha256: z.string().regex(/^[a-f0-9]{64}$/),
factTables: reportFactTablesSchema.refine(tables => tables.length === 8),
charts: reportDocumentV2Schema.shape.charts.refine(charts =>
CHART_IDS.every(id => charts.filter(chart => chart.id === id).length === 1)
&& charts.every(chart => chart.houses.length === 12 && chart.houses.some(house => house.occupants.length > 0))),
readerDashaApplicability: z.string().min(1),
});
export type LongformSnapshot = z.infer<typeof snapshotSchema>;
export function validateLongformSnapshot(value: unknown, identity: LongformSnapshotIdentity): LongformSnapshot {
const snapshot = snapshotSchema.parse(value);
if (snapshot.reportId !== identity.reportId || snapshot.userId !== identity.userId
|| snapshot.requestId !== identity.requestId || snapshot.contentSha256 !== hashLongformMarkdown(snapshot.markdown)) {
throw new Error("longform_snapshot_binding_invalid");
}
return snapshot;
}
type SnapshotFilter = {
eq(column: string, value: unknown): SnapshotFilter;
maybeSingle(): PromiseLike<{ data: unknown; error: unknown }>;
};
export type LongformSnapshotReader = { from(table: string): { select(columns: string): { eq(column: string, value: unknown): SnapshotFilter } } };
export async function readLongformSnapshot(client: LongformSnapshotReader, identity: LongformSnapshotIdentity) {
const result = await client.from(LONGFORM_SNAPSHOT_TABLE)
.select("user_id,request_id,section_id,status,payload")
.eq("user_id", identity.userId).eq("request_id", identity.requestId)
.eq("section_id", LONGFORM_SNAPSHOT_SECTION_ID).maybeSingle();
if (result.error) throw new Error("longform_snapshot_read_failed");
if (result.data === null) return null;
const row = result.data as Record<string, unknown>;
if (!row || row.user_id !== identity.userId || row.request_id !== identity.requestId
|| row.section_id !== LONGFORM_SNAPSHOT_SECTION_ID) throw new Error("longform_snapshot_binding_invalid");
if (row.status === "pending" && row.payload === null) return { status: "pending" as const };
if (row.status !== "ready") throw new Error("longform_snapshot_invalid");
return { status: "ready" as const, snapshot: validateLongformSnapshot(row.payload, identity) };
}
export async function persistLongformSnapshot(client: AppendixClient, identity: LongformSnapshotIdentity, snapshot: LongformSnapshot | null) {
if (snapshot) validateLongformSnapshot(snapshot, identity);
const args = { p_user_id: identity.userId, p_request_id: identity.requestId, p_section_id: LONGFORM_SNAPSHOT_SECTION_ID };
// Existing SQL: INSERT ON CONFLICT DO NOTHING; completion UPDATE ... WHERE status='pending'.
// Neither an in-flight ensure nor a late completion can overwrite a committed winner.
const result = snapshot
? await client.rpc("complete_personal_report_section", { ...args, p_payload: snapshot })
: await client.rpc("ensure_personal_report_section", { ...args, p_max_attempts: 2 });
if (result.error) throw new Error("longform_snapshot_persist_failed");
const stored = await readLongformSnapshot(client, identity);
if (!stored || (snapshot && stored.status !== "ready")) throw new Error("longform_snapshot_persist_failed");
// Always return the committed winner, never the losing worker's local calculation.
return stored;
}
+2
View File
@@ -11,6 +11,7 @@ export const REPORT_SECTION_KINDS = [
"current_phase",
"action_notes",
"charts",
"fact_tables",
"claim_evidence_appendix",
"blocked_conflict_disclosure",
"provenance",
@@ -206,6 +207,7 @@ export function buildPersonalReportSectionPlan(
: []),
fixedSection("action-notes", "action_notes", depth),
fixedSection("charts", "charts", depth),
...(bundle.factTables?.length ? [fixedSection("fact-tables", "fact_tables", depth)] : []),
fixedSection("claim-evidence-appendix", "claim_evidence_appendix", depth, bundle.evidenceRefs.map((entry) => entry.id)),
fixedSection("blocked-conflict-disclosure", "blocked_conflict_disclosure", depth, [
...bundle.blockedSections.flatMap((section) => section.missingTechniqueRefs),
@@ -0,0 +1,13 @@
import { cleanReaderAppendixMarkdown } from "./reader-appendix-language";
import { sanitizeRawAppendixMarkdown } from "./report-public-projection";
/** Explicit independent channel. Never used to project ordinary report content. */
export const MISSING_DASHA_APPLICABILITY = "\n\n## 辅助时间轴适用性(参数敏感)\n\n缺少本次计算的结构化适用性证据,无法确认各辅助时间轴是否适用;不补算、不补写日期。\n";
export function releaseRawReportAppendix(markdown: string, readerDashaApplicability?: string) {
return {
documentKind: "raw_appendix" as const,
format: "markdown" as const,
markdown: cleanReaderAppendixMarkdown(sanitizeRawAppendixMarkdown(markdown + (readerDashaApplicability ?? (markdown.endsWith(MISSING_DASHA_APPLICABILITY) ? "" : MISSING_DASHA_APPLICABILITY)))),
};
}
@@ -741,6 +741,11 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
body: {
report: reportView(row, job),
longformMarkdown: projectOrdinaryReportMarkdown(markdown),
...(validated.ok && validated.document && typeof validated.document === "object" && "charts" in validated.document
? { calculationCharts: validated.document.charts } : {}),
...(validated.ok && validated.document && typeof validated.document === "object"
&& "factTables" in validated.document && validated.document.factTables
? { factTables: validated.document.factTables } : {}),
...(validated.ok ? { reportDocument: projectOrdinaryReportDocument(validated.document) } : {}),
},
};
@@ -1,3 +1,4 @@
import { LONGFORM_SNAPSHOT_SECTION_ID } from "./personal-report-longform-snapshot";
import type { PersonalReportAgentOutput } from "@/mastra/personal-report";
export type PersonalReportSectionPayload = PersonalReportAgentOutput["thematicNarrative"][number];
@@ -116,7 +117,7 @@ export function createPersonalReportSectionService(client: DataClient): Personal
const result = await client.from("personal_report_sections").select(columns).eq("user_id", userId).eq("request_id", requestId).order("section_id", { ascending: true });
const value = requireData(result);
if (!Array.isArray(value)) throw new Error("section list returned invalid data");
return value.map(row);
return value.filter(value => (value as DbRow)?.section_id !== LONGFORM_SNAPSHOT_SECTION_ID).map(row);
},
async start(input) {
const result = await client.rpc("start_personal_report_section", { p_user_id: input.userId, p_request_id: input.requestId, p_section_id: input.sectionId });
@@ -0,0 +1,27 @@
/** Cache compatibility for the Python reader-only vocabulary projection. */
const STATUS_LABELS: Readonly<Record<string, string>> = {
parameter_sensitive: "参数敏感",
pyjhora_behavior_only: "仅单一外部参照,未做多引擎核对",
not_multiengine_parity: "仅单一外部参照,未做多引擎核对",
unresolved_external_tuple_boundary: "外部边界未对齐",
partial_verified: "部分核验",
raw_appendix_only: "仅原始附录可见",
missing_in_local: "本地暂无",
internal_reference_omitted: "内部参照已省略",
};
const CELL_LABELS: Readonly<Record<string, string>> = {
blocked: "暂不可用", executed: "已执行", available: "可用", computed: "已计算",
};
export function cleanReaderAppendixMarkdown(markdown: string): string {
return markdown.split(/(^[ \t]*```jyotish-chart[^\n]*\n[\s\S]*?^[ \t]*```[^\n]*(?:\n|$))/m)
.map((part, index) => index % 2 ? part : part
.replace(new RegExp(`\\b(${Object.keys(STATUS_LABELS).join("|")})\\b`, "g"), (value) => STATUS_LABELS[value])
.replace(/\b(?:jyotish_engine\.)?(?:cmd_[a-z0-9_]+|render_pl9_markdown|build_professional_report_reference_packet)\b/g, "本地计算")
.replace(/\b(?:[a-z0-9_]+pl9[a-z0-9_]*|pl9_[a-z0-9_]+)(?:\.v\d+)?\b/gi, "外部参照资料")
.replace(/\bPL9\b(?:\.pdf)?(?:[ \t]*(?:第[ \t]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*[ \t]*页|(?:pages?|p)[ \t-]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*))?/gi, "外部参照资料")
.replace(/\b(?:PyJHora|JHora)\b/gi, "外部参照引擎")
.replace(/\|([ \t]*)(`?)(blocked|executed|available|computed)\2([ \t]*)(?=\|)/g,
(_match, before: string, quote: string, value: string, after: string) => `|${before}${quote}${CELL_LABELS[value]}${quote}${after}`))
.join("");
}
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { reportFactTablesSchema } from "./report-fact-table-schema";
export type ClaimStatus =
| "multi_system_consensus"
@@ -235,6 +236,7 @@ export type ReportEvidenceBundleV2 = Readonly<{
calculationProfile: CalculationProfileReceipt;
skill: SkillPackageIdentity;
charts: readonly ReportChartFact[];
factTables?: import("./report-fact-table-schema").ReportFactTable[];
claimCards: readonly ReportClaimCard[];
interpretiveFacts: ReportInterpretiveFacts;
themeNarrativeSeeds: readonly ReportThemeNarrativeSeed[];
@@ -286,13 +288,13 @@ const deterministicClaimBoundarySchema = z.enum([
"relationship_combinations",
]);
const allowedTechniqueNames = new Set([
"D1", "D2", "D4", "D6", "D7", "D8", "D9", "D10", "D11", "D12", "D24", "D30",
"D1", "D2", "D3", "D4", "D6", "D7", "D8", "D9", "D10", "D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D60",
"A7", "A10", "UL", "DK", "AmK",
"Vimshottari", "Narayana", "Transit", "Yoga", "Ashtakavarga",
"Functional Benefic/Malefic", "Planet Degrees", "House Degrees",
"Planetary Friendship",
]);
const missingTechniquePattern = /^(?:career|marriage|wealth|education|migration_home|family|health_pressure|timing|general):(?:D1|D2|D4|D6|D6\/D30|D7\/D12|D8|D9|D10|D11|D12|D24|D30|A7|A10|UL|DK|AmK|Vimshottari|Narayana|Transit|Yoga|Ashtakavarga)$/;
const missingTechniquePattern = /^(?:career|marriage|wealth|education|migration_home|family|health_pressure|timing|general):(?:D1|D2|D4|D6|D6\/D30|D7\/D12|D8|D9|D10|D11|D12|D16|D20|D24|D27|D30|D40|D60|A7|A10|UL|DK|AmK|Vimshottari|Narayana|Transit|Yoga|Ashtakavarga)$/;
const techniqueSchema = z.string().max(80).refine(
(value) => allowedTechniqueNames.has(value) || missingTechniquePattern.test(value),
"report_bundle_technique_not_allowlisted",
@@ -474,6 +476,7 @@ export const reportEvidenceBundleV2Schema = z.object({
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(20),
}).strict()).min(1).max(24),
factTables: reportFactTablesSchema.optional(),
claimCards: z.array(z.object({
id: evidenceIdSchema,
theme: idSchema,
@@ -0,0 +1,27 @@
import { z } from "zod";
export const FACT_TABLE_GROUPS = ["vimshottari", "ashtakavarga", "shadbala", "functional_roles", "avasthas", "special_points", "sade_sati", "annual"] as const;
const sourcePath = z.string().min(1).max(400).regex(/^[A-Za-z0-9_.\[\]-]+$/);
/** Immutable server facts; never part of a writer output schema. */
export const reportFactTableSchema = z.object({
id: z.enum(FACT_TABLE_GROUPS),
title: z.string().min(1).max(120),
claimStatus: z.enum(["multi_system_consensus", "single_system_inference", "parameter_sensitive", "unclosed_divisional_chart", "user_history_verification_required", "blocked"]),
sourcePath,
note: z.string().max(500),
columns: z.array(z.string().min(1).max(80)).min(1).max(16),
rows: z.array(z.object({
sourcePath,
cells: z.array(z.union([z.string().max(300), z.number().finite(), z.boolean(), z.null()])).min(1).max(16),
}).strict()).max(2000),
}).strict().superRefine((table, context) => {
table.rows.forEach((row, index) => {
if (row.cells.length !== table.columns.length) context.addIssue({ code: "custom", path: ["rows", index], message: "fact_table_cell_count" });
if (row.sourcePath !== table.sourcePath && !row.sourcePath.startsWith(`${table.sourcePath}.`) && !row.sourcePath.startsWith(`${table.sourcePath}[`)) context.addIssue({ code: "custom", path: ["rows", index, "sourcePath"], message: "fact_table_source_mismatch" });
});
});
export const reportFactTablesSchema = z.array(reportFactTableSchema).max(8).superRefine((tables, context) => {
if (new Set(tables.map((table) => table.id)).size !== tables.length) context.addIssue({ code: "custom", message: "duplicate_fact_table" });
});
export type ReportFactTable = z.infer<typeof reportFactTableSchema>;
+128
View File
@@ -0,0 +1,128 @@
import { findForbiddenContent } from "./personal-report-contract";
import { reportFactTablesSchema, type ReportFactTable } from "./report-fact-table-schema";
type RecordValue = Record<string, unknown>;
const record = (value: unknown): RecordValue => value && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {};
const at = (value: unknown, path: string): unknown => path.split(".").reduce((next, key) => record(next)[key], value);
const safeCell = (value: unknown): string | number | boolean | null => {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "boolean") return value;
if (typeof value === "string" && value.length <= 300 && !findForbiddenContent(value).length) return value;
return null;
};
const SOURCES = [
["vimshottari", "Vimshottari 主运与分运", "timing_and_predictive_systems.dasha", "dasha"],
["ashtakavarga", "SAV / BAV 八分力", "strengths_and_scores.ashtakavarga", "ashtakavarga"],
["shadbala", "Shadbala 六分量", "strengths_and_scores.shadbala", "shadbala"],
["functional_roles", "功能吉凶与宫主", "strengths_and_scores.functional_benefic_malefic", "functional_benefic_malefic"],
["avasthas", "Avastha 行星状态", "advanced_systems.avasthas", "avasthas"],
["special_points", "附属星点与特殊上升点", "divisional_and_special_charts", ""],
["sade_sati", "Sade Sati 阶段框架", "advanced_systems.yogas_doshas.sade_sati", "yogas_doshas.sade_sati"],
["annual", "年度回归与 Saham", "timing_and_predictive_systems.annual_tajika_pack", "annual_tajika_pack"],
] as const;
/** Explicit leaves only: no interpretations, remedies, profile, audit or producer text. */
const SCALAR_KEYS = new Set([
"longitude", "degree", "degree_in_sign", "sign_degree", "sign", "sign_idx", "sign_index", "house",
"state", "degree_range", "strength", "lord", "start", "end", "is_current", "years",
"dig_bala", "chesta_bala", "naisargika_bala", "drik_bala", "total", "total_virupas", "total_rupas",
"is_active", "phase", "moon_sign", "rising", "peak", "setting", "jd_ut", "dt_ut", "dt_local", "sun_lon",
]);
export function assembleReportFactTables(packet: unknown): ReportFactTable[] {
const root = record(packet);
const worksheets = record(root.worksheets);
const modules = record(record(root.chart).modules ?? root.modules);
const tables: ReportFactTable[] = [];
for (const [id, title, key, moduleKey] of SOURCES) {
const worksheetValue = at(worksheets, key);
const rootValue = at(root, key);
const source = worksheetValue ?? rootValue ?? (moduleKey ? at(modules, moduleKey) : modules);
if (!source || !Object.keys(record(source)).length) continue;
const sourcePath = worksheetValue !== undefined ? `worksheets.${key}` : rootValue !== undefined ? key
: `${root.chart ? "chart." : ""}modules${moduleKey ? `.${moduleKey}` : ""}`;
const raw = record(source);
const declared = raw.claimStatus ?? raw.claim_status;
// No new certainty: retain declared limitations; unspecified calculations stay sensitive.
const claimStatus = raw.status === "blocked" || raw.status === "missing_in_local" || declared === "blocked" ? "blocked"
: declared === "unclosed_divisional_chart" || declared === "user_history_verification_required"
|| declared === "single_system_inference" || declared === "multi_system_consensus" ? declared : "parameter_sensitive";
const rows: ReportFactTable["rows"] = [];
const add = (path: string, label: string, value: unknown) => {
const cell = safeCell(value);
if (cell !== null) rows.push({ sourcePath: path, cells: [label, cell] });
};
const walk = (value: unknown, path: string, label: string, allScalars = false, depth = 0) => {
if (depth > 10) return;
if (Array.isArray(value)) {
value.forEach((entry, index) => walk(entry, `${path}[${index}]`, `${label}[${index}]`, allScalars, depth + 1));
} else if (value && typeof value === "object") {
Object.entries(record(value)).forEach(([name, entry]) => {
if (!/^[A-Za-z0-9_-]+$/.test(name)) return;
if (SCALAR_KEYS.has(name) || allScalars || name === "avasthas" || ["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi", "sthana_bala", "kala_bala"].includes(name)) {
walk(entry, `${path}.${name}`, `${label}.${name}`, allScalars, depth + 1);
}
});
} else add(path, label, value);
};
const pick = (path: string, allScalars = false) => {
const value = at(source, path);
if (value !== undefined) walk(value, `${sourcePath}.${path}`, path, allScalars);
};
if (claimStatus !== "blocked") {
if (id === "vimshottari") {
const timeline = Array.isArray(raw.timeline) ? raw.timeline : [];
timeline.forEach((period, index) => {
const path = `${sourcePath}.timeline[${index}]`;
walk(period, path, `MD[${index}]`);
if (record(period).is_current === true) {
const ad = record(period).antardasha_timeline;
if (Array.isArray(ad)) ad.forEach((entry, ai) => {
walk(entry, `${path}.antardasha_timeline[${ai}]`, `AD[${ai}]`);
const pd = record(entry).pratyantar_dasha_timeline;
if (Array.isArray(pd)) pd.forEach((item, pi) => walk(item, `${path}.antardasha_timeline[${ai}].pratyantar_dasha_timeline[${pi}]`, `AD[${ai}].PD[${pi}]`));
});
}
});
} else if (id === "ashtakavarga") {
pick("sav.scores", true);
Object.keys(record(raw.bav)).forEach((planet) => pick(`bav.${planet}.bindus`, true));
} else if (id === "shadbala") {
Object.keys(record(raw.planets)).forEach((planet) => {
for (const component of ["sthana_bala.total", "dig_bala", "kala_bala.total", "chesta_bala", "naisargika_bala", "drik_bala"]) pick(`planets.${planet}.${component}`);
});
} else if (id === "functional_roles") {
for (const bucket of ["functional_benefics", "functional_malefics", "functional_neutrals", "yogakarakas", "owned_houses"]) pick(bucket, true);
} else if (id === "avasthas") {
Object.keys(raw).forEach((planet) => pick(`${planet}.avasthas`));
} else if (id === "special_points") {
for (const kind of ["upagrahas.raw", "special_lagnas", "sensitive_points"]) {
Object.keys(record(at(source, kind))).forEach((name) => pick(`${kind}.${name}`));
}
} else if (id === "sade_sati") {
for (const field of ["is_active", "phase", "moon_sign", "phases"]) pick(field);
} else if (id === "annual") {
pick("solar_return.data");
for (const field of ["asc_sign", "asc_sign_idx", "age"]) {
const path = `annual_chart.data.${field}`;
add(`${sourcePath}.${path}`, path, at(source, path));
}
Object.keys(record(at(source, "annual_chart.data.planets"))).forEach((planet) => pick(`annual_chart.data.planets.${planet}`));
Object.keys(record(at(source, "sahams.data"))).forEach((point) => pick(`sahams.data.${point}`));
const sahamPath = worksheetValue !== undefined ? "worksheets.advanced_systems.sahams"
: rootValue !== undefined ? "advanced_systems.sahams" : `${root.chart ? "chart." : ""}modules.sahams`;
const sahams = record(rootValue !== undefined && worksheetValue === undefined ? undefined : at(root, sahamPath));
Object.entries(sahams).forEach(([name, point]) => {
if (/^[A-Za-z0-9_]+_saham$/.test(name)) walk(point, `${sahamPath}.${name}`, `sahams.${name}`);
});
}
}
const tableSourcePath = id === "annual" ? (worksheetValue !== undefined ? "worksheets"
: rootValue !== undefined ? "timing_and_predictive_systems" : `${root.chart ? "chart." : ""}modules`) : sourcePath;
tables.push({ id, title, claimStatus, sourcePath: tableSourcePath,
note: id === "sade_sati" ? "这里只列本次计算返回的阶段框架;尚无三轮起止日期,不能据此判断时间。"
: rows.length ? "原始计算供核对,不增加结论的确定性。" : "本次没有可展示的数据,不补算、不补写。",
columns: ["原始字段", "数值 / 状态"], rows });
}
return reportFactTablesSchema.parse(tables);
}
@@ -430,6 +430,18 @@ function projectProseLine(line: string): { keep: boolean; text: string; limitati
return { keep: true, text: cleaned, limitations };
}
/** Safety-only boundary for the separately authorized raw attachment, not prose projection. */
export function sanitizeRawAppendixMarkdown(markdown: string): string {
return parseBlocks(markdown).map((block) => {
if (block.type === "fence" && block.lang === "jyotish-chart") {
return fenceIsPublic(block.raw) ? block.raw : "";
}
return stripSecrets(stripHtml(block.raw)).split(/(\r?\n)/).map((line) => (
/^\r?\n$/.test(line) ? line : projectInline(line)
)).join("");
}).join("");
}
function projectInline(text: string): string {
let next = text.replace(/<!--[\s\S]*?-->/g, "");
next = next.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_match, alt: string) => stripSecrets(stripHtml(String(alt))).trim());
+9 -3
View File
@@ -39,7 +39,7 @@ export const claimStatusSchema = z.enum([
]);
export type ReportVargaHouses = Readonly<{
id: "D2" | "D6" | "D8" | "D9" | "D10" | "D11" | "D24" | "D30";
id: Exclude<(typeof import("@/lib/personal-report-contract").CHART_IDS)[number], "D1">;
houses: readonly ReportChartHouse[];
}>;
@@ -238,12 +238,18 @@ export class PersonalReportAgentOutputError extends Error {
* text are structurally excluded by the agent definition (no skills, no tools,
* no memory).
*/
function writerBundle(bundle: ReportEvidenceBundleV2) {
const { factTables, ...facts } = bundle;
void factTables;
return facts;
}
export function buildReportPrompt(
bundle: ReportEvidenceBundleV2,
plan: PersonalReportSectionPlan,
): string {
return `请根据以下唯一的已验证 ReportEvidenceBundleV2 和 PersonalReportSectionPlan 生成个人报告 JSON。只输出 plan 中 disposition=write 的 thematic section;blocked section 由服务器确定性生成,不得补写。只使用 claimCards 中的结论,不得提升 assertionLevel。每个 section 的 id、theme 和 evidenceRefs 必须严格匹配 plan。严格按输出 schema 返回 JSON。
${JSON.stringify({ bundle, plan })}`;
${JSON.stringify({ bundle: writerBundle(bundle), plan })}`;
}
export type ReportAgentGenerateOptions = Readonly<{
@@ -344,7 +350,7 @@ function sectionPrompt(
section: ReportSectionPlanEntry,
completedTitles: readonly string[],
): string {
return `请只生成以下一个个人报告 thematicNarrative 条目。严格输出单个 JSON 对象,不要数组、Markdown 或额外文字。只使用给定证据;section id、theme、evidenceRefs 必须与 plan 完全一致。evidenceRefs 必须逐字复制 plan.evidenceRefs 数组,不得增删。已完成章节标题仅用于避免重复,不要复述正文。\n${JSON.stringify({ bundle, plan: section, completedSectionTitles: completedTitles })}`;
return `请只生成以下一个个人报告 thematicNarrative 条目。严格输出单个 JSON 对象,不要数组、Markdown 或额外文字。只使用给定证据;section id、theme、evidenceRefs 必须与 plan 完全一致。evidenceRefs 必须逐字复制 plan.evidenceRefs 数组,不得增删。已完成章节标题仅用于避免重复,不要复述正文。\n${JSON.stringify({ bundle: writerBundle(bundle), plan: section, completedSectionTitles: completedTitles })}`;
}
/**
@@ -2,6 +2,11 @@ import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
import { readFileSync } from "node:fs";
import { assembleReportFactTables } from "../src/lib/report-fact-tables";
import { assembleLongformCharts } from "../src/lib/personal-report-generation";
import { hashLongformMarkdown } from "../src/lib/personal-report-longform-appendix";
import { LONGFORM_SNAPSHOT_SECTION_ID, validateLongformSnapshot } from "../src/lib/personal-report-longform-snapshot";
const runnerPath = new URL("../scripts/db-migrate.mjs", import.meta.url).pathname;
const USER_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
@@ -150,6 +155,26 @@ test("personal report sections enforce owner-read RLS and service-owned durable
),
"SET\n0",
);
const packet = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8"));
const reader = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-reader.json", import.meta.url), "utf8"));
const identity = { reportId: REPORT_ID, userId: USER_A, requestId: REQUEST_ID };
const snapshot = validateLongformSnapshot({
...identity, kind: "longform_calculation_snapshot", version: 1,
markdown: reader.markdown, contentSha256: hashLongformMarkdown(reader.markdown),
factTables: assembleReportFactTables(packet), charts: assembleLongformCharts(packet, "ev-cover-longform"),
readerDashaApplicability: reader.reader_dasha_applicability,
}, identity);
const encoded = JSON.stringify(snapshot).replaceAll("'", "''");
fixture.psqlAs("service_runtime", "service-runtime-test-password", serviceSql(`
select status from public.ensure_personal_report_section('${USER_A}', '${REQUEST_ID}', '${LONGFORM_SNAPSHOT_SECTION_ID}', 2);
select status from public.complete_personal_report_section('${USER_A}', '${REQUEST_ID}', '${LONGFORM_SNAPSHOT_SECTION_ID}', '${encoded}'::jsonb);
-- Late ensure and conflicting completion must preserve the first committed snapshot.
select status from public.ensure_personal_report_section('${USER_A}', '${REQUEST_ID}', '${LONGFORM_SNAPSHOT_SECTION_ID}', 2);
select status from public.complete_personal_report_section('${USER_A}', '${REQUEST_ID}', '${LONGFORM_SNAPSHOT_SECTION_ID}', '{"kind":"stale-worker"}'::jsonb);
`));
const durable = JSON.parse(fixture.psql(`select payload from public.personal_report_sections where user_id = '${USER_A}' and request_id = '${REQUEST_ID}' and section_id = '${LONGFORM_SNAPSHOT_SECTION_ID}'`));
assert.deepEqual(validateLongformSnapshot(durable, identity), snapshot);
assert.equal(fixture.psqlAs("app_runtime", "app-runtime-test-password", selectAsAuthenticated(USER_B, `select count(*) from public.personal_report_sections where section_id = '${LONGFORM_SNAPSHOT_SECTION_ID}'`)), `SET\n${USER_B}\n0`);
} finally {
fixture.stop();
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1443,6 +1443,7 @@ test("failed report reads aggregate existing section error codes without importi
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.match(createRoute, /select\("request_id, section_id, status, last_error_code"\)/);
assert.match(createRoute, /row\.section_id === LONGFORM_SNAPSHOT_SECTION_ID\) continue/);
assert.doesNotMatch(itemRoute, /personal-report-generation/);
});
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { projectOrdinaryReportMarkdown } from "../src/lib/report-public-projection";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { cleanReaderAppendixMarkdown } from "../src/lib/reader-appendix-language";
import { releaseRawReportAppendix } from "../src/lib/personal-report-raw-appendix";
import { createPersonalReportSectionService } from "../src/lib/personal-report-section-service-core";
import { LONGFORM_SNAPSHOT_SECTION_ID } from "../src/lib/personal-report-longform-snapshot";
const golden = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-reader.json", import.meta.url), "utf8"));
test("density full engine reader cleanup removes embedded product contracts without losing rows or numeric fields", () => {
assert.equal(golden.fixtureProvenance.fictional, true);
const raw = golden.markdown as string;
// Captured by running baeec66f8's actual projector on this complete fictional engine Markdown.
assert.equal(createHash("sha256").update(projectOrdinaryReportMarkdown(raw)).digest("hex"), "01cc1e03c5adca2833e014156e1162fff12e5cb336e4484c5871859cd878acd7");
assert.match(raw, /pl9_personal_long_report\.v2/);
const clean = cleanReaderAppendixMarkdown(raw);
assert.doesNotMatch(clean, /pl9/i);
assert.equal(clean.split("\n").length, raw.split("\n").length);
assert.equal(clean.split("\n").filter(line => line.startsWith("|")).length, raw.split("\n").filter(line => line.startsWith("|")).length);
for (const field of ["sign_cn", "degree_in_sign", "nakshatra_lord"]) {
assert.equal(clean.split(field).length, raw.split(field).length);
}
assert.doesNotMatch(releaseRawReportAppendix(raw, golden.reader_dasha_applicability).markdown, /pl9/i);
});
test("density snapshot never enters writer recovery or chapter progress lists", async () => {
const row = { user_id: "123e4567-e89b-12d3-a456-426614174001", request_id: "123e4567-e89b-12d3-a456-426614174002", section_id: LONGFORM_SNAPSHOT_SECTION_ID, status: "ready", payload: { kind: "longform_calculation_snapshot", version: 1 } };
const client = { from() { return { select() { return this; }, eq() { return this; }, order: async () => ({ data: [row], error: null }) }; } };
const service = createPersonalReportSectionService(client as never);
assert.deepEqual(await service.list(row.user_id, row.request_id), []);
const listSource = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
assert.match(listSource, /row\.section_id === LONGFORM_SNAPSHOT_SECTION_ID\) continue/);
});
for (const mode of ["snapshot-only", "appendix-only", "final-ready", "legacy", "corrupt", "mismatch", "pending-with-markdown", "late-pending", "late-ready", "parallel-ready"]) {
test(`density durable retry golden: ${mode}`, () => {
const script = `
import assert from "node:assert/strict";
import { mock } from "node:test";
import { readFileSync } from "node:fs";
mock.module("server-only", { defaultExport: {} });
const { generatePersonalReportLongform } = await import("./src/lib/personal-report-longform-generate.ts");
const { readLongformSnapshot } = await import("./src/lib/personal-report-longform-snapshot.ts");
const { projectOrdinaryReportMarkdown } = await import("./src/lib/report-public-projection.ts");
const { releaseRawReportAppendix } = await import("./src/lib/personal-report-raw-appendix.ts");
const packet = JSON.parse(readFileSync("tests/fixtures/report-density-fictional-engine.json", "utf8"));
const golden = JSON.parse(readFileSync("tests/fixtures/report-density-fictional-reader.json", "utf8"));
const mode = ${JSON.stringify(mode)};
const rows = new Map(); let calls = 0; let interrupt = mode === "snapshot-only";
let releaseWrite, reachedWrite;
const writeGate = new Promise(resolve => { releaseWrite = resolve; });
const reached = new Promise(resolve => { reachedWrite = resolve; });
let delayed = false;
const admin = { async rpc(name, args) {
if (!delayed && ((mode === "late-pending" && name === "ensure_personal_report_section") || ((mode === "late-ready" || mode === "parallel-ready") && name === "complete_personal_report_section"))) {
delayed = true; reachedWrite(); await writeGate;
}
const table = "personal_report_sections"; const current = rows.get(table);
if (name === "ensure_personal_report_section" && !current) rows.set(table, { user_id: args.p_user_id, request_id: args.p_request_id, section_id: args.p_section_id, status: "pending", payload: null });
if (name === "complete_personal_report_section" && current?.status === "pending") rows.set(table, { ...current, status: "ready", payload: structuredClone(args.p_payload) });
return { data: [], error: null };
}, from(table) { return {
select() { return this; }, eq() { return this; },
async maybeSingle() { return { data: structuredClone(rows.get(table) ?? null), error: null }; },
async upsert(row) {
if (table === "personal_report_longform_appendices" && interrupt) { interrupt = false; return { error: { message: "simulated crash" } }; }
rows.set(table, structuredClone(row)); return { error: null };
}
}; }};
const report = { id: "123e4567-e89b-12d3-a456-426614174000", userId: "123e4567-e89b-12d3-a456-426614174001", requestId: "123e4567-e89b-12d3-a456-426614174002", reportType: "personal_full", presentationMode: "default", depth: "standard", requestedThemes: ["career"], skillName: "jyotish-vedic-astrology", skillVersion: "6.9.14", skillSourceCommit: null, skillSnapshotSha256: "ab".repeat(32) };
const deps = { report, admin, profile: { birth_date: "2000-01-01", reported_birth_time: "12:00", birth_time_status: "reported", latitude: 0, longitude: 0, timezone_offset: 0, ayanamsa: "lahiri" }, candidateRange: null, displayName: "虚构测试", birthTimeStatus: "reported", now: () => new Date("2026-09-22T12:00:00Z"), fetchImpl: async () => { calls++; return Response.json({ format: "markdown", markdown: golden.markdown, fact_table_packet: packet, reader_dasha_applicability: golden.reader_dasha_applicability }); } };
if (mode === "legacy") rows.set("personal_report_longform_appendices", { report_id: report.id, user_id: report.userId, request_id: report.requestId, status: "ready", markdown: golden.markdown });
if (mode === "late-pending" || (mode === "late-ready" || mode === "parallel-ready")) {
const controller = new AbortController();
const stale = generatePersonalReportLongform({ ...deps, signal: controller.signal, fetchImpl: async () => {
calls++; return Response.json({ format: "markdown", markdown: golden.markdown + "\\nSTALE", fact_table_packet: packet, reader_dasha_applicability: golden.reader_dasha_applicability });
} });
const staleResult = stale.then(value => ({ value }), error => ({ error }));
await reached;
if (mode !== "parallel-ready") controller.abort();
const winner = await generatePersonalReportLongform(deps);
assert.equal(winner.document.factTables.length, 8); assert.equal(winner.document.charts.length, 16);
const committed = structuredClone(rows.get("personal_report_sections"));
releaseWrite(); const loser = await staleResult;
if (mode === "parallel-ready") {
assert.ok(loser.value); assert.deepEqual(loser.value.document, winner.document);
}
assert.deepEqual(rows.get("personal_report_sections"), committed);
assert.equal(rows.get("personal_report_longform_appendices").markdown, golden.markdown);
} else if (mode === "snapshot-only") await assert.rejects(generatePersonalReportLongform(deps), /appendix_persist_failed/);
else {
const first = await generatePersonalReportLongform(deps);
assert.equal(first.document.charts.length, mode === "legacy" ? 1 : 16);
if (mode !== "legacy") assert.equal(first.document.factTables.length, 8);
// appendix-only deliberately discards the result before final report persistence.
if (mode === "final-ready") report.reportDocument = structuredClone(first.document);
}
const snapshotRow = rows.get("personal_report_sections");
if (mode === "corrupt") snapshotRow.payload.charts = [];
if (mode === "mismatch") snapshotRow.payload.requestId = report.userId;
if (mode === "pending-with-markdown") { snapshotRow.status = "pending"; snapshotRow.payload = null; }
if (["corrupt", "mismatch", "pending-with-markdown"].includes(mode)) {
await assert.rejects(generatePersonalReportLongform(deps), /longform_snapshot_invalid/);
assert.equal(calls, 1);
} else {
const retry = await generatePersonalReportLongform(deps);
assert.equal(calls, mode === "legacy" ? 0 : (mode === "late-ready" || mode === "parallel-ready") ? 2 : 1);
assert.equal(retry.document.factTables?.length ?? 0, mode === "legacy" ? 0 : 8);
assert.equal(retry.document.charts.length, mode === "legacy" ? 1 : 16);
const storedMarkdown = rows.get("personal_report_longform_appendices").markdown;
assert.equal(storedMarkdown, golden.markdown);
assert.equal(projectOrdinaryReportMarkdown(storedMarkdown), projectOrdinaryReportMarkdown(golden.markdown));
const persisted = await readLongformSnapshot(admin, { reportId: report.id, userId: report.userId, requestId: report.requestId });
const raw = releaseRawReportAppendix(storedMarkdown, persisted?.snapshot?.readerDashaApplicability).markdown;
assert.doesNotMatch(raw, /pl9/i);
assert.match(raw, mode === "legacy" ? /缺少本次计算的结构化适用性证据/ : /Tribhagi-40/);
if (mode === "final-ready") {
mock.module("@/lib/personal-report-entitlement", { namedExports: { checkSameOrigin: () => ({ ok: true }), resolveAllowedReportOrigins: () => [] } });
mock.module("@/lib/personal-report-service", { namedExports: { createSupabasePersonalReportService: () => ({ getOwnedById: async () => ({ ...report, status: "ready" }) }) } });
mock.module("@/lib/supabase/config", { namedExports: { isSupabaseConfigurationError: () => false } });
mock.module("@/lib/supabase/server", { namedExports: { createServerSupabaseClient: async () => ({ ...admin, auth: { getUser: async () => ({ data: { user: { id: report.userId } }, error: null }) } }) } });
const { POST } = await import("./src/app/api/reports/[reportId]/raw-appendix/route.ts");
const response = await POST(new Request("https://staging.jyotisha.chat/api/reports/" + report.id + "/raw-appendix", { method: "POST" }), { params: Promise.resolve({ reportId: report.id }) });
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.markdown, raw);
assert.doesNotMatch(body.markdown, /pl9|pyjhora|jhora|cmd_[a-z0-9_]+|build_professional_report_reference_packet/i);
assert.equal(calls, 1);
}
}
`;
const result = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], {
cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8", timeout: 120_000,
});
assert.equal(result.status, 0, result.stderr + result.stdout);
});
}
@@ -111,6 +111,7 @@ function makeBundle(input: Readonly<{
const blocked = input.blocked ?? [];
const statuses = new Map<string, EvidenceRefStatus>();
statuses.set("ev-tech-d1", "verified");
for (const chart of input.charts ?? []) statuses.set(`ev-tech-${chart.id.toLowerCase()}`, "verified");
for (const theme of input.themes) {
for (const ref of theme.refs) statuses.set(ref, "verified");
}
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { releaseRawReportAppendix, MISSING_DASHA_APPLICABILITY } from "../src/lib/personal-report-raw-appendix.ts";
import { projectOrdinaryReportMarkdown } from "../src/lib/report-public-projection.ts";
import { cleanReaderAppendixMarkdown } from "../src/lib/reader-appendix-language.ts";
// Vocabulary/security cases, not fabricated engine contract fixtures.
test("raw appendix retains status rows and raw columns without changing ordinary projection", () => {
const source = "# 数据\n\n| sign_cn | degree_in_sign | nakshatra_lord |\n| --- | --- | --- |\n| Aries | 12.5 | parameter_sensitive |\n";
const before = projectOrdinaryReportMarkdown(source);
const result = releaseRawReportAppendix(source);
assert.equal(result.documentKind, "raw_appendix");
assert.equal(result.markdown, source.replace("parameter_sensitive", "参数敏感") + MISSING_DASHA_APPLICABILITY);
assert.equal(result.markdown.split("\n").filter((line) => line.startsWith("|")).length, 3);
assert.equal(projectOrdinaryReportMarkdown(source), before);
assert.doesNotMatch(before, /parameter_sensitive|参数敏感|12\.5/);
});
test("raw appendix cleans old cache vocabulary but leaves English prose intact", () => {
for (const [value, label] of Object.entries({
parameter_sensitive: "参数敏感", pyjhora_behavior_only: "仅单一外部参照,未做多引擎核对",
not_multiengine_parity: "仅单一外部参照,未做多引擎核对", unresolved_external_tuple_boundary: "外部边界未对齐",
partial_verified: "部分核验", raw_appendix_only: "仅原始附录可见", missing_in_local: "本地暂无",
})) assert.equal(cleanReaderAppendixMarkdown(value), label);
for (const value of ["PL9 第 83-88 页", "PL9 第 40 / 48 页", "PL9 page 99-100", "PL9.pdf"]) {
assert.equal(cleanReaderAppendixMarkdown(value), "外部参照资料");
}
assert.equal(cleanReaderAppendixMarkdown("PyJHora JHora cmd_full_reading"), "外部参照引擎 外部参照引擎 本地计算");
const prose = "The available result was computed and executed, not blocked.";
assert.equal(cleanReaderAppendixMarkdown(prose), prose);
assert.equal(cleanReaderAppendixMarkdown("| blocked | `executed` | available | computed |"), "| 暂不可用 | `已执行` | 可用 | 已计算 |");
});
test("raw appendix keeps established HTML URL secret and chart-fence safety boundaries", () => {
const fence = '```jyotish-chart\n{"version":1,"id":"D1","title":"本命","layout":"north","ascendant":{"sign":"Aries","degree":1},"planets":[]}\n```\n';
const raw = '<script>alert(1)</script>\n<iframe src="https://evil.example"></iframe>\n[bad](javascript:alert(1))\nhttp://127.0.0.1:5200/internal\nsk-testsecretvalue\n' + fence;
const result = releaseRawReportAppendix(raw).markdown;
assert.doesNotMatch(result, /script|iframe|javascript:|127\.0\.0\.1|sk-testsecretvalue/);
assert.ok(result.includes(fence));
assert.equal(releaseRawReportAppendix(result).markdown, result);
});
function routeCase(mode: string) {
const script = `
import { mock } from "node:test";
const mode = ${JSON.stringify(mode)};
const id = "123e4567-e89b-12d3-a456-426614174000";
const filters = [];
let reads = 0;
mock.module("@/lib/personal-report-entitlement", { namedExports: {
checkSameOrigin: () => ({ ok: mode !== "cross-origin" }), resolveAllowedReportOrigins: () => [],
}});
mock.module("@/lib/personal-report-service", { namedExports: {
createSupabasePersonalReportService: () => ({ getOwnedById: async (userId, reportId) => {
if (userId !== "owner" || reportId !== id) throw new Error("identity mismatch");
return mode === "foreign" ? null : { status: mode === "generating" ? "generating" : "ready", requestId: "request" };
}}),
}});
mock.module("@/lib/supabase/config", { namedExports: { isSupabaseConfigurationError: () => false }});
mock.module("@/lib/supabase/server", { namedExports: {
createServerSupabaseClient: async () => ({
auth: { getUser: async () => ({ data: { user: mode === "anonymous" ? null : { id: "owner" } }, error: null }) },
from: (table) => table === "personal_report_sections" ? {
select() { return this; }, eq() { return this; }, async maybeSingle() { return { data: null, error: null }; }
} : ({
select() { return this; }, eq(key, value) { filters.push([key,value]); return this; },
async maybeSingle() { reads++; return { error: null, data: mode === "missing" ? null : {
report_id: mode === "wrong-report" ? "other" : id,
user_id: mode === "wrong-owner" ? "other" : "owner",
request_id: mode === "wrong-request" ? "other" : "request",
status: mode === "pending" ? "pending" : "ready", markdown: "| degree_in_sign | parameter_sensitive |",
}}; },
}),
}),
}});
globalThis.fetch = () => { throw new Error("must not recalculate"); };
const { POST } = await import("./src/app/api/reports/[reportId]/raw-appendix/route.ts");
const response = await POST(new Request("https://staging.jyotisha.chat/api/reports/" + id + "/raw-appendix", { method: "POST" }),
{ params: Promise.resolve({ reportId: mode === "invalid-id" ? "invalid" : id }) });
console.log(JSON.stringify({ status: response.status, body: await response.json(), cache: response.headers.get("cache-control"), filters, reads }));
`;
const result = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], {
cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
return JSON.parse(result.stdout.trim());
}
for (const [mode, status] of Object.entries({ anonymous: 401, "cross-origin": 403, foreign: 404, "invalid-id": 404, generating: 409, missing: 410, pending: 410, "wrong-report": 410, "wrong-owner": 410, "wrong-request": 410, ready: 200 })) {
test(`raw appendix route guards ${mode}`, () => {
const result = routeCase(mode);
assert.equal(result.status, status);
assert.equal(result.cache, "private, no-store");
if (status < 410 && status !== 200) assert.equal(result.reads, 0);
if (status === 200) {
assert.deepEqual(result.filters, [["report_id", "123e4567-e89b-12d3-a456-426614174000"], ["user_id", "owner"], ["request_id", "request"]]);
assert.deepEqual(result.body, { documentKind: "raw_appendix", format: "markdown", markdown: "| degree_in_sign | 参数敏感 |" + MISSING_DASHA_APPLICABILITY });
} else assert.equal(result.body.markdown, undefined);
});
}
test("raw appendix UI is independent of ordinary export and accepts no birth payload", () => {
const ui = readFileSync(new URL("../src/components/personal-report/raw-appendix-download.tsx", import.meta.url), "utf8");
assert.match(ui, /下载原始附录/);
assert.match(ui, /\/raw-appendix/);
assert.doesNotMatch(ui, /正在加载|Spinner|body:|birth|projectOrdinaryReportMarkdown/);
const ordinary = readFileSync(new URL("../src/lib/personal-report-longform-download.ts", import.meta.url), "utf8");
assert.doesNotMatch(ordinary, /raw-appendix/);
});
+70
View File
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { assembleReportFactTables } from "../src/lib/report-fact-tables";
import { reportFactTablesSchema } from "../src/lib/report-fact-table-schema";
import { assembleLongformCharts } from "../src/lib/personal-report-generation";
import { buildLongformCoverDocument } from "../src/lib/personal-report-longform-cover";
import { CHART_IDS, reportDocumentV2Schema } from "../src/lib/personal-report-contract";
import { projectOrdinaryReportMarkdown } from "../src/lib/report-public-projection";
const packet = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8"));
const resolve = (path: string) => path.replace(/\[(\d+)\]/g, ".$1").split(".").reduce((value, key) => value?.[key], packet);
test("density golden: sixteen real charts have complete houses and nine occupants", () => {
const charts = assembleLongformCharts(packet, "ev-cover-longform");
assert.deepEqual(charts.map(chart => chart.id), [...CHART_IDS]);
assert.equal(reportDocumentV2Schema.shape.charts.safeParse(charts).success, true);
const document = buildLongformCoverDocument({
report: { id: "123e4567-e89b-12d3-a456-426614174000", reportType: "personal_full", presentationMode: "default", depth: "standard", requestedThemes: ["career"], skillName: "jyotish-vedic-astrology", skillVersion: "6.9.14", skillSourceCommit: null, skillSnapshotSha256: "ab".repeat(32) },
subject: { displayName: "虚构测试", birthTimeStatus: "reported", birthPlaceLabel: "虚构地点" },
markdown: "# 个人报告\n\n## 摘要\n\n原始计算供核对。", charts, factTables: assembleReportFactTables(packet),
});
assert.equal(document.factTables?.length, 8);
assert.equal(document.charts.length, 16);
for (const chart of charts) {
assert.equal(chart.houses.length, 12);
assert.equal(chart.houses.flatMap(h => h.occupants).length, 9);
assert.equal(chart.claimStatus, "parameter_sensitive");
}
});
test("density applicability remains outside ordinary narrative projection", () => {
const prose = "# 个人报告\n\n这是普通正文。\n";
const appendix = "\n## 辅助时间轴适用性(参数敏感)\n\n| 大运族 | 是否适用 / 原因 | 使用边界 |\n| --- | --- | --- |\n| Yogini | 已返回周期 | 第二时间轴,仅供交叉核对,不单独生成应期 |\n";
assert.equal(projectOrdinaryReportMarkdown(prose + appendix), projectOrdinaryReportMarkdown(prose + "\n## 核对(参数敏感)\n"));
assert.doesNotMatch(projectOrdinaryReportMarkdown(prose + appendix), /Yogini|第二时间轴/);
});
test("density golden: eight server groups contain only unchanged engine leaves", () => {
assert.equal(packet.fixtureProvenance.fictional, true);
const tables = assembleReportFactTables(packet);
assert.equal(tables.length, 8);
for (const table of tables) {
assert.ok(table.rows.length > 0, table.id);
for (const row of table.rows) assert.deepEqual(row.cells[1], resolve(row.sourcePath), row.sourcePath);
}
assert.equal(tables.find(t => t.id === "shadbala")?.rows.length, 42);
assert.equal(tables.find(t => t.id === "ashtakavarga")?.rows.length, 108);
assert.ok(tables.find(t => t.id === "annual")?.rows.some(row => row.sourcePath.includes("advanced_systems.sahams")));
});
test("density golden: Sade Sati reports missing cycle dates without inventing them", () => {
const table = assembleReportFactTables(packet).find(t => t.id === "sade_sati")!;
assert.match(table.note, /尚无三轮起止日期/);
assert.equal(table.rows.length, 6);
assert.equal(table.claimStatus, "parameter_sensitive");
assert.ok(table.rows.every(row => !/start|end|date/.test(row.sourcePath)));
});
test("density facts preserve declared limitations and reject malformed provenance", () => {
const altered = structuredClone(packet);
altered.worksheets.advanced_systems.yogas_doshas.sade_sati.claimStatus = "user_history_verification_required";
assert.equal(assembleReportFactTables(altered).find(t => t.id === "sade_sati")?.claimStatus, "user_history_verification_required");
const tables = assembleReportFactTables(packet);
tables[0].rows[0].sourcePath = tables[0].sourcePath + "_wrong.lord";
assert.equal(reportFactTablesSchema.safeParse(tables).success, false);
assert.deepEqual(assembleReportFactTables(undefined), []);
});
+9 -2
View File
@@ -43,7 +43,7 @@ TECHNIQUE_STATUSES = ("verified", "partial", "blocked")
CONFLICT_STATUSES = ("unresolved", "partial", "resolved")
REPORT_ACTION_PRIORITIES = ("now", "next", "watch")
V1_CHART_IDS = ("D1", "D9", "D10")
CHART_IDS = ("D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30")
CHART_IDS = ("D1", "D2", "D3", "D4", "D7", "D9", "D10", "D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D60")
HOUSE_NUMBERS = tuple(range(1, 13))
FAILURE_CODES = (
@@ -778,7 +778,14 @@ def validate_report_document(document: Any) -> ValidationResult:
"thematicNarrative", "blockedConflictDisclosure", "evidenceAppendix", "disclaimer",
)
required = v1_required if version == V1_SCHEMA_VERSION else v2_required
_check_keys(result, "(root)", document, required)
_check_keys(result, "(root)", document, required,
(*required, "factTables") if version == V2_SCHEMA_VERSION else required)
if version == V2_SCHEMA_VERSION and "factTables" in document:
try:
from scripts.report_fact_table_contract import validate_fact_tables
except ModuleNotFoundError:
from report_fact_table_contract import validate_fact_tables
validate_fact_tables(document["factTables"], result.add)
_check_pattern(result, "reportId", document.get("reportId"), UUID_PATTERN, "uuid")
_check_enum(result, "reportType", document.get("reportType"), REPORT_TYPES)
_check_enum(result, "presentationMode", document.get("presentationMode"), PRESENTATION_MODES)
+12
View File
@@ -12,6 +12,15 @@ from importlib import import_module
from types import SimpleNamespace
from typing import Any
try:
from scripts.reader_appendix_language import clean_reader_appendix_markdown
from scripts.reader_dasha_applicability import render_dasha_applicability
from scripts.report_density_packet import report_density_packet
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from reader_appendix_language import clean_reader_appendix_markdown
from reader_dasha_applicability import render_dasha_applicability
from report_density_packet import report_density_packet
class ProfessionalReportReferenceInputError(ValueError):
"""The professional-reference request is outside the public contract."""
@@ -109,6 +118,9 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
return {
"format": "markdown",
"markdown": resolved_engine.render_pl9_markdown(packet),
**({"fact_table_packet": report_density_packet(packet),
"reader_dasha_applicability": clean_reader_appendix_markdown(render_dasha_applicability(packet))}
if body.get("include_fact_tables") is True else {}),
}
return {
"format": "json",
+45
View File
@@ -0,0 +1,45 @@
"""Reader-only vocabulary projection; calculation packets and CLI stay untouched."""
from __future__ import annotations
import re
STATUS_LABELS = {
"parameter_sensitive": "参数敏感",
"pyjhora_behavior_only": "仅单一外部参照,未做多引擎核对",
"not_multiengine_parity": "仅单一外部参照,未做多引擎核对",
"unresolved_external_tuple_boundary": "外部边界未对齐",
"partial_verified": "部分核验",
"raw_appendix_only": "仅原始附录可见",
"missing_in_local": "本地暂无",
"internal_reference_omitted": "内部参照已省略",
}
CELL_LABELS = {"blocked": "暂不可用", "executed": "已执行", "available": "可用", "computed": "已计算"}
_TOKEN = re.compile(r"\b(" + "|".join(STATUS_LABELS) + r")\b")
_PRODUCT = re.compile(
r"\bPL9(?:\.pdf)?(?:[ \t]*(?:第[ \t]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*[ \t]*页|"
r"(?:pages?|p)[ \t-]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*))?\b|"
r"\bPL9[ \t]*第[ \t]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*[ \t]*页",
re.IGNORECASE,
)
_FUNCTION = re.compile(r"\b(?:jyotish_engine\.)?(?:cmd_[a-z0-9_]+|render_pl9_markdown|build_professional_report_reference_packet)\b")
_CELL = re.compile(r"(?<=\|)([ \t]*)(`?)(blocked|executed|available|computed)\2([ \t]*)(?=\|)")
def clean_reader_appendix_markdown(markdown: str) -> str:
"""Translate copy without dropping lines, field names, or numeric cells.
Ambiguous English words are changed only as complete pipe-table cells.
Chart fences are immutable: their JSON is a separate renderer contract.
"""
parts = re.split(r"(^[ \t]*```jyotish-chart[^\n]*\n[\s\S]*?^[ \t]*```[^\n]*(?:\n|$))", markdown, flags=re.MULTILINE)
for index in range(0, len(parts), 2):
value = _TOKEN.sub(lambda match: STATUS_LABELS[match[0]], parts[index])
value = _FUNCTION.sub("本地计算", value)
value = re.sub(r"\b(?:[a-z0-9_]+pl9[a-z0-9_]*|pl9_[a-z0-9_]+)(?:\.v\d+)?\b", "外部参照资料", value, flags=re.IGNORECASE)
value = _PRODUCT.sub("外部参照资料", value)
value = re.sub(r"\b(?:PyJHora|JHora)\b", "外部参照引擎", value, flags=re.IGNORECASE)
value = _CELL.sub(lambda match: f"{match[1]}{match[2]}{CELL_LABELS[match[3]]}{match[2]}{match[4]}", value)
parts[index] = value
return "".join(parts)
+73
View File
@@ -0,0 +1,73 @@
"""Reader-only applicability notes; never changes dasha calculations or periods."""
BOUNDARY = "第二时间轴,仅供交叉核对,不单独生成应期"
MISSING = "缺少本次计算的结构化适用性证据,无法确认各辅助时间轴是否适用;不补算、不补写日期。"
REASON_LABELS = {
"patyayini_dasha_missing": "本次计算未提供帕特雅伊尼年运周期,暂不能用于时间核对。",
"The Shodashottari hora-plus-paksha applicability gate is not met.": "未满足该大运对时辰分区与月相组合的适用条件。",
"This profile requires Navamsa Lagna in Taurus or Libra.": "该大运要求九分盘上升落在金牛座或天秤座,本次未满足。",
"Dwisaptatisama requires Lagna lord in the 1st or 7th house.": "该大运要求上升主星位于第一宫或第七宫,本次未满足。",
"Panchottari requires a Cancer Ascendant that is also Cancer in Dwadasamsa.": "该大运要求本命及十二分盘上升均为巨蟹座,本次未满足。",
"Satabdika requires a vargottama Ascendant (same Rasi and Navamsa sign).": "该大运要求本命及九分盘上升同星座,本次未满足。",
"Chaturshitisama requires the 10th lord to occupy the 10th house.": "该大运要求第十宫主星位于第十宫,本次未满足。",
"Shastihayani requires Sun in the Lagna sign.": "该大运要求太阳位于上升星座,本次未满足。",
}
def _reader_reason(value):
if value in REASON_LABELS:
return REASON_LABELS[value]
if value.startswith("Moon Nakshatra '") and "does not qualify for Ashtottari Dasha under " in value:
return "月宿与月相组合未满足该大运的适用条件,不绕过判定。"
return "计算返回了限制原因,但暂无可核对的详细说明;不据此补写适用条件或日期。"
def _has_periods(key, raw, source):
fields = ("periods", "major") + (("mahadasha_sequence", "dense_boundaries") if key == "narayana" else ())
return any(isinstance(data.get(field), (list, dict)) and bool(data[field]) for data in (raw, source) for field in fields)
def _cell(value):
return str(value).replace("|", "/").replace("\r", " ").replace("\n", " ").replace("<", "<").replace(">", ">")
def render_dasha_applicability(packet):
timing = packet.get("worksheets", {}).get("timing_and_predictive_systems", {})
master = timing.get("dasha_master_pack", {})
families = master.get("families", {})
lines = ["", "## 辅助时间轴适用性(参数敏感)", ""]
if not isinstance(families, dict) or not families:
return "\n".join(lines + [MISSING, ""])
families = dict(families)
annual = timing.get("annual_tajika_pack", {})
for key, value in annual.items():
if key.endswith("_dasha"):
families.setdefault(key.removesuffix("_dasha"), value)
lines += ["| 大运族 | 是否适用 / 原因 | 使用边界 |", "| --- | --- | --- |"]
for key in sorted(families):
if key == "vimshottari":
continue
raw = families[key] if isinstance(families[key], dict) else {}
source_key = "kalachakra_dasha" if key == "kala_chakra" else f"{key}_dasha"
source = timing.get(source_key, {})
source = source if isinstance(source, dict) else {}
status = raw.get("status", raw.get("execution_status"))
applicable = source.get("applicable", raw.get("applicable"))
if applicable is False or status == "not_applicable":
reason = "本次不适用,不绕过原适用条件,不补写日期。"
elif status in {"blocked", "missing_in_local", "unavailable"}:
reason = "本次不可用:本地计算尚未提供可用周期,不能据此判断时间。"
elif _has_periods(key, raw, source):
reason = "本次已返回周期,可作交叉核对;这不代表已完成多引擎验证。"
else:
reason = "本次适用性尚未核实,缺少可核对周期,不补写日期。"
if applicable is True:
reason += " 原适用性判定:适用。"
reasons = list(dict.fromkeys(_reader_reason(str(value)) for value in (raw.get("reason"), source.get("reason")) if value))
if reasons:
reason += " 原因:" + ";".join(reasons)
name = "Kala Chakra" if key == "kala_chakra" else key.replace("_", "-").title()
lines.append(f"| {_cell(name)} | {_cell(reason)} | {BOUNDARY} |")
return "\n".join(lines) + "\n"
+19
View File
@@ -0,0 +1,19 @@
"""Select calculation groups for server-owned report assembly, not writer input."""
GROUPS = {
"strengths_and_scores": ("ashtakavarga", "shadbala", "functional_benefic_malefic"),
"timing_and_predictive_systems": ("dasha", "annual_tajika_pack"),
"advanced_systems": ("avasthas", "yogas_doshas", "sahams"),
"divisional_and_special_charts": ("upagrahas", "special_lagnas", "sensitive_points", "varga_full"),
}
def report_density_packet(packet):
worksheets = packet.get("worksheets", {})
return {
"core_chart": packet.get("core_chart", {}),
"worksheets": {
group: {key: worksheets.get(group, {})[key] for key in keys if key in worksheets.get(group, {})}
for group, keys in GROUPS.items()
},
}
+61
View File
@@ -0,0 +1,61 @@
"""Optional v2 server-owned fact tables; no writer output accepts these fields."""
import math
import re
GROUPS = {"vimshottari", "ashtakavarga", "shadbala", "functional_roles", "avasthas", "special_points", "sade_sati", "annual"}
PATH = re.compile(r"[A-Za-z0-9_.\[\]-]{1,400}")
STATUSES = {"multi_system_consensus", "single_system_inference", "parameter_sensitive", "unclosed_divisional_chart", "user_history_verification_required", "blocked"}
def validate_fact_tables(value, add):
def text(item, maximum, minimum=0):
return isinstance(item, str) and minimum <= len(item) <= maximum
if not isinstance(value, list) or len(value) > 8:
add("factTables", "must be array with at most eight groups")
return
seen = set()
for index, table in enumerate(value):
path = f"factTables[{index}]"
if not isinstance(table, dict) or set(table) != {"id", "title", "claimStatus", "sourcePath", "note", "columns", "rows"}:
add(path, "invalid table keys")
continue
group = table["id"]
if not isinstance(group, str) or group not in GROUPS or group in seen:
add(path, "invalid or duplicate group")
else:
seen.add(group)
source = table["sourcePath"]
if not isinstance(source, str) or not PATH.fullmatch(source):
add(path, "invalid source path")
continue
if not text(table["title"], 120, 1) or not text(table["note"], 500):
add(path, "invalid text")
if table["claimStatus"] not in STATUSES:
add(path, "invalid claim status")
columns = table["columns"]
if not isinstance(columns, list) or not 1 <= len(columns) <= 16 or not all(text(c, 80, 1) for c in columns):
add(path, "invalid columns")
continue
rows = table["rows"]
if not isinstance(rows, list) or len(rows) > 2000:
add(path, "invalid rows")
continue
for row in rows:
if not isinstance(row, dict) or set(row) != {"sourcePath", "cells"}:
add(path, "invalid row keys")
continue
origin = row["sourcePath"]
if not isinstance(origin, str) or not PATH.fullmatch(origin) or not (origin == source or origin.startswith(source + ".") or origin.startswith(source + "[")):
add(path, "fact_table_source_mismatch")
cells = row["cells"]
if not isinstance(cells, list) or len(cells) != len(columns):
add(path, "fact_table_cell_count")
continue
for cell in cells:
if cell is None or isinstance(cell, bool) or text(cell, 300):
continue
if isinstance(cell, (int, float)) and math.isfinite(cell):
continue
add(path, "invalid cell")
+70
View File
@@ -0,0 +1,70 @@
"""Copy-only tests: these strings are vocabulary cases, not engine fixtures."""
import pytest
from scripts.reader_appendix_language import clean_reader_appendix_markdown
@pytest.mark.parametrize("raw,label", [
("parameter_sensitive", "参数敏感"),
("pyjhora_behavior_only", "仅单一外部参照,未做多引擎核对"),
("not_multiengine_parity", "仅单一外部参照,未做多引擎核对"),
("unresolved_external_tuple_boundary", "外部边界未对齐"),
("partial_verified", "部分核验"),
("raw_appendix_only", "仅原始附录可见"),
("missing_in_local", "本地暂无"),
])
def test_status_copy_preserves_rows(raw, label):
source = f"| sign_cn | degree_in_sign | nakshatra_lord |\n| 白羊座 | 12.5 | {raw} |\n"
result = clean_reader_appendix_markdown(source)
assert result == source.replace(raw, label)
assert result.count("\n") == source.count("\n")
@pytest.mark.parametrize("raw", ["PyJHora", "JHora", "pyjhora"])
def test_external_engine_names(raw):
assert clean_reader_appendix_markdown(raw) == "外部参照引擎"
@pytest.mark.parametrize("raw", ["PL9", "PL9 第 83-88 页", "PL9 第 40 / 48 页", "PL9 page 99-100", "PL9.pdf", "PL9 第 43–44 页"])
def test_external_product_pages(raw):
assert clean_reader_appendix_markdown(raw) == "外部参照资料"
@pytest.mark.parametrize("raw", ["cmd_full_reading", "jyotish_engine.cmd_full_reading", "render_pl9_markdown", "build_professional_report_reference_packet"])
def test_internal_functions(raw):
assert clean_reader_appendix_markdown(raw) == "本地计算"
@pytest.mark.parametrize("raw,label", [("blocked", "暂不可用"), ("executed", "已执行"), ("available", "可用"), ("computed", "已计算")])
def test_ambiguous_words_only_in_whole_cells(raw, label):
prose = f"The {raw} result is not a claim.\n| {raw} result | `{raw}` | {raw} |\n"
assert clean_reader_appendix_markdown(prose) == f"The {raw} result is not a claim.\n| {raw} result | `{label}` | {label} |\n"
assert clean_reader_appendix_markdown(raw) == raw
def test_chart_fence_unchanged_and_copy_idempotent():
fence = '```jyotish-chart\n{"title":"parameter_sensitive","id":"D1"}\n```\n'
source = "参数敏感\n" + fence + "| parameter_sensitive |\n"
result = clean_reader_appendix_markdown(source)
assert fence in result
assert clean_reader_appendix_markdown(result) == result
def test_public_boundary_only_leaves_internal_renderer_and_json_unchanged():
from scripts.professional_report_reference import build_professional_report_reference
from types import SimpleNamespace
packet = {"status": "parameter_sensitive"}
engine = SimpleNamespace(
build_professional_report_reference_packet=lambda *args: packet,
render_pl9_markdown=lambda value: "| parameter_sensitive | cmd_full_reading |",
)
handler = SimpleNamespace(
_high_rigor_birth_payload=lambda body: {"year": 2000, "hour": 0, "minute": 0, "today": "2026-01-01"},
_compute_full_reading_for_thematic=lambda birth: {},
)
assert build_professional_report_reference(handler, {"format": "json"}, engine=engine)["report"] is packet
assert build_professional_report_reference(handler, {"format": "markdown"}, engine=engine)["markdown"] == "| parameter_sensitive | cmd_full_reading |"
assert engine.render_pl9_markdown(packet) == "| parameter_sensitive | cmd_full_reading |"
assert packet == {"status": "parameter_sensitive"}
+89
View File
@@ -0,0 +1,89 @@
"""Golden facts use deliberately fictional input, never a user birth record."""
import copy
import json
from pathlib import Path
from scripts.reader_dasha_applicability import BOUNDARY, render_dasha_applicability
from scripts.report_fact_table_contract import validate_fact_tables
ROOT = Path(__file__).resolve().parents[1]
def test_golden_applicability_never_relaxes_missing_or_inapplicable_families():
packet = json.loads((ROOT / "frontend/tests/fixtures/report-density-fictional-reader.json").read_text(encoding="utf8"))
original = copy.deepcopy(packet)
output = render_dasha_applicability(packet)
families = packet["worksheets"]["timing_and_predictive_systems"]["dasha_master_pack"]["families"]
assert len(families) == 15
assert output.count(BOUNDARY) == len(families) - 1 + 2
for key, value in families.items():
if key == "vimshottari":
assert "| Vimshottari |" not in output
continue
name = "Kala Chakra" if key == "kala_chakra" else key.replace("_", "-").title()
assert f"| {name} |" in output
if value.get("reason"):
from scripts.reader_dasha_applicability import REASON_LABELS
assert REASON_LABELS[value["reason"]] in output
assert value["reason"] not in output
assert all(name in output for name in ["Narayana", "Tribhagi-40", "Shattrimshatsama", "Satabdika", "Mudda", "Patyayini"])
assert "月宿与月相组合未满足该大运的适用条件" in output
assert "本地计算尚未提供可用周期" in output
assert packet == original
def test_golden_narayana_periods_and_readable_known_and_unknown_reasons():
packet = json.loads((ROOT / "frontend/tests/fixtures/report-density-fictional-reader.json").read_text(encoding="utf8"))
timing = packet["worksheets"]["timing_and_predictive_systems"]
assert len(timing["narayana_dasha"]["mahadasha_sequence"]) == 12
assert len(timing["narayana_dasha"]["dense_boundaries"]) == 156
output = render_dasha_applicability(packet)
narayana = next(line for line in output.splitlines() if line.startswith("| Narayana |"))
assert "本次已返回周期" in narayana
assert "这不代表已完成多引擎验证" in narayana
assert "缺少可核对周期" not in narayana
assert "本次计算未提供帕特雅伊尼年运周期" in output
assert "patyayini_dasha_missing" not in output
timing["annual_tajika_pack"]["patyayini_dasha"]["reason"] = "unknown_internal_reason_123 arbitrary English detail"
unknown = render_dasha_applicability(packet)
assert "暂无可核对的详细说明" in unknown
assert "unknown_internal_reason_123" not in unknown
assert "arbitrary English detail" not in unknown
def test_legacy_applicability_discloses_missing_evidence_without_guessing_families():
output = render_dasha_applicability({})
assert "缺少本次计算的结构化适用性证据" in output
assert BOUNDARY not in output
def test_full_golden_shared_markdown_is_byte_identical_and_reader_copy_is_clean():
from types import SimpleNamespace
from scripts.professional_report_reference import build_professional_report_reference
from scripts.reader_appendix_language import clean_reader_appendix_markdown
packet = json.loads((ROOT / "frontend/tests/fixtures/report-density-fictional-reader.json").read_text(encoding="utf8"))
raw = packet["markdown"]
engine = SimpleNamespace(build_professional_report_reference_packet=lambda *args: packet, render_pl9_markdown=lambda value: raw)
handler = SimpleNamespace(_high_rigor_birth_payload=lambda body: {"year": 2000, "hour": 12, "minute": 0, "today": "2026-09-22"}, _compute_full_reading_for_thematic=lambda birth: {})
for include in (False, True):
result = build_professional_report_reference(handler, {"format": "markdown", "include_fact_tables": include}, engine=engine)
assert result["markdown"].encode("utf8") == raw.encode("utf8")
if include:
assert result["reader_dasha_applicability"] == packet["reader_dasha_applicability"]
clean = clean_reader_appendix_markdown(raw)
assert "pl9" in raw.lower()
assert "pl9" not in clean.lower()
assert len(clean.splitlines()) == len(raw.splitlines())
assert sum(line.startswith("|") for line in clean.splitlines()) == sum(line.startswith("|") for line in raw.splitlines())
for field in ("degree_in_sign", "sign_cn", "nakshatra_lord"):
assert clean.count(field) == raw.count(field)
def test_optional_table_validator_fails_closed_on_bad_shape():
errors = []
validate_fact_tables([{"id": "annual"}], lambda *error: errors.append(error))
assert errors
errors.clear()
validate_fact_tables([], lambda *error: errors.append(error))
assert not errors