15 KiB
TASK · 长报告页渲染全部星盘(北印式、前端自绘)— 2026-09-09
- 基线:
origin/staging@54adb0d1(2026-09-09)。开工前git fetch origin --prune,以远端为准。 - 分支:
codex/report-chart-render-20260909,worktree.worktrees/report-chart-render-20260909。 - 涉及:Python 引擎(
scripts/jyotish_engine.py图盘输出、scripts/chart_renderer.py)+ 前端报告页(personal-report-markdown-view.tsx、vedic-chart-svg.tsx、下载)。不改 api_server、不改数据库、不改写作 agent。 - 串行:同日无其他任务书改这几个文件。
TASK-report-*系列在 09-09 只有本单。
1. 事故实证
产品负责人对照竞品(同类产品报告页每张分盘都有图,北印/南印可切)发现:我们的长报告页 /reports/<id> 里 #### D1 — Rashi Chart(本命盘)、#### D9 — Navamsa(婚盘)、#### Moon Chart(月亮参考盘) 以及「Vargas I」图集下的 19 个 #### D2…D60 标题下面全部是空白,只有标题没有图。
链路逐段核过(基线 SHA 上按符号定位):
| 段 | 位置 | 结论 |
|---|---|---|
| 引擎渲染 | scripts/jyotish_engine.py render_pl9_markdown 内 _render_south_chart(L4184)→ chart_renderer.render_south_indian_chart(scripts/chart_renderer.py L60–126) |
正常。本地用 pl9-export 跑真实引擎,Markdown 2,985 行 / 293 KB,含 22 个内联 <svg>(D1、D9、Moon 各 1 + 图集 19) |
| 脱敏 | sanitize_professional_report_reference_markdown(L17530) |
只做路径脱敏,不动 <svg> |
| 接口 / 存储 | scripts/professional_report_reference.py L108–112 返回 markdown;前端 personal-report-longform-generate.ts L140–159 原样入库 |
不丢 |
| 前端渲染 | frontend/src/components/personal-report/personal-report-markdown-view.tsx renderMarkdown(L55–66):react-markdown + skipHtml + disallowedElements=[script,iframe,object,embed,img] |
丢在这里。skipHtml 把所有内联 HTML 节点静默丢弃,<svg>…</svg> 整块消失,标题后只剩空行 |
对照历史:09-06 之前的「五章写作稿」阅读页(personal-report-document-view.tsx,现已退役、仅存储不渲染)是用 React 组件 VedicChartSvg 画北印式图的;09-06 TASK-report-md-page-20260906.md 把阅读页换成 Markdown 直渲后,图盘随 skipHtml 一起没了。这是产品回归,不是引擎缺能力。
skipHtml 本身不是错:--birthplace-label 等用户字符串会进 Markdown 正文,放开原始 HTML(rehype-raw)等于把用户输入当 HTML 渲染,09-06 任务书 §渲染安全把它列为红线,本单维持这条红线。
2. 根因
- 图盘只以
<svg>内联 HTML 的形式存在于 Markdown 里,而报告页的渲染器按设计不渲染任何内联 HTML。两边各自正确,合起来没有一条让图到达页面的通路。 - 引擎 SVG 本身也不适合直接上页面:南印式、英文缩写、无逆行标记、每张分盘中心都写死
Rasi Chart (D1)(chart_renderer.pyL118–119)、颜色写死不跟主题。
3. 决策记录(产品负责人 2026-09-09 拍板)
| 决策 | 内容 |
|---|---|
| D1 | 北印式。报告页所有图盘用北印式(菱形)布局;不做南印 / 西洋切换,不做「分盘下拉」。 |
| D2 | 全部渲染。引擎输出了几张就渲染几张(当前 22 张:D1、D9、Moon、图集 D2–D60),不挑。 |
| D3 | 前端自己的组件画图。数据由引擎以结构化 JSON 给出,前端 zod 校验后用 React SVG 组件绘制;不引入 rehype-raw / rehype-sanitize,skipHtml 红线不动。 |
| D4 | 引擎 Markdown 里的 <svg> 保留(.md 导出与外部阅读器、tests/run_all.py 的 '<svg' in svg 断言仍靠它),在其旁边追加一段 ```jyotish-chart 围栏 JSON;报告页只认围栏块。 |
| D5 | 「导出报告(.md)」下载前剥掉围栏 JSON 块(读者不需要看 22 段 JSON),<svg> 留在文件里。 |
| D6 | 现有 VedicChartSvg 的 4×4 方格 + 装饰菱形不是北印式,要改成真正的北印式几何(§5 任务 3 给了顶点表)。退役的 document view 仍引用该组件,随之一起换几何即可,不需要单独适配。 |
| D7 | 标签语言:宫内行星用一字中文(日 月 火 水 木 金 土 罗 计)+ 整数度(日 12°),逆行加「逆」(土逆 3°);每宫角上标星座序号(1=白羊 … 12=双鱼,北印惯例);图下不再加标题(Markdown 里的 #### 标题就是标题)。 |
无推翻既有红线。TASK-report-md-page-20260906.md §渲染安全(skipHtml、disallowedElements、urlTransform)保持。
4. 硬红线
- 不引入
rehype-raw、rehype-sanitize、dangerouslySetInnerHTML;图盘的每一个文本节点都必须来自 zod 校验过的 JSON 字段,且行星名 / 星座名只能是白名单枚举映射后的中文,不得把 JSON 里的字符串原样画到 SVG 上。 skipHtml、disallowedElements、urlTransform三项不改。scripts/jyotish_api_server.py不增行。引擎侧新逻辑进新模块scripts/report_chart_block.py,jyotish_engine.py只在_render_south_chart处接一行。- 现有 Markdown 里
<svg>的数量、顺序、#### D1 — Rashi Chart(本命盘)等标题文案不变(tests/test_full_report_quality_gate.py、scripts/full_report_quality_gate.pyL232、tests/run_all.pyL270–272 / L600 依赖)。 - 前端
tsc --noEmit0 错、npm run lint0 error、测试总数不低于开工实测、next build后/仍 Static、首屏 gzip ±2%(报告页是独立路由,首屏不该动)。 - 合同测试 fixture 必须是真实引擎输出(golden),不得手造形状;fixture 用公开名人或虚构出生资料。
- 不顺手改
chart_renderer.py的南印布局、颜色;只修 §5 任务 4 那一处标签。
5. 任务分解
任务 1 · 引擎:每张图旁边输出结构化围栏块
新模块 scripts/report_chart_block.py:
def build_chart_block(chart_id: str, title: str, positions: dict, ascendant_row: dict,
retrograde_by_planet: dict[str, bool]) -> str
返回:
```jyotish-chart
{"version":1,"id":"D9","title":"D9 — Navamsa(婚盘)","layout":"north",
"ascendant":{"sign":"Leo","degree":12.34},
"planets":[{"name":"Sun","sign":"Leo","degree":3.21,"retrograde":false}, …]}
```
规则:
id从 varga key 取D前缀部分(D2_Hora→D2,D1,月亮盘固定MOON);title与现有####标题一致。sign用引擎英文星座名(Aries…Pisces);degree是宫内度degree_in_sign,保留两位小数;行星固定九曜顺序。retrograde分盘里没有,一律从packet['core_chart']['planets'][name]['retrograde']取;Rahu/Ketu 按引擎原值。- 在
_render_south_chart(L4184)里 SVG 之后拼接:svg + '\n\n' + block。_图盘生成失败_分支不追加块。 - JSON 单行、
ensure_ascii=False、json.dumps生成;不得把任何用户输入字符串(地名、备注、报告 ID)放进块。
验收:
.venv/bin/python -m pytest tests/test_report_chart_block.py:用真实引擎(build_professional_report_reference_packet→render_pl9_markdown,与tests/test_full_report_quality_gate.py同一条路径)跑一份 golden,断言 围栏块数 ==<svg>数(当前 22);每块 JSON 可解析、planets≤ 9、degree ∈ [0,30)、sign在 12 枚举内;D1块的ascendant.sign==core_chart.ascendant.sign;每块每颗星的retrograde==core_chart同名值;块内不含 golden 请求里的地名字符串。tests/test_full_report_quality_gate.py、tests/run_all.py相关断言仍绿。- 快速门
run_quality_gate.py --profile quick通过。
任务 2 · 前端:围栏块 → zod → 北印式组件
新文件 frontend/src/lib/report-chart-block.ts:
reportChartBlockSchema(zodstrictObject):version: literal(1);id: /^(D\d{1,3}|MOON)$/;title: string ≤120;layout: literal("north");ascendant: {sign: enum(12 英文), degree: number ≥0 <30};planets: array(≤9) of {name: enum(Sun…Ketu), sign, degree, retrograde: boolean}。parseReportChartBlock(source: string): ReportChartBlock | null:JSON.parse失败或 zod 失败均返回null,不抛。toNorthIndianChart(block):按整宫制从上升星座序号推 12 宫(第 i 宫星座 =(ascIndex + i) % 12,参考personal-report-generation.tsderiveVargaHousesFromEngineL394 的做法),每宫occupants为行星显示标签(§3 D7 口径),行星→中文与星座→序号用本文件内的常量表,不复用SAFE_CELESTIAL_NAMES(那是英文)。stripReportChartBlocks(markdown: string): string:删除所有```jyotish-chart围栏块(含前后各一空行)。
personal-report-markdown-view.tsx:
markdownComponents增加pre渲染器:用markdown-code-block.tsx同样的language-xxx判法取围栏语言;语言为jyotish-chart→parseReportChartBlock→ 成功则渲染<figure className="personal-report-chart-figure"><NorthIndianChartSvg … /></figure>;失败则渲染一行<p className="personal-report-chart-invalid">图盘数据无效</p>,不把源码回显。其他语言的pre保持现状(当前报告 Markdown 里没有别的围栏块)。- 图形容器:最大宽 360px、居中、上下
--space-*间距;图集 19 张在 ≥720px 视口按两列网格排(.personal-report-chart-grid,由 H3 section 内相邻 figure 自动成组即可,不改 outline);打印保持单列。
personal-report-longform-download.ts / 报告中心「导出报告(.md)」:调用 downloadMarkdownReport 前先 stripReportChartBlocks。
验收:
frontend/tests/report-chart-block.test.ts:fixturefrontend/tests/fixtures/report-chart-blocks-golden.json由任务 1 的真实引擎输出提取(脚本或手工,进度记录写来源命令);断言 22 块全部 parse 成功;D1 块第 1 宫星座 == ascendant,第 12 宫 == ascendant 前一个;逆行行星标签以「逆」结尾;title里塞<img onerror>的篡改样本 parse 后不影响输出(标题不上图);坏 JSON / 多余字段 /degree: 31/ 未知行星名 →null。frontend/tests/personal-report-markdown-view.test.tsx(新):renderToStaticMarkup一段含围栏块 + 一段原始<svg>的 Markdown,断言输出含<svg(来自组件,含role="img")且不含引擎 SVG 的viewBox="0 0 420 480";坏块输出「图盘数据无效」且不含<script。- 下载测试:
stripReportChartBlocks(golden)后不含```jyotish-chart,<svg数不变。 tsc/lint/npm test/next build四项红线。
任务 3 · VedicChartSvg 改成真正的北印式几何
现组件是 4×4 方格外圈 + 中央装饰菱形,宫位是方格,不是北印式。改为标准北印式:正方形边长 S(viewBox 400),两条对角线 + 四边中点连成的内菱形,12 宫多边形顶点(h=S/2, q=S/4):
| 宫 | 形状 | 顶点 |
|---|---|---|
| 1 | 上中菱形 | (h,0) (3q,q) (h,h) (q,q) |
| 2 | 左上三角 | (0,0) (h,0) (q,q) |
| 3 | 左上三角 | (0,0) (q,q) (0,h) |
| 4 | 左中菱形 | (0,h) (q,q) (h,h) (q,3q) |
| 5 | 左下三角 | (0,h) (q,3q) (0,S) |
| 6 | 左下三角 | (0,S) (q,3q) (h,S) |
| 7 | 下中菱形 | (h,S) (q,3q) (h,h) (3q,3q) |
| 8 | 右下三角 | (h,S) (3q,3q) (S,S) |
| 9 | 右下三角 | (S,S) (3q,3q) (S,h) |
| 10 | 右中菱形 | (S,h) (3q,3q) (h,h) (3q,q) |
| 11 | 右上三角 | (S,h) (3q,q) (S,0) |
| 12 | 右上三角 | (S,0) (3q,q) (h,0) |
- 星座序号放在每宫靠内顶点处(菱形宫放中心侧顶点内 12px,三角宫放直角顶点内 14px),muted;行星标签从宫多边形质心起竖排,最多 6 行,超出合并为
+N;文字用clipPath裁到本宫多边形。 - 颜色只用现有 token(线
--color-border-strong、文字--color-ink、muted--color-ink-muted),暗色主题跟随;不引入新颜色。 - props 类型从
ChartV1放宽为结构类型{houses: {houseNumber, sign, occupants}[]; planets?: {name, retrograde}[]}(ChartV1可赋值给它),personal-report-contract.ts的 zod 合同不动。 - 保留现有导出名
VedicChartSvg、hasRealChartData、findChart;personal-report-document-view.tsx不改。
验收:
- 现有引用
vedic-chart-svg的测试仍绿;若改断言写「原值 / 新值 / 原因」三栏。 - 12 宫顶点常量有单测:每宫多边形面积之和 ==
S²(允许浮点误差),且相邻宫不重叠(用采样点 point-in-polygon 各归一宫)。 frontend/DESIGN.md「Personal report reader」加一段 chart figure 规范(尺寸、网格、标签口径、打印),同一提交。
任务 4 · chart_renderer.py 中心标签跟标题走
render_south_indian_chart L118–119 每张分盘都写 Rasi Chart (D1)。改为从 title 取 D\d+ 或 Moon 作第二行,第一行改 Chart;D1 输出不变。
验收:
tests/test_report_chart_block.py里加一条:D9 的<svg>含(D9)、不含(D1)。
任务 5 · 记录
docs/BUG_HISTORY.md新增一条(编号见 §8):状态resolved,现象 / 触发 / 根因(skipHtml 丢<svg>,09-06 直渲回归)/ 修复 / 验证 / 防复发(任务 2 的 markdown-view 测试)/ 关联TASK-report-md-page-20260906.md。CHANGELOG.md:「报告页恢复星盘,改北印式,全部分盘可见」。Skill 版本不 bump(引擎输出只加围栏块,SKILL.md 不改)。docs/tasks/PROGRESS-report-chart-render-20260909.md:门禁四项实测数字、测试总数开工 / 收工、golden 来源命令、gzip 前后。docs/testing/report-chart-render-20260909.md:真人清单(生成一份新报告 → 22 张图可见、暗色主题可读、手机单列、打印单列、导出.md打开无 JSON 块、旧报告(无围栏块)仍显示标题不报错)。
6. 让步顺序
做不完时按这个顺序砍,砍了写进 PROGRESS:
- 任务 3 的两列网格与
+N折叠(先单列、先截断)。 - 任务 4(中心标签)。
- 任务 2 的下载剥块(先允许
.md里带 JSON 块)。 - 不可砍:任务 1、任务 2 的渲染与测试、任务 3 的北印几何、任务 5。
7. 开工前置命令
git -C /workspace/Jyotisha status -sb | head -1 # 确认不在别人的分支上
git fetch origin --prune
git worktree add -b codex/report-chart-render-20260909 .worktrees/report-chart-render-20260909 origin/staging
cd .worktrees/report-chart-render-20260909
.venv/bin/python -m pytest tests/test_full_report_quality_gate.py -q # 基线
cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -5 # 记录开工测试总数
引擎改动只在 render_pl9_markdown 输出层与 chart_renderer.py 一行标签,不涉及运行入口 / 镜像 / 外部 oracle,§9 预检不要求。
8. BUG 编号起点
docs/BUG_HISTORY.md 当前最大 BUG-601;TASK-rectification-timeline-fix-20260909.md 已占 602–603,TASK-rectification-conversation-economy-20260909.md 已占 604–606。本单从 BUG-607 起,开工时重新核对。