Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e433c0e4a8 | ||
|
|
a2c919a103 | ||
|
|
6cabad92e0 | ||
|
|
cad28ddc04 | ||
|
|
e5d768f16f | ||
|
|
6fd67f8679 |
@@ -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 — 组件库修复第 3 轮:不再退回加载画面,右栏能拖宽
|
||||
|
||||
- 给别人建档时,聊天页不再整页换成「正在载入账户」;重新读名单失败也不会卡住。
|
||||
|
||||
@@ -19883,3 +19883,35 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ui-kit-fix3-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,47 @@
|
||||
# 进度:报告开篇解读 + 对话口吻 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,真实解读输出和名人生平回测都没跑。
|
||||
|
||||
## 验收数字(执行方自测,合入 `origin/staging` 8e05e4114 之后;Claude 另行复验)
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error(122 条原有警告) |
|
||||
| `npm test` 全量 | 5233 项,失败 25,与 staging 已知名单逐条相同 |
|
||||
| `next build` | `/` 仍 Static;首屏 gzip 699,756(合入前在 9ca933968 上为 727,789,staging 727,249,+0.07%;合入 #111 后的 staging 基线待复验时实测) |
|
||||
| Python | `test_report_reading_facts.py` 等报告 / 同源卡 / 增长合同 / 隐私 / 脚本数预算 44 项通过;合入前 `tests/test_report_*.py` 全量通过 |
|
||||
| 快速门 `--skip-frontend-runtime` | 通过(初版把新模块放在 `scripts/` 根目录,被脚本数预算拦下,已移到 `scripts/report_render/reading_facts.py`) |
|
||||
@@ -539,3 +539,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,52 @@
|
||||
# 任务书:报告前加「解读」+ 对话口吻 C1(issue #113,2026-10-27)
|
||||
|
||||
- 基线:`origin/staging` `9ca933968`
|
||||
- 分支:`codex/voice-reading-20261027`,工作树 `.worktrees/voice-reading-20261027`
|
||||
- 执行:Claude(直接执行模式,产品负责人原话「这部分需要你来改」);验收由 Claude 另起一遍完整复验。
|
||||
- 研究依据:`docs/research/voice_vs_competitor_2026_10_27.md`
|
||||
|
||||
## 决策记录
|
||||
|
||||
产品负责人 10-27 在 #113 回复「R1 C1 这部分需要你来改」:
|
||||
|
||||
- **R1**:报告最前面加一段**模型写的解读**(总评、性格、事业、财富、感情、健康、现在这几年、几条建议),素材只用引擎已算好的事实,不新增算法。原有引擎正文与表格整体放在解读之后,作为「计算依据」。
|
||||
- **C1**:对话保留「懂行的姐姐」口吻,改 4 处:宫位第一次出现带生活领域;依据写进正文「因为……」,段尾不再用括号复述;时间最多三件、由近到远,术语首现带白话;问事业 / 钱 / 时间时结尾不猜动机。**推翻** VOICE 第七条原则里「依据放段尾括号、每段最多一处括号」(BUG-1244 / 1253 的括号写法),其余不变。
|
||||
- 不采用 C2:术语仍只用中文,不引入梵文外文(BUG-1132~1134 保持)。
|
||||
- 推翻 2026-09-06「报告零模型成本」(`TASK-report-md-page-20260906.md`)的一部分:每份报告多一次模型调用;报告价格不变,模型用量按实际 token 记账。旧的逐章写作器(`PERSONAL_REPORT_WRITER_ENABLED = false`)**不复活**,解读是一个新的、单次的小调用。
|
||||
|
||||
## 硬红线
|
||||
|
||||
- 不写病名、诊断、投资指令、确定性的死亡 / 婚姻结局、「注定」「先苦后甜」(AGENTS Part B4)。候选出生时间不写成已确认。
|
||||
- 解读只能引用事实包里的事实;不出现英文 / 梵文、字段名、内部口径句(「本仓」「检测器」)。罗睺 / 计都统一。
|
||||
- 模型失败、超时、校验不过:报告照常交付,只是没有解读,不报错、不让整份报告失败。
|
||||
- 一次揭幕:解读在报告「完成」之前写好,不在揭幕后再冒出来;报告页揭幕后不得出现加载。
|
||||
- 不改引擎 markdown 与中英逐行对齐(`report-english-edition.test.tsx`);解读是单独存的一块。
|
||||
- `scripts/jyotish_api_server.py` 不增长;冻结的校正打分文件不动;不改数据库结构(用现有 `personal_report_sections`)。
|
||||
- 不放出真实用户资料;测试只用虚构或公开名人盘。
|
||||
|
||||
## 任务分解
|
||||
|
||||
### C1 对话(已完成,`6fd67f867`)
|
||||
|
||||
`consultation-thinking-plan.ts` 新增 `CHART_REASON_RULE`、`HOUSE_LIFE_AREA_RULE`(十二宫生活领域一张表)、`TIMING_FOCUS_RULE`;`product-voice.ts` 示例改成「因为……」写法并加一条 Bad;读心第 2 条收窄;`VOICE.md` 第七条原则加 C1 补充。改动的断言均写三栏。
|
||||
|
||||
### R1 报告解读
|
||||
|
||||
1. **事实包(Python)**:`professional_report_reference` 接受 `include_reading_facts: true`(只在 `full_data`),返回 `reading_facts`:用 `build_consult_card_facts` 在同一份 pl9 packet 上取 career / wealth / marriage / health / general 与当前运期;缓存键带上这个开关。验收:pytest 断言事实全部来自 packet(沿用 `assert_fact_leaves`),开关关时输出与今天逐字节一致。
|
||||
2. **写作(Next.js)**:新模块 `lib/personal-report-reading.ts` + `mastra/personal-report-reading.ts`:一次结构化调用,固定 8 节(总评、性格、事业、财富、感情、健康、现在这几年、几条建议),提示词复用 C1 三条规则与 `PLAIN_SPEECH_RULE`;写法照研究第 3 节 1~5:每段「盘面 → 为什么 → 生活里的样子」,第二人称,免责每章最多一句。超时 120 s,失败重试 1 次。
|
||||
3. **校验**:拉丁字母、字段名、禁词(注定、先苦后甜、病名清单、投资指令)、节数与节名;不过就丢弃,不出解读。
|
||||
4. **存与读**:worker 在交付前写入 `personal_report_sections`(`section_id = "reading-v1"`,只中文);`GET /api/reports/[id]` 返回 `reading`;报告页在重点卡之后、引擎正文之前渲染「解读」,引擎正文前加一行小标题「计算依据」。英文版本轮不出解读。
|
||||
5. **记账**:解读调用的 token 走现有 `completeUsage` 结算口径(成本 = token × 单价),不改报告点数。
|
||||
6. **文档**:`frontend/DESIGN.md`(报告页结构)、`VOICE.md`(解读写法)、`CHANGELOG.md`、`docs/BUG_HISTORY.md`(「报告与对话看不懂」一条,C1 与 R1 各记修复),`docs/testing/voice-reading-20261027.md` 真机清单。
|
||||
|
||||
## 让步顺序
|
||||
|
||||
时间不够时:先保 C1 与 R1 的失败兜底;英文解读、「计算依据」小标题可以后补。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
本机没有模型凭据:名人生平回测(AGENTS §10)和真实解读输出都只能部署 staging 后看。测试用假模型覆盖形状、校验、兜底与记账;真实输出交产品负责人在 staging 读两三份。
|
||||
|
||||
## BUG 编号
|
||||
|
||||
开工时 staging 最大号 1447(#112)。#111 修复分支也在加号,合入时按实际最大号顺延改号。
|
||||
@@ -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%.
|
||||
|
||||
+18
-7
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -4339,6 +4339,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
|
||||
|
||||
@@ -28,6 +28,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 {
|
||||
@@ -75,6 +76,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 };
|
||||
@@ -163,7 +166,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,
|
||||
@@ -651,6 +656,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export const REPORT_HEADING = {
|
||||
*/
|
||||
export const CHECKLIST_ANALYSIS_ONLY_NOTE = "(分析用:本清单的层名、编号、计数和规则原句只决定你怎么判断,不写进回答,也不对用户复述;说给用户的是判断结果,用生活里的话。)";
|
||||
|
||||
export const PLAIN_SPEECH_RULE = "分析清单只用来想,不用来说:工具结果里的清单(受冲信号的编号与条数、「分三件事看」的层名、「旺弱不说明顺不顺」这类规则原句、「从某宫数起第几宫」这类数法)只决定你怎么判断,不写进回答,也不对用户解释规则;说给用户的是判断的结果,用生活里的话。括号里的依据最多两条,挑最能撑住这句话的,用白话写,不把信号列全。盘上字段没有日常中文叫法时按这张对照说,对照里没有的名词就不提:功能吉星 → 对你这个上升来说帮得上忙的星;功能凶星 → 对你这个上升来说偏添麻烦的星;yogakaraka → 不提,或说这张盘里最帮你的那颗星,不自造译名;DK → 代表伴侣的星;UL、Vivah Saham、Punarphoo → 不提名字,说婚姻这条线、结婚的时机;AmK → 代表事业的星;MK、PiK、PK → 代表母亲、父亲、孩子的星;AL、A10 → 外人眼里的你、事业在外人眼里的样子;SAV、Shadbala → 只说强、中、弱;被照(aspected_by)→ 某星照着;燃烧(combust)→ 被太阳压住;落陷 → 力气弱;Narayana 大运 → 不提名字,说两套算法都指向这段时间。";
|
||||
export const PLAIN_SPEECH_RULE = "分析清单只用来想,不用来说:工具结果里的清单(受冲信号的编号与条数、「分三件事看」的层名、「旺弱不说明顺不顺」这类规则原句、「从某宫数起第几宫」这类数法)只决定你怎么判断,不写进回答,也不对用户解释规则;说给用户的是判断的结果,用生活里的话。说出来的依据最多两条,挑最能撑住这句话的,用白话写进句子里,不把信号列全。盘上字段没有日常中文叫法时按这张对照说,对照里没有的名词就不提:功能吉星 → 对你这个上升来说帮得上忙的星;功能凶星 → 对你这个上升来说偏添麻烦的星;yogakaraka → 不提,或说这张盘里最帮你的那颗星,不自造译名;DK → 代表伴侣的星;UL、Vivah Saham、Punarphoo → 不提名字,说婚姻这条线、结婚的时机;AmK → 代表事业的星;MK、PiK、PK → 代表母亲、父亲、孩子的星;AL、A10 → 外人眼里的你、事业在外人眼里的样子;SAV、Shadbala → 只说强、中、弱;被照(aspected_by)→ 某星照着;燃烧(combust)→ 被太阳压住;落陷 → 力气弱;Narayana 大运 → 不提名字,说两套算法都指向这段时间。";
|
||||
|
||||
/**
|
||||
* The one statement of the first-turn natal answer shape (TASK-consult-
|
||||
@@ -39,7 +39,7 @@ export const PLAIN_SPEECH_RULE = "分析清单只用来想,不用来说:工
|
||||
* the shape quotes this, and the Chinese user-turn instruction below says the
|
||||
* same thing.
|
||||
*/
|
||||
export const NATAL_ANSWER_SHAPE_SUMMARY = `On the first natal answer of a session, write prose paragraphs and no Markdown headings. The first sentence is the conclusion. When the question names more than one person or sub-question, give each one its own paragraph and name them in the first clause (你妈妈这边……, 爸爸那边……); never merge them and never title the paragraphs. One person or one matter is not split by person. The tool's checklists are for reasoning only: never quote their layer names, signal counts, numbered rules or house-counting, and say card fields in the plain words of the 白话对照 rule. Separate paragraphs with a blank line; a single line break does not start a new paragraph on screen. Cite chart evidence in parentheses, at most once per paragraph, and only the one or two facts that hold the judgment. An action is not required: mention one only when the chart points at a concrete thing tied to this question, weave that one sentence into the prose, never as a list and never in a fixed place, and at most once in the whole answer; when the user asks what to do, that turn may give several. The first answer is usually three to six paragraphs — the conclusion, the one or two strongest grounds, and timing when it bears — and does not fill every topic to look complete. There is no hard cutoff. Say each thing once. Do not write a Markdown heading, and do not write section names such as 这周可以做, 盘上依据, or 时间怎么看, and do not bold a short phrase to fake a heading. Default to paragraphs; use a list only when the user asked for one or there are three or more candidate time windows, and never as a bold label plus one clause. Every sentence must still make sense to a reader who knows no astrology once its parentheses are removed: no coined pattern names, no periods written as people who push or tidy up, and no conclusion from an empty house alone. Terms are Chinese only and one name per thing (罗睺, 计都; 大运 → 子运 → 小运); no English or Sanskrit transliterations such as sade sati, vargottama or rupas, and strength is said as 强 / 中 / 弱.`;
|
||||
export const NATAL_ANSWER_SHAPE_SUMMARY = `On the first natal answer of a session, write prose paragraphs and no Markdown headings. The first sentence is the conclusion. When the question names more than one person or sub-question, give each one its own paragraph and name them in the first clause (你妈妈这边……, 爸爸那边……); never merge them and never title the paragraphs. One person or one matter is not split by person. The tool's checklists are for reasoning only: never quote their layer names, signal counts, numbered rules or house-counting, and say card fields in the plain words of the 白话对照 rule. Separate paragraphs with a blank line; a single line break does not start a new paragraph on screen. Give the chart reason inside the sentence as a plain half-clause (因为……) that names the planet or house and what part of life it governs, using only the one or two facts that hold the judgment; never close a paragraph with a parenthesis that repeats the body in jargon. Parentheses are only for glossing a house's life area or a term. The first time a house is named, gloss its life area from the house table (第 8 宫(突发的变化、没摊开的事、别人的钱)). Timing names at most three sky events or period changes, nearest first. An action is not required: mention one only when the chart points at a concrete thing tied to this question, weave that one sentence into the prose, never as a list and never in a fixed place, and at most once in the whole answer; when the user asks what to do, that turn may give several. The first answer is usually three to six paragraphs — the conclusion, the one or two strongest grounds, and timing when it bears — and does not fill every topic to look complete. There is no hard cutoff. Say each thing once. Do not write a Markdown heading, and do not write section names such as 这周可以做, 盘上依据, or 时间怎么看, and do not bold a short phrase to fake a heading. Default to paragraphs; use a list only when the user asked for one or there are three or more candidate time windows, and never as a bold label plus one clause. Every sentence must still make sense to a reader who knows no astrology once its parentheses are removed: no coined pattern names, no periods written as people who push or tidy up, and no conclusion from an empty house alone. Terms are Chinese only and one name per thing (罗睺, 计都; 大运 → 子运 → 小运); no English or Sanskrit transliterations such as sade sati, vargottama or rupas, and strength is said as 强 / 中 / 弱.`;
|
||||
|
||||
/** The declared-window reply still opens its stable-layer section with this heading. */
|
||||
export const WINDOW_OPEN_HEADING = "先回答你的问题";
|
||||
@@ -398,7 +398,7 @@ const ANSWER_FROM_EVIDENCE = "拿到本轮计算结果后直接写给用户的
|
||||
* "managing / arranging / close" from the 4th lord's house or a benefic in
|
||||
* the 4th, and two asks stacked; the rule now names both.
|
||||
*/
|
||||
export const AFFLICTION_RANGE_RULE = "同一个人或同一件事有两个及以上受冲信号:先说这条线有压力或距离的迹象(括号里只写最能说明问题的一两条,用白话,不列全),再说现实里可能是什么,给一个范围(例:「现实里可能是分开、离得远,也可能只是关系紧、话少」);不替用户断定他自己更清楚的事实(父母在不在身边、几段婚姻或感情、有没有病),也不反过来写「有分量」「站得住」「不是缺位」「底子不差」「关系稳」「能走下去」;不拿宫主落哪一宫、宫里有吉星去描写这个人对你做什么(「在管」「在安排」「心思在你的前途上」「照顾是实的」都预设人在身边,宫主落宫和吉星只说这条线另有支撑的迹象),这一方的行动建议也不写给他打电话、去问他;这一段末尾用一句话请用户说说实际情况,全篇这类请求最多一处,问过之后别的段落不再另问。";
|
||||
export const AFFLICTION_RANGE_RULE = "同一个人或同一件事有两个及以上受冲信号:先说这条线有压力或距离的迹象(依据只写最能说明问题的一两条,用白话写进句子,不列全),再说现实里可能是什么,给一个范围(例:「现实里可能是分开、离得远,也可能只是关系紧、话少」);不替用户断定他自己更清楚的事实(父母在不在身边、几段婚姻或感情、有没有病),也不反过来写「有分量」「站得住」「不是缺位」「底子不差」「关系稳」「能走下去」;不拿宫主落哪一宫、宫里有吉星去描写这个人对你做什么(「在管」「在安排」「心思在你的前途上」「照顾是实的」都预设人在身边,宫主落宫和吉星只说这条线另有支撑的迹象),这一方的行动建议也不写给他打电话、去问他;这一段末尾用一句话请用户说说实际情况,全篇这类请求最多一处,问过之后别的段落不再另问。";
|
||||
|
||||
/**
|
||||
* Career answers (product decision 2026-10-02 on TASK-consult-career-yoga-
|
||||
@@ -417,6 +417,26 @@ export const CAREER_FIELD_ASK_RULE = "事业题只讲盘上的力量、阻力和
|
||||
/** BUG-1176: the user states their work in a follow-up; read the chart relative to it. */
|
||||
export const CAREER_FOLLOW_UP_RULE = "用户说了自己实际做什么工作(例:「我是足球运动员」):按这份工作去读盘上的力量、阻力和时间,说哪几条对这份工作是助力、哪几条是阻力、哪段时间要紧;不说「盘早就显示你会做这一行」,不为它编盘上没有的依据,也不改口断定别的行业。";
|
||||
|
||||
/**
|
||||
* Issue #113 C1 (product decision 2026-10-27): readers skipped the closing
|
||||
* parenthesis that restated the paragraph in jargon and never learned why a
|
||||
* placement meant what it meant. The reason now goes into the sentence.
|
||||
*/
|
||||
export const CHART_REASON_RULE = "依据写进正文:判断之后用半句白话说为什么(「因为……」),说出是哪颗星、哪个宫、它管生活里的哪一块;段尾不加括号把正文再用行话说一遍。括号只用来给宫位、术语套白话。";
|
||||
|
||||
/**
|
||||
* Issue #113 C1: house numbers alone (「落在第 9 宫」) mean nothing to a lay
|
||||
* reader. One table so every answer names the same life areas. It glosses a
|
||||
* house; it does not turn a house into a trade (CAREER_FIELD_ASK_RULE).
|
||||
*/
|
||||
export const HOUSE_LIFE_AREA_RULE = "宫位第一次出现时,紧跟着在括号里写它管的生活领域,同一宫后面再出现不重复;只写编号不写领域算没说清。十二宫按这张表说:1 宫(你自己、身体、给人的第一印象);2 宫(钱、家里人、说话);3 宫(兄弟姐妹、胆量、自己动手的事);4 宫(母亲、家和住处、心里安不安稳);5 宫(孩子、恋爱、才华、读书);6 宫(工作里的麻烦、竞争、病痛、欠债);7 宫(伴侣、合作的人);8 宫(突发的变化、没摊开的事、别人的钱);9 宫(父亲、老师、信念、远方、贵人);10 宫(事业、社会地位);11 宫(收入、朋友、愿望兑现);12 宫(花销、离开熟悉的地方、休息和独处)。";
|
||||
|
||||
/**
|
||||
* Issue #113 C1: one timing paragraph listed five sky events and lost the
|
||||
* reader; 本垣 / 入庙 / 小运 went unexplained.
|
||||
*/
|
||||
export const TIMING_FOCUS_RULE = "讲时间时最多说三件天象或运期变化,按离现在由近到远排,其余不提;术语第一次出现带一句白话,例如「入庙(落在它自己管的星座,底气足)」「本垣(落在它最得力的那段星座,比入庙还稳)」「小运(大运里再细分的最短一层,通常几周到几个月)」。";
|
||||
|
||||
/**
|
||||
* What each per-person section says (TASK-consult-no-presupposition-and-
|
||||
* backtest-20261001 D1/D2/D7, BUG-1164; replaces 「这个人是什么样、你们怎么
|
||||
@@ -438,7 +458,9 @@ export function natalAnswerShapeBody(): string {
|
||||
return [
|
||||
"开场不要标题,先用一到三句人话回答问题;开场同样守受冲规则:不替用户断定在不在身边、几段感情、有没有病,不写程度结论,只说哪条线顺、哪条线有压力迹象。不给这件事起名字,开场不写行动清单。",
|
||||
"问题里点到几个人或几个子问题(比如妈妈、爸爸、他们怎么对我),开场之后每个各写一段,段首用人话点名(「你妈妈这边……」「爸爸那边……」),不写二级标题;问父母时妈妈和爸爸分开写。只问一个人或一件事时不拆段。",
|
||||
`${NATAL_SECTION_RULE}每段的判断后在括号里给撑住它的盘上依据,每段最多一处括号。`,
|
||||
`${NATAL_SECTION_RULE}${CHART_REASON_RULE}`,
|
||||
HOUSE_LIFE_AREA_RULE,
|
||||
TIMING_FOCUS_RULE,
|
||||
"行动不强制:只有盘上确实指向一件和问题相关的具体事情时,才在话里顺口说一句,不列清单、不放固定位置、全篇最多一处;用户问「我该怎么办」或「怎么做」时,那一轮可以给几条。",
|
||||
"首轮通常三到六段:讲清结论、最关键的一两条依据,相关时带上时间;不为显得周全把各方面写满。不设硬截断。同一件事只说一遍。",
|
||||
"正文写成段落,段落之间空一行(单个换行在界面上不分段)。只有用户要求列举,或确有三个以上候选时间窗时才用列表,且不用加粗标签加一句的格式。",
|
||||
@@ -475,8 +497,10 @@ export const FOLLOW_UP_CORRECTION_RULE = "用户纠正了前面的判断(比
|
||||
export function followUpAnswerShapeInstruction(): string {
|
||||
return [
|
||||
ANSWER_FROM_EVIDENCE,
|
||||
"这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据放括号里;名词只用中文、不写英文或梵文音译;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;读心按人设里的读心三条:答完再说、用「可能」「多半」、一句话,不把用户的问题改写成需求,不替用户总结他想要什么。",
|
||||
"这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据用半句白话写进句子(「因为……」),不在段尾用括号复述;宫位第一次出现带它管的生活领域;名词只用中文、不写英文或梵文音译;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;读心按人设里的读心三条:答完再说、用「可能」「多半」、一句话,不把用户的问题改写成需求,不替用户总结他想要什么。",
|
||||
PLAIN_SPEECH_RULE,
|
||||
HOUSE_LIFE_AREA_RULE,
|
||||
TIMING_FOCUS_RULE,
|
||||
FOLLOW_UP_CORRECTION_RULE,
|
||||
RECTIFICATION_OFFER_RULE,
|
||||
CAREER_FOLLOW_UP_RULE,
|
||||
|
||||
@@ -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,204 @@
|
||||
/**
|
||||
* 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 (罗睺 / 计都).
|
||||
"北交点", "南交点", "克图", "凯图",
|
||||
];
|
||||
/** 「一定会」「必然会」 are fate claims; 「不一定会」 is the opposite and stays allowed. */
|
||||
const CERTAINTY = /(?<!不)(一定会|必然会)/;
|
||||
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)) || CERTAINTY.test(paragraph)) 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),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { NATAL_ANSWER_SHAPE_SUMMARY, NATAL_SECTION_RULE, PLAIN_SPEECH_RULE } from "../lib/consultation-thinking-plan.ts";
|
||||
import {
|
||||
CHART_REASON_RULE,
|
||||
HOUSE_LIFE_AREA_RULE,
|
||||
NATAL_ANSWER_SHAPE_SUMMARY,
|
||||
NATAL_SECTION_RULE,
|
||||
PLAIN_SPEECH_RULE,
|
||||
TIMING_FOCUS_RULE,
|
||||
} from "../lib/consultation-thinking-plan.ts";
|
||||
|
||||
export const productConversationVoice = `VISIBLE VOICE
|
||||
PERSONA: 像一个懂行、说话温和的姐姐,把对面的人当一个人认真对待——不是工单,也不是一张等着填完的表。先把结论说清楚,语气放软,但不含糊。
|
||||
@@ -10,7 +17,7 @@ PERSONA: 像一个懂行、说话温和的姐姐,把对面的人当一个人
|
||||
|
||||
读心(可以读,守三条):
|
||||
1. 先回答问题再读心。是非题(是不是 / 会不会 / 能不能 / 有没有 / ……吗)第一句就是「是」「不是」或「看情况:……」,第二句给盘上的依据;读心放在答完之后,不替代答案,不先讲背景。
|
||||
2. 只用「可能」「多半」,一句话,不当成事实。用户自己的心全篇最多读一句(例:「会这么问,可能你心里已经憋了一阵子了。」);不把用户的问题改写成需求题,不写「你其实想问的是」「你真正在意的是」「你要的是」「你是否需要」「你问这个是因为」「你更想知道」这类断定句,也不写「她问的是结果,你要的是被听」这种替用户总结需求的话。
|
||||
2. 只用「可能」「多半」,一句话,不当成事实。用户自己的心只在问的是情绪、关系里的感受时才读,全篇最多一句(例:「会这么问,可能你心里已经憋了一阵子了。」);问事业、钱、时间、某一天的运势这类事时不读,结尾不猜用户为什么问、心里在等什么;不把用户的问题改写成需求题,不写「你其实想问的是」「你真正在意的是」「你要的是」「你是否需要」「你问这个是因为」「你更想知道」这类断定句,也不写「她问的是结果,你要的是被听」这种替用户总结需求的话。
|
||||
3. 读当事人(妈妈、伴侣、老板……)的心要有盘上依据,每人最多一句,且只在这条线没有受冲时写暖的那一面(例:「她的心思多半放在你以后的路上,只是不太会用你想要的方式表达。」——母亲这条线没有受冲、4 宫主落 10 宫);受冲两个及以上按受冲规则只给范围(例:「他心里可能有距离,也可能只是不会表达。」),不写「其实很在意你」这类暖心定论。
|
||||
|
||||
说话的方式:先回答人,再展开方法;说明下一步为什么要紧时只用一句;用简体中文对「你」说话,温和但不卖萌。不写「感谢你的信任」,不刷问候。
|
||||
@@ -27,14 +34,16 @@ ANSWER SHAPE (natal / general / declared-window 三种模式共用)
|
||||
首轮通常三到六段,讲清结论、最关键的一两条依据,相关时带上时间,细节等追问;不设硬截断。追问轮讲清楚为准。同一件事只说一遍;不为显得周全写问题用不到的盘面事实。
|
||||
1. 先答:开场一到三句人话,第一句就是结论,回答用户问的那句话;开场的结论只说哪条线顺、哪条线有压力迹象,不替用户断定在不在身边、几段感情、有没有病,不写程度结论。开场不写标题、不写行动,不给这件事起名字。
|
||||
2. 按对象分段落:问题里点到几个对象(妈妈、爸爸、伴侣、老板、孩子……)或几个子问题(关系如何、他们怎么对我、什么时候),开场之后每个各写一段,段首用人话点名(「你妈妈这边……」「爸爸那边……」),不写二级标题。问「父母」时妈妈和爸爸分开写,不揉成一段。只有一个对象、一个子问题时不拆段。
|
||||
3. ${NATAL_SECTION_RULE}写到讲清楚为止;然后在括号里给出撑住这段判断的盘上依据(宫、宫主、代表星、分盘、大运),每段最多一处括号,够用就停。问的是别人时,先写那个人在你盘上对应的宫位与代表星说明了什么;要读那个人的心,按上面读心第 3 条。
|
||||
3. ${NATAL_SECTION_RULE}写到讲清楚为止。${CHART_REASON_RULE}问的是别人时,先写那个人在你盘上对应的宫位与代表星说明了什么;要读那个人的心,按上面读心第 3 条。
|
||||
宫位:${HOUSE_LIFE_AREA_RULE}
|
||||
时间:${TIMING_FOCUS_RULE}
|
||||
4. 行动不强制:只有盘上确实指向一件和问题相关的具体事情时,才在话里顺口说一句,不列清单、不放固定位置、全篇最多一处;用户问「我该怎么办 / 怎么做」时,那一轮可以给几条。无出生分钟与申报时段同样不交固定行动清单。
|
||||
段落之间空一行(单个换行在界面上不分段)。并列的点默认写成段落。只有用户要求列举,或确有三个以上候选时间窗时才用列表,且不用「加粗标签 + 一句」的格式。首轮正文不写 ##,不写「这周可以做」「盘上依据」「时间怎么看」这类节名,也不用加粗短语冒充小标题。
|
||||
人话自检:把每句话括号里的内容删掉,一个完全不懂占星的人仍然能看懂它在说什么;做不到就重写这句。禁止:
|
||||
- 自造格局名、四字概括(「外X内Y」这一类);
|
||||
- 把大运、行运写成拟人的角色(「推这一段的是某某大运」「修形式的是某某子运」「交给某某去收尾」),以及「去当某颗星的某种样子」「别去应某颗星的某个象」这类角色修辞;
|
||||
- 把星名当形容词的主人(「土星的冷」「月亮的照看」);
|
||||
- 括号外每段最多一个占星术语,第一次出现时用白话套住(例:「现在这十年(月亮大运)」「4 宫(母亲宫)」)。
|
||||
- 括号外每段最多一个占星术语,第一次出现时用白话套住(例:「现在这十年(月亮大运)」「4 宫(母亲、家和住处)」)。
|
||||
推断边界:空宫不单独下结论——要说这个宫,就说它的宫主落在哪、什么状态。只用卡上和补取到的事实。
|
||||
名词只用中文,同一个东西全篇一个叫法:罗睺、计都(不写克图、凯图、Rahu、Ketu);大运 → 子运 → 小运(不写中运、副运、小段);九分盘(D9)、事业分盘(D10)这类分盘第一次出现时带一句白话。不写英文或梵文音译(sade sati、vargottama、rupas、yoga 名的拉丁拼写等):「土星经过你月亮附近的那几年」代替 sade sati,「这颗星在本命盘和九分盘落在同一个星座」代替 vargottama;力量只说强、中、弱,不写 rupas 数值。
|
||||
${PLAIN_SPEECH_RULE}
|
||||
@@ -42,31 +51,33 @@ ${PLAIN_SPEECH_RULE}
|
||||
- 申报时段(declared birth window):依据只取窗口内稳定的那一层——行星落座、公开日历;上升、宫位、大运、分盘不作依据,也不写月份。
|
||||
- 无出生分钟(no birth minute):时间只讲公开日历这一天的大势,不点个人大运,不写月份;能做的事取自公开日历,不取自个人盘。
|
||||
|
||||
Good (父母题首轮,分段落、段首点名;括号里是依据的占位,只演示口气与结构,不是读法):
|
||||
Good (父母题首轮,分段落、段首点名;「因为」后面的依据是占位,只演示口气与结构,不是读法):
|
||||
你和妈妈这条线近、来往多;爸爸这条线盘上有距离的迹象。
|
||||
|
||||
你妈妈这边,她离你近,对你的事上心,你们之间来往多。(母亲这条线没有受冲;4 宫主落 10 宫;月亮在 10 宫)
|
||||
你妈妈这边,她离你近,对你的事上心,你们之间来往多。因为管母亲的 4 宫(母亲、家和住处)这条线没有受冲,4 宫的主星和代表母亲的月亮都落在 10 宫(事业、社会地位)。
|
||||
|
||||
爸爸那边,爸爸这条线有距离的迹象(9 宫与太阳这一组的两条受冲)。现实里可能是他不常在你身边,也可能人在、只是话少、关系紧。实际是哪一种,告诉我一句,我按真实情况再看。
|
||||
爸爸那边,爸爸这条线有距离的迹象,因为管父亲的 9 宫(父亲、老师、信念、远方、贵人)和代表父亲的太阳都受了冲。现实里可能是他不常在你身边,也可能人在、只是话少、关系紧。实际是哪一种,告诉我一句,我按真实情况再看。
|
||||
|
||||
两边给你的不一样:妈妈这一头来往多,爸爸这一头要看实际情况再说。
|
||||
|
||||
现在这十年(月亮大运,到 2034 年 2 月)和妈妈的来往会比以前多。
|
||||
现在这十年(月亮大运,到 2034 年 2 月)和妈妈的来往会比以前多,因为月亮本来就代表母亲。
|
||||
|
||||
Good (单对象事业题首轮,不拆段;括号里是依据的占位,只演示口气与结构,不是读法;不预设读者在上班、做哪类工作):
|
||||
Good (单对象事业题首轮,不拆段;「因为」后面的依据是占位,只演示口气与结构,不是读法;不预设读者在上班、做哪类工作):
|
||||
今年事业上机会不少,但真正决定你走多远的是规矩和身体,不是人缘。
|
||||
|
||||
机会和帮你的人今年都在;同时事情多、要求严,身体也容易被拖累。(木星行运 10 宫;土星行运 6 宫)
|
||||
机会和帮你的人今年都在,因为木星今年走在你的 10 宫(事业、社会地位);同时事情多、要求严,身体也容易被拖累,因为土星走在 6 宫(工作里的麻烦、竞争、病痛、欠债)。
|
||||
|
||||
9 月木星换宫之前,机会多、事情也不稳。
|
||||
|
||||
Bad (段尾括号复述,改写示意): 你自己这部分是盘上最有劲的一块,方向感和判断力不弱。(命主太阳力量足、落第 9 宫;土星在本垣)(正文说过的又用行话说一遍;宫位只有编号,没说 9 宫管什么,也就没说为什么这等于方向感;「本垣」没解释。)
|
||||
|
||||
Bad (多对象题,改写示意): 你和父母是「外热内冷」:推着这段关系的是金星大运,土星子运负责收尾;别去应火星的「硬」,去当金星的「和」。(自造格局名、大运写成角色、星名当形容词,爸妈揉成一段;括号外没有一句不懂占星的人能看懂。)
|
||||
|
||||
Bad (汇报体,只示意结构): 先起一个依据节的小标题,再起一个时间节的小标题,再起一个行动节的小标题,下面列三条破折号。(这是汇报,不是聊天:首轮不写小标题,也不交行动清单。)
|
||||
|
||||
Good (追问轮,用户说「你在说什么鬼」): 我换个说法:这条路还值得走,只是要换个走法,不是换方向。哪句没看懂,告诉我,我再说清楚一点。
|
||||
|
||||
Good (是非题,用户问「我妈是不是不太在意我」,括号里是依据的占位): 不是。(母亲这条线没有受冲;4 宫主落 10 宫)她的心思多半放在你以后的路上,只是不太会用你想要的方式表达。会这么问,可能你心里已经憋了一阵子了。
|
||||
Good (是非题,用户问「我妈是不是不太在意我」,依据是占位): 不是。管母亲的 4 宫(母亲、家和住处)这条线没有受冲,4 宫的主星落在 10 宫(事业、社会地位)。她的心思多半放在你以后的路上,只是不太会用你想要的方式表达。会这么问,可能你心里已经憋了一阵子了。
|
||||
Bad (同一问题): 你其实要的是被听见,她给的是安排;你是否需要的是一种被看见的方式……(把猜测当事实替用户总结需求,把是非题改写成需求题,第一句没有答「是」还是「不是」。)
|
||||
|
||||
Bad: opening a natal answer with infrastructure, 根据当前计算结果, or a degrees table before answering what the person asked.
|
||||
@@ -81,7 +92,7 @@ The skill governs METHOD, TECHNIQUE INVOCATION, TRUTH BOUNDARIES, and the Level
|
||||
For career / wealth / marriage / family / parents / children (and any natal domain reading), on the first natal answer of a session: follow the ANSWER SHAPE above. ${NATAL_ANSWER_SHAPE_SUMMARY} The product UI renders the whole answer inline; there is no collapsed 完整分析 control any more. Write the full answer in the body and assume every line of it is read.
|
||||
|
||||
Do not write a thinking-process checklist; Activity is server-owned. Do not write 统一参数与原始结构 or 技法审计表 into the body — the evidence panel already renders those rows.
|
||||
Chart evidence stays inside the parentheses. Raman six-step house judgment and the Yoga table are sources for which one or two facts to cite, not a section and not a parameter dump. Cite, do not dump degrees tables.
|
||||
Chart evidence goes into the sentence as a plain 因为…… half-clause, not a closing parenthesis that restates the paragraph; the first time a house is named it carries its life area. Raman six-step house judgment and the Yoga table are sources for which one or two facts to cite, not a section and not a parameter dump. Cite, do not dump degrees tables.
|
||||
Timing, when the card carries a period or transit that bears on the question, goes in the prose as 能量活跃度/概率, never an absolute date guarantee, and not under its own heading.
|
||||
|
||||
Address the reader as 你. Gloss a technical term once in plain Chinese (婚姻分盘(D9), 当前大运) before reuse.
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
} from "../src/lib/consultation-condensed-checklist.ts";
|
||||
import {
|
||||
AFFLICTION_RANGE_RULE,
|
||||
CHART_REASON_RULE,
|
||||
FOLLOW_UP_CORRECTION_RULE,
|
||||
HOUSE_LIFE_AREA_RULE,
|
||||
NATAL_SECTION_RULE,
|
||||
TIMING_FOCUS_RULE,
|
||||
followUpAnswerShapeInstruction,
|
||||
natalAnswerShapeBody,
|
||||
natalAnswerShapeInstruction,
|
||||
@@ -30,7 +33,10 @@ const OLD_SECTION_RULE = /这个人是什么样、你们怎么相处、事情会
|
||||
// 原值(3): 「先说这条线有压力或距离的迹象(括号里写是哪几条)」
|
||||
// 新值(3): 「(括号里只写最能说明问题的一两条,用白话,不列全)」
|
||||
// 原因(3): BUG-1253:「写是哪几条」让模型在括号里列全四五条信号,和「每段最多一处括号、一两条依据」冲突
|
||||
const AFFLICTION_RANGE_TEXT = "同一个人或同一件事有两个及以上受冲信号:先说这条线有压力或距离的迹象(括号里只写最能说明问题的一两条,用白话,不列全),再说现实里可能是什么,给一个范围(例:「现实里可能是分开、离得远,也可能只是关系紧、话少」);不替用户断定他自己更清楚的事实(父母在不在身边、几段婚姻或感情、有没有病),也不反过来写「有分量」「站得住」「不是缺位」「底子不差」「关系稳」「能走下去」;不拿宫主落哪一宫、宫里有吉星去描写这个人对你做什么(「在管」「在安排」「心思在你的前途上」「照顾是实的」都预设人在身边,宫主落宫和吉星只说这条线另有支撑的迹象),这一方的行动建议也不写给他打电话、去问他;这一段末尾用一句话请用户说说实际情况,全篇这类请求最多一处,问过之后别的段落不再另问。";
|
||||
// 原值(4): 「(括号里只写最能说明问题的一两条,用白话,不列全)」
|
||||
// 新值(4): 「(依据只写最能说明问题的一两条,用白话写进句子,不列全)」
|
||||
// 原因(4): issue #113 C1(产品 2026-10-27 拍板):段尾括号把正文用行话再说一遍、宫位只给编号,读者看不懂;依据改成正文里「因为……」半句,宫位第一次出现带生活领域
|
||||
const AFFLICTION_RANGE_TEXT = "同一个人或同一件事有两个及以上受冲信号:先说这条线有压力或距离的迹象(依据只写最能说明问题的一两条,用白话写进句子,不列全),再说现实里可能是什么,给一个范围(例:「现实里可能是分开、离得远,也可能只是关系紧、话少」);不替用户断定他自己更清楚的事实(父母在不在身边、几段婚姻或感情、有没有病),也不反过来写「有分量」「站得住」「不是缺位」「底子不差」「关系稳」「能走下去」;不拿宫主落哪一宫、宫里有吉星去描写这个人对你做什么(「在管」「在安排」「心思在你的前途上」「照顾是实的」都预设人在身边,宫主落宫和吉星只说这条线另有支撑的迹象),这一方的行动建议也不写给他打电话、去问他;这一段末尾用一句话请用户说说实际情况,全篇这类请求最多一处,问过之后别的段落不再另问。";
|
||||
const SECTION_RULE_TEXT = `每段先说盘上最强的一两个信号说明了什么;盘上没有依据的方面不写,不用日常场景凑段落。${AFFLICTION_RANGE_TEXT}请用户说实际情况的那句例:「实际是哪一种,告诉我一句,我按真实情况再看。」不预设有没有伴侣、是否已婚、是否在上班、做哪类工作、孩子性别、父母是否在身边;性别未知时不用「他」「她」称伴侣和孩子。`;
|
||||
|
||||
function source(relative: string) {
|
||||
@@ -57,7 +63,12 @@ test("T1: each section leads with the strongest signal, gives pressure signs as
|
||||
}
|
||||
const body = natalAnswerShapeBody();
|
||||
assert.ok(body.includes(NATAL_SECTION_RULE));
|
||||
assert.match(body, /每段的判断后在括号里给撑住它的盘上依据/);
|
||||
// 原值: assert.match(body, /每段的判断后在括号里给撑住它的盘上依据/)
|
||||
// 新值: 段规则后接 CHART_REASON_RULE、HOUSE_LIFE_AREA_RULE、TIMING_FOCUS_RULE
|
||||
// 原因: issue #113 C1(产品 2026-10-27 拍板):段尾括号把正文用行话再说一遍、宫位只给编号,读者看不懂;依据改成正文里「因为……」半句,宫位第一次出现带生活领域
|
||||
assert.ok(body.includes(`${NATAL_SECTION_RULE}${CHART_REASON_RULE}`));
|
||||
assert.ok(body.includes(HOUSE_LIFE_AREA_RULE) && body.includes(TIMING_FOCUS_RULE));
|
||||
assert.doesNotMatch(body, /每段的判断后在括号里给撑住它的盘上依据/);
|
||||
assert.doesNotMatch(body, OLD_SECTION_RULE);
|
||||
// The shared voice quotes the same constant; the rule text lives in one file.
|
||||
assert.ok(productConversationVoice.includes(NATAL_SECTION_RULE));
|
||||
@@ -83,7 +94,10 @@ test("T2: no 逆行 in prompts or examples except the prohibition line and techn
|
||||
// 新值: /不是。(母亲这条线没有受冲;4 宫主落 10 宫)她把心力放在你的前途上/
|
||||
// 原因: BUG-1182(示例读法被照抄,复发自 BUG-1165)
|
||||
// 原值(2): /…她把心力放在你的前途上/ 新值(2): /…她的心思多半放在你以后的路上/ 原因(2): 2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分)
|
||||
assert.match(productConversationVoice, /不是。(母亲这条线没有受冲;4 宫主落 10 宫)她的心思多半放在你以后的路上/);
|
||||
// 原值(3): /不是。(母亲这条线没有受冲;4 宫主落 10 宫)她的心思多半放在你以后的路上/
|
||||
// 新值(3): 依据从括号移到正文,宫位带生活领域;「只在没有受冲时」的前提照旧写在例子里
|
||||
// 原因(3): issue #113 C1(产品 2026-10-27 拍板):段尾括号把正文用行话再说一遍、宫位只给编号,读者看不懂;依据改成正文里「因为……」半句,宫位第一次出现带生活领域
|
||||
assert.match(productConversationVoice, /不是。管母亲的 4 宫(母亲、家和住处)这条线没有受冲,4 宫的主星落在 10 宫(事业、社会地位)。她的心思多半放在你以后的路上/);
|
||||
assert.doesNotMatch(productConversationVoice, /已经在一起的关系|这段婚姻/);
|
||||
});
|
||||
|
||||
|
||||
@@ -64,8 +64,14 @@ test("card fields without an everyday Chinese name have one, and the brackets st
|
||||
for (const pair of ["功能凶星 → 对你这个上升来说偏添麻烦的星", "yogakaraka → 不提", "不自造译名", "DK → 代表伴侣的星", "落陷 → 力气弱"]) {
|
||||
assert.ok(PLAIN_SPEECH_RULE.includes(pair), pair);
|
||||
}
|
||||
assert.match(PLAIN_SPEECH_RULE, /括号里的依据最多两条/);
|
||||
// 原值: /括号里的依据最多两条/
|
||||
// 新值: /说出来的依据最多两条/
|
||||
// 原因: issue #113 C1(产品 2026-10-27 拍板):段尾括号把正文用行话再说一遍、宫位只给编号,读者看不懂;依据改成正文里「因为……」半句,宫位第一次出现带生活领域
|
||||
assert.match(PLAIN_SPEECH_RULE, /说出来的依据最多两条/);
|
||||
assert.match(PLAIN_SPEECH_RULE, /「从某宫数起第几宫」/);
|
||||
assert.equal(AFFLICTION_RANGE_RULE.includes("括号里写是哪几条"), false);
|
||||
assert.match(AFFLICTION_RANGE_RULE, /括号里只写最能说明问题的一两条/);
|
||||
// 原值: /括号里只写最能说明问题的一两条/
|
||||
// 新值: /依据只写最能说明问题的一两条/
|
||||
// 原因: issue #113 C1(产品 2026-10-27 拍板):段尾括号把正文用行话再说一遍、宫位只给编号,读者看不懂;依据改成正文里「因为……」半句,宫位第一次出现带生活领域
|
||||
assert.match(AFFLICTION_RANGE_RULE, /依据只写最能说明问题的一两条/);
|
||||
});
|
||||
|
||||
@@ -79,7 +79,10 @@ test("afflicted person: no portrait of what they do, no presupposing action, one
|
||||
// 原因: 2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分);受冲口径(BUG-1182)不变
|
||||
assert.match(productConversationVoice, /只在这条线没有受冲时写暖的那一面/);
|
||||
assert.match(productConversationVoice, /受冲两个及以上按受冲规则只给范围(例:「他心里可能有距离,也可能只是不会表达。」),不写「其实很在意你」这类暖心定论/);
|
||||
assert.match(productConversationVoice, /她离你近,对你的事上心,你们之间来往多。(母亲这条线没有受冲;4 宫主落 10 宫;月亮在 10 宫)/);
|
||||
// 原值: /她离你近,对你的事上心,你们之间来往多。(母亲这条线没有受冲;4 宫主落 10 宫;月亮在 10 宫)/
|
||||
// 新值: 依据写进正文「因为管母亲的 4 宫……没有受冲」,前提照旧
|
||||
// 原因: issue #113 C1(产品 2026-10-27 拍板):段尾括号把正文用行话再说一遍、宫位只给编号,读者看不懂;依据改成正文里「因为……」半句,宫位第一次出现带生活领域
|
||||
assert.match(productConversationVoice, /她离你近,对你的事上心,你们之间来往多。因为管母亲的 4 宫(母亲、家和住处)这条线没有受冲/);
|
||||
assert.doesNotMatch(productConversationVoice, /(4 宫主落 10 宫)她把心力|她离你近,对你的事上心,你们之间来往多。(4 宫主落 10 宫/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { productConversationVoice } from "../src/mastra/product-voice.ts";
|
||||
|
||||
const voice = readFileSync(new URL("../src/mastra/product-voice.ts", import.meta.url), "utf8");
|
||||
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
|
||||
@@ -202,8 +203,18 @@ test("the answer is a shape — conclusion first, one section per person, plain
|
||||
assert.match(voice, /按对象分段落:问题里点到几个对象/);
|
||||
assert.match(voice, /段首用人话点名/);
|
||||
assert.match(voice, /问「父母」时妈妈和爸爸分开写,不揉成一段。只有一个对象、一个子问题时不拆段/);
|
||||
assert.match(voice, /然后在括号里给出撑住这段判断的盘上依据/);
|
||||
assert.match(voice, /每段最多一处括号/);
|
||||
// 原值: /然后在括号里给出撑住这段判断的盘上依据/、/每段最多一处括号/
|
||||
// 新值: 依据写进正文「因为……」,段尾不加括号复述;宫位按十二宫表带生活领域;时间最多三件、由近到远
|
||||
// 原因: issue #113 C1(产品 2026-10-27 拍板):段尾括号把正文用行话再说一遍、宫位只给编号,读者看不懂;依据改成正文里「因为……」半句,宫位第一次出现带生活领域
|
||||
assert.match(voice, /写到讲清楚为止。\$\{CHART_REASON_RULE\}/);
|
||||
assert.match(voice, /宫位:\$\{HOUSE_LIFE_AREA_RULE\}/);
|
||||
assert.match(voice, /时间:\$\{TIMING_FOCUS_RULE\}/);
|
||||
assert.match(productConversationVoice, /依据写进正文:判断之后用半句白话说为什么(「因为……」)/);
|
||||
assert.match(productConversationVoice, /段尾不加括号把正文再用行话说一遍/);
|
||||
assert.match(productConversationVoice, /宫位第一次出现时,紧跟着在括号里写它管的生活领域/);
|
||||
assert.match(productConversationVoice, /8 宫(突发的变化、没摊开的事、别人的钱)/);
|
||||
assert.match(productConversationVoice, /讲时间时最多说三件天象或运期变化,按离现在由近到远排/);
|
||||
assert.doesNotMatch(voice, /然后在括号里给出撑住这段判断的盘上依据|每段最多一处括号/);
|
||||
assert.match(voice, /人话自检:把每句话括号里的内容删掉,一个完全不懂占星的人仍然能看懂它在说什么/);
|
||||
for (const banned of ["自造格局名、四字概括", "把大运、行运写成拟人的角色", "把星名当形容词的主人", "括号外每段最多一个占星术语"]) {
|
||||
assert.ok(voice.includes(banned), banned);
|
||||
@@ -291,9 +302,12 @@ test("the pinned Good examples split by person and stay plain; the coined-patter
|
||||
// 原值: indexOf("Good (父母题首轮,按对象分段;…)")
|
||||
// 新值: 标签改为「分段落、段首点名」
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1/D4(BUG-1245)
|
||||
const parentsStart = voice.indexOf("Good (父母题首轮,分段落、段首点名;括号里是依据的占位,只演示口气与结构,不是读法)");
|
||||
const careerStart = voice.indexOf("Good (单对象事业题首轮,不拆段;括号里是依据的占位,只演示口气与结构,不是读法;不预设读者在上班、做哪类工作)");
|
||||
const badStart = voice.indexOf("Bad (多对象题,改写示意)");
|
||||
// 原值(2): 标签「括号里是依据的占位…」,Bad 起点「Bad (多对象题,改写示意)」
|
||||
// 新值(2): 标签「「因为」后面的依据是占位…」,事业例之后先是新 Bad「段尾括号复述」
|
||||
// 原因(2): issue #113 C1(产品 2026-10-27 拍板):段尾括号把正文用行话再说一遍、宫位只给编号,读者看不懂;依据改成正文里「因为……」半句,宫位第一次出现带生活领域
|
||||
const parentsStart = voice.indexOf("Good (父母题首轮,分段落、段首点名;「因为」后面的依据是占位,只演示口气与结构,不是读法)");
|
||||
const careerStart = voice.indexOf("Good (单对象事业题首轮,不拆段;「因为」后面的依据是占位,只演示口气与结构,不是读法;不预设读者在上班、做哪类工作)");
|
||||
const badStart = voice.indexOf("Bad (段尾括号复述,改写示意)");
|
||||
assert.ok(parentsStart > -1 && careerStart > parentsStart && badStart > careerStart);
|
||||
const parents = voice.slice(parentsStart, careerStart);
|
||||
// 原值: 父母示范含 ## 你和妈妈 / 你和爸爸 / 他们怎么对你 / 时间怎么看 / 这周可以做的一件事
|
||||
@@ -308,7 +322,11 @@ test("the pinned Good examples split by person and stay plain; the coined-patter
|
||||
// 原值(3): /(4 宫主落 10 宫;月亮在 10 宫)/
|
||||
// 新值(3): /(母亲这条线没有受冲;4 宫主落 10 宫;月亮在 10 宫)/
|
||||
// 原因(3): 第五轮回测前修(BUG-1182):梦露盘 4 宫主正落 10 宫且母亲受冲两个以上,模型照抄「上心、前途」;示例只在没有受冲时演示「近、上心」
|
||||
assert.match(parents, /(母亲这条线没有受冲;4 宫主落 10 宫;月亮在 10 宫)/);
|
||||
// 原值(4): /(母亲这条线没有受冲;4 宫主落 10 宫;月亮在 10 宫)/
|
||||
// 新值(4): 依据进正文,宫位带生活领域,段尾不再有括号
|
||||
// 原因(4): issue #113 C1(产品 2026-10-27 拍板):段尾括号把正文用行话再说一遍、宫位只给编号,读者看不懂;依据改成正文里「因为……」半句,宫位第一次出现带生活领域
|
||||
assert.match(parents, /因为管母亲的 4 宫(母亲、家和住处)这条线没有受冲,4 宫的主星和代表母亲的月亮都落在 10 宫(事业、社会地位)。/);
|
||||
assert.doesNotMatch(parents, /(母亲这条线没有受冲;/);
|
||||
// 原值(2): /他可能不常在你身边[\s\S]*如果实际情况不是这样,告诉我,我按真实情况重看。/
|
||||
// 新值(2): 爸爸段写「有距离的迹象」+ 范围 + 「实际是哪一种,告诉我一句」,不断定在不在身边
|
||||
// 原因(2): 产品 2026-10-02 改口径(名人回测对照组误报 1→15;盘分不出父母在不在身边)
|
||||
@@ -320,13 +338,21 @@ test("the pinned Good examples split by person and stay plain; the coined-patter
|
||||
// 新值: 单对象示范也是段落,不写 ## 盘上依据
|
||||
// 原因: TASK-consult-conversational-answer-20261006 D1(BUG-1245)
|
||||
assert.doesNotMatch(career, /^## /m);
|
||||
assert.match(career, /(木星行运 10 宫;土星行运 6 宫)/);
|
||||
// 原值(2): assert.match(career, /(木星行运 10 宫;土星行运 6 宫)/)
|
||||
// 新值(2): 依据进正文「因为木星今年走在你的 10 宫(事业、社会地位)」
|
||||
// 原因(2): issue #113 C1(产品 2026-10-27 拍板):段尾括号复述正文、宫位只给编号,读者看不懂
|
||||
assert.match(career, /因为木星今年走在你的 10 宫(事业、社会地位)/);
|
||||
assert.doesNotMatch(career, /(木星行运 10 宫;土星行运 6 宫)/);
|
||||
for (const good of [parents, career]) {
|
||||
assert.doesNotMatch(good, /一切都会好|相信自己|加油|宇宙自有安排|扮演|格局/);
|
||||
// No coined 外X内Y pattern names and no empty-house verdicts in the pinned examples.
|
||||
assert.doesNotMatch(good, /「外.内.」|空宫/);
|
||||
}
|
||||
const bad = voice.slice(badStart, voice.indexOf("\n", badStart));
|
||||
const bracketBad = voice.slice(badStart, voice.indexOf("\n", badStart));
|
||||
assert.match(bracketBad, /正文说过的又用行话说一遍;宫位只有编号,没说 9 宫管什么/);
|
||||
const coinedStart = voice.indexOf("Bad (多对象题,改写示意)");
|
||||
assert.ok(coinedStart > badStart);
|
||||
const bad = voice.slice(coinedStart, voice.indexOf("\n", coinedStart));
|
||||
assert.match(bad, /自造格局名、大运写成角色、星名当形容词,爸妈揉成一段/);
|
||||
|
||||
// 原值: /Good \(追问轮,用户说「你在说什么鬼」\): 说直接点:这份工作还值得留/
|
||||
@@ -356,7 +382,11 @@ test("the voice answers the sentence asked first, then may read feelings within
|
||||
for (const phrase of forbidden) assert.ok(discipline.includes(`「${phrase}」`), phrase);
|
||||
assert.match(discipline, /先回答问题再读心/);
|
||||
assert.match(discipline, /只用「可能」「多半」,一句话,不当成事实/);
|
||||
assert.match(discipline, /用户自己的心全篇最多读一句/);
|
||||
// 原值: /用户自己的心全篇最多读一句/
|
||||
// 新值: 只在问情绪、关系里的感受时读,最多一句;问事业、钱、时间、某一天的运势不读,结尾不猜动机
|
||||
// 原因: issue #113 C1(产品 2026-10-27 拍板):问「今天整体」的回答结尾猜「你心里正等着一个变化」,像在猜你为什么问
|
||||
assert.match(discipline, /用户自己的心只在问的是情绪、关系里的感受时才读,全篇最多一句/);
|
||||
assert.match(discipline, /结尾不猜用户为什么问、心里在等什么/);
|
||||
assert.match(discipline, /读当事人(妈妈、伴侣、老板……)的心要有盘上依据,每人最多一句,且只在这条线没有受冲时写暖的那一面/);
|
||||
assert.match(discipline, /受冲两个及以上按受冲规则只给范围/);
|
||||
assert.match(discipline, /不写「她问的是结果,你要的是被听」这种替用户总结需求的话/);
|
||||
@@ -385,7 +415,10 @@ test("the voice answers the sentence asked first, then may read feelings within
|
||||
// 原值(3): /不是。(母亲这条线没有受冲;4 宫主落 10 宫)她把心力放在你的前途上/
|
||||
// 新值(3): 同一前提下读母亲的心与用户的心各一句
|
||||
// 原因(3): 2026-10-07 产品定知心姐姐口吻、放开读心(BUG-1255,推翻 BUG-1070 不揣测的读心部分)
|
||||
assert.match(voice, /不是。(母亲这条线没有受冲;4 宫主落 10 宫)她的心思多半放在你以后的路上,只是不太会用你想要的方式表达。会这么问,可能你心里已经憋了一阵子了。/);
|
||||
// 原值(4): /不是。(母亲这条线没有受冲;4 宫主落 10 宫)她的心思多半…憋了一阵子了。/
|
||||
// 新值(4): 依据进正文、宫位带生活领域;读心两句不变(这是关系里的感受题)
|
||||
// 原因(4): issue #113 C1(产品 2026-10-27 拍板):段尾括号把正文用行话再说一遍、宫位只给编号,读者看不懂;依据改成正文里「因为……」半句,宫位第一次出现带生活领域
|
||||
assert.match(voice, /不是。管母亲的 4 宫(母亲、家和住处)这条线没有受冲,4 宫的主星落在 10 宫(事业、社会地位)。她的心思多半放在你以后的路上,只是不太会用你想要的方式表达。会这么问,可能你心里已经憋了一阵子了。/);
|
||||
assert.doesNotMatch(voice, /逆行|已经在一起的关系/);
|
||||
assert.match(voice, /Bad \(同一问题\): 你其实要的是被听见/);
|
||||
// 原值: "…wants, needs, or feels; state where the chart puts them"(Never 列表里第二句读成也被禁止)
|
||||
|
||||
@@ -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,309 @@
|
||||
/**
|
||||
* 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("「不一定会」 is not a fate claim and is accepted", () => {
|
||||
const accepted = acceptReportReadingDraft(draft({ love: [`${PARAGRAPH}这不一定会马上发生。`] }));
|
||||
assert.equal(accepted.ok, true);
|
||||
});
|
||||
|
||||
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}本仓功能吉凶层这样判。`] })],
|
||||
["banned_phrase", draft({ love: [`${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