报告前面加一段模型写的解读(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:
co-authored by
Claude Opus 5.5
parent
e5d768f16f
commit
cad28ddc04
@@ -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`。没有调用模型,不得写成通过。
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-27 — 报告前面加一段「解读」,对话把「为什么」写进正文
|
||||
|
||||
- 新生成的中文报告最前面多一段「解读」:总评、性格、事业、财富、感情、健康、现在这几年、几条建议。由模型按引擎已算好的盘面来写,用「你」说话,每段说清盘上是什么、为什么、生活里是什么样子。原来的报告内容放在后面,标为「计算依据」,内容不变。
|
||||
- 解读写不出来或没通过检查时,报告照常生成,只是没有这一段。英文版和以前的报告不变。报告价格不变。
|
||||
- 对话里提到宫位时,第一次会说这一宫管生活里的哪一块,例如「第 8 宫(突发的变化、没摊开的事、别人的钱)」。依据不再放在段尾括号里重复一遍,改成正文里一句「因为……」。讲时间最多说三件事,由近到远。问事业、钱、时间时,结尾不再猜你为什么问。
|
||||
- Skill 版本不变(BUG-1460、BUG-1461)。
|
||||
|
||||
## 2026-10-27 — 星盘页说明上升会不会换,校正开场说清限度
|
||||
|
||||
- 星盘页在出生资料下面多一句:按你给的时间精度,上升是不是都落在同一个星座。会换时可以点「出生时间校正」。算不出来就不显示这句,星盘照常打开。
|
||||
|
||||
@@ -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,真实解读输出和名人生平回测都没跑。
|
||||
|
||||
## 验收数字
|
||||
|
||||
见本文件末尾(提交前补)。
|
||||
@@ -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` |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 真机清单:报告开篇解读与对话口吻(issue #113,2026-10-27)
|
||||
|
||||
在 staging 上用自己的账号走。不要把截图里的出生资料贴进仓库。
|
||||
|
||||
## 报告
|
||||
|
||||
1. 新生成一份个人报告(旧报告不会补上解读)。等待过程和以前一样,没有新的加载画面。
|
||||
2. 打开后,标题和重点卡下面先是「解读」:总评、性格、事业、财富、感情、健康、现在这几年、几条建议八小节。
|
||||
3. 每节读起来是对「你」说的话:说了盘上是什么、为什么、生活里是什么样子。宫位第一次出现带括号说明它管什么。
|
||||
4. 没有英文字母、没有「本仓」「检测器」这类内部说法,没有病名、没有买卖股票基金的建议,没有「注定」「先苦后甜」。
|
||||
5. 「现在这几年」写了当前大运和接下来两段子运的起止年月,和后面「计算依据」里的大运表对得上。
|
||||
6. 解读后面有一行小字「计算依据」,下面是原来的报告,内容没变。目录第一条是「解读」,点了跳回开头。
|
||||
7. 切到 English:没有解读,其余和以前一样。
|
||||
8. 如果某份报告没有解读,报告本身照常能看(记下时间,告诉我查日志)。
|
||||
|
||||
## 对话
|
||||
|
||||
9. 问一个事业或感情问题:提到宫位时第一次带生活领域,例如「第 10 宫(事业、社会地位)」。
|
||||
10. 段尾不再有一串括号把正文重复一遍;依据写在句子里,「因为……」。
|
||||
11. 问「这段时间整体怎么样」:讲时间最多三件事,由近到远;结尾不猜你为什么问。
|
||||
12. 问「我妈是不是不太在意我」这类感受题:先答是或不是,可以有一句读你心情的话。
|
||||
|
||||
走完回一句「过」,或者贴出不对的段落。
|
||||
@@ -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%.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user