diff --git a/BLOCKED.md b/BLOCKED.md index 2d6efca4..f6c7cd07 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -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`。没有调用模型,不得写成通过。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 949b1917..fa3eb297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-10-27 — 报告前面加一段「解读」,对话把「为什么」写进正文 + +- 新生成的中文报告最前面多一段「解读」:总评、性格、事业、财富、感情、健康、现在这几年、几条建议。由模型按引擎已算好的盘面来写,用「你」说话,每段说清盘上是什么、为什么、生活里是什么样子。原来的报告内容放在后面,标为「计算依据」,内容不变。 +- 解读写不出来或没通过检查时,报告照常生成,只是没有这一段。英文版和以前的报告不变。报告价格不变。 +- 对话里提到宫位时,第一次会说这一宫管生活里的哪一块,例如「第 8 宫(突发的变化、没摊开的事、别人的钱)」。依据不再放在段尾括号里重复一遍,改成正文里一句「因为……」。讲时间最多说三件事,由近到远。问事业、钱、时间时,结尾不再猜你为什么问。 +- Skill 版本不变(BUG-1460、BUG-1461)。 + ## 2026-10-27 — 星盘页说明上升会不会换,校正开场说清限度 - 星盘页在出生资料下面多一句:按你给的时间精度,上升是不是都落在同一个星座。会换时可以点「出生时间校正」。算不出来就不显示这句,星盘照常打开。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 89f368ac..43882f48 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 + diff --git a/docs/tasks/PROGRESS-voice-reading-20261027.md b/docs/tasks/PROGRESS-voice-reading-20261027.md new file mode 100644 index 00000000..6f1ff88e --- /dev/null +++ b/docs/tasks/PROGRESS-voice-reading-20261027.md @@ -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` | `` | `headings={tocHeadings}`,只在有解读时多一条「解读」 | R1 解读进目录 | + +## 环境缺口 + +见 `BLOCKED.md` 同名条目:无模型 key,真实解读输出和名人生平回测都没跑。 + +## 验收数字 + +见本文件末尾(提交前补)。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 8a70c1bd..eccfd6b8 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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` | diff --git a/docs/testing/voice-reading-20261027.md b/docs/testing/voice-reading-20261027.md new file mode 100644 index 00000000..4d79485d --- /dev/null +++ b/docs/testing/voice-reading-20261027.md @@ -0,0 +1,23 @@ +# 真机清单:报告开篇解读与对话口吻(issue #113,2026-10-27) + +在 staging 上用自己的账号走。不要把截图里的出生资料贴进仓库。 + +## 报告 + +1. 新生成一份个人报告(旧报告不会补上解读)。等待过程和以前一样,没有新的加载画面。 +2. 打开后,标题和重点卡下面先是「解读」:总评、性格、事业、财富、感情、健康、现在这几年、几条建议八小节。 +3. 每节读起来是对「你」说的话:说了盘上是什么、为什么、生活里是什么样子。宫位第一次出现带括号说明它管什么。 +4. 没有英文字母、没有「本仓」「检测器」这类内部说法,没有病名、没有买卖股票基金的建议,没有「注定」「先苦后甜」。 +5. 「现在这几年」写了当前大运和接下来两段子运的起止年月,和后面「计算依据」里的大运表对得上。 +6. 解读后面有一行小字「计算依据」,下面是原来的报告,内容没变。目录第一条是「解读」,点了跳回开头。 +7. 切到 English:没有解读,其余和以前一样。 +8. 如果某份报告没有解读,报告本身照常能看(记下时间,告诉我查日志)。 + +## 对话 + +9. 问一个事业或感情问题:提到宫位时第一次带生活领域,例如「第 10 宫(事业、社会地位)」。 +10. 段尾不再有一串括号把正文重复一遍;依据写在句子里,「因为……」。 +11. 问「这段时间整体怎么样」:讲时间最多三件事,由近到远;结尾不猜你为什么问。 +12. 问「我妈是不是不太在意我」这类感受题:先答是或不是,可以有一句读你心情的话。 + +走完回一句「过」,或者贴出不对的段落。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 18bf9032..a0e3aab9 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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%. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 40fda313..00f230c8 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -274,6 +274,17 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 出生资料下面可以有一句上升会不会换。本人上升会换、或不知道出生时间时,「出生时间校正」是链接:已经有没做完的校正就回到那一次,没有才新开。别人的星盘只说到「(大约 HH:MM 换座)。」或「上升星座定不下来。」,不写「出生时间校正」,也不给这个链接。 - 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。行星位置这类数值表太宽时,在表自己的框里横滑,页面不被撑开。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里,格子里的字可以换行。 +## 报告开篇解读(2026-10-27,issue #113 R1) + +报告最前面的「解读」由模型写,素材只用引擎已算好的事实(与对话同源卡同一批叶子),后面原有的引擎正文改称「计算依据」。 + +- 固定八节:总评、性格、事业、财富、感情、健康、现在这几年、几条建议。节名是我们定的,模型只写段落。 +- 写法和对话 C1 同一套:对「你」说话;每段「盘面 → 因为…… → 生活里的样子」,依据写进正文,不放段尾括号;宫位第一次出现带生活领域(十二宫表见第七条原则 C1 补充);时间最多三件、由近到远,现在这几年写当前大运和接下来两段子运的起止年月。 +- 免责每节最多一句,不逐句写「可能」。语气笃定但不说「注定」「先苦后甜」「宿命」「一定会」。 +- 不写病名和诊断,健康只说作息和要多休息的时段;不给投资指令,不点名股票、基金、币;婚姻、生死不下确定结论。 +- 只用中文,罗睺 / 计都;不写英文字母、分盘代号、字段名、分数原值、「本仓」「检测器」「口径」这类内部说法。出生时间没确认时不把上升、宫位说成定论。 +- 校验不过(外文、字段名、禁词、节数不对、过长过短、一节两句以上免责)或模型失败、超时:报告照常交付,只是没有解读,不重写成别的样子。 + ## 报告分块导出与删除 - 列表已完成行只留「查看报告」。阅读页操作条只留「返回报告中心」、「中文 / English」切换(只在有英文版的报告上出现)与带字的「导出」按钮(读屏名「导出报告」,2026-09-29 起不再是只有图标的按钮)。不再显示「下载原始附录」或「打印」入口文案;浏览器 Ctrl+P 排版仍保留。 diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts index 7b1c110f..5bf014f0 100644 --- a/frontend/src/app/api/reports/[reportId]/route.ts +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -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>> | 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 => { - 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 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 29783349..6fa2cd87 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; diff --git a/frontend/src/components/personal-report/personal-report-markdown-view.tsx b/frontend/src/components/personal-report/personal-report-markdown-view.tsx index 12e49efd..d8e30fc5 100644 --- a/frontend/src/components/personal-report/personal-report-markdown-view.tsx +++ b/frontend/src/components/personal-report/personal-report-markdown-view.tsx @@ -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(() => ( + 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({
@@ -370,6 +380,7 @@ export function PersonalReportMarkdownView({ {lead} ) : null} + {safeChapter === 0 && shownReading ? : null} {printing ? chapters.map((section, index) => (
@@ -394,13 +405,14 @@ export function PersonalReportMarkdownView({ return (
- +
{lead ? (
{lead}
) : null} + {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 : ( {state.calculationCharts && } diff --git a/frontend/src/components/personal-report/report-reading-section.tsx b/frontend/src/components/personal-report/report-reading-section.tsx new file mode 100644 index 00000000..6babff65 --- /dev/null +++ b/frontend/src/components/personal-report/report-reading-section.tsx @@ -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 ( + <> +
+

{REPORT_READING_HEADING}

+ {reading.sections.map((section) => ( +
+

{section.title}

+ {section.paragraphs.map((paragraph, index) => ( +

{paragraph}

+ ))} +
+ ))} +
+

{REPORT_READING_BASIS_LABEL}

+ + ); +} diff --git a/frontend/src/lib/personal-report-longform-generate.ts b/frontend/src/lib/personal-report-longform-generate.ts index 33b0dec0..90972d3c 100644 --- a/frontend/src/lib/personal-report-longform-generate.ts +++ b/frontend/src/lib/personal-report-longform-generate.ts @@ -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"); diff --git a/frontend/src/lib/personal-report-longform-snapshot.ts b/frontend/src/lib/personal-report-longform-snapshot.ts index 1445c7ef..16401eea 100644 --- a/frontend/src/lib/personal-report-longform-snapshot.ts +++ b/frontend/src/lib/personal-report-longform-snapshot.ts @@ -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", }), diff --git a/frontend/src/lib/personal-report-reading-contract.ts b/frontend/src/lib/personal-report-reading-contract.ts new file mode 100644 index 00000000..1ff44b1f --- /dev/null +++ b/frontend/src/lib/personal-report-reading-contract.ts @@ -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; + +/** 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; diff --git a/frontend/src/lib/personal-report-reading.ts b/frontend/src/lib/personal-report-reading.ts new file mode 100644 index 00000000..b8f98d31 --- /dev/null +++ b/frontend/src/lib/personal-report-reading.ts @@ -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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const facts = value as Record; + 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, 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((_, 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 { + 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 }; +} diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index 0b2c662d..6326f41c 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -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; + /** 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; }>; export type EnglishEditionRead = @@ -775,10 +782,16 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise ({ 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"), diff --git a/frontend/src/lib/personal-report-worker.ts b/frontend/src/lib/personal-report-worker.ts index 2f2442ef..6162f860 100644 --- a/frontend/src/lib/personal-report-worker.ts +++ b/frontend/src/lib/personal-report-worker.ts @@ -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, }, diff --git a/frontend/src/mastra/personal-report-reading.ts b/frontend/src/mastra/personal-report-reading.ts new file mode 100644 index 00000000..2f4d80ad --- /dev/null +++ b/frontend/src/mastra/personal-report-reading.ts @@ -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), + }; + }; +} diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts index 31b2e3df..5ecedecf 100644 --- a/frontend/tests/personal-report-view.test.ts +++ b/frontend/tests/personal-report-view.test.ts @@ -238,7 +238,11 @@ test("the report TOC is a persistent right-hand rail that leaves the paper palet // 原值:`//` // 新值:同一 headings 之外允许并只允许多一个 `language` 属性 // 原因:TASK-report-english-edition-20260929 E6——目录标签随报告语言切换;目录仍只从同一份 outline 的 heading id 构建 - assert.match(viewSource, //); + // 原值(2):`//` + // 新值(2):`tocHeadings` = 有解读时在 outline.headings 前加一条「解读」,其余仍是同一份 outline 的 heading id + // 原因(2):issue #113 R1(产品 2026-10-27 拍板「R1 C1」):报告前面的解读要进目录 + assert.match(viewSource, //); + 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"); diff --git a/frontend/tests/personal-report-worker.test.ts b/frontend/tests/personal-report-worker.test.ts index 95524f31..64d0e484 100644 --- a/frontend/tests/personal-report-worker.test.ts +++ b/frontend/tests/personal-report-worker.test.ts @@ -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"\)/); diff --git a/frontend/tests/report-reading-20261027.test.tsx b/frontend/tests/report-reading-20261027.test.tsx new file mode 100644 index 00000000..07d7ade1 --- /dev/null +++ b/frontend/tests/report-reading-20261027.test.tsx @@ -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> = {}) { + 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 = {}) { + 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[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) => 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(); + 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(`

${title}

`), title); + const en = renderToStaticMarkup(); + assert.equal(en.includes("report-reading"), false); + assert.equal(en.includes("计算依据"), false); + const legacy = renderToStaticMarkup(); + 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); + }); +} diff --git a/scripts/professional_report_reference.py b/scripts/professional_report_reference.py index 7bf14215..0b78e06b 100644 --- a/scripts/professional_report_reference.py +++ b/scripts/professional_report_reference.py @@ -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( diff --git a/scripts/report_render/reading_facts.py b/scripts/report_render/reading_facts.py new file mode 100644 index 00000000..4b24ae3d --- /dev/null +++ b/scripts/report_render/reading_facts.py @@ -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)}, + } diff --git a/tests/test_report_reading_facts.py b/tests/test_report_reading_facts.py new file mode 100644 index 00000000..8f849cbc --- /dev/null +++ b/tests/test_report_reading_facts.py @@ -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