报告前面加一段模型写的解读(issue #113 R1)

引擎在同一份 packet 上给出与对话同源卡相同的事实(include_reading_facts),
报告任务交付前用一次结构化模型调用写八节解读,校验不过或失败就不出解读、
报告照常交付。解读存在计算快照里,读接口一起返回,页面放在引擎正文之前,
后面标「计算依据」。用量按实际 token 记账(BUG-1460、BUG-1461)。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
This commit is contained in:
Jesse_Chen
2026-10-27 11:17:56 +08:00
co-authored by Claude Opus 5.5
parent e5d768f16f
commit cad28ddc04
26 changed files with 1188 additions and 22 deletions
+7
View File
@@ -1,5 +1,12 @@
# BLOCKED
## TASK-voice-reading-20261027:真实模型输出没看(issue #113,2026-10-27)
- **报告解读**:本机没有 `DS_KEY`、`BACKTEST_MODEL_KEY`、`OPENAI_API_KEY`、`ANTHROPIC_API_KEY`。解读的形状、校验、失败兜底、记账都用假模型测过;真实模型写出来的解读是什么样、几成能一次过校验、耗时多少,都没测,不得写成通过。
- **对话 C1**:改了普通对话提示词,AGENTS §10 要求的名人生平回测(`frontend/scripts/research/consult-biography-backtest.mts`)需要模型 key,没跑。
- 替代证据:提示词合同测试与解读单元测试。部署 staging 后由产品负责人按 `docs/testing/voice-reading-20261027.md` 读两三份新报告、问几轮对话。
- 解除:有 key 的环境跑一次回测,并在 staging 上看过解读的通过率和耗时(服务端日志 `[personal-report] reading=`)。
## TASK-synastry-full-report-fix-20261019:真实模型没跑,aspects.py 不换(2026-10-19)
- **真实模型**:修复单要求用两位公开名人各跑一份中文和一份英文,质检一次或两次内通过,并把每节字数写进进度。本机环境里没有 `DS_KEY`、`BACKTEST_MODEL_KEY`、`OPENAI_API_KEY`、`ANTHROPIC_API_KEY`、`GEMINI_API_KEY`。没有调用模型,不得写成通过。
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-10-27 — 报告前面加一段「解读」,对话把「为什么」写进正文
- 新生成的中文报告最前面多一段「解读」:总评、性格、事业、财富、感情、健康、现在这几年、几条建议。由模型按引擎已算好的盘面来写,用「你」说话,每段说清盘上是什么、为什么、生活里是什么样子。原来的报告内容放在后面,标为「计算依据」,内容不变。
- 解读写不出来或没通过检查时,报告照常生成,只是没有这一段。英文版和以前的报告不变。报告价格不变。
- 对话里提到宫位时,第一次会说这一宫管生活里的哪一块,例如「第 8 宫(突发的变化、没摊开的事、别人的钱)」。依据不再放在段尾括号里重复一遍,改成正文里一句「因为……」。讲时间最多说三件事,由近到远。问事业、钱、时间时,结尾不再猜你为什么问。
- Skill 版本不变(BUG-1460、BUG-1461)。
## 2026-10-27 — 星盘页说明上升会不会换,校正开场说清限度
- 星盘页在出生资料下面多一句:按你给的时间精度,上升是不是都落在同一个星座。会换时可以点「出生时间校正」。算不出来就不显示这句,星盘照常打开。
+32
View File
@@ -19690,3 +19690,35 @@
- 复发自:无
- 修复版本:分支 `codex/lagna-precheck-20261027`。
## BUG-1460 | 对话里宫位只给编号、依据在段尾括号里重复一遍,读者看不懂
- 状态:resolved(真实模型输出待 staging 复核)
- 首次发现:2026-10-27
- 最近更新:2026-10-27
- 影响面:普通对话的首轮与追问轮回答(本命、无出生分钟、申报时段三种模式共用的口吻)。
- 用户现象:产品负责人贴来一段现行回答:「落在第 9 宫」「走过你的第 8 宫」只有编号;段尾括号把正文用行话再说一遍;为什么 9 宫就等于方向感没有说;时间那段一口气列了五件天象;问整体运势时结尾猜「你心里正等着一个变化」。朋友对照竞品的评价是竞品「像占星师说的话」,我们的看不懂。
- 触发条件:任何引用宫位或给出盘上依据的回答。
- 根因:2026-10-06 起的回答形状要求「每段判断后在括号里给盘上依据,每段最多一处括号」(BUG-1244 / 1253)。模型照做,括号成了给内行看的脚注;提示词没有要求宫位带生活领域,也没有限制时间段里的天象件数;读心规则允许在任何问题的结尾读用户自己的心。研究见 `docs/research/voice_vs_competitor_2026_10_27.md`。
- 修复:产品 2026-10-27 拍板「R1 C1」。依据改为正文里「因为……」半句,段尾不再用括号复述(`CHART_REASON_RULE`);宫位第一次出现带生活领域,十二宫一张表(`HOUSE_LIFE_AREA_RULE`);讲时间最多三件、由近到远,入庙 / 本垣 / 小运首现带白话(`TIMING_FOCUS_RULE`);用户自己的心只在问情绪、关系感受时读,问事业、钱、时间时结尾不猜动机。示例改成新写法并加一条「段尾括号复述」的 Bad。术语仍只用中文(不采用 C2)。
- 验证:`frontend/tests/consultation-voice-contract.test.ts`、`consult-no-presupposition-20261001.test.ts`、`consult-plain-speech-20261006.test.ts`、`consult-upstream-functional-v2-20261002.test.ts` 按新规则改断言(三栏),consult 全部 536 项通过。名人生平回测需要模型 key,未跑(BLOCKED.md)。
- 防复发:三条规则各是一个常量,提示词只引用常量;合同测试锁住「依据写进正文」「宫位带生活领域」「时间最多三件」和旧括号句不再出现。示例里不再出现「逆行」(BUG-1165 照旧)。
- 相关记录:BUG-1244、BUG-1253、BUG-1255、BUG-1165、BUG-1132~1134。issue #113。
- 复发自:无(推翻 BUG-1244 / 1253 的括号写法,是产品决定)
- 修复版本:分支 `codex/voice-reading-20261027`。
## BUG-1461 | 报告是引擎模板拼的,没有一段写给人读的解读
- 状态:resolved(真实模型输出待 staging 复核)
- 首次发现:2026-10-27
- 最近更新:2026-10-27
- 影响面:新生成的中文个人报告(完整数据版)。
- 用户现象:报告没有总评和性格章,九成以上是表格;大运段每句都在免责,「可能」类词是竞品的约 55 倍,全篇没有一个「你」;还有「本仓功能吉凶层」这类内部说法。用户读不出「我是一个什么样的人、现在这几年怎么走」。
- 触发条件:任何新报告。
- 根因:2026-09-06 产品决定报告改用引擎 Markdown(逐章写作器停用),报告从此是模板拼出来的逐条说明,写不出把几条因素合成一个人的叙事。
- 修复:产品 2026-10-27 拍板 R1。报告任务在引擎正文算好之后、交付之前,多一次模型调用写「解读」:总评、性格、事业、财富、感情、健康、现在这几年、几条建议。素材只用引擎已算好的事实:`/api/professional_report_reference` 新增 `include_reading_facts`,在同一份 packet 上取与对话同源卡相同的叶子(`scripts/report_render/reading_facts.py`),不新增算法。写法与对话 C1 同一套规则。校验不过(外文、字段名、宿命 / 病名 / 投资禁词、节数、长度、免责过多)就重试一次,仍不过或模型失败、超时(120 秒)就不出解读,报告照常交付。解读存在计算快照里,读接口一起返回,报告页在标题与重点卡之后、引擎正文之前显示,后面加「计算依据」小标题;只中文,英文版本轮不出。用量按实际 token 记账,报告价格不变。逐章写作器没有复活。
- 验证:`tests/test_report_reading_facts.py`(事实全来自 packet、开关关时响应不变、开关进缓存键、事实失败不影响报告);`frontend/tests/report-reading-20261027.test.tsx`(校验、重试、超时、取消、快照、读接口、页面顺序与英文不显示、任务链路三种情形、记账)。
- 防复发:解读失败只写 `readingUnavailable`,不让报告失败;页面只读合同模块,不把提示词打进页面包;揭幕后不再加载。真实模型的通过率和耗时看日志 `[personal-report] reading=`。
- 相关记录:BUG-1460。issue #113。`TASK-report-md-page-20260906.md`(零模型成本的决定在此部分推翻)。
- 复发自:无
- 修复版本:分支 `codex/voice-reading-20261027`。
@@ -0,0 +1,40 @@
# 进度:报告开篇解读 + 对话口吻 C1(issue #113,2026-10-27)
- 任务书:`docs/tasks/TASK-voice-reading-20261027.md`
- 分支:`codex/voice-reading-20261027`(基线 `origin/staging` `9ca933968`)
- 执行:Claude(直接执行模式)。BUG-1460(C1)、BUG-1461(R1);staging 合入 #111 后最大号 1459,编号即为最终号。
## 做了什么
| 项 | 状态 | 说明 |
| --- | --- | --- |
| C1 对话 | 完成 | `CHART_REASON_RULE` / `HOUSE_LIFE_AREA_RULE` / `TIMING_FOCUS_RULE`;示例改写;读心第 2 条收窄;VOICE 第七条原则补充 |
| R1-1 事实包 | 完成 | `scripts/report_render/reading_facts.py`;`professional_report_reference` 接 `include_reading_facts`(只在 full_data + markdown),开关进缓存键,失败只返回 `gaps` |
| R1-2 写作 | 完成 | `lib/personal-report-reading.ts`(提示词、重试、超时 120 s)+ `mastra/personal-report-reading.ts`(结构化输出,与合盘写作器同一模式) |
| R1-3 校验 | 完成 | 外文、字段名、禁词(宿命 / 病名 / 投资 / 内部说法 / 北交点)、节数与顺序、长度、每节免责 ≤1 |
| R1-4 存与读 | 完成 | 存进计算快照 v2 的 `reading` / `readingUnavailable`(不改表);读接口返回 `reading`,与英文版共用一次快照读取;页面在导读之后、引擎正文之前渲染,加「计算依据」,目录首条「解读」;英文不显示 |
| R1-5 记账 | 完成 | 报告用量取解读调用的 token,没有解读时为 0;单价结算沿用 `completeUsage` |
| R1-6 文档 | 完成 | DESIGN、VOICE、CHANGELOG、BUG_HISTORY、BLOCKED、真机清单 |
## 改动的既有断言(三栏也写在测试里)
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `consultation-voice-contract.test.ts` | 「然后在括号里给出撑住这段判断的盘上依据」「每段最多一处括号」 | 依据写进正文「因为……」,宫位带领域,时间最多三件;旧句不得出现 | issue #113 C1 |
| 同上 | 示例标签「括号里是依据的占位」;Bad 起点「多对象题」 | 「「因为」后面的依据是占位」;先是新 Bad「段尾括号复述」 | issue #113 C1 |
| 同上 | 父母例括号 `(母亲这条线没有受冲;…)`、事业例括号 `(木星行运 10 宫;…)` | 依据进正文,宫位带领域 | issue #113 C1 |
| 同上 | 「用户自己的心全篇最多读一句」 | 只在情绪、关系感受题读;结尾不猜动机 | issue #113 C1 |
| 同上 | 是非题例 `不是。(母亲这条线没有受冲;4 宫主落 10 宫)…` | `不是。管母亲的 4 宫(母亲、家和住处)这条线没有受冲…` | issue #113 C1 |
| `consult-no-presupposition-20261001.test.ts` | 受冲规则「括号里只写…」;`每段的判断后在括号里给…`;是非题例 | 「依据只写…用白话写进句子」;段规则后接三条新常量;是非题例新写法 | issue #113 C1 |
| `consult-plain-speech-20261006.test.ts` | 「括号里的依据最多两条」「括号里只写最能说明问题的一两条」 | 「说出来的依据最多两条」「依据只写最能说明问题的一两条」 | issue #113 C1 |
| `consult-upstream-functional-v2-20261002.test.ts` | 妈妈段例括号 | 「因为管母亲的 4 宫…没有受冲」 | issue #113 C1 |
| `personal-report-worker.test.ts` | `/inputTokens: 0/` | `/inputTokens: generated\.usage\?\.inputTokens \?\? 0/` | R1 解读按实际 token 记账 |
| `personal-report-view.test.ts` | `<ReportToc headings={outline.headings} …/>` | `headings={tocHeadings}`,只在有解读时多一条「解读」 | R1 解读进目录 |
## 环境缺口
见 `BLOCKED.md` 同名条目:无模型 key,真实解读输出和名人生平回测都没跑。
## 验收数字
见本文件末尾(提交前补)。
+1
View File
@@ -538,3 +538,4 @@
| — | `PROGRESS-full-data-parity-1-fix2-20261022.md` | **#91 A 段第 2 次打回**(Claude 口头修复单 F1~F4):11 节只印「未判定」和键名改读真实记录;测试只认非第一列真实值、禁止键名格;中文瑜伽组合句由中文源给出(BUG-1418);六章降为 `##`、节 `###`(BUG-1419) | 本地完成,待验收 | `codex/full-data-parity-1-fix2-20261022` |
| `TASK-lagna-precheck-20261027.md` | — | **#112 选 A**:星盘页页头告诉用户上升在其时间精度内会不会换(复用现成的 D1 窗口扫描,纯计算);校正开场说清能做 / 不能做(BUG-1444、BUG-1445) | 待执行 | `codex/lagna-precheck-20261027` |
| `TASK-lagna-precheck-fix-20261027.md` | — | **#112 第 1 次打回**:已有进行中的校正时点链接报冲突(应走首页续上);别人的星盘页不该给校正链接 | 待执行 | `codex/lagna-precheck-20261027` |
| `TASK-voice-reading-20261027.md` | `PROGRESS-voice-reading-20261027.md` | **#113 选 R1 C1**:报告前面加一段模型写的「解读」(八节,素材只用引擎事实,失败不影响报告);对话依据写进正文、宫位带生活领域、时间最多三件(BUG-1460、BUG-1461) | Claude 直接执行,待验收 | `codex/voice-reading-20261027` |
+23
View File
@@ -0,0 +1,23 @@
# 真机清单:报告开篇解读与对话口吻(issue #113,2026-10-27)
在 staging 上用自己的账号走。不要把截图里的出生资料贴进仓库。
## 报告
1. 新生成一份个人报告(旧报告不会补上解读)。等待过程和以前一样,没有新的加载画面。
2. 打开后,标题和重点卡下面先是「解读」:总评、性格、事业、财富、感情、健康、现在这几年、几条建议八小节。
3. 每节读起来是对「你」说的话:说了盘上是什么、为什么、生活里是什么样子。宫位第一次出现带括号说明它管什么。
4. 没有英文字母、没有「本仓」「检测器」这类内部说法,没有病名、没有买卖股票基金的建议,没有「注定」「先苦后甜」。
5. 「现在这几年」写了当前大运和接下来两段子运的起止年月,和后面「计算依据」里的大运表对得上。
6. 解读后面有一行小字「计算依据」,下面是原来的报告,内容没变。目录第一条是「解读」,点了跳回开头。
7. 切到 English:没有解读,其余和以前一样。
8. 如果某份报告没有解读,报告本身照常能看(记下时间,告诉我查日志)。
## 对话
9. 问一个事业或感情问题:提到宫位时第一次带生活领域,例如「第 10 宫(事业、社会地位)」。
10. 段尾不再有一串括号把正文重复一遍;依据写在句子里,「因为……」。
11. 问「这段时间整体怎么样」:讲时间最多三件事,由近到远;结尾不猜你为什么问。
12. 问「我妈是不是不太在意我」这类感受题:先答是或不是,可以有一句读你心情的话。
走完回一句「过」,或者贴出不对的段落。
+1
View File
@@ -630,6 +630,7 @@ Action results and errors never take page layout. They are toasts from `src/lib/
- **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed.
- **报告标题 (2026-10-13, BUG-1316):** the Markdown `h1` is the report title. It uses `--font-display` at `--type-display-md` (larger than the `h2` `--type-display-sm`), weight 500, with `--space-2` above and `--space-3` below. Under it, one caption (`.personal-report-md-byline`, `--color-ink-tertiary`, `--type-caption`, body face) reads `{名字} · 生成于 YYYY-MM-DD`, or `Generated` in English. No name: date only. The title already contains the name: date only, the name is not repeated. The name comes from the signed-in read (`subjectDisplayName`); it is not added to the public projection or the export. Old reports without a name still show the date.
- **开篇重点卡 (2026-10-13, BUG-1316):** the first chapter is one card (`.personal-report-focus-card`) drawn from a `jyotish-focus` fence. The engine writes the JSON; the page only parses it. First row, 「现在走的大运」: 主运 / 子运 / 次子运, each the planet's Chinese name plus `YYYY.MM – YYYY.MM`, in a wrapping row. Then 事业 / 财富 / 婚恋: 「第 N 宫主 · 行星 · 落第 M 宫」 and two tags (`.personal-report-focus-tag`, same muted pill as `.chart-role-badge`: `--color-canvas-muted`, `--color-ink-secondary`, `--type-overline`). The last line is the caveat at `--type-caption` / `--color-ink-tertiary`. No MD / AD / PD on the card. A bad fence says 「重点数据无效」 (English "Focus data is invalid"), the same invalid line as a bad chart, and does not echo the JSON. The card wraps; it does not scroll sideways. Tokens only, so light and dark both stay readable. Reports with no fence render as before. Export turns the fence into those same words; the file has no raw JSON. The public projection keeps a public fence the way it keeps `jyotish-chart`, and does not add a name field.
- **开篇解读 (2026-10-27, issue #113 R1):** a Chinese report with a stored reading shows it right after the title / focus card and before the first engine chapter: one `h2` 「解读」 (`#report-reading`, also the first entry in the contents rail and drawer), then eight `h3` parts 总评 / 性格 / 事业 / 财富 / 感情 / 健康 / 现在这几年 / 几条建议, each one to four plain paragraphs in the article's own body type. No card, no icon, no badge: it reads like the rest of the article. After it, a quiet label 「计算依据」 (`.personal-report-basis-label`: `--type-overline`, `--color-ink-tertiary`, a 1px top rule) introduces the engine report, which is unchanged. In paged mode the reading sits on chapter 1 only. The reading arrives in the same read as the report: no loader, no second request, nothing appears after reveal. English view and reports without a reading render exactly as before.
- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. The chart section opens with one caveat line under its heading ("分盘对出生时间敏感,原始计算供核对,不单独增加结论的确定性。", engine-written, English in the English edition). Each chart is drawn once: the trailing 「分盘核对」 block only draws structured charts the body has no fence for — the export's rule, shared via `fencedReportChartIds` — so a current report has no trailing block at all and an older report keeps only the charts its body lacks (BUG-1203). Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`.
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge.
- **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them. Report tables (Markdown and fact tables, never chat bubbles) carry a header band of `--color-ink` at 6% and zebra rows at 3%, both mixed into transparency so they work on paper and on the dark sheet; print lightens them to 5% / 2.5%.
+11
View File
@@ -274,6 +274,17 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 出生资料下面可以有一句上升会不会换。本人上升会换、或不知道出生时间时,「出生时间校正」是链接:已经有没做完的校正就回到那一次,没有才新开。别人的星盘只说到「(大约 HH:MM 换座)。」或「上升星座定不下来。」,不写「出生时间校正」,也不给这个链接。
- 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。行星位置这类数值表太宽时,在表自己的框里横滑,页面不被撑开。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里,格子里的字可以换行。
## 报告开篇解读(2026-10-27,issue #113 R1)
报告最前面的「解读」由模型写,素材只用引擎已算好的事实(与对话同源卡同一批叶子),后面原有的引擎正文改称「计算依据」。
- 固定八节:总评、性格、事业、财富、感情、健康、现在这几年、几条建议。节名是我们定的,模型只写段落。
- 写法和对话 C1 同一套:对「你」说话;每段「盘面 → 因为…… → 生活里的样子」,依据写进正文,不放段尾括号;宫位第一次出现带生活领域(十二宫表见第七条原则 C1 补充);时间最多三件、由近到远,现在这几年写当前大运和接下来两段子运的起止年月。
- 免责每节最多一句,不逐句写「可能」。语气笃定但不说「注定」「先苦后甜」「宿命」「一定会」。
- 不写病名和诊断,健康只说作息和要多休息的时段;不给投资指令,不点名股票、基金、币;婚姻、生死不下确定结论。
- 只用中文,罗睺 / 计都;不写英文字母、分盘代号、字段名、分数原值、「本仓」「检测器」「口径」这类内部说法。出生时间没确认时不把上升、宫位说成定论。
- 校验不过(外文、字段名、禁词、节数不对、过长过短、一节两句以上免责)或模型失败、超时:报告照常交付,只是没有解读,不重写成别的样子。
## 报告分块导出与删除
- 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」、「中文 / English」切换(只在有英文版的报告上出现)与带字的「导出」按钮(读屏名「导出报告」,2026-09-29 起不再是只有图标的按钮)。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。
@@ -55,10 +55,17 @@ async function resolvePersistenceForUser() {
listSections: undefined,
loadLongformAppendix: undefined,
loadLongformEnglish: undefined,
loadLongformReading: undefined,
};
}
const persistence = createSupabasePersonalReportService(supabase);
const sections = createPersonalReportSectionService(supabase as never);
// The English edition and the reading live in the same snapshot row: read it once per request.
let snapshotRead: Promise<Awaited<ReturnType<typeof readLongformSnapshot>>> | null = null;
const readSnapshotOnce = (input: Readonly<{ userId: string; reportId: string; requestId: string }>) => {
snapshotRead ??= readLongformSnapshot(supabase as unknown as LongformSnapshotReader, input);
return snapshotRead;
};
return {
userId: user.id,
supabase,
@@ -92,7 +99,7 @@ async function resolvePersistenceForUser() {
};
},
loadLongformEnglish: async (input: Readonly<{ userId: string; reportId: string; requestId: string }>): Promise<EnglishEditionRead> => {
const stored = await readLongformSnapshot(supabase as unknown as LongformSnapshotReader, input);
const stored = await readSnapshotOnce(input);
if (stored?.status !== "ready") return { status: "legacy" };
const snapshot = stored.snapshot;
if (snapshot.version === 1) return { status: "legacy" };
@@ -100,12 +107,17 @@ async function resolvePersistenceForUser() {
? { status: "ready", markdown: snapshot.english.markdown, factTables: snapshot.english.factTables }
: { status: "unavailable" };
},
loadLongformReading: async (input: Readonly<{ userId: string; reportId: string; requestId: string }>) => {
const stored = await readSnapshotOnce(input);
if (stored?.status !== "ready" || stored.snapshot.version === 1) return null;
return stored.snapshot.reading ?? null;
},
};
}
export async function GET(request: Request, context: RouteContext) {
try {
const { userId, persistence, jobs, listSections, loadLongformAppendix, loadLongformEnglish, supabase } = await resolvePersistenceForUser();
const { userId, persistence, jobs, listSections, loadLongformAppendix, loadLongformEnglish, loadLongformReading, supabase } = await resolvePersistenceForUser();
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json(
@@ -150,6 +162,7 @@ export async function GET(request: Request, context: RouteContext) {
listSections,
loadLongformAppendix,
loadLongformEnglish,
loadLongformReading,
validateReadyDocument: (document) => {
const parsed = safeParseServerReportDocument(document);
return parsed.ok
+13
View File
@@ -4381,6 +4381,19 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
line-height: 1.65;
}
.personal-report-md-placeholder { min-height: 24rem; }
/* Model-written reading in front of the engine report (issue #113 R1): plain
paragraphs, no card; the engine report follows under a quiet label. */
.personal-report-reading { min-width: 0; }
.personal-report-reading > h2 { margin-top: var(--space-6); }
.personal-report-reading-part h3 { margin-top: var(--space-5); }
.personal-report-md-article .personal-report-basis-label {
margin: var(--space-8) 0 0;
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
color: var(--color-ink-tertiary);
font-size: var(--type-overline);
letter-spacing: .06em;
}
.personal-report-toc-drawer summary {
min-height: 44px;
display: flex;
@@ -7,6 +7,8 @@ import remarkGfm from "remark-gfm";
import { fencedLanguage, fencedSource } from "@/components/markdown-code-block";
import { ReportFocusCard } from "@/components/personal-report/report-focus-card";
import { ReportReadingSection } from "@/components/personal-report/report-reading-section";
import { REPORT_READING_ANCHOR as READING_ANCHOR, REPORT_READING_HEADING, type ReportReading } from "@/lib/personal-report-reading-contract";
import {
chartAriaLabel,
parseReportChartBlock,
@@ -295,8 +297,11 @@ export function PersonalReportMarkdownView({
drawnThrough = null,
subjectName,
createdAt,
reading = null,
}: {
markdown: string;
/** Model-written reading (issue #113 R1). Chinese only; shown after the lead, before the engine chapters. */
reading?: ReportReading | null;
language?: ReportLanguage;
/** Chapter index (h2 order) through which sections render immediately; used when switching editions. */
drawnThrough?: number | null;
@@ -305,6 +310,11 @@ export function PersonalReportMarkdownView({
createdAt?: string;
}) {
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
const shownReading = language === "zh" ? reading : null;
// The reading sits on the first page, so its contents entry opens chapter 0.
const tocHeadings = useMemo<readonly LongformHeading[]>(() => (
shownReading ? [{ id: READING_ANCHOR, level: 2, title: REPORT_READING_HEADING }, ...outline.headings] : outline.headings
), [shownReading, outline]);
const chapters = useMemo(() => outline.sections.filter((section) => !section.eager), [outline]);
const paged = markdown.length > FULL_DATA_CHAPTER_PAGE_CHARS && chapters.length > 1;
const [chapter, setChapter] = useState(() => {
@@ -351,7 +361,7 @@ export function PersonalReportMarkdownView({
: null
), [paged, printing, chapters, language, subjectName, createdAt]);
const openChapter = useCallback((id: string) => {
const index = chapters.findIndex((section) => section.id === id || section.headings.some((heading) => heading.id === id));
const index = id === READING_ANCHOR ? 0 : chapters.findIndex((section) => section.id === id || section.headings.some((heading) => heading.id === id));
if (index >= 0) setChapter(index);
}, [chapters]);
@@ -360,7 +370,7 @@ export function PersonalReportMarkdownView({
<div className="personal-report-md-layout">
<ReportToc
activeHeadingId={currentChapter?.id}
headings={outline.headings}
headings={tocHeadings}
language={language}
onSelect={openChapter}
/>
@@ -370,6 +380,7 @@ export function PersonalReportMarkdownView({
{lead}
</section>
) : null}
{safeChapter === 0 && shownReading ? <ReportReadingSection reading={shownReading} /> : null}
{printing
? chapters.map((section, index) => (
<section className="personal-report-md-section" id={section.id} key={section.id}>
@@ -394,13 +405,14 @@ export function PersonalReportMarkdownView({
return (
<div className="personal-report-md-layout">
<ReportToc headings={outline.headings} language={language} />
<ReportToc headings={tocHeadings} language={language} />
<article className="personal-report-document personal-report-md-article">
{lead ? (
<section className="personal-report-md-lead">
{lead}
</section>
) : null}
{shownReading ? <ReportReadingSection reading={shownReading} /> : null}
{/* Each section is one chapter heading, in the same order as the article's h2s. */}
{outline.sections.map((section, index) => (section.eager ? null : (
<LazyMarkdownSection
@@ -27,6 +27,7 @@ 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 { reportReadingSchema, type ReportReading } from "@/lib/personal-report-reading-contract";
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
import { ReportOpening } from "./report-opening";
import {
@@ -74,6 +75,8 @@ export type ReportLoadState =
englishEdition: EnglishEditionStatus;
markdownEn?: string;
factTablesEn?: ReportFactTable[];
/** Model-written reading in front of the report (issue #113 R1); absent on older or failed readings. */
reading?: ReportReading;
}
| { phase: "legacy-unavailable" }
| { phase: "synastry-ready"; reportId: string; synastry: unknown; createdAt?: string };
@@ -162,7 +165,9 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
const english = readEnglishEdition(json);
const subjectDisplayName = readSubjectDisplayName(json);
const reading = reportReadingSchema.safeParse(json.reading);
return {
...(reading.success ? { reading: reading.data } : {}),
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
...(charts.success ? { calculationCharts: charts.data } : {}),
...english,
@@ -648,6 +653,7 @@ export function PersonalReportPage({
drawnThrough={drawnThrough}
language={shown}
markdown={markdown}
reading={state.reading ?? null}
subjectName={state.subjectDisplayName}
/>
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} markdown={markdown} language={shown} />}
@@ -0,0 +1,30 @@
import {
REPORT_READING_ANCHOR,
REPORT_READING_BASIS_LABEL,
REPORT_READING_HEADING,
type ReportReading,
} from "@/lib/personal-report-reading-contract";
/**
* The model-written reading in front of the engine report (issue #113 R1).
* Plain paragraphs under one heading; the engine report follows under the
* 「计算依据」 label. Rendered with the report, never loaded after it.
*/
export function ReportReadingSection({ reading }: { reading: ReportReading }) {
return (
<>
<section aria-labelledby={REPORT_READING_ANCHOR} className="personal-report-reading">
<h2 id={REPORT_READING_ANCHOR}>{REPORT_READING_HEADING}</h2>
{reading.sections.map((section) => (
<div className="personal-report-reading-part" key={section.id}>
<h3>{section.title}</h3>
{section.paragraphs.map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
))}
</section>
<p className="personal-report-basis-label">{REPORT_READING_BASIS_LABEL}</p>
</>
);
}
@@ -7,6 +7,7 @@ import { acceptEnglishEdition, type EnglishOutcome } from "./personal-report-eng
import { hashLongformMarkdown } from "./personal-report-longform-appendix";
import { annotateUnreliableChartSections } from "./personal-report-chart-caveats";
import { asRecord as record } from "@/lib/guards";
import { writeReportReading, type ReportReadingWriter } from "./personal-report-reading";
import {
LONGFORM_APPENDIX_TABLE,
@@ -69,6 +70,8 @@ export type LongformGenerateDeps = Readonly<{
fetchImpl?: typeof fetch;
now?: () => Date;
signal?: AbortSignal;
/** Writes the reading in front of the engine report (issue #113 R1). Absent: no reading is requested. */
writeReading?: ReportReadingWriter;
}>;
function text(value: unknown): string | null {
@@ -135,7 +138,8 @@ async function fetchLongformMarkdown(input: Readonly<{
apiBase: string;
fetchImpl: typeof fetch;
signal?: AbortSignal;
}>): Promise<{ markdown: string; factTables: ReportFactTable[]; charts: ReportDocumentV2["charts"]; readerDashaApplicability: string } & EnglishOutcome> {
includeReadingFacts?: boolean;
}>): Promise<{ markdown: string; factTables: ReportFactTable[]; charts: ReportDocumentV2["charts"]; readerDashaApplicability: string; readingFacts?: unknown } & EnglishOutcome> {
const timeout = AbortSignal.timeout(ENGINE_TIMEOUT_MS);
const signal = input.signal
? AbortSignal.any([input.signal, timeout])
@@ -146,7 +150,10 @@ 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, include_fact_tables: true, edition: "full_data", languages: ["zh", "en"] }),
body: JSON.stringify({
...input.payload, include_fact_tables: true, edition: "full_data", languages: ["zh", "en"],
...(input.includeReadingFacts ? { include_reading_facts: true } : {}),
}),
cache: "no-store",
signal,
});
@@ -158,13 +165,14 @@ async function fetchLongformMarkdown(input: Readonly<{
upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
);
}
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown; markdown_en?: unknown; english_unavailable?: unknown; fact_table_packet?: unknown; reader_dasha_applicability?: unknown } | null;
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown; markdown_en?: unknown; english_unavailable?: unknown; fact_table_packet?: unknown; reader_dasha_applicability?: unknown; reading_facts?: unknown } | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
throw new LongformGenerateError("calculation_unavailable", true, "empty_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 : "",
...(input.includeReadingFacts ? { readingFacts: result.reading_facts } : {}),
...acceptEnglishEdition(result.markdown_en, result.english_unavailable, result.fact_table_packet) };
} catch (error) {
throw error;
@@ -206,6 +214,7 @@ export async function generatePersonalReportLongform(
}
let markdown = current?.status === "ready" && current.markdown ? current.markdown : null;
let snapshot: LongformSnapshot | null = null;
let readingUsage: { inputTokens: number; outputTokens: number } | null = null;
try {
const stored = await readLongformSnapshot(deps.admin, identity);
if (stored?.status === "ready") snapshot = stored.snapshot;
@@ -226,21 +235,38 @@ export async function generatePersonalReportLongform(
apiBase: deps.apiBase ?? process.env.JYOTISH_API_BASE ?? DEFAULT_API_BASE,
fetchImpl: deps.fetchImpl ?? fetch,
signal: deps.signal,
includeReadingFacts: Boolean(deps.writeReading),
});
const { readingFacts, ...engine } = generated;
// The reading is written before the snapshot, so the report reveals once with it (or without it).
const reading = deps.writeReading
? await writeReportReading({
facts: readingFacts,
birthTimeConfirmed: deps.birthTimeStatus === "confirmed" || deps.birthTimeStatus === "accepted",
write: deps.writeReading,
signal: deps.signal,
})
: null;
if (reading) {
readingUsage = { inputTokens: reading.inputTokens, outputTokens: reading.outputTokens };
console.info(`[personal-report] reading=${reading.reading ? "ready" : reading.unavailable}`);
}
// Segment-v1 adoptions that left D9/D10 undetermined get a line under those
// chart sections before the snapshot is hashed (BUG-1139); no-op otherwise.
const unreliable = deps.candidateRange?.unreliableVargas;
const annotated = unreliable?.length ? {
...generated,
markdown: annotateUnreliableChartSections(generated.markdown, unreliable, "zh"),
...(generated.english ? { english: {
...generated.english,
markdown: annotateUnreliableChartSections(generated.english.markdown, unreliable, "en"),
contentSha256: hashLongformMarkdown(annotateUnreliableChartSections(generated.english.markdown, unreliable, "en")),
...engine,
markdown: annotateUnreliableChartSections(engine.markdown, unreliable, "zh"),
...(engine.english ? { english: {
...engine.english,
markdown: annotateUnreliableChartSections(engine.english.markdown, unreliable, "en"),
contentSha256: hashLongformMarkdown(annotateUnreliableChartSections(engine.english.markdown, unreliable, "en")),
} } : {}),
} : generated;
} : engine;
snapshot = validateLongformSnapshot({
...annotated, ...identity, kind: "longform_calculation_snapshot", version: 2,
...(reading?.reading ? { reading: reading.reading } : {}),
...(reading && !reading.reading ? { readingUnavailable: reading.unavailable ?? "model_failed" } : {}),
contentSha256: hashLongformMarkdown(annotated.markdown),
}, identity);
// Durable complete snapshot precedes the shared Markdown cache and final report.
@@ -282,6 +308,7 @@ export async function generatePersonalReportLongform(
status: "ready",
document,
evidenceHash: document.provenance.evidenceHash,
...(readingUsage ? { usage: readingUsage } : {}),
};
} catch {
throw new LongformGenerateError("report_schema_invalid", false, "final_parse_rejected");
@@ -2,6 +2,7 @@ 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 { reportReadingSchema } from "./personal-report-reading-contract";
import type { AppendixClient } from "./personal-report-longform-generate";
/** Reserved server-owned cache, never a writer chapter or reader progress row. */
@@ -40,6 +41,9 @@ const snapshotSchema = z.union([
version: z.literal(2),
english: longformEnglishEditionSchema.optional(),
englishUnavailable: z.string().regex(/^[a-z_]{1,64}$/).optional(),
/** The model-written reading in front of the engine report (issue #113 R1). Older snapshots carry neither field. */
reading: reportReadingSchema.optional(),
readingUnavailable: z.string().regex(/^[a-z_]{1,64}$/).optional(),
}).refine(value => (value.english === undefined) !== (value.englishUnavailable === undefined), {
message: "a v2 snapshot carries exactly one of english / englishUnavailable",
}),
@@ -0,0 +1,49 @@
/**
* Shape of the report reading (issue #113 R1) shared by the server and the
* report page. No prompt text here, so the page bundle stays small.
*/
import { z } from "zod";
export const REPORT_READING_SECTIONS = [
{ id: "overview", title: "总评" },
{ id: "character", title: "性格" },
{ id: "career", title: "事业" },
{ id: "wealth", title: "财富" },
{ id: "love", title: "感情" },
{ id: "health", title: "健康" },
{ id: "now", title: "现在这几年" },
{ id: "advice", title: "几条建议" },
] as const;
export type ReportReadingSectionId = (typeof REPORT_READING_SECTIONS)[number]["id"];
export const SECTION_IDS = REPORT_READING_SECTIONS.map((section) => section.id) as [ReportReadingSectionId, ...ReportReadingSectionId[]];
/** Label above the engine report once a reading sits in front of it. */
export const REPORT_READING_BASIS_LABEL = "计算依据";
export const REPORT_READING_HEADING = "解读";
/** Heading id of the reading; also its entry in the report contents. */
export const REPORT_READING_ANCHOR = "report-reading";
export const MAX_PARAGRAPH_CHARS = 420;
export const MIN_TOTAL_CHARS = 900;
export const MAX_TOTAL_CHARS = 9_000;
/** What the model returns. Titles are ours, not the model's. */
export const reportReadingDraftSchema = z.object({
sections: z.array(z.object({
id: z.enum(SECTION_IDS),
paragraphs: z.array(z.string().min(1)).min(1).max(5),
}).strict()).length(REPORT_READING_SECTIONS.length),
}).strict();
export type ReportReadingDraft = z.infer<typeof reportReadingDraftSchema>;
/** What is stored in the calculation snapshot and returned by the read API. */
export const reportReadingSchema = z.strictObject({
version: z.literal(1),
language: z.literal("zh"),
sections: z.array(z.strictObject({
id: z.enum(SECTION_IDS),
title: z.string().min(1).max(12),
paragraphs: z.array(z.string().min(1).max(MAX_PARAGRAPH_CHARS)).min(1).max(5),
})).length(REPORT_READING_SECTIONS.length),
});
export type ReportReading = z.infer<typeof reportReadingSchema>;
+202
View File
@@ -0,0 +1,202 @@
/**
* The model-written reading at the front of the personal report (issue #113 R1,
* product decision 2026-10-27 「R1 C1」).
*
* The engine report stays as it is and moves behind this reading as the
* calculation basis. The reading is one structured model call over the
* engine's own facts (`reading_facts` from /api/professional_report_reference,
* the same leaves the chat card uses). It is optional: any failure, timeout or
* rejected draft leaves the report without a reading, never failed.
*/
import {
CHART_REASON_RULE,
HOUSE_LIFE_AREA_RULE,
PLAIN_SPEECH_RULE,
TIMING_FOCUS_RULE,
} from "./consultation-thinking-plan";
import {
MAX_PARAGRAPH_CHARS,
MAX_TOTAL_CHARS,
MIN_TOTAL_CHARS,
REPORT_READING_SECTIONS,
reportReadingDraftSchema,
type ReportReading,
} from "./personal-report-reading-contract";
export {
REPORT_READING_BASIS_LABEL,
REPORT_READING_HEADING,
REPORT_READING_SECTIONS,
reportReadingSchema,
type ReportReading,
} from "./personal-report-reading-contract";
export const REPORT_READING_TIMEOUT_MS = 120_000;
/** Never in the reading: deterministic fate, medical, investment (AGENTS Part B4). */
const BANNED_PHRASES = [
"注定", "先苦后甜", "宿命", "命中注定", "必然会", "一定会",
"癌", "肿瘤", "糖尿病", "高血压", "心脏病", "抑郁症", "焦虑症", "不孕", "流产", "早逝", "寿命", "死亡", "去世",
"股票", "基金", "炒股", "期货", "比特币", "加密货币", "买入", "卖出", "抄底", "投资建议",
// Internal wording the engine report leaked (research §4.1).
"本仓", "检测器", "字段", "口径",
// One name per thing, same as the chat (罗睺 / 计都).
"北交点", "南交点", "克图", "凯图",
];
const DISCLAIMER = /仅供参考|不构成|请咨询专业|不能替代/g;
export type ReadingRejection =
| "shape"
| "latin"
| "field_name"
| "banned_phrase"
| "too_long"
| "too_short"
| "disclaimer";
/** The draft becomes the stored reading only when every check passes. */
export function acceptReportReadingDraft(value: unknown):
| { ok: true; reading: ReportReading }
| { ok: false; reason: ReadingRejection } {
const parsed = reportReadingDraftSchema.safeParse(value);
if (!parsed.success) return { ok: false, reason: "shape" };
const sections = parsed.data.sections;
if (sections.some((section, index) => section.id !== REPORT_READING_SECTIONS[index]!.id)) {
return { ok: false, reason: "shape" };
}
let total = 0;
for (const section of sections) {
let disclaimers = 0;
for (const raw of section.paragraphs) {
const paragraph = raw.trim();
if (!paragraph) return { ok: false, reason: "shape" };
if (paragraph.length > MAX_PARAGRAPH_CHARS) return { ok: false, reason: "too_long" };
if (/[A-Za-z]/.test(paragraph)) return { ok: false, reason: "latin" };
if (/[_={}<>#*|`]/.test(paragraph)) return { ok: false, reason: "field_name" };
if (BANNED_PHRASES.some((phrase) => paragraph.includes(phrase))) return { ok: false, reason: "banned_phrase" };
disclaimers += paragraph.match(DISCLAIMER)?.length ?? 0;
total += paragraph.length;
}
if (disclaimers > 1) return { ok: false, reason: "disclaimer" };
}
if (total < MIN_TOTAL_CHARS) return { ok: false, reason: "too_short" };
if (total > MAX_TOTAL_CHARS) return { ok: false, reason: "too_long" };
return {
ok: true,
reading: {
version: 1,
language: "zh",
sections: sections.map((section, index) => ({
id: section.id,
title: REPORT_READING_SECTIONS[index]!.title,
paragraphs: section.paragraphs.map((paragraph) => paragraph.trim()),
})),
},
};
}
/** Facts are usable only in the schema the engine promises and without a gap marker. */
export function acceptReadingFacts(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const facts = value as Record<string, unknown>;
if (facts.schema !== "report-reading-facts-v1" || Array.isArray(facts.gaps)) return null;
if (!facts.planets || !facts.domains || !facts.timing) return null;
return facts;
}
export const REPORT_READING_SYSTEM = [
"你在给一份个人星盘报告写开头的「解读」。读者不懂占星。你只用用户消息里给的盘面事实,不编事实,不另起算法。",
"写法:像一个懂行、说话温和的姐姐,对「你」说话,语气笃定但不夸张。每段一条链:先点出盘上的一两个配置,用半句白话说它为什么这样起作用,再落到你在工作、家里、钱上、感情里会是什么样子。几条因素合起来说成一个人,不逐条罗列。",
CHART_REASON_RULE,
HOUSE_LIFE_AREA_RULE,
TIMING_FOCUS_RULE,
PLAIN_SPEECH_RULE,
"八节固定,按顺序:总评(三五句说清这张盘最突出的两三点)、性格、事业、财富、感情、健康、现在这几年(当前大运和接下来两段子运,写起止年月)、几条建议(两到四条,和盘面直接相关的具体做法)。每节一到四段,每段不超过四百字,全篇两千到六千字。",
"术语只用中文,同一个东西一个叫法:罗睺、计都;大运、子运、小运。不写任何英文字母或梵文音译,不写分盘代号(写九分盘、事业分盘),不写字段名、分数原值、「本仓」「检测器」这类内部说法。力量只说强、中、弱。",
"红线:不写病名、诊断和身体部位的疾病;健康只说哪段时间要多休息、哪方面的作息要留意。不给投资指令,不点名股票、基金、币。不写「注定」「先苦后甜」「宿命」「一定会」。婚姻、生死不下确定结论。每节最多一句提醒(例如「这只是倾向,实际怎样以你的经历为准」),不逐句免责,不写「可能」一连串。",
"出生时间如果不是确认过的,不把上升、宫位说成定论,也不写「校正」「确认」。",
"只输出 JSON:{\"sections\":[{\"id\":\"overview\",\"paragraphs\":[\"……\"]}, …]},八节的 id 依次是 overview、character、career、wealth、love、health、now、advice。",
].join("\n");
export function reportReadingUserMessage(facts: Record<string, unknown>, birthTimeConfirmed: boolean): string {
return [
birthTimeConfirmed ? "出生时间:已确认。" : "出生时间:用户填报,没有确认到分钟。",
"盘面事实(引擎已算好,只能用这些):",
JSON.stringify(facts),
].join("\n");
}
export type ReportReadingWriter = (input: Readonly<{
system: string;
user: string;
signal: AbortSignal;
}>) => Promise<{ body: unknown; inputTokens: number; outputTokens: number }>;
export type ReportReadingOutcome = Readonly<{
reading: ReportReading | null;
/** Why there is no reading; absent when there is one. */
unavailable?: string;
inputTokens: number;
outputTokens: number;
}>;
function timedSignal(parent: AbortSignal | undefined, timeoutMs: number) {
const controller = new AbortController();
const timer = globalThis.setTimeout(() => controller.abort(new Error("reading_timeout")), timeoutMs);
const onParent = () => controller.abort(parent?.reason ?? new Error("aborted"));
if (parent) {
if (parent.aborted) onParent();
else parent.addEventListener("abort", onParent, { once: true });
}
// A writer that ignores its signal must still lose the race.
const lost = new Promise<never>((_, reject) => {
if (controller.signal.aborted) reject(controller.signal.reason);
else controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
});
lost.catch(() => undefined);
return {
signal: controller.signal,
lost,
dispose: () => {
globalThis.clearTimeout(timer);
parent?.removeEventListener("abort", onParent);
},
};
}
/** One attempt plus one retry. Never throws, except when the job itself is aborted. */
export async function writeReportReading(input: Readonly<{
facts: unknown;
birthTimeConfirmed: boolean;
write: ReportReadingWriter;
signal?: AbortSignal;
timeoutMs?: number;
attempts?: number;
}>): Promise<ReportReadingOutcome> {
const facts = acceptReadingFacts(input.facts);
if (!facts) return { reading: null, unavailable: "facts_unavailable", inputTokens: 0, outputTokens: 0 };
const user = reportReadingUserMessage(facts, input.birthTimeConfirmed);
let inputTokens = 0;
let outputTokens = 0;
let unavailable = "model_failed";
for (let attempt = 0; attempt < (input.attempts ?? 2); attempt += 1) {
input.signal?.throwIfAborted();
const timed = timedSignal(input.signal, input.timeoutMs ?? REPORT_READING_TIMEOUT_MS);
try {
const result = await Promise.race([
input.write({ system: REPORT_READING_SYSTEM, user, signal: timed.signal }),
timed.lost,
]);
inputTokens += Math.max(0, result.inputTokens);
outputTokens += Math.max(0, result.outputTokens);
const accepted = acceptReportReadingDraft(result.body);
if (accepted.ok) return { reading: accepted.reading, inputTokens, outputTokens };
unavailable = `rejected_${accepted.reason}`;
} catch {
if (input.signal?.aborted) throw input.signal.reason ?? new Error("aborted");
unavailable = timed.signal.aborted ? "model_timeout" : "model_failed";
} finally {
timed.dispose();
}
}
return { reading: null, unavailable, inputTokens, outputTokens };
}
@@ -1,5 +1,6 @@
import type { ReportFactTable } from "./report-fact-table-schema";
import type { EnglishEditionStatus } from "./report-language";
import type { ReportReading } from "./personal-report-reading-contract";
import { parseReportCandidateRange, type ReportCandidateClockRange } from "./report-candidate-range";
/**
* Personal report route handlers as a dependency-injected core (no
@@ -707,6 +708,12 @@ export type ReportReadCoreDeps = Readonly<{
reportId: string;
requestId: string;
}>) => Promise<EnglishEditionRead>;
/** The model-written reading in front of the report (issue #113 R1); null when the report has none. */
loadLongformReading?: (input: Readonly<{
userId: string;
reportId: string;
requestId: string;
}>) => Promise<ReportReading | null>;
}>;
export type EnglishEditionRead =
@@ -775,10 +782,16 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
? await deps.loadLongformEnglish({ userId: deps.userId, reportId: deps.reportId, requestId: row.requestId })
.catch((): EnglishEditionRead => ({ status: "unavailable" }))
: { status: "legacy" };
// The reading is optional like the English edition; a failed read leaves it out.
const reading = deps.loadLongformReading
? await deps.loadLongformReading({ userId: deps.userId, reportId: deps.reportId, requestId: row.requestId })
.catch(() => null)
: null;
return {
status: 200,
body: {
report: reportView(row, job),
...(reading ? { reading } : {}),
englishEdition: english.status,
...(english.status === "ready" ? {
longformMarkdownEn: projectOrdinaryReportMarkdown(english.markdown, "en"),
+5 -2
View File
@@ -6,6 +6,7 @@ import { asRecord as record, finiteNumberOrNull as finiteNumber } from "@/lib/gu
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import { createReportReadingWriter } from "@/mastra/personal-report-reading";
import {
buildReportEvidenceBundleV2,
generatePersonalReport,
@@ -220,13 +221,15 @@ async function generateLongformReport(
displayName,
birthTimeStatus: usableBirth.status,
signal: context.signal,
writeReading: createReportReadingWriter(model),
});
if (generated.status !== "ready") return generated;
return {
...generated,
usage: {
inputTokens: 0,
outputTokens: 0,
// Only the reading (issue #113 R1) calls a model; the engine report costs no tokens.
inputTokens: generated.usage?.inputTokens ?? 0,
outputTokens: generated.usage?.outputTokens ?? 0,
actualModelId: model.id,
modelConfigVersion: model.configVersion,
},
@@ -0,0 +1,34 @@
import { Agent } from "@mastra/core/agent";
import type { z } from "zod";
import type { ResolvedLanguageModel } from "./model.ts";
import { reportReadingDraftSchema } from "@/lib/personal-report-reading-contract";
import type { ReportReadingWriter } from "@/lib/personal-report-reading";
/** One structured call for the report reading (issue #113 R1). Same pattern as the synastry section writer. */
export function createReportReadingWriter(model: ResolvedLanguageModel): ReportReadingWriter {
const agent = new Agent({
id: `personal-report-reading-${model.id}`,
name: "Personal Report Reading",
model: model.model,
instructions: "Follow the system message. Return only the requested JSON.",
});
return async function writeReading(input) {
const result = await agent.generate(
[
{ role: "system", content: input.system },
{ role: "user", content: input.user },
],
{
abortSignal: input.signal,
structuredOutput: { schema: reportReadingDraftSchema as z.ZodType, jsonPromptInjection: "inline" },
},
);
const usage = await Promise.resolve((result as { usage?: { inputTokens?: number; outputTokens?: number } }).usage);
return {
body: (result as { object?: unknown }).object,
inputTokens: Math.max(0, usage?.inputTokens ?? 0),
outputTokens: Math.max(0, usage?.outputTokens ?? 0),
};
};
}
+5 -1
View File
@@ -238,7 +238,11 @@ test("the report TOC is a persistent right-hand rail that leaves the paper palet
// 原值:`/<ReportToc headings=\{outline\.headings\} \/>/`
// 新值:同一 headings 之外允许并只允许多一个 `language` 属性
// 原因:TASK-report-english-edition-20260929 E6——目录标签随报告语言切换;目录仍只从同一份 outline 的 heading id 构建
assert.match(viewSource, /<ReportToc headings=\{outline\.headings\} language=\{language\} \/>/);
// 原值(2):`/<ReportToc headings=\{outline\.headings\} language=\{language\} \/>/`
// 新值(2):`tocHeadings` = 有解读时在 outline.headings 前加一条「解读」,其余仍是同一份 outline 的 heading id
// 原因(2):issue #113 R1(产品 2026-10-27 拍板「R1 C1」):报告前面的解读要进目录
assert.match(viewSource, /<ReportToc headings=\{tocHeadings\} language=\{language\} \/>/);
assert.match(viewSource, /\[\{ id: READING_ANCHOR, level: 2, title: REPORT_READING_HEADING \}, \.\.\.outline\.headings\] : outline\.headings/);
assert.match(viewSource, /aria-current=\{item\.id === activeId \? "location" : undefined\}/);
assert.match(viewSource, /rehypePlugins=\{\[reportChartGrid\]\}/, "BUG-616/617 grid stays wired");
@@ -872,7 +872,10 @@ test("instrumentation retains the Skill guard and starts a singleton Node worker
assert.match(productionAdapter, /loadReportCandidateRange/);
assert.match(productionAdapter, /generatePersonalReportLongform/);
assert.match(productionAdapter, /PERSONAL_REPORT_WRITER_ENABLED/);
assert.match(productionAdapter, /inputTokens: 0/);
// 原值: assert.match(productionAdapter, /inputTokens: 0/)
// 新值: 报告 token 取解读那一次模型调用的用量,没有解读时为 0
// 原因: issue #113 R1(产品 2026-10-27 拍板「R1 C1」):报告前面多一次模型写的解读,用量按实际 token 记账
assert.match(productionAdapter, /inputTokens: generated\.usage\?\.inputTokens \?\? 0/);
assert.doesNotMatch(productionAdapter, /actualModelId: "unknown"/);
assert.doesNotMatch(productionAdapter, /from\("agentic_rectification_cases"\)/);
assert.doesNotMatch(productionAdapter, /from\("birth_time_rectification_cases"\)/);
@@ -0,0 +1,303 @@
/**
* Issue #113 R1 (product decision 2026-10-27 「R1 C1」): a model-written
* reading in front of the engine report. Fake writers only (no model key in
* CI); facts and charts are fictional.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import test from "node:test";
import { fileURLToPath } from "node:url";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view";
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page";
import { hashLongformMarkdown } from "../src/lib/personal-report-longform-appendix";
import { validateLongformSnapshot } from "../src/lib/personal-report-longform-snapshot";
import {
REPORT_READING_SECTIONS,
REPORT_READING_SYSTEM,
acceptReportReadingDraft,
writeReportReading,
type ReportReadingWriter,
} from "../src/lib/personal-report-reading";
import { resolveReportRead } from "../src/lib/personal-report-route-core";
import type { PersonalReportRecord } from "../src/lib/personal-report-service-core";
import { assembleReportFactTables } from "../src/lib/report-fact-tables";
import { assembleLongformCharts } from "../src/lib/personal-report-generation";
Object.assign(globalThis, { React });
const engine = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8"));
const zhReader = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-fictional.json", import.meta.url), "utf8"));
const PARAGRAPH = "你的上升落在金牛座,因为管你自己的 1 宫(你自己、身体、给人的第一印象)由金星主管,金星在盘上力气中等,你给人的感觉是稳、慢热、讲究分寸。做事先看清楚再动手,一旦认定就不太轻易改主意,这份耐心是你最可靠的本钱。熟悉的人会觉得你靠得住,刚认识的人可能要多处一阵才知道你心里热。";
function draft(overrides: Partial<Record<string, string[]>> = {}) {
return {
sections: REPORT_READING_SECTIONS.map((section) => ({
id: section.id,
paragraphs: overrides[section.id] ?? [PARAGRAPH],
})),
};
}
const FACTS = {
schema: "report-reading-facts-v1",
minute_confirmed: true,
ascendant: { sign: "Taurus", lord: "Venus" },
planets: { Sun: { d1_sign: "Pisces", d1_house: 11 } },
domains: { career: { varga: "D10", houses: { "10": { lord: "Saturn" } } } },
timing: { md: "Rahu", mahadasha: { lord: "Rahu", start: "2023-07-08", end: "2041-07-08" } },
};
test("a clean eight-part draft becomes the stored reading with our titles", () => {
const accepted = acceptReportReadingDraft(draft());
assert.ok(accepted.ok);
assert.deepEqual(accepted.reading.sections.map((section) => section.title), ["总评", "性格", "事业", "财富", "感情", "健康", "现在这几年", "几条建议"]);
assert.equal(accepted.reading.language, "zh");
});
test("drafts with Latin, field names, fate, disease, investment or stacked disclaimers are rejected", () => {
const cases: Array<[string, unknown]> = [
["latin", draft({ career: [`${PARAGRAPH}这段是 Rahu 大运。`] })],
["field_name", draft({ career: [`${PARAGRAPH}mars_house=2`.replace(/[a-z]/g, "")] })],
["banned_phrase", draft({ love: [`${PARAGRAPH}你注定晚婚。`] })],
["banned_phrase", draft({ now: [`${PARAGRAPH}这几年先苦后甜。`] })],
["banned_phrase", draft({ health: [`${PARAGRAPH}要小心糖尿病。`] })],
["banned_phrase", draft({ wealth: [`${PARAGRAPH}适合炒股。`] })],
["banned_phrase", draft({ overview: [`${PARAGRAPH}北交点在 10 宫。`] })],
["banned_phrase", draft({ overview: [`${PARAGRAPH}本仓功能吉凶层这样判。`] })],
["disclaimer", draft({ health: [`${PARAGRAPH}仅供参考。`, `${PARAGRAPH}不构成医疗意见。`] })],
["shape", { sections: draft().sections.slice(0, 7) }],
["shape", { sections: [...draft().sections].reverse() }],
["too_short", draft(Object.fromEntries(REPORT_READING_SECTIONS.map((section) => [section.id, ["很短的一句。"]])))],
["too_long", draft({ career: [PARAGRAPH.repeat(5)] })],
];
for (const [reason, value] of cases) {
const accepted = acceptReportReadingDraft(value);
assert.equal(accepted.ok, false, reason);
assert.equal(!accepted.ok && accepted.reason, reason, JSON.stringify(value).slice(0, 120));
}
});
test("the prompt carries the C1 rules and the red lines, and only engine facts go in", () => {
for (const rule of ["因为……", "宫位第一次出现时", "讲时间时最多说三件", "不写病名", "不给投资指令", "「注定」「先苦后甜」", "罗睺、计都", "只输出 JSON"]) {
assert.ok(REPORT_READING_SYSTEM.includes(rule), rule);
}
});
function writer(bodies: unknown[], calls: string[] = []): ReportReadingWriter {
return async (input) => {
calls.push(input.user);
const body = bodies.shift();
if (body instanceof Error) throw body;
return { body, inputTokens: 1000, outputTokens: 500 };
};
}
test("one retry after a rejected draft; tokens of both attempts are counted", async () => {
const calls: string[] = [];
const outcome = await writeReportReading({ facts: FACTS, birthTimeConfirmed: false, write: writer([draft({ love: ["你注定晚婚。"] }), draft()], calls) });
assert.ok(outcome.reading);
assert.equal(outcome.inputTokens, 2000);
assert.equal(outcome.outputTokens, 1000);
assert.equal(calls.length, 2);
assert.match(calls[0]!, /没有确认到分钟/);
assert.match(calls[0]!, /"Taurus"/);
});
test("no facts, a failing model or a hung model all leave the report without a reading", async () => {
const calls: string[] = [];
const noFacts = await writeReportReading({ facts: { schema: "report-reading-facts-v1", gaps: ["reading_facts_unavailable"] }, birthTimeConfirmed: true, write: writer([draft()], calls) });
assert.deepEqual(noFacts, { reading: null, unavailable: "facts_unavailable", inputTokens: 0, outputTokens: 0 });
assert.equal(calls.length, 0);
const failed = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([new Error("500"), new Error("500")]) });
assert.equal(failed.reading, null);
assert.equal(failed.unavailable, "model_failed");
// A writer that ignores its abort signal still loses to the timeout.
const hung = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, timeoutMs: 20, write: () => new Promise(() => undefined) });
assert.equal(hung.reading, null);
assert.equal(hung.unavailable, "model_timeout");
const rejected = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([draft({ health: ["要小心糖尿病。"] }), draft({ health: ["要小心糖尿病。"] })]) });
assert.equal(rejected.unavailable, "rejected_banned_phrase");
});
test("a cancelled job still cancels; the reading never swallows the abort", async () => {
const controller = new AbortController();
const pending = writeReportReading({
facts: FACTS, birthTimeConfirmed: true, signal: controller.signal,
write: ({ signal }) => new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))),
});
controller.abort(new Error("job_cancelled"));
await assert.rejects(pending, /job_cancelled/);
});
const identity = {
reportId: "11111111-1111-4111-8111-111111111111",
userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
requestId: "22222222-2222-4222-8222-222222222222",
};
function snapshot(extra: Record<string, unknown> = {}) {
const markdown = zhReader.markdown as string;
return {
kind: "longform_calculation_snapshot", version: 2, ...identity, markdown,
contentSha256: hashLongformMarkdown(markdown),
factTables: assembleReportFactTables(engine),
charts: assembleLongformCharts(engine, "ev-cover-longform"),
readerDashaApplicability: "x",
englishUnavailable: "han_leak",
...extra,
};
}
test("snapshots: older ones read without a reading; a stored reading must be well formed", () => {
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
assert.equal(validateLongformSnapshot(snapshot(), identity).version, 2);
const stored = validateLongformSnapshot(snapshot({ reading }), identity);
assert.equal(stored.version === 2 && stored.reading?.sections.length, 8);
assert.equal(validateLongformSnapshot(snapshot({ readingUnavailable: "model_timeout" }), identity).version, 2);
assert.throws(() => validateLongformSnapshot(snapshot({ reading: { ...(reading as object), sections: [] } }), identity));
assert.throws(() => validateLongformSnapshot(snapshot({ readingUnavailable: "Model Timeout!" }), identity));
});
function readyRecord(): PersonalReportRecord {
return {
id: identity.reportId, userId: identity.userId, sessionId: null, chartProfileId: null,
requestId: identity.requestId, requestFingerprint: "f".repeat(64), reportType: "personal_full",
presentationMode: "default", depth: "standard", requestedThemes: ["career"], status: "ready",
evidenceHash: null, reportDocument: null, cardSummary: null, failureCode: null,
createdAt: "2026-10-27T00:00:00.000Z", completedAt: "2026-10-27T00:01:00.000Z", updatedAt: "2026-10-27T00:01:00.000Z",
} as unknown as PersonalReportRecord;
}
async function read(loadLongformReading?: Parameters<typeof resolveReportRead>[0]["loadLongformReading"]) {
return resolveReportRead({
requestUrl: "https://jyotisha.chat/api/reports/x", origin: null, allowedOrigins: [],
userId: identity.userId, reportId: identity.reportId,
persistence: { async getOwnedById() { return readyRecord(); } },
loadLongformAppendix: async () => ({ status: "ready", lastErrorCode: null, markdown: zhReader.markdown }),
...(loadLongformReading ? { loadLongformReading } : {}),
validateReadyDocument: () => ({ ok: false }),
});
}
test("the read API returns the reading when there is one and never fails the report over it", async () => {
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
const ready = await read(async () => reading as never);
assert.equal(ready.status, 200);
assert.deepEqual(ready.body.reading, reading);
const none = await read(async () => null);
assert.equal("reading" in none.body, false);
const broken = await read(async () => { throw new Error("db down"); });
assert.equal(broken.status, 200);
assert.equal("reading" in broken.body, false);
assert.equal("reading" in (await read()).body, false);
});
test("the page keeps a reading only in the stored shape and shows it before the engine report in Chinese only", () => {
const reading = (acceptReportReadingDraft(draft()) as { reading: { sections: unknown[] } }).reading;
const envelope = (extra: Record<string, unknown>) => classifyReportEnvelope(200, {
report: { id: identity.reportId, status: "ready", createdAt: "2026-10-27T00:00:00.000Z" },
longformMarkdown: zhReader.markdown, ...extra,
});
const ready = envelope({ reading });
assert.equal(ready.phase === "markdown-ready" && ready.reading?.sections.length, 8);
const bad = envelope({ reading: { ...reading, sections: reading.sections.slice(1) } });
assert.equal(bad.phase === "markdown-ready" && "reading" in bad, false);
const markdown = "# 印度占星星盘报告\n\n## 出生资料与图盘\n\n引擎正文。\n";
const zh = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} reading={reading as never} language="zh" />);
const readingAt = zh.indexOf('id="report-reading"');
const basisAt = zh.indexOf("计算依据");
const engineAt = zh.indexOf('id="出生资料与图盘"');
assert.ok(zh.indexOf('href="#report-reading"') > -1 && zh.indexOf('href="#report-reading"') < zh.indexOf('href="#出生资料与图盘"'), "contents lists the reading first");
assert.ok(readingAt > zh.indexOf("印度占星星盘报告") && readingAt < basisAt && basisAt < engineAt, zh.slice(0, 400));
for (const title of ["总评", "现在这几年", "几条建议"]) assert.ok(zh.includes(`<h3>${title}</h3>`), title);
const en = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} reading={reading as never} language="en" />);
assert.equal(en.includes("report-reading"), false);
assert.equal(en.includes("计算依据"), false);
const legacy = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} language="zh" />);
assert.equal(legacy.includes("计算依据"), false);
});
test("the reading arrives with the report: no loader of its own, no request after reveal", () => {
const section = readFileSync(new URL("../src/components/personal-report/report-reading-section.tsx", import.meta.url), "utf8");
assert.doesNotMatch(section, /use(State|Effect)|fetch\(|Skeleton|Spinner|加载/);
const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8");
assert.doesNotMatch(page, /fetch\([^)]*reading/);
// The page bundle reads the contract, never the prompt module.
for (const file of ["personal-report-page.tsx", "personal-report-markdown-view.tsx", "report-reading-section.tsx"]) {
const source = readFileSync(new URL(`../src/components/personal-report/${file}`, import.meta.url), "utf8");
assert.doesNotMatch(source, /lib\/personal-report-reading"/, file);
}
});
test("the worker writes the reading in the report job and bills its tokens", () => {
const worker = readFileSync(new URL("../src/lib/personal-report-worker.ts", import.meta.url), "utf8");
assert.match(worker, /writeReading: createReportReadingWriter\(model\)/);
assert.match(worker, /inputTokens: generated\.usage\?\.inputTokens \?\? 0/);
assert.match(worker, /outputTokens: generated\.usage\?\.outputTokens \?\? 0/);
});
for (const mode of ["reading", "model-down", "no-writer"]) {
test(`report job with the reading: ${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 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 draft = ${JSON.stringify(draft())};
const facts = ${JSON.stringify(FACTS)};
const rows = new Map(); const bodies = [];
const admin = { async rpc(name, args) {
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) { 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 writeReading = mode === "no-writer" ? undefined : async () => {
if (mode === "model-down") throw new Error("model down");
return { body: draft, inputTokens: 1200, outputTokens: 800 };
};
const result = await generatePersonalReportLongform({ 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-10-27T12:00:00Z"),
fetchImpl: async (_url, init) => { bodies.push(JSON.parse(init.body)); return Response.json({ format: "markdown", markdown: golden.markdown, fact_table_packet: packet, reader_dasha_applicability: golden.reader_dasha_applicability, ...(mode === "no-writer" ? {} : { reading_facts: facts }) }); },
...(writeReading ? { writeReading } : {}) });
assert.equal(result.status, "ready");
assert.equal(bodies.length, 1);
assert.equal(bodies[0].include_reading_facts, mode === "no-writer" ? undefined : true);
const stored = await readLongformSnapshot(admin, { reportId: report.id, userId: report.userId, requestId: report.requestId });
assert.equal(stored.status, "ready");
assert.equal(stored.snapshot.markdown, golden.markdown);
if (mode === "reading") {
assert.equal(stored.snapshot.reading.sections.length, 8);
assert.equal(stored.snapshot.reading.sections[0].title, "总评");
assert.deepEqual(result.usage, { inputTokens: 1200, outputTokens: 800 });
} else if (mode === "model-down") {
assert.equal(stored.snapshot.reading, undefined);
assert.equal(stored.snapshot.readingUnavailable, "model_failed");
assert.deepEqual(result.usage, { inputTokens: 0, outputTokens: 0 });
} else {
assert.equal(stored.snapshot.reading, undefined);
assert.equal(stored.snapshot.readingUnavailable, undefined);
assert.equal(result.usage, undefined);
}
`;
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);
});
}
+38 -3
View File
@@ -195,6 +195,18 @@ def _export_args(birth: dict[str, Any]) -> SimpleNamespace:
return SimpleNamespace(**payload)
def _reading_facts(packet: dict, full_reading: dict, minute_confirmed: bool, today: str | None) -> dict[str, Any]:
"""Facts for the model-written reading (issue #113 R1). Never fails the report."""
try:
try:
from scripts.report_render.reading_facts import build_report_reading_facts
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from report_render.reading_facts import build_report_reading_facts
return build_report_reading_facts(packet, full_reading, minute_confirmed=minute_confirmed, today=today)
except Exception as exc: # noqa: BLE001 - the reading is optional, the report is not
return {"schema": "report-reading-facts-v1", "gaps": ["reading_facts_unavailable"], "error_type": type(exc).__name__}
def _load_report_cache():
try:
from scripts import consult_full_data_cache as cache
@@ -215,6 +227,7 @@ def _report_cache_identity(
languages: list[str],
include_fact_tables: bool,
sha: str,
reading: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"birth": birth,
@@ -225,6 +238,8 @@ def _report_cache_identity(
"include_fact_tables": include_fact_tables,
"report_version": REPORT_VERSION_FULL,
"github_sha": sha,
"include_reading_facts": reading is not None,
**({"reading": reading} if reading is not None else {}),
}
@@ -237,6 +252,7 @@ def _assemble_professional_report_reference(
languages: list[str],
include_fact_tables: bool,
engine,
reading: dict[str, Any] | None = None,
) -> dict[str, Any]:
full_reading = handler._compute_full_reading_for_thematic(birth)
resolved_engine = engine or _load_engine()
@@ -270,6 +286,9 @@ def _assemble_professional_report_reference(
"reader_dasha_applicability": clean_reader_appendix_markdown(render_dasha_applicability(packet))}
if include_fact_tables else {}),
**(english if edition == FULL_DATA_EDITION and "en" in languages else {}),
**({"reading_facts": _reading_facts(
packet, full_reading, reading["minute_confirmed"], reading.get("today"),
)} if reading is not None and edition == FULL_DATA_EDITION else {}),
}
return {
"format": "json",
@@ -278,6 +297,21 @@ def _assemble_professional_report_reference(
}
def _reading_request(body: dict[str, Any]) -> dict[str, Any] | None:
"""`include_reading_facts: true` asks for the reading facts; off by default."""
if body.get("include_reading_facts") is not True:
return None
try:
from scripts.consult_card_domain_facts import minute_confirmed_from_body
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from consult_card_domain_facts import minute_confirmed_from_body
today = body.get("today")
return {
"minute_confirmed": minute_confirmed_from_body(body),
"today": today if isinstance(today, str) else None,
}
def build_professional_report_reference(handler, body: dict[str, Any], *, engine=None) -> dict[str, Any]:
"""Build one JSON or Markdown response from one reused full-reading result."""
if not isinstance(body, dict):
@@ -291,6 +325,7 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
birth = handler._high_rigor_birth_payload(body)
include_fact_tables = body.get("include_fact_tables") is True
reading = _reading_request(body) if edition == FULL_DATA_EDITION and output_format == "markdown" else None
sha = _report_cache_sha()
use_cache = (
report_reference_cache_enabled()
@@ -300,16 +335,16 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
)
if not use_cache:
return _assemble_professional_report_reference(
handler, birth, output_format, edition, packs, languages, include_fact_tables, engine,
handler, birth, output_format, edition, packs, languages, include_fact_tables, engine, reading,
)
identity = _report_cache_identity(
birth, output_format, edition, packs, languages, include_fact_tables, sha,
birth, output_format, edition, packs, languages, include_fact_tables, sha, reading,
)
started = time.perf_counter()
def builder(_identity: dict[str, Any]) -> dict[str, Any]:
return _assemble_professional_report_reference(
handler, birth, output_format, edition, packs, languages, include_fact_tables, engine,
handler, birth, output_format, edition, packs, languages, include_fact_tables, engine, reading,
)
cached = _load_report_cache().get_or_build(
+126
View File
@@ -0,0 +1,126 @@
"""Facts for the model-written reading at the front of the report (issue #113 R1).
The reading is written by a model, but only from what the engine already
computed. This module copies the consult-card leaves (the same facts the chat
uses) from the report's own full-data packet, drops paths and gaps, and adds
the current dasha dates. It computes nothing new.
"""
from __future__ import annotations
import copy
from typing import Any
try:
from scripts.consult_card_domain_facts import assert_fact_leaves, build_consult_card_facts, dig
from scripts.consult_full_data_cache import _attach_graha_drishti
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from consult_card_domain_facts import assert_fact_leaves, build_consult_card_facts, dig
from consult_full_data_cache import _attach_graha_drishti
READING_FACTS_SCHEMA = "report-reading-facts-v1"
READING_DOMAINS = ("general", "career", "wealth", "marriage", "health")
DASHA = "worksheets.timing_and_predictive_systems.dasha.current_dasha"
UPCOMING_SUB_PERIODS = 2
def _compact(node: Any) -> Any:
"""Leaf values only. Gaps, withheld and blocked leaves are left out."""
if isinstance(node, dict):
if "status" in node and "check" in node:
return node.get("value") if node["status"] == "value" else None
out = {}
for key, value in node.items():
kept = _compact(value)
if kept is not None and kept != {} and kept != []:
out[key] = kept
return out
if isinstance(node, list):
return [item for item in (_compact(entry) for entry in node) if item is not None]
return node
def _period(row: Any) -> dict[str, Any] | None:
if not isinstance(row, dict) or not isinstance(row.get("lord"), str):
return None
out = {"lord": row["lord"]}
for key in ("start", "end"):
if isinstance(row.get(key), str):
out[key] = row[key]
return out
def _dasha_dates(packet: dict) -> dict[str, Any]:
current = dig(packet, DASHA)
if not isinstance(current, dict):
return {}
dates: dict[str, Any] = {}
md = _period(current)
if md:
dates["mahadasha"] = md
rows = current.get("antardasha_timeline")
if isinstance(rows, list):
index = next((i for i, row in enumerate(rows) if isinstance(row, dict) and row.get("is_current") is True), None)
if index is not None:
now = _period(rows[index])
if now:
dates["antardasha"] = now
upcoming = [_period(row) for row in rows[index + 1:index + 1 + UPCOMING_SUB_PERIODS]]
dates["next_antardashas"] = [row for row in upcoming if row]
return dates
def _domain_factors(row: dict) -> list[dict[str, Any]]:
factors = []
for factor in row.get("factors") or []:
if isinstance(factor, dict) and factor.get("kind") == "planet" and isinstance(factor.get("planet"), str):
factors.append({"role": factor.get("role"), "planet": factor["planet"]})
return factors
def build_report_reading_facts(
packet: dict,
full_reading: dict,
*,
minute_confirmed: bool,
today: str | None = None,
) -> dict[str, Any]:
"""Compact, packet-sourced facts for the reading. Raises on any non-packet leaf."""
working = _attach_graha_drishti(copy.deepcopy(packet), full_reading)
raw = build_consult_card_facts(working, list(READING_DOMAINS), minute_confirmed=minute_confirmed)
rows = {row["domain"]: row for row in raw["domains"]}
for row in rows.values():
assert_fact_leaves(working, row)
general = rows["general"]
planets = {}
for name, record in general["planets"].items():
kept = _compact({key: value for key, value in record.items() if key != "domain_varga_house"})
if kept.get("present"):
kept.pop("present")
planets[name] = kept
domains = {}
for name, row in rows.items():
domains[name] = {
"varga": row["domain_varga"],
"houses": _compact(row["houses"]),
"planets_in_varga": {
planet: house
for planet, record in row["planets"].items()
if (house := _compact(record.get("domain_varga_house"))) is not None
},
"significators": _domain_factors(row),
}
ascendant = dig(packet, "core_chart.ascendant")
return {
"schema": READING_FACTS_SCHEMA,
"minute_confirmed": minute_confirmed,
**({"today": today} if today else {}),
"ascendant": _compact({
key: {"status": "value", "check": "path", "value": ascendant.get(key)}
for key in ("sign", "lord", "nakshatra")
if isinstance(ascendant, dict) and ascendant.get(key) is not None
}),
"planets": planets,
"domains": domains,
"timing": {**_compact(general["timing"]), **_dasha_dates(packet)},
}
+167
View File
@@ -0,0 +1,167 @@
"""Reading facts for the model-written report reading (issue #113 R1).
Clearly fictional chart only. The facts must be copies of the report's own
packet (no new algorithm), the flag must default off with the response
unchanged, and the flag must be part of the report cache key.
"""
from __future__ import annotations
import copy
import json
import pytest
import scripts.professional_report_reference as reference
from scripts.consult_card_domain_facts import dig
from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading
from scripts.professional_report_reference import _export_args, build_professional_report_reference
from scripts.report_render.reading_facts import READING_DOMAINS, build_report_reading_facts
from tests.test_report_reader_main import FICTIONAL
@pytest.fixture(scope="module")
def chart():
case = dict(FICTIONAL)
reading = cmd_full_reading(type("Args", (), case)())
packet = dict(build_professional_report_reference_packet(reading, _export_args(case), []))
packet["report_version"] = "pl9_personal_long_report.v3"
return case, reading, packet
def test_reading_facts_are_packet_leaves(chart) -> None:
_case, reading, packet = chart
before = json.dumps(packet, ensure_ascii=False, sort_keys=True, default=str)
facts = build_report_reading_facts(packet, reading, minute_confirmed=True, today="2026-04-07")
# Building the facts never touches the report's packet.
assert json.dumps(packet, ensure_ascii=False, sort_keys=True, default=str) == before
assert facts["schema"] == "report-reading-facts-v1"
assert sorted(facts["domains"]) == sorted(READING_DOMAINS)
for name, planet in facts["planets"].items():
assert planet["d1_sign"] == dig(packet, f"core_chart.planets.{name}.sign")
assert planet["d1_house"] == dig(packet, f"core_chart.planets.{name}.house")
assert planet["d1_dignity"] == dig(packet, f"core_chart.planets.{name}.status")
for key in ("sign", "lord", "nakshatra"):
assert facts["ascendant"][key] == dig(packet, f"core_chart.ascendant.{key}")
current = dig(packet, "worksheets.timing_and_predictive_systems.dasha.current_dasha")
assert facts["timing"]["mahadasha"] == {"lord": current["lord"], "start": current["start"], "end": current["end"]}
rows = current["antardasha_timeline"]
index = next(i for i, row in enumerate(rows) if row.get("is_current") is True)
assert facts["timing"]["antardasha"]["lord"] == rows[index]["lord"]
assert [row["lord"] for row in facts["timing"]["next_antardashas"]] == [row["lord"] for row in rows[index + 1:index + 3]]
# Paths, checks and gaps stay out of what the model reads.
encoded = json.dumps(facts, ensure_ascii=False)
for marker in ('"path"', '"check"', '"status"', '"citation"', "references/"):
assert marker not in encoded
assert len(encoded) < 20_000
def test_withheld_varga_houses_stay_out_without_a_confirmed_minute(chart) -> None:
_case, reading, packet = chart
facts = build_report_reading_facts(packet, reading, minute_confirmed=False)
assert facts["minute_confirmed"] is False
assert "出生时间精度不够" not in json.dumps(facts, ensure_ascii=False)
class _Handler:
def __init__(self, case: dict, reading: dict) -> None:
self.case = case
self.reading = reading
self.calls = 0
def _high_rigor_birth_payload(self, body: dict) -> dict:
payload = {key: value for key, value in self.case.items() if key != "name"}
if body.get("today"):
payload["today"] = body["today"]
return payload
def _compute_full_reading_for_thematic(self, _birth: dict) -> dict:
self.calls += 1
return self.reading
@pytest.fixture
def stubbed(chart, monkeypatch, tmp_path):
import scripts.consult_full_data_cache as cache
case, reading, packet = chart
def _dir(name: str = "consult_full_data_cache"):
path = tmp_path / name
path.mkdir(parents=True, exist_ok=True)
return path
class _Engine:
def build_professional_report_reference_packet(self, _reading, _args, _packs, **_kwargs):
return copy.deepcopy(packet)
def render_pl9_markdown(self, _packet):
return "reference"
monkeypatch.setattr(cache, "cache_dir", _dir)
monkeypatch.setattr(reference, "render_full_data_markdown", lambda p: "en-body" if p.get("report_language") == "en" else "zh-body")
monkeypatch.setattr(reference, "report_density_packet", lambda _packet: {"facts": 1})
monkeypatch.setattr(reference, "render_dasha_applicability", lambda _packet, language="zh": f"app-{language}")
monkeypatch.setattr(reference, "clean_reader_appendix_markdown", lambda text: text)
monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "0")
monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "0")
return _Handler(case, reading), _Engine()
def _body(**extra) -> dict:
body = {"format": "markdown", "edition": "full_data", "include_fact_tables": True, "languages": ["zh"], "today": "2026-04-07"}
body.update(extra)
return body
def _dumps(result: dict) -> str:
return json.dumps(result, ensure_ascii=False, sort_keys=True, default=str)
def test_flag_off_response_is_unchanged(stubbed) -> None:
handler, engine = stubbed
absent = build_professional_report_reference(handler, _body(), engine=engine)
off = build_professional_report_reference(handler, _body(include_reading_facts=False), engine=engine)
assert _dumps(absent) == _dumps(off)
assert "reading_facts" not in absent
assert sorted(absent) == ["edition", "fact_table_packet", "format", "markdown", "reader_dasha_applicability", "report_version"]
def test_flag_on_adds_reading_facts_and_keeps_the_report(stubbed) -> None:
handler, engine = stubbed
plain = build_professional_report_reference(handler, _body(), engine=engine)
with_facts = build_professional_report_reference(
handler, _body(include_reading_facts=True, birth_time_accuracy="confirmed", hour=9, minute=15), engine=engine,
)
facts = with_facts.pop("reading_facts")
assert _dumps(with_facts) == _dumps(plain)
assert facts["schema"] == "report-reading-facts-v1"
assert facts["minute_confirmed"] is True
assert facts["today"] == "2026-04-07"
assert "gaps" not in facts
def test_reading_facts_failure_never_fails_the_report(stubbed, monkeypatch) -> None:
import scripts.report_render.reading_facts as module
handler, engine = stubbed
def broken(*_args, **_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(module, "build_report_reading_facts", broken)
result = build_professional_report_reference(handler, _body(include_reading_facts=True), engine=engine)
assert result["markdown"] == "zh-body"
assert result["reading_facts"]["gaps"] == ["reading_facts_unavailable"]
def test_reading_flag_is_part_of_the_report_cache_key(stubbed, monkeypatch) -> None:
handler, engine = stubbed
monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "1")
monkeypatch.setenv("GITHUB_SHA", "reading-cache-test")
build_professional_report_reference(handler, _body(), engine=engine)
build_professional_report_reference(handler, _body(include_reading_facts=True), engine=engine)
assert handler.calls == 2
cached = build_professional_report_reference(handler, _body(include_reading_facts=True), engine=engine)
assert handler.calls == 2
assert "reading_facts" in cached