Compare commits

...
Author SHA1 Message Date
Jesse_Chen 0034333b87 Merge codex/chart-dasha-yogini-20261021 (#88) into staging
Independent Staging Quality Gate / validate (push) Successful in 14m47s
Independent Staging Quality Gate / publish (push) Successful in 4m43s
2026-10-22 09:18:42 +08:00
Jesse_Chen 61462e7e48 Merge codex/synastry-staging-fix-20261021 (#85 T7-T11) into staging
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 1m3s
# Conflicts:
#	CHANGELOG.md
#	docs/BUG_HISTORY.md
#	docs/tasks/README.md
2026-10-22 09:17:02 +08:00
Jesse_Chen a2ebb28244 Merge codex/goel-jaimini-strength-20261022 (#94) into staging
Independent Staging Quality Gate / validate (push) Successful in 15m44s
Independent Staging Quality Gate / publish (push) Successful in 4m27s
# Conflicts:
#	CHANGELOG.md
#	docs/tasks/README.md
2026-10-22 08:53:17 +08:00
Jesse_ChenandClaude Opus 5.5 9a0d5e11f3 docs(tasks): English han-leak fix brief (agent loop, issue #89 round 1)
The branch edits scripts/jaimini.py, a frozen rectification scoring file;
the sweep's jhora block does not reach the replay subprocesses, so the
tests render a packet production never sees; missing English is blanked
instead of exposed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-22 08:05:18 +08:00
jesse-uxGrok (agent loop)Cursor
52e5d6dc02 docs: publish the Goel note and the strength boundary
Issue #94 T6. Keep the upstream calculation note, replace the local manuscript path with a book citation, and add our one-line pointer. Record ERR-116 and a changelog line. Skill version is unchanged.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 01:29:22 +08:00
jesse-uxGrok (agent loop)Cursor
489ae2fb35 docs: record the Goel Jaimini strength regression
Issue #94 T5. Two hundred fixed-seed fictional charts on mean and true nodes all execute, and the Atmakaraka matches chara_karaka_7. Chinese and English full-data markdown for the three fixed fictional charts is unchanged. The frozen-file intersection is empty.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 01:28:51 +08:00
jesse-uxGrok (agent loop)Cursor
413140b40c fix(synastry): 补上 issue #85 第 1 次验收打回的三处
G1 路由清单在 synastry 与 terms 之间加入 synastry/[reportId]。G2 把「同一对已经有报告时,仍打开原来那份」改成「同一对正在生成时,仍打开那份」。G3 删掉永远成立的侧栏断言,改为渲染 /synastry/<id> 并核对当前项是婚恋合盘。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 01:19:45 +08:00
jesse-uxGrok (agent loop)Cursor
a769533032 test: lock Goel node policy, tied degrees, and raw longitudes
Issue #94 T4. Wired jaimini output keeps both node policies, blocks equal seven-planet degrees without an Atmakaraka or a rank, and full-reading passes chart degree_raw into Goel within 1e-6 degrees.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:57:58 +08:00
jesse-uxGrok (agent loop)Cursor
a1c7f6ce67 feat: attach Goel Jaimini strength to jaimini and full-reading
Issue #94 T3. Add _goel_jaimini_evidence and merge it for jaimini modes all/special and before modules.jaimini is stored. Goel failures are logged and returned as blocked so they cannot drop the jaimini module. cmd_jaimini passes degree_raw, falling back to degree.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:56:10 +08:00
jesse-uxGrok (agent loop)Cursor
eb67962d70 test: port Goel Jaimini strength checks onto our Niryaana selector
Issue #94 T2. Keep the upstream test and retarget select_niryaana_shoola_start_sign to scripts.rashi_dashas_native. Add test_full_reading_exposes_goel_jaimini_strength using the existing fictional BASE_BIRTH_ARGS.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:54:02 +08:00
jesse-uxGrok (agent loop)Cursor
424f03a4d5 feat: vendor four upstream Goel Jaimini strength modules
Issue #94 T1. Place goel_jaimini_strength, trikona_raman, sthira_brahma_goel_short_method, and trikona_dasha from upstream 5fb8379c under scripts/. The only local edit is the SOURCE book citation. Raise ROOT_PY_BUDGET from 448 to 452.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:52:52 +08:00
jesse-ux 2579fa14e5 Merge remote-tracking branch 'origin/staging' into codex/synastry-staging-fix-20261021 2026-10-22 00:50:34 +08:00
jesse-uxGrok (agent loop)Cursor
57ee79cc5f fix(synastry): let the same pair start another report
A finished match used to reopen the old report. Starting again now writes a new one and charges again; a report still being written is reused.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:08:21 +08:00
jesse-uxandCursor dd9efb3447 fix(synastry): keep the picker order in the report title
The pair key stays sorted for dedup, and generation now loads the two people in the order they were chosen. The report back link uses Link so the new route passes the page-link lint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:26:26 +08:00
jesse-uxandCursor e1afd0ea3d fix(synastry): drop a section title repeated at the start of the body
The gate now rejects that opening and asks for a rewrite, and the saved text no longer keeps the duplicate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:09:19 +08:00
jesse-uxandCursor dc80a43aae fix(synastry): open the report on its own address
The shared report URL lit the personal-report item in the sidebar. The new address keeps the synastry item current and replaces the old link.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 18:07:51 +08:00
jesse-uxandCursor 9d2f63b6ac docs(synastry): keep bug history line endings when recording the scroll fix
The previous commit stored the whole history file with Windows line endings. This puts it back to the repository's line endings and keeps the BUG-1404 entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:56:37 +08:00
jesse-uxandCursor 229df727c9 fix(synastry): scroll the report from the full-width outer container
The narrow column was the only scrollport, so the wheel did nothing on the side blanks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 17:53:08 +08:00
43 changed files with 2194 additions and 87 deletions
+25
View File
@@ -31,6 +31,31 @@
- 报告「行星原始位置」表按内容定列宽。星宿的英文名用浅色小字,跟在中文后面同一行。速度写到小数点后 4 位。放不下时表在自己的框里横向滑动。
- Skill 版本不变(BUG-1410)。
## 2026-10-22 — 引擎算出 Goel Jaimini 强度和三个特殊点
- full-reading 与 jaimini 输出增加 Goel 第 1 章完整强度,以及 Brahma、Maheshwara、Rudra。报告正文暂不显示这一章。
- Skill 版本不 bump。
## 2026-10-21 — 同一对人可以再生成一份合盘
- 已经写好的合盘还在。再点「开始合盘」会新写一份,并再扣一次点数。正在写的那份仍打开原来的等待页,不重复扣。选人页在点数说明下写出已经合过几次、再生成会另扣多少点。Skill 版本不变(BUG-1408)。
## 2026-10-21 — 合盘两人顺序跟选人页一致
- 先选的人写在前面。等待页、报告标题、报告中心卡片和追问对话标题用同一个顺序。同一对正在生成时,仍打开那份。Skill 版本不变(BUG-1407)。
## 2026-10-21 — 合盘正文不再重复节标题
- 某一节的正文如果以这一节的标题开头,质检会要求重写这一节。保存前也会把开头重复的标题去掉。中文和英文都是这样。Skill 版本不变(BUG-1406)。
## 2026-10-21 — 合盘报告有自己的地址
- 合盘报告改走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍是「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,英文链接保留 `?lang=en`。Skill 版本不变(BUG-1405)。
## 2026-10-21 — 合盘报告两侧空白也能滚动
- 合盘报告改由整宽的外层滚动,中间一栏只负责居中排版。指针停在两侧空白处时,整份报告一起动。打印仍按整页展开。Skill 版本不变(BUG-1404)。
## 2026-10-21 — 合盘时间线写出起止,删掉的模型改用默认模型
- 关系时间线每段标题写出起止年月。第一段从本月开始时,起点写「现在」或 Now。
+80
View File
@@ -37016,6 +37016,86 @@
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
## BUG-1404 | 合盘报告两侧空白滚不动
- 状态:resolved(待 staging 真机复测)
- 首次发现:2026-10-21(产品负责人真机)
- 最近更新:2026-10-21
- 影响面:合盘报告阅读页
- 用户现象:鼠标停在报告两侧空白处滚动时,报告不动。停在中间正文上可以滚。
- 触发条件:桌面宽度下打开一份已写好的合盘报告,指针放在 720px 正文栏以外的空白处滚动。
- 根因:`.synastry-report` 自己是滚动口,宽度又是 `min(720px, 100%)`。两侧空白属于外层 `.chat-panel`(`overflow: clip`),滚轮到不了中间这一栏。个人报告是整宽的 `.personal-report-reader` 在滚,内文再居中。
- 修复:新增整宽的 `.synastry-report-scroll`,高度 100%、纵向滚动,和 `.personal-report-reader` 同一做法。`.synastry-report` 只保留居中和排版,不再带 `height: 100%` 和 `overflow`。打印时外层改为 `height: auto`、`overflow: visible`,长报告不会被裁成一页。
- 验证:`frontend/tests/synastry-full-report.test.ts` 锁定外层整宽滚动、内栏没有 `height: 100%` / `overflow: auto`、阅读页用这个外层包住报告,以及打印解锁。
- 防复发:上述样式与源码断言。不要把滚动再放回宽度受限的 `.synastry-report`。
- 相关记录:issue #85。
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
## BUG-1405 | 合盘报告打开后侧栏变成「我的报告」
- 状态:resolved(待 staging 真机复测)
- 首次发现:2026-10-21(产品负责人真机)
- 最近更新:2026-10-21
- 影响面:婚恋合盘报告的地址、报告中心卡片、选人页的「开始合盘」和「之前的合盘」
- 用户现象:点「开始合盘」之后,侧栏当前项从「婚恋合盘」变成「我的报告」。浏览器标题也是「个人报告」。
- 触发条件:从选人页进入一份合盘,或从报告中心打开合盘卡片。地址是 `/reports/报告`。
- 根因:合盘和个盘共用个人报告阅读页。侧栏按地址前缀点亮,「我的报告」是 `/reports`。任务书原先只要求跳进阅读页,没有给合盘单独的地址。
- 修复:新增 `/synastry/报告`,仍用原来的阅读页,文档标题是「婚恋合盘 · Jyotisha」,`?lang=` 保留。选人页的「开始合盘」和「之前的合盘」、报告中心的合盘卡片改走这个地址。旧的 `/reports/报告` 打开合盘时换成新地址,不另留一条历史。新地址若打开的是个人报告,换回 `/reports/报告`。左上角「‹ 婚恋合盘」不变。打印样式只在打印时收起侧栏。侧栏本来就用地址前缀点亮 `/synastry`,所以新地址仍是「婚恋合盘」。
- 验证:`frontend/tests/synastry-report-surface.test.ts`。旧地址带 `lang=en` 换成 `/synastry/报告?lang=en`;个人报告留在 `/reports`;新页面标题和 `surface="synastry"`;选人页和报告中心的链接;侧栏对 `/synastry/` 前缀点亮。失败态带上报告类型,个人报告的失败断言从「没有 reportType」改为带上 `personal_full`。
- 防复发:上述测试。不要把合盘再链回 `/reports/报告`。
- 相关记录:issue #85。BUG-1404。
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
## BUG-1406 | 合盘正文开头重复节标题
- 状态:resolved(待 staging 真机复测)
- 首次发现:2026-10-21(产品负责人真机)
- 最近更新:2026-10-21
- 影响面:合盘报告各节正文,中文和英文
- 用户现象:节标题下面,正文又用同一句话开头,标题等于出现了两次。
- 触发条件:模型把这一节的标题写进正文开头。保存和质检都没有拦住。
- 根因:每一节单独生成,提示里有节名,但保存前不剥离开头的标题,质检也不把这件事当成失败。
- 修复:正文以该节标题开头(可重复一次,后面是空白)时,质检记 `repeated_heading` 并只重写这一节。重写提示写明不要以节标题开头。通过之后,保存前再剥掉开头重复的标题。中文和英文同一条规则。标题后面紧接着别的字、不是空白也不是再来一遍标题时,不剥,避免误伤。
- 验证:`frontend/tests/synastry-full-report.test.ts`:正文以「沟通与理解」开头时先重写,保存结果不再以这个标题开头;英文标题同样剥掉;「沟通与理解力」不剥。`tests/test_synastry_full_reader_gate.py`:同一中文样例和英文「Communication and Understanding」被质检判为失败。
- 防复发:上述两条。不要只在页面上藏起来,生成和质检都要认。
- 相关记录:issue #85。
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
## BUG-1407 | 合盘两人顺序和选人页相反
- 状态:resolved(待 staging 真机复测)
- 首次发现:2026-10-21(产品负责人真机)
- 最近更新:2026-10-21
- 影响面:合盘等待页、报告标题、报告中心卡片、追问对话标题
- 用户现象:选人页先选甲、再选乙。等待页是甲 × 乙。写完之后,报告标题、报告中心卡片和追问对话标题变成乙 × 甲。
- 触发条件:先选的那个人,编号排在后选的那个人后面。已经写好的报告保留当时的顺序。再生成一份时按这次的选人顺序(BUG-1408)。
- 根因:去重用的一对编号按编号从小到大存放。生成时把较小的编号当成甲、较大的编号当成乙。等待页读的是选人页当场记下的顺序,所以只有等待页是对的。
- 修复:创建时另存选人顺序(先选的人、后选的人)。生成按这个顺序加载两个人,报告标题、卡片摘要和追问标题都用这个顺序。去重用的编号排序不变。没有这份顺序的旧报告仍按原来的编号顺序。等待页本来就是选人顺序,没有改。
- 验证:`frontend/tests/synastry-display-order.test.ts`:先选的人编号更大时,标题和追问标题仍是甲 × 乙;等待页句子是甲在前。`frontend/tests/database-synastry-full-create.test.ts`:请求先是本人、后是另一人时,存下的顺序是本人在前,而去重编号仍是另一人在前。
- 防复发:上述两条。生成不要再把较小的编号直接当成甲。
- 相关记录:issue #85。
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
## BUG-1408 | 同一对人不能再生成一份合盘
- 状态:resolved(待 staging 真机复测)
- 首次发现:2026-10-21(产品负责人)
- 最近更新:2026-10-21
- 影响面:婚恋合盘的「开始合盘」、点数、之前的合盘
- 用户现象:同一对人已经有一份写好的合盘,再点「开始合盘」会打开上一份,不能新写。
- 触发条件:两人、出生资料和规则版本都没变,上一份状态是 ready。
- 根因:创建时把 ready 和 generating 都当成可复用,并且数据库不允许同一对有第二份 ready。这是 #82 决策 E3。产品在 K6 推翻了复用这一部分。
- 修复:只有 generating 才打开原来那份、不扣点。ready 时新建一份并再扣 `synastry.full`,旧报告保留。去掉只允许一份 ready 的索引。引擎结果仍落在原来的 `synastry_reports` 一行上,报告正文按新的一份重写。选人页在点数说明下写出已经合过几次、再生成会另扣多少点;正在生成时不写这句。
- 验证:`frontend/tests/database-synastry-full-create.test.ts`:generating 时第二次仍是原 id、点数不变;写成 ready 后再建得到新 id、点数再减。`frontend/tests/database-synastry-full-report.test.ts`:同一对可以留下两份 ready。`frontend/tests/synastry-full-report.test.ts`:已有两份且不在生成中时,渲染出「这两人已经合过 2 次,再生成会另扣 19 点」。
- 防复发:上述三条。不要把 ready 再当成复用,也不要把那条唯一索引加回来。
- 相关记录:issue #85。推翻 #82 决策 E3 的复用部分。BUG-1407。
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
## BUG-1409 | 导出弹窗把「没有英文」写成常驻说明,选中态没有主题色
- 状态:resolved(待 staging 真机复测)
@@ -0,0 +1,206 @@
上游 `5fb8379c` 原文;我方 Niryaana 选择器在 `scripts/rashi_dashas_native.py`;书稿未入库
# Goel Jaimini public calculation module
## Scope and entry points
`scripts/goel_jaimini_strength.py` publishes the existing frozen runtime's
position-only arithmetic, with input validation and explicit blocked points.
It contains no customer identity, birth record, report, private fixture, or
runtime dependency on a frozen directory. The historical frozen-engine
publication receipt remains true: its complete payload is not published.
Public functions:
- `calculate_goel_strength(planet_longitudes, node_occupancy_policy=...)`
- `build_goel_chart_evidence(planet_longitudes, ascendant_longitude)`
Both accept already calculated sidereal longitudes in degrees, in `[0, 360)`.
Supply exactly Sun, Moon, Mars, Mercury, Jupiter, Venus, Saturn, Rahu and Ketu;
nodes must be opposite. The builder also requires a finite numeric Ascendant.
No ayanamsa, node mode, birth time or ephemeris is inferred. The caller owns
those upstream settings. Invalid inputs raise `ValueError`; equal seven-karaka
degrees block the calculation instead of inventing a ranking.
The native `jaimini --mode special` / `--mode all` commands and `full-reading`
now expose `goel_full_strength` and `goel_special_points`. The builder's
`strength` and `special_points` keys map to those two report keys. Existing
eight-karaka and other Jaimini profiles remain independent.
## Sources and page numbering
V. P. Goel, *Predicting through Jaimini Astrology*, Chapter 1, printed pp10–17
(PDF file pages14–21). Local source:
`V. P. Goel, Predicting through Jaimini Astrology`.
Transcription SHA256:
`0a8eb5e6de409d5d8ae6684be927b1273acdc5fa72a7f1c36c31de33907341a6`.
Line numbers below refer to that transcription, not printed line numbers.
| Calculation | Printed pages | PDF pages | Transcription locator |
|---|---|---|---|
| Dignity values and natural friendship | 10–11 | 14–15 | L461 onward |
| Seven-karaka rank; Kendriya, Kartari, Adhipati | 11–12 | 15–16 | L510–553 |
| Sign permanent and karaka components | 13 | 17 | L558–593 |
| Sthana, aspect roles, sign-lord strength | 14 | 18 | L598–632 |
| Worked planetary/sign totals | 15 | 19 | L636–697 |
| Brahma and Maheshwara; first Rudra rule | 16 | 20 | L701–727 |
| Rudra malefic-contact exception | 17 | 21 | L731–736 |
| Mercury/Jupiter as sign lord: double role credit | 103 | 107 | L4359–4389 |
The p103 clarification occurs in Goel's **short method** chapter. Only its
explicit double-role explanation is used to clarify the three-role reading
of p14. The short method's different weights are NOT mixed into this full
method. Numeric reproduction of p15 also checks this role convention.
Degree-specific Moolatrikona ranges reuse the existing named
`raman_manual_degree_mt_exaltation_first_v1` profile from
`scripts/trikona_raman.py`: B. V. Raman, *A Manual of Hindu Astrology*,
section29, printed p12. Goel provides the score categories; this auxiliary
profile supplies degree ranges. Exaltation is checked first. This is an
explicit method choice, not a claim that all Goel/Raman conventions coincide.
## Formula contract
Planet total is dignity + karaka + kendriya + kartari + adhipati.
- Dignity: exalted60, Moolatrikona45, own30, friend22.5, neutral15,
enemy7.5, debilitated3.75; natural friendship, not compound friendship.
- Seven classical planets ranked by degree within sign: 60,45,30,22.5,15,7.5,3.75.
Nodes never receive these karaka points.
- Kendra / Panaphara / Apoklima from AK: 60 / 30 / 15.
- Kartari: 60 when the planet is in an odd sign and both adjacent signs have
occupants; nodes count in this component under both occupancy policies.
- Adhipati: 60 for a qualifying odd-sign lord with a malefic conjunction or
Jaimini rasi aspect. All classical planets except Moon own an odd sign.
This implementation declares Sun/Mars/Saturn/Rahu/Ketu malefics and excludes
self-contact; phase-dependent Moon and conditioned Mercury are not added.
Sign total is permanent + occupying karaka points + Sthana + aspect roles
+ sign-lord total. Movable/fixed/dual permanent values are15/30/60.
Sthana is zero if empty, otherwise45+15*n. Jupiter, Mercury and sign lord are
three roles worth60 each; if one planet fulfills two roles it can earn120.
Conjunction and aspect qualify a role once, not twice. Aspects are Jaimini
sign aspects, not Parashari planetary aspects.
Special points:
- Brahma first checks AK's eighth lord occupying AK's eighth. Otherwise it
compares Lagna/seventh Goel totals and selects the strongest non-Saturn
6/8/12 lord from the stronger anchor. The Saturn exclusion belongs to the
fallback branch; it is not silently applied to the first override.
- Maheshwara is AK's eighth lord; if exalted, compare AK's twelfth lord.
Saturn is allowed.
- Rudra compares Lagna's second/eighth lords. The weaker candidate wins when
it alone has malefic contact; when both have contact the stronger wins.
The frozen convention treats conjunction and rasi aspect as contact;
the p17 exception itself says “aspect” and its discussion adds association.
Retain this declared convention when porting rather than hiding it.
- Equal final scores remain `blocked`; no silent degree or list-order tie-break.
## Node discrepancy and default
Printed p14 explicitly includes Rahu and Ketu in Sthana. The p15 worked
totals reproduce when they are excluded from **that component only**.
`include_nodes_text` is the default because it follows the explicit rule.
`exclude_nodes_worked_example` is the reproducibility alternative, retained
under `strength.worked_example_node_exclusion` by the builder.
This switch does NOT remove nodes from Kartari or malefic contacts and does
not give them karaka points. For the published example, excluding nodes gives
Virgo281.25 / Pisces363.75; including them gives296.25 /378.75. The other ten
sign totals and all seven planet totals agree between policies. This is an
observed text/table discrepancy, not proof of the author's intended erratum.
## Relation to 9f08b57b and 258e08e3
These are **different algorithms**:
| Field / producer | Comparison | Method / use |
|---|---|---|
| `lagna_seventh_strength` | First/seventh Goel numeric totals | Goel Brahma/Sthira-related explicit selector evidence |
| Historical `niryaana_shoola_strength` | Same first/seventh totals | Deprecated frozen-export alias only |
| `select_niryaana_shoola_start_sign` | Second/eighth | PVR Ayur ordered comparison: occupancy, all-planet rasi aspects, exalted-occupant presence |
`9f08b57b` implements the last row and `258e08e3` explicitly includes both
nodes in its occupancy/aspect counts. Those changes are already on main.
This publication does not replace them. The legacy alias now carries
`deprecated: true`, `comparison: lagna_vs_seventh`, and `not_for` metadata.
Use the canonical new key for Goel totals; use the existing PVR selector for
Niryaana. Sthira's existing explicit-input short selector is a separate
contract: do not label Goel full weights as the Chapter5 short weights, and
do not infer that publishing these points automatically executes Sthira Dasha.
## Portable synthetic example
```python
from scripts.goel_jaimini_strength import build_goel_chart_evidence
positions = dict(Sun=29., Moon=58., Mars=87., Mercury=116.,
Jupiter=145., Venus=174., Saturn=203., Rahu=245., Ketu=65.)
result = build_goel_chart_evidence(positions, 0.)
```
This is deliberately invented positional data, not a real birth chart.
| Planet | Dignity | Karaka | Kendriya | Kartari | Adhipati | Total |
|---|---:|---:|---:|---:|---:|---:|
| Sun | 60 | 60 | 60 | 0 | 0 | 180 |
| Moon | 60 | 45 | 30 | 0 | 0 | 135 |
| Mars | 7.5 | 30 | 15 | 60 | 60 | 172.5 |
| Mercury | 7.5 | 22.5 | 60 | 0 | 0 | 90 |
| Jupiter | 22.5 | 15 | 30 | 60 | 60 | 187.5 |
| Venus | 3.75 | 7.5 | 15 | 0 | 60 | 86.25 |
| Saturn | 60 | 3.75 | 60 | 0 | 0 | 123.75 |
AK=Sun. Lagna=367.5; seventh=225. Brahma=Jupiter; Maheshwara=Mars;
Rudra=Mars. Gemini Sthana=75 (alternative60); Sagittarius Sthana=60
(alternative0). The existing PVR Niryaana selector instead selects Taurus.
`tests/test_goel_jaimini_strength.py` contains these literal expectations,
the printed p15 totals without birth identity, branch checks, input rejection,
blocked points and native entry wiring. Run:
```bash
python3 -m pytest -q tests/test_goel_jaimini_strength.py tests/test_niryaana_shoola_dasha.py
```
Status: source-bounded wired, with one published numerical table replay and
synthetic arithmetic regression. No predictive-case validation, global
closure, PL9 parity or automatic change to a frozen customer pin is claimed.
Verification:166 tests passed across `test_goel_jaimini_strength`,
`test_niryaana_shoola_dasha`, `test_sthira_brahma_goel_short_method`,
`test_trikona_raman_native`, `test_jaimini`, `test_jaimini_source_identity`
and `test_bphs_validation_runtime`. This includes the real full-reading CLI
and its Goel output, as well as the existing PVR Niryaana behavior.
## Current-window exploration and publication boundary
Started from main258e08e3 in an isolated `codex/goel-publication` worktree;
the original workspace was on customer-export-r32-release with unrelated
dirty files, all preserved. Checked all six existing linked worktree paths,
branch/recent-commit status, tracked file inventory, producer/CLI/report/test
searches and the two whole-machine sweep records. WorkBuddy skill is a
symlink to the primary repository; its registry remains a separate authority.
The configured r32 runtime supplies the reviewed position-only Goel module;
its hash was235fc8f90718b6da45af0ee1edc2a7c195be92664ff55bbb7b8e0398b605b33d.
The archived 10-16 publication receipt confirms the full payload was excluded.
The separately inspected local 10-16 r2 engine has neither this named module
nor the two Goel output fields; therefore the archive date must not be used
as proof of that report's actual generating pin. This extraction is from
the inspected r32 source, not a claim that the omitted r2 payload contained it.
Repository APIs/renderers remain consumers of the native engine, not a second
implementation. No external engine code or book pages are republished here.
Pre-work check passed, including external adapter diagnostics and remote
visibility; direct `git ls-remote origin refs/heads/main` returned258e08e3.
The broader governance audit separately flags `main_worktree_dirty` and
`remote_head_unverified`; the former is real and preserved, and the latter
does not certify publication despite the successful direct visibility check.
Capability authority snapshot and governance audit were invoked;
the former's historical customer examples are not authority for this module.
The reused chains are Goel position arithmetic, Raman dignity helpers,
Jaimini rasi aspects, and the existing PVR Niryaana selector. Unclosed items
are lineage differences, equal-strength selections without a source rule and
independent predictive verification. This is code publication, not a chart
interpretation, personal report export, or MEVG predictive validation.
+6
View File
@@ -484,3 +484,9 @@ Prevention: 打分路径新增或改动输入模块时,先查它在不在两
`TASK-report-chart-blank-columns-20261003` T2 写的是「`_map_to_houses_values` 增字段」,`scripts/ashtakavarga.py` 在 `sealed_holdout_rerun.PRODUCTION_FILES` 里,只加字段、分数不变也让 `test_rectification_validation_integrity_gate` 4 条与 `test_sealed_holdout_contract_freshness` 3 条失败(ERR-110 同型)。改为在 `pl9_reader_export.py` 用引擎已有字段拆分,冻结文件零改动,门禁恢复全绿,无需重新冻结。
Prevention: 写任务书时(不只是执行时)就对照 `PRODUCTION_FILES` 与 dataset `frozen_scoring.files`;只为展示的字段优先在消费层(reader / mapper)从已有数据推出,不进冻结文件。关联 ERR-110、BUG-1200。
## ERR-116 | Goel 首/七强度不是 PVR Niryaana 的二/八,完整法也不是简法 | observed 2026-10-22
Goel《Predicting through Jaimini Astrology》第 1 章完整强度的 `lagna_seventh_strength` 比的是命宫和第七宫的 Goel 总分。弃用别名 `niryaana_shoola_strength` 是同一组首/七数字,带 `deprecated`、`replacement`、`not_for`。我方 Niryaana Shoola(#84)的 `select_niryaana_shoola_start_sign` 在 `scripts/rashi_dashas_native.py`,比的是第二宫和第八宫的 PVR Ayur 口径。两套不能互相接入。`scripts/goel_short_strength.py` 是第 5 章简法,给 Sthira / Niryaana 等用,也不是这一套完整法。
Prevention: 不要把 `lagna_seventh_strength` 或 `niryaana_shoola_strength` 接到 Niryaana 选择或大运计算。不要用 `goel_jaimini_strength` 替换 `goel_short_strength`。报告里印这一章交给 #91。关联 issue #94、#84。
@@ -0,0 +1,105 @@
# 进度:Goel Jaimini 强度(issue #94,2026-10-22)
执行分支 `codex/goel-jaimini-strength-20261022`。工作树 `.worktrees/goel-jaimini-strength-20261022`。未 push。Python 用 `G:\Ferti\Jyotisha\.worktrees\agent-loop\.venv\Scripts\python.exe`(本工作树没有 `.venv`)。`core.autocrlf=false`。上游只读仓 `G:\Ferti\yinduzhanxing-ref`,提交 `5fb8379c`。
## 做了什么
- T1:四个上游模块放到 `scripts/` 根目录。`ROOT_PY_BUDGET` 448 → 452,常量旁写了三栏。`scripts/*.py` 计数 452。
- T2:测试原样搬入,只把 Niryaana 选择器的 import 改到 `scripts.rashi_dashas_native`。`test_bphs_validation_runtime.py` 新增 `test_full_reading_exposes_goel_jaimini_strength`,用文件里已有的虚构 `BASE_BIRTH_ARGS`。
- T3:`_goel_jaimini_evidence` 接到 `cmd_jaimini`(`mode` 为 `all` 或 `special`)和 full-reading 写入 `modules.jaimini` 之前。
- T4:接线后的口径测试(两种交点政策、七星同度 blocked 且不出 AK / 排序、full-reading 喂给 Goel 的经度)。
- T5:三张固定虚构盘与 200 张固定种子虚构盘。没有让步缩盘:三张盘 full-reading 均值 5.212 秒,200 张 × 交点两种口径约 34.74 分钟,低于 40 分钟,mean 与 true 都跑了。
- T6:研究笔记、ERR-116、CHANGELOG。Skill 版本不 bump。
## 四个 blob
| 文件 | 上游 `5fb8379c` | 落地后 `git hash-object` |
| --- | --- | --- |
| `scripts/goel_jaimini_strength.py` | `2170f781740affbad7b8ff4e048b88d87f997bcc` | `d13b3f785a86a17970e450f111d60fc8d52108ac` |
| `scripts/trikona_raman.py` | `1fc9283396acba98b9113bde7af0e27a80841a30` | 相同 |
| `scripts/sthira_brahma_goel_short_method.py` | `6e010b54f5ba480db3e6d8d79e711a1cc2d59fbf` | 相同 |
| `scripts/trikona_dasha.py` | `e6c8a27cf5e23e5c686154406ba5183226d12d79` | 相同 |
`goel_jaimini_strength.py` 相对上游的 diff 只有 `SOURCE` 一行,LF。旧值是上游本地书稿路径,没有入库。新值是 `SOURCE = 'V. P. Goel, Predicting through Jaimini Astrology'`。
测试文件上游 blob `e29c47182c03c2277cff46e271a7f664c3349601`。相对上游先只改一行:
```
-from scripts.niryaana_shoola_dasha import select_niryaana_shoola_start_sign
+from scripts.rashi_dashas_native import select_niryaana_shoola_start_sign
```
T4 在这个文件末尾另加了三条接线测试,不再是单行 diff。
## 接线与上游的两处差异
1. 捕获 `(ImportError, KeyError, TypeError, ValueError)` 时 `logger.warning` 记下异常类和信息,`reason` 写成 `f'{exc.__class__.__name__}: {exc}'`。这几类失败在 helper 内返回 blocked,不会抛进 full-reading 外层的 `except Exception`,因此不会把整个 `modules.jaimini` 带掉。
2. `cmd_jaimini` 传给 Goel 的经度用 `pd.get('degree_raw', pd['degree'])`。模拟盘只有 `degree` 时回退,上游那条特殊点测试仍过。full-reading 本来的 `planet_lons` 已是 `degree_raw`,原样传给 Goel。上升点仍用原来的 `asc_deg`(`lon`)。
没有把 `lagna_seventh_strength` / `niryaana_shoola_strength` 接到 Niryaana 选择或大运。没有改冻结文件、报告渲染、API 主文件、数据库。
## T5 数据(全是虚构盘)
三张盘是 `tests/test_report_english_edition.py` 的 day / night / south。`PYTHONHASHSEED=0`。
单独计时的 full-reading:day 6.343 秒、night 4.692 秒、south 4.600 秒,均值 5.212 秒。三张都是 `executed`,Goel AK 与 `chara_karaka_7` 的 AK 都是同一颗星(Venus / Mercury / Jupiter)。四个新键都在。
改前引擎是接线提交的父本 `eb67962d7`。改前改后:
| 检查 | 结果 |
| --- | --- |
| 中文完整数据版 Markdown | 三张 SHA-256 相同(day `1de47056e69f…`,night `0415e1a20dfe…`,south `51eb80c838cf…`) |
| 英文完整数据版 Markdown | 三张 SHA-256 相同(day `3e6eaadae15e…`,night `a36abde746d5…`,south `adef6214ebbb…`) |
| full-reading 与 `cmd_jaimini` 的 jaimini,去掉四个新键 | SHA-256 相同 |
| 改后四个新键 | full-reading 与 `cmd_jaimini`(mode=all)都有,状态 `executed` |
| `build_domain_facts` 选出的对话卡 | 12 个域 × 3 张盘,规范化 JSON 相同 |
`read_full_data_domain` 的原始副本不是逐字节相同。它整份拷贝 `worksheets`,其中 `advanced_systems.jaimini` 多了四个新键;这是任务书已经写明的资料包 JSON 变大,正文不印。另外两边还有运行时噪声:审计耗时、缓存时间、PyJHora 年盘快照、一张盘的 Yogini `reference_date`。去掉四个新键和这些噪声字段后,day / night 还剩 PyJHora 年盘快照差异,south 只剩 `reference_date`。没有为了抹平这些差异去改渲染或对话卡。
200 张固定种子(`Random(20261022)`,年 1940–2015、日 1–28、纬度 −45~60、经度 −120~150、时区按经度取半小时、岁差在 lahiri / raman / kp 间)。mean 与 true 各 200 次,4 个进程,墙钟 556.6 秒。
| 项 | 数 |
| --- | --- |
| 次数 | 400 |
| `goel_full_strength.status == executed` | 400 |
| blocked | 0 |
| AK 与 `chara_karaka_7` 不一致 | 0 |
| 单次 full-reading | 均值 5.537 秒,最小 4.839,最大 7.848 |
| mean / true 均值 | 5.530 / 5.544 秒 |
三张盘改前改后的 full-reading 耗时互相重叠(改前均值 5.672 秒,同一次改后 6.069 秒,另一次改后计时 5.212 秒),没有拆出稳定的增量。
冻结身份交集(`PRODUCTION_FILES` + `RESEARCH_FILES` + `frozen_scoring.files` 对上 `origin/staging...HEAD`)输出是 `[]`。
## 测试
工作树内 `pytest -q`。Windows 默认 GBK 标准输出打不出 full-reading 里已有的 emoji(`U+1F4D0`、`U+1F534`),`test_bphs_validation_runtime.py` 两条因此在子进程退出码 1。下面的数字是 `PYTHONUTF8=1` 再跑的结果。屏蔽 PyJHora 用临时目录的 `sitecustomize.py` 放在 `PYTHONPATH` 最前(`import jhora` 变成 `ModuleNotFoundError`),没有卸载包。
两次都是 238 条收集、237 通过、1 跳过、0 失败。跳过的是 `test_consultation_contract_golden.py` 里未设置 `JYOTISH_LIVE_CONTRACT_GOLDEN=1` 的线上重抓。
| 文件 | 结果 |
| --- | --- |
| `tests/test_goel_jaimini_strength.py` | 22 通过 |
| `tests/test_bphs_validation_runtime.py` | 3 通过 |
| `tests/test_python_hygiene_budget.py` | 2 通过 |
| `tests/test_rashi_dashas_native.py` | 18 通过 |
| `tests/test_niryaana_shoola_dasha.py` | 3 通过 |
| `tests/test_jaimini.py` | 42 通过 |
| `tests/test_runtime_jhora_import_scan.py` | 4 通过 |
| `tests/test_api_server_growth_contract.py` | 9 通过 |
| `tests/test_consultation_contract_golden.py` | 2 通过,1 跳过 |
| `tests/test_sealed_holdout_contract_freshness.py` | 9 通过 |
| `tests/test_rectification_validation_integrity_gate.py` | 22 通过 |
| `tests/test_reported_offset_research.py` | 14 通过 |
| `tests/test_rectification_engine_memoization.py` | 25 通过 |
| `tests/test_repo_privacy_markers.py` | 62 通过(文档提交前再复跑一次) |
没有弱化既有断言。没有重冻,没有改冻结记录或 golden。
## 移交 #91
本单不印报告。交给 #91:
- 报告印「Goel Jaimini 强度与特殊点」章,中英标签齐全。`blocked` 的中文写「无法判定」、英文写「Not determinable」,并注明原因(七星度数相同 / 强度相同无唯一选择)。
- `jyotish_engine.py` 受限资料 `jaimini_special_points.reason` 里「Brahma/Maheshwara/Rudra still lack a same-profile structured producer」随印章一并改。
- 交点两种口径并列印出,标明默认遵循正文(`include_nodes_text`)。
@@ -1,4 +1,4 @@
# 进度:婚恋合盘 staging 修复(T3–T6 与红线 3)
# 进度:婚恋合盘 staging 修复(T3–T6 已部署;T8 滚动已做)
- 任务书:`docs/tasks/TASK-synastry-staging-fix-20261021.md`
- 基线:`origin/staging` `998d07baf`(T1、T2 已在这条上,BUG-1399)
@@ -45,3 +45,68 @@
3. 切到 English。各节和时间线都有收起的 Analysis,点开能看到 Chart / So。
4. 从报告末尾选一节进对话,发出问题,能按这一节问下去。
5. 另:后台删掉某个非默认的已发布模型后,钉着它的旧咨询对话还能继续,并且改用当前默认模型。没有默认模型时才是「模型暂不可用」。
## T8 报告滚动(BUG-1404)
- 合盘报告外层改为整宽的 `.synastry-report-scroll`(`width: 100%`、`height: 100%`、`overflow-y: auto`)。`.synastry-report` 只居中排版,不再自带 `height: 100%` 和 `overflow`。打印时外层 `height: auto`、`overflow: visible`。
- 真机(执行方未做,不得写成已通过):桌面打开一份合盘报告,把指针放在正文栏两侧的空白上滚动,整份报告应跟着动。窄屏和打印另看一眼,长报告不要被裁成一页。
## T7 独立地址(BUG-1405)
- 新页面 `/synastry/[reportId]` 复用 `PersonalReportPage`,`surface="synastry"`,文档标题「婚恋合盘 · Jyotisha」,`lang` 仍走 `readReportLanguage`。
- 选人页「开始合盘」和「之前的合盘」、报告中心的合盘卡片改到 `/synastry/报告`。个人报告卡片仍是 `/reports/报告`。
- 旧地址打开合盘时 `location.replace` 到新地址,英文保留 `?lang=en`,不另留历史。新地址打开个人报告时换回 `/reports/报告`。
- 合盘揭幕后的打印样式加上 `media="print"`,平时侧栏还在。
- 失败态带上 `reportType`。个人报告失败断言的原值是没有这个字段,新值带上 `personal_full`,因为合盘地址要能认出个人报告。
- 真机(执行方未做,不得写成已通过):点「开始合盘」后,侧栏当前项仍是「婚恋合盘」。
- 左上角回到选人页仍是「‹ 婚恋合盘」。新路由让页面链接规则不接受普通 `<a href="/synastry">`,所以改成 `Link`,目的地不变。
## T9 重复标题(BUG-1406)
- 正文以该节标题开头时,质检返回 `repeated_heading`,只重写这一节。通过后保存前再剥掉开头的标题。中文和英文同一条规则。「沟通与理解力」这种紧挨着的字不剥。
- 真机(执行方未做,不得写成已通过):打开一份新写的合盘,各节标题下面的第一句不要再重复标题。
## T10 两人顺序(BUG-1407)
- 根因:去重把两个人按编号从小到大存放,生成又把较小的编号当成甲。等待页用的是选人页当场记下的顺序,所以只有等待页没反。
- 创建时把选人顺序另存一节。生成按这个顺序加载两个人,标题、卡片和追问标题跟着走。去重用的编号排序不动。同一对正在生成时,仍打开那份,顺序是当时保存的。
- 真机(执行方未做,不得写成已通过):先选甲、再选乙。等待页、揭幕后的标题、报告中心卡片、从报告进的对话标题都是甲 × 乙。
## 这一轮检查
| 检查 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| 定向:滚动、重复标题、地址、顺序、报告渲染 | 通过,0 cancelled。失败态断言原值没有 `reportType`,新值带上 `personal_full` |
| 质检 `tests/test_synastry_full_reader_gate.py` | 通过。正文以节标题开头判失败 |
| 合盘创建真库(单独) | 通过。请求先是本人、后是另一人;顺序节是本人在前,去重编号是另一人在前 |
| `npm run build` | `/` 与 `/synastry` 为 Static。`/synastry/[reportId]` 为 Dynamic。首屏引用的 js/css gzip -9:JS 577,319,CSS 70,518,合计 647,837。对照 643,725,差 +0.64%,在 ±2% 内 |
| 全量 `eslint` | 0 error,122 warning,与基线相同。中途有过 1 个 error:新路由不接受 `<a href="/synastry">`。返回链接改成 `Link` 后消失 |
全量 Python 快速门没有跑:这个工作树没有项目虚拟环境。质检定向测试用系统 Python 跑过。
未推 staging,未改 `.gitea/`。真机未做,上面的真机步骤都不得写成已通过。
## T11 允许重新生成(BUG-1408)
- 已有 `ready` 报告时,「开始合盘」新建一份并再扣 `synastry.full`。旧报告留在「之前的合盘」。已有 `generating` 时仍打开那份等待页,不扣点。
- 去掉 `personal_reports_synastry_pair_ready_uidx`,同一对可以有多份写好的报告。`synastry_reports` 的一对一行没动,引擎结果继续复用,正文按新报告重写。
- 选人页在点数说明下,两人已有写好的合盘且没有正在生成时,显示「这两人已经合过 N 次,再生成会另扣 X 点」。N、X 来自这对人的 ready 份数和当前点数。
- 真机(执行方未做,不得写成已通过):同一对人再点「开始合盘」,应进入一份新的等待页,点数再减一次。上一份仍在「之前的合盘」里。按钮下能看到已经合过几次。
## 断言变更(T11)
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `synastry-full-create-route.test.ts` 同一对复用 | 已有 ready 时 status 200、`reused`、不扣点 | 只有 generating 复用,status 202、不扣点 | K6,BUG-1408。ready 不再复用 |
| `database-synastry-full-report.test.ts` 第二份 ready | 插入被 `personal_reports_synastry_pair_ready_uidx` 拒绝,count 为 1 | 第二份 ready 可以留下,count 为 2 | 旧报告要保留,不能再靠这个唯一索引挡住 |
## 这一轮检查(T11)
| 检查 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error,122 warning,与基线相同 |
| 选人页渲染、创建路由 | 通过,0 cancelled。复用断言从 ready 的 200 改为 generating 的 202 |
| 合盘创建真库 | 通过。generating 时第二次仍是原 id,点数停在 81;写成 ready 后再建得到新 id,点数到 62。ready 份数是 1,生成中为真 |
| 同一对两份 ready | 通过。第二份 ready 留下,count 为 2。随后取消排队任务,指纹变化的另一份仍能写入 |
@@ -0,0 +1,42 @@
# 进度:婚恋合盘 T7~T11 第 1 次打回(issue #85)
- 修复单:`docs/tasks/TASK-synastry-staging-fix-fix-20261022.md`
- 被验提交:`57ee79cc5`
- 分支:`codex/synastry-staging-fix-20261021`
- 工作树:`.worktrees/synastry-staging-fix-20261021`
- 开工:`git fetch origin --prune` 后 `git merge origin/staging`(不 rebase)。无冲突。合入提交 `2579fa14e`,只带进任务书和验收清单。
- 没有新 BUG 号。没有改数据库、`.gitea`、引擎。没有 push。
## 做了什么
- G1:`tests/test_api_server_security.py` 的路由表在 `synastry` 和 `terms` 之间加入 `synastry/[reportId]`,旁边写了三栏。
- G2:`CHANGELOG.md` 的 BUG-1407 和 `PROGRESS-synastry-staging-fix-20261021.md` 的 T10,把「同一对已经有报告时,仍打开原来那份」改成「同一对正在生成时,仍打开那份」。其余句子没动。
- G3:删掉 `frontend/tests/synastry-report-surface.test.ts` 里永远成立的 `assert.equal("/synastry/abc".startsWith("/synastry/"), true)`。改为按现有侧栏渲染写法(`SidebarProvider` + `AppSidebar`)渲染侧栏,`pathname` 为 `/synastry/abc`,当前项 `data-active="true"` 是「婚恋合盘」,「我的报告」不是。合盘开关按外壳传入打开,否则这一项不会画出来。
## 断言三栏
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `tests/test_api_server_security.py` 路由表 | `synastry` 后面直接是 `terms` | 加入 `synastry/[reportId]` | issue #85 T7 合盘报告独立地址 |
| `frontend/tests/synastry-report-surface.test.ts` 侧栏当前项 | `assert.equal("/synastry/abc".startsWith("/synastry/"), true)` | `pathname` 为 `/synastry/abc` 时渲染侧栏,`data-active="true"` 的是「婚恋合盘」,「我的报告」不是 | 原断言永远成立,没有渲染侧栏 |
## 命令与数字
| 检查 | 结果 |
| --- | --- |
| 快速门 | 退出码 0。pytest 1099 条:通过 1097,失败 0,错误 0,跳过 2。G1 `test_capability_audit_scans_registry_and_local_sources` 通过。失败名单空。跳过的两条不是失败:`test_dig_bala_jupiter_synthetic_north_china_case_needs_better_than_house_only_linear_model`、`test_slice_glyphs_cover_their_table_characters` |
| `tsc --noEmit -p .` | 退出码 0,0 错 |
| `npm run lint` | 退出码 0。0 error,122 warning,与上一轮进度记录的基线相同 |
| `synastry-report-surface.test.ts` | 退出码 0。2 通过,0 失败,0 cancelled |
工作树没有 `.venv`,解释器用 `.worktrees/agent-loop/.venv/Scripts/python.exe`。
`tools/agent-loop/windows-known-failures.txt` 列的是前端 `test:db` 的 13 条,不是这次 pytest。这次 pytest 没有失败,没有多出来的名字。
## 偏离
- 快速门加了 `--skip-frontend-runtime`。`quick` 默认还会跑前端全量 `npm test`、lint 和 `next build`。修复单写明全量测试和 build 由验收方复跑,本轮不跑。Python 编译、审计和 pytest 目标与 `--profile quick` 相同。
- 第一次快速门退出码 1,发生在 pytest 之前:控制台编码是 GBK,`scripts/validate_bphs_invariants.py` 打印符号时 `UnicodeEncodeError`。没有改这个脚本。重跑设置 `PYTHONUTF8=1` 后,上面的退出码 0 才是结果。
- 为了在门禁只印一行时仍能数 pytest,设置了 `PYTEST_ADDOPTS` 把 junit 写到 `gate-logs/`(已被忽略,未提交)。
- `docs/tasks/README.md` 里这份修复单的进度改指 `PROGRESS-synastry-staging-fix-fix-20261022.md`,状态从待领取改为待验收。修复单「不用改的」那一节没有动。
- 快速门把 5 个 `references/oracle/artifacts/pending_packets/*.json` 改成了 CRLF,内容无差。已还原,没有提交。
+5 -4
View File
@@ -510,15 +510,16 @@
| `TASK-niryaana-shoola-pvr-20261018.md` | `PROGRESS-niryaana-shoola-pvr-20261018.md` | #84 Niryaana Shoola 改 PVR 口径(我们的表与 Shoola 逐行相同;推翻 BUG-1283 所选 profile;占星师 7a773d13) | 待验收 | `codex/niryaana-shoola-pvr-20261018` |
| `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 |
| `TASK-synastry-report-chat-20261018.md` | `PROGRESS-synastry-report-chat-20261018.md` | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 已验收 | `549e37b99`;动表 |
| `TASK-synastry-staging-fix-20261021.md` | `PROGRESS-synastry-staging-fix-20261021.md` | #85 婚恋合盘 staging:P0 `.gte` 已在 staging(BUG-1399)。本分支补时间线起止、英文分析逻辑、删模型后旧对话改用默认模型、选人页开关文案,以及合盘创建真库测试(BUG-1400~1403) | 待验收 | `codex/synastry-staging-fix-20261021` |
| `TASK-synastry-staging-fix-20261021.md` | `PROGRESS-synastry-staging-fix-20261021.md` | #85 婚恋合盘 staging:T3~T6 已在 staging `ff6b228dd`(BUG-1400~1403)。本分支继续报告滚动、独立地址、重复标题、两人顺序(BUG-1404~1407) | 已合入 staging | `codex/synastry-staging-fix-20261021` |
| `TASK-synastry-report-chat-fix-20261020.md` | `PROGRESS-synastry-report-chat-20261018.md` | 大运核对要认报告里的「小运」和英文 mahadasha / antardasha;预填文案只留一句 | 已验收 | `549e37b99` |
| `TASK-other-subject-birth-time-edit-20261021.md` | — | 其他人的出生时间可以修改(去掉 `chart_profiles_guard_birth` 的填报时间不可改;推翻 people-archive P1 §验收 2 该条;active 锁不变) | 排队(等 #86 合入) | `codex/other-subject-birth-time-edit-20261021`;动表;不进循环 |
| `TASK-report-export-dialog-polish-20261021.md` | `PROGRESS-report-export-dialog-polish-20261021.md` | #90 导出弹窗:英文不可用说明改顶部浮层(每次打开一次);选中态描边 / 圆点 / 勾选框用主题色、底色中性(推翻 DESIGN「Selected states use canvas-muted + ink」,BUG-1078 主语保留) | 已合入 staging | `codex/report-export-dialog-polish-20261021` |
| `TASK-chart-ascendant-slash-20261021.md` | `PROGRESS-chart-ascendant-slash-20261021.md` | #87 上升状态格「不适用」改「/」+ 悬停 / 点按说明;报告上升行同改并在表下加说明(推翻 10-15 口径) | 已合入 staging | `codex/chart-ascendant-slash-20261021` |
| `TASK-report-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 待领取(最先做;#91/#92 在后) | `codex/report-english-han-leak-20261021` |
| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 待领取(等 #86、#87 合入) | `codex/chart-dasha-yogini-20261021` |
| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 已合入 staging | `codex/chart-dasha-yogini-20261021` |
| `TASK-full-data-parity-1-20261021.md` | — | #91 完整数据版补齐(一):A 段渲染(中文附录改中文源、71 小节归 6 章、导出星座名)→ B 段引擎(Yogi 点、标准大运子运、Kalachakra 简化子运、年运三年全展开、36 个月星历、解释补长);不设大小上限 | 待领取(等 #89 合入;T3 与 #90 串行) | `codex/full-data-parity-1-20261021` |
| `TASK-goel-jaimini-strength-20261022.md` | — | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 待领取(可与 #89 并行;须在 #91 印章前合入) | `codex/goel-jaimini-strength-20261022` |
| `TASK-goel-jaimini-strength-20261022.md` | `PROGRESS-goel-jaimini-strength-20261022.md` | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 已合入 staging | `codex/goel-jaimini-strength-20261022` |
| `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` |
| `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `codex/full-data-parity-3-20261022` |
| `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-20261021.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 待领取 | `codex/synastry-staging-fix-20261021` |
| `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-fix-20261022.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 已合入 staging | `codex/synastry-staging-fix-20261021` |
| `TASK-report-english-han-leak-fix-20261022.md` | `PROGRESS-report-english-han-leak-20261021.md` | **#89 第 1 次打回**:改了冻结的校正打分文件 `jaimini.py`;测试的 PyJHora 屏蔽没传到子进程(测的不是生产那份报告,也是慢的原因);缺英文时留空藏住漏洞 | 待领取 | `codex/report-english-han-leak-20261021` |
@@ -0,0 +1,51 @@
# 修复单:英文版漏译(issue #89,第 1 次打回,2026-10-22)
- 被验提交:`ba1ffeb35`(分支 `codex/report-english-han-leak-20261021`)
- 原任务书:`TASK-report-english-han-leak-20261021.md`
- 执行方:Grok。同一分支继续,开工前 `git merge origin/staging`(不 rebase),只推这个分支。合并只会在 `CHANGELOG.md`、`docs/BUG_HISTORY.md`、`docs/tasks/README.md` 上冲突(都是追加,两边都留);`pl9_reader_export.py` 与 #86(速度 4 位)、#87(上升「/」)能自动合并,合并后两处都要在。
- BUG 编号:本分支的 BUG-1410、1411 与 staging 上 #86 的 1410~1412 撞号。合并 staging 后改成当时最大号 +1 起(按现在是 1413、1414),CHANGELOG、PROGRESS 同步改。
## 验收结论(Claude 逐行审阅)
做对了的:四张末尾词表一条没加;中文字段只新增 `*_en` 孪生字段,中文渲染分支不变;`_english_edition`、`acceptEnglishEdition`、`_retain_remaining_cjk_lines` 与前端都没动;Rath 新函数放在 `narayana_legacy_label.py`,`narayana_rath` 没被第二个文件导入;`yoga_rules.json` 只改了一条英文模板;测试只用虚构盘。
全量 pytest、快速门、前端判定这次**没跑**(下面 H1 已足以判未通过,H2 会让测试结果不可信),下一轮一并跑。
## H1 · 改了冻结的校正打分文件(必修,红线 4)
`scripts/jaimini.py` 在 `references/rectification_sealed_holdout.v1.json` 的 `production_scoring_files` 里(三处),钉死 sha256 `6b88658da540…`。本分支给 `calc_chara_karaka_7`、`calc_chara_karaka_8` 加了 `KARAKA_DOMAINS_EN` / `domain_en`,哈希变成 `cb32081951d5…`,`tests/test_sealed_holdout_contract_freshness.py` 会红,也违反「不得碰冻结打分文件」(ERR-110 / ERR-115)。
修法:`scripts/jaimini.py` **逐字节恢复**成 `origin/staging` 版本(`git checkout origin/staging -- scripts/jaimini.py`)。Karaka 职责的英文改在渲染侧按 karaka 名查表(放在 `pl9_reader_export.py` 或一个新的非冻结模块,例如 `scripts/report_render/` 下)。
验收:`git diff origin/staging -- scripts/jaimini.py` 为空;`pytest tests/test_sealed_holdout_contract_freshness.py` 通过;开工前把本分支改动的文件清单与该 JSON 里所有 `scripts/*` 路径求交集,结果为空,写进 PROGRESS。
## H2 · 测试里的 PyJHora 屏蔽没传到子进程(必修,红线 5)
`scripts/annual_pyjhora_replay.py` 的 `_run_isolated_tajaka_year_lord`、`_run_isolated_annual_replay` 每份报告都另起 `sys.executable -c …` 子进程(`timeout=30`)。`tests/report_english_fictional.py` 的 `block_jhora` 只改了本进程的 `builtins.__import__`,子进程不受影响:装了 PyJHora 的机器上子进程会真跑 PyJHora,返回生产上根本没有的年运数据(Claude 实测 1.4 秒拿到真实 `annual_chart`)。所以 200 张与 12 张两份测试渲染的不是生产那份报告——测出来汉字为 0 不能说明生产为 0。
这也是测试慢的原因:每张盘多起两个跑真 PyJHora 的子进程,Windows 上 4 进程并发时有的撞上 30 秒超时。
修法(二选一,推荐第一种):
1. 在 `render_english` / 进程池初始化里把这两个 `_run_isolated_*` 替换成直接返回「blocked」的桩,与生产无 PyJHora 时的返回形状一致;
2. 或用环境变量 + `sitecustomize` 让子进程同样屏蔽 `jhora`。
验收:加一条断言——测试渲染时这两个函数返回的是 blocked 形状,且整个测试过程中没有启动 PyJHora 子进程(例如给 `subprocess.run` 计数);在装了 PyJHora 的机器上 smoke 与 200 张重跑仍全过;PROGRESS 记下新的耗时(Windows 串行 / 进程池各一次)。smoke 已在 `CORE_PYTEST_TARGETS` 里,改完后若 Windows 上仍超过 90 秒,写明实测数字,由 Claude 在 Linux 上复测后决定。
## H3 · 缺英文时现在是「留空」,等于把漏洞藏起来(必修)
`render_pl9_parity_markdown` 的瑜伽、Dosha、Sade Sati、Jaimini 表,以及 `_render_pl9_ai_density_raw_data_markdown_en` 的 `_english_plain`,遇到组合 / 作用 / 强度 / 摘要 / 职责里有汉字时直接输出空格。任务书 T2 写的是「两者都没有时不得回落到中文——让 200 张盘测试暴露出来」:留空既不回落中文,也让测试看不见缺的 `combination_en`。
修法:留空的地方改成写一个固定标记(例如 `[translation missing: <字段名>]`),并在 200 张与 smoke 测试里断言这个标记出现次数为 0;出现的就去生产者补英文孪生字段。若某处确实补不了,PROGRESS 列出字段、盘数和源头位置,交 Claude 判断。
## 顺手改(小)
- `tests/test_narayana_legacy_label.py`:新的三栏说明覆盖掉了原来 BUG-1218 那一段,改成追加在下面,旧的保留。
- `tests/report_english_fictional.py` 的 `chart_flags` 与已有 `_flags_from_packet` 重复,能复用就复用。
- `yogas_doshas.calc_raj_yogas` 遍历 `set`,结果顺序随 `PYTHONHASHSEED` 变(改前就有)。不在本单修,但 BUG_HISTORY 新增一条 `investigating` 记下现象与位置,便于以后排序固定。
## 交回前必跑
- `pytest tests/test_sealed_holdout_contract_freshness.py tests/test_report_english_sweep_smoke.py tests/test_report_english_sweep.py tests/test_narayana_legacy_label.py tests/test_report_english_han_sources.py` 及原单列出的既有测试文件。
- **Python 快速门** `python scripts/run_quality_gate.py --profile quick`(工作树 `.venv`),回报退出码与通过 / 失败数。
- 中文逐字节对比(30 张,`PYTHONHASHSEED=0`,对照合并后的 `origin/staging`)。
- 全量 pytest、前端英文判定由 Claude 验收时跑。
+1 -1
View File
@@ -2,7 +2,7 @@
## 婚恋合盘报告(2026-10-19)
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。这两人已经有写好的合盘、且没有一份正在生成时,点数说明下面再写已经合过几次、再生成会另扣多少点。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。
## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途)
+1 -1
View File
@@ -34,7 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 吵完之后,怎样递出台阶才不会变成认输?
- 过日子的安排里,哪一件需要现在就说清楚?
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页只显示标题和副标题。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。这两人已经有写好的合盘、且没有一份正在生成时,点数说明下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页只显示标题和副标题。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { PersonalReportPage } from "@/components/personal-report/personal-report-page";
import { readReportLanguage } from "@/lib/report-language";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "婚恋合盘 · Jyotisha",
robots: { index: false, follow: false },
};
export default async function SynastryReportPage({
params,
searchParams,
}: {
params: Promise<{ reportId: string }>;
searchParams: Promise<{ lang?: string | string[] }>;
}) {
const { reportId } = await params;
const { lang } = await searchParams;
return (
<PersonalReportPage
reportId={reportId}
initialLanguage={readReportLanguage(lang)}
surface="synastry"
/>
);
}
+11 -1
View File
@@ -46,7 +46,7 @@ import {
} from "@/lib/subject-birth";
import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver";
import { clampReportLimit, nextReportCursor, parseReportCursor, reportCursorFilter } from "@/lib/report-cursor";
import { createSynastryFullReport } from "@/lib/synastry-full-create";
import { createSynastryFullReport, readSynastryPairState } from "@/lib/synastry-full-create";
export const runtime = "nodejs";
export const maxDuration = 120;
@@ -111,6 +111,16 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const url = new URL(request.url);
if (url.searchParams.get("reportType") === "synastry_full" && url.searchParams.get("pair") === "1") {
const state = await readSynastryPairState({
userId: user.id,
supabase,
admin: createAdminSupabaseClient(),
subjectA: url.searchParams.get("subjectA"),
subjectB: url.searchParams.get("subjectB"),
});
return toNextResponse(state);
}
// Keyset pages of 10 (BUG-1124). No `limit` is the first page, so an old
// client that never sends one gets the newest 10 (was a fixed 20).
const limit = clampReportLimit(url.searchParams.get("limit"));
+12 -3
View File
@@ -4774,17 +4774,25 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
}
.synastry-steps li.done { color: var(--color-ink-tertiary); text-decoration: line-through; }
.synastry-report {
width: min(720px, 100%);
.synastry-report-scroll {
width: 100%;
height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
scrollbar-gutter: stable;
}
.synastry-report {
width: min(720px, 100%);
margin-inline: auto;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 22px;
padding: var(--space-6);
overflow: auto;
overflow-wrap: anywhere;
}
.synastry-report-back { color: var(--color-ink-secondary); font-size: 14px; text-decoration: none; }
@@ -5432,6 +5440,7 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
.personal-report-print-always { display: block !important; }
.personal-report-appendix-details:not([open]) > .personal-report-print-always { display: block !important; }
.personal-report-reader { width: auto !important; height: auto !important; min-height: 0 !important; overflow: visible !important; background: #fff !important; }
.synastry-report-scroll { width: auto !important; height: auto !important; min-height: 0 !important; overflow: visible !important; }
.personal-report-reader-body { width: auto !important; padding: 0 !important; }
.personal-report-toc { display: none !important; }
.personal-report-md-layout { display: block !important; }
@@ -28,6 +28,11 @@ import {
const LIST_POLL_INTERVAL_MS = 3000;
function reportOpenHref(report: Pick<ReportListItem, "id" | "reportType">): string {
const id = encodeURIComponent(report.id);
return report.reportType === "synastry_full" ? `/synastry/${id}` : `/reports/${id}`;
}
/**
* `nextCursor` is where 「加载更多」 continues; `extended` says the user has
* opened pages past the first, so a refreshed first page is merged over them
@@ -258,13 +263,13 @@ export function PersonalReportCenter() {
</div>
{report.status === "ready" ? (
<div className="report-center-row-actions">
<Button render={<AppLink href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
<Button render={<AppLink href={reportOpenHref(report)} />} nativeButton={false} variant="outline">
查看报告
</Button>
</div>
) : report.status === "generating" ? (
<div className="report-center-row-actions">
<Button render={<AppLink href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
<Button render={<AppLink href={reportOpenHref(report)} />} nativeButton={false} variant="outline">
查看进度
</Button>
</div>
@@ -35,6 +35,7 @@ import {
} from "@/lib/personal-report-longform-copy";
import { projectOrdinaryReportMarkdown } from "@/lib/report-public-projection";
import { containsHan, type EnglishEditionStatus, type ReportLanguage } from "@/lib/report-language";
import { reportSurfaceRedirect, reportTypeForSurface, type ReportSurface } from "@/lib/synastry-report-href";
import {
describeReportProgress,
REPORT_PROGRESS_STALL_MS,
@@ -57,7 +58,7 @@ export type ReportLoadState =
sections?: readonly ReportSectionProgress[];
}
| { phase: "timed-out" }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null; reportType?: string }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| {
@@ -200,6 +201,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
return {
phase: "failed",
failureCode: appendixLastErrorCode ?? code,
...(typeof view.reportType === "string" ? { reportType: view.reportType } : {}),
...(summary ? { failureSummary: summary } : {}),
...(appendixLastErrorCode ? { appendixLastErrorCode } : {}),
};
@@ -291,7 +293,15 @@ export function formatWaitedDuration(elapsedMs: number): string {
return minutes > 0 ? `${minutes} 分 ${seconds} 秒` : `${seconds} 秒`;
}
export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { reportId: string; initialLanguage?: ReportLanguage }) {
export function PersonalReportPage({
reportId,
initialLanguage = "zh",
surface = "personal",
}: {
reportId: string;
initialLanguage?: ReportLanguage;
surface?: ReportSurface;
}) {
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
const [language, setLanguage] = useState<ReportLanguage>(initialLanguage);
const [drawnThrough, setDrawnThrough] = useState<number | null>(null);
@@ -413,8 +423,23 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
onPoll: tick,
});
const redirectTo = reportSurfaceRedirect({
surface,
reportType: reportTypeForSurface(state),
reportId,
language,
});
useEffect(() => {
if (!redirectTo) return;
window.location.replace(redirectTo);
}, [redirectTo]);
if (state.phase === "loading") return <ReportOpening />;
if (redirectTo) {
return <SecondaryHeader title={redirectTo.startsWith("/synastry/") ? "婚恋合盘" : "个人报告"} />;
}
if (state.phase === "generating" && state.reportType === "synastry_full") {
return (
<>
@@ -554,9 +579,11 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
if (state.phase === "synastry-ready") {
return (
<>
<style>{REPORT_SHELL_PRINT_CSS}</style>
<style media="print">{REPORT_SHELL_PRINT_CSS}</style>
<SecondaryHeader title="婚恋合盘" />
<SynastryReportView reportId={state.reportId} snapshot={state.synastry} createdAt={state.createdAt} initialLanguage={language} />
<div className="synastry-report-scroll">
<SynastryReportView reportId={state.reportId} snapshot={state.synastry} createdAt={state.createdAt} initialLanguage={language} />
</div>
</>
);
}
@@ -28,6 +28,8 @@ export function SynastryPicker() {
const [busy, setBusy] = useState(false);
const [creditCost, setCreditCost] = useState<number | null>(null);
const [pricingNote, setPricingNote] = useState("");
const [readyCount, setReadyCount] = useState(0);
const [pairGenerating, setPairGenerating] = useState(false);
const [earlier, setEarlier] = useState<Earlier[]>([]);
const [access, setAccess] = useState<"pending" | "open" | "closed" | "unreachable">("pending");
const pickRef = useRef<HTMLDivElement>(null);
@@ -93,6 +95,41 @@ export function SynastryPicker() {
return () => window.clearTimeout(timer);
}, []);
useEffect(() => {
let cancelled = false;
const timer = window.setTimeout(() => {
if (access !== "open" || !left || !right || left === right) {
setReadyCount(0);
setPairGenerating(false);
return;
}
setReadyCount(0);
setPairGenerating(false);
const params = new URLSearchParams({
reportType: "synastry_full",
pair: "1",
subjectA: left,
subjectB: right,
});
void fetch(`/api/reports?${params}`, { cache: "no-store" })
.then((response) => response.ok ? response.json() : null)
.then((payload: { readyCount?: unknown; generating?: unknown } | null) => {
if (cancelled || !payload) return;
setReadyCount(typeof payload.readyCount === "number" ? payload.readyCount : 0);
setPairGenerating(payload.generating === true);
})
.catch(() => {
if (cancelled) return;
setReadyCount(0);
setPairGenerating(false);
});
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [access, left, right]);
useEffect(() => {
if (!open && !adding) return;
function onKey(event: KeyboardEvent) {
@@ -161,7 +198,7 @@ export function SynastryPicker() {
b: people.find((person) => person.id === right)?.name ?? "",
};
sessionStorage.setItem(`synastry-wait:${payload.report.id}`, JSON.stringify(pair));
window.location.assign(`/reports/${payload.report.id}`);
window.location.assign(`/synastry/${payload.report.id}`);
} finally {
setBusy(false);
}
@@ -215,7 +252,7 @@ export function SynastryPicker() {
</div>
<div className="synastry-go">
<button type="button" className="button-primary" onClick={() => void createReport()} disabled={busy || !ready}>开始合盘</button>
{creditCost !== null ? <p className="synastry-cost">生成一份完整合盘报告,用掉 {creditCost} 点。大约要几分钟。</p> : pricingNote ? <p className="synastry-cost">{pricingNote}</p> : null}
<SynastryGoNotes creditCost={creditCost} pricingNote={pricingNote} readyCount={readyCount} generating={pairGenerating} />
{message ? <p className="synastry-cost" role="status">{message}</p> : null}
</div>
{earlier.length > 0 ? (
@@ -224,7 +261,7 @@ export function SynastryPicker() {
<ul>
{earlier.map((report) => (
<li key={report.id}>
<a className="synastry-history-item" href={`/reports/${report.id}`}>
<a className="synastry-history-item" href={`/synastry/${report.id}`}>
<span className="who">{historyWho(report.cardSummary)}</span>
{report.createdAt ? <span className="meta">{historyWhen(report.createdAt)}</span> : null}
</a>
@@ -250,6 +287,26 @@ export function SynastryPicker() {
);
}
export function synastryAgainNote(readyCount: number, creditCost: number | null, generating: boolean): string | null {
if (generating || creditCost === null || readyCount < 1) return null;
return `这两人已经合过 ${readyCount} 次,再生成会另扣 ${creditCost} 点`;
}
export function SynastryGoNotes(props: {
creditCost: number | null;
pricingNote: string;
readyCount: number;
generating: boolean;
}) {
const again = synastryAgainNote(props.readyCount, props.creditCost, props.generating);
return (
<>
{props.creditCost !== null ? <p className="synastry-cost">生成一份完整合盘报告,用掉 {props.creditCost} 点。大约要几分钟。</p> : props.pricingNote ? <p className="synastry-cost">{props.pricingNote}</p> : null}
{again ? <p className="synastry-cost">{again}</p> : null}
</>
);
}
function personRow(id: string, role: "self" | "other", profile: Profile): Person {
const gender = parseProfileGender(profile.gender);
const date = effectiveBirthDate(profile);
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { SynastryWait } from "@/components/synastry/synastry-wait";
import { beginSynastrySectionChat } from "@/lib/synastry-report-chat-client";
@@ -131,7 +132,7 @@ export function SynastryReportView(props: {
return (
<article className="synastry-report">
<a className="synastry-report-back" href="/synastry">‹ 婚恋合盘</a>
<Link className="synastry-report-back" href="/synastry">‹ 婚恋合盘</Link>
<header className="synastry-report-heading">
<h1>{title}</h1>
<div className="synastry-report-meta">
@@ -0,0 +1,15 @@
/** Selection order for a synastry report. The pair key stays sorted for dedup. */
export const SYNASTRY_ORDER_SECTION = "synastry_order";
export function synastryDisplaySubjectIds(low: string, high: string, order: unknown): [string, string] {
const row = order && typeof order === "object" && !Array.isArray(order)
? order as Record<string, unknown>
: null;
const first = typeof row?.first === "string" ? row.first : "";
const second = typeof row?.second === "string" ? row.second : "";
if (first !== second && (first === low || first === high) && (second === low || second === high)) {
return [first, second];
}
return [low, high];
}
+104 -33
View File
@@ -5,6 +5,7 @@ import type { DataQueryBuilder } from "@/lib/db/data-client";
import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver";
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
import { MARRIAGE_MATCH_RULES_VERSION, marriageMatchFingerprint } from "@/lib/marriage-match-service";
import { SYNASTRY_ORDER_SECTION } from "@/lib/synastry-display-order";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
import { readPersonalReportDailyLimit } from "@/lib/personal-report-entitlement";
@@ -52,21 +53,27 @@ function selectReports(admin: unknown, columns: string): Query {
return (admin as { from(table: string): { select(columns: string): Query } }).from("personal_reports").select(columns);
}
export async function createSynastryFullReport(input: Readonly<{
type OpenedPair = {
userId: string;
subjectA: string;
subjectB: string;
pair: ReturnType<typeof pairOf>;
};
async function openSynastryPair(input: Readonly<{
userId: string | null;
body: unknown;
supabase: unknown;
admin: Parameters<typeof createSupabasePersonalReportService>[0];
}>): Promise<SynastryCreateResponse> {
if (!input.userId) return { status: 401, body: { error: "请先登录" } };
subjectA: unknown;
subjectB: unknown;
}>): Promise<{ response: SynastryCreateResponse } | OpenedPair> {
if (!input.userId) return { response: { status: 401, body: { error: "请先登录" } } };
if (!await isProductEnabled("compatibility") || !await isProductEnabled("report_center")) {
return { status: 503, body: { error: "合盘服务暂未开放", code: "compatibility_product_disabled" } };
return { response: { status: 503, body: { error: "合盘服务暂未开放", code: "compatibility_product_disabled" } } };
}
const body = input.body && typeof input.body === "object" ? input.body as Record<string, unknown> : {};
const subjectA = subjectId(body.subjectA ?? "self");
const subjectB = subjectId(body.subjectB ?? body.with);
if (!subjectA || !subjectB) return { status: 400, body: { error: "请选择两个人", code: "subject_required" } };
if (subjectA === subjectB) return { status: 400, body: { error: "请选择两个不同的人", code: "same_subject" } };
const subjectA = subjectId(input.subjectA ?? "self");
const subjectB = subjectId(input.subjectB);
if (!subjectA || !subjectB) return { response: { status: 400, body: { error: "请选择两个人", code: "subject_required" } } };
if (subjectA === subjectB) return { response: { status: 400, body: { error: "请选择两个不同的人", code: "same_subject" } } };
let rowA: Record<string, unknown>;
let rowB: Record<string, unknown>;
@@ -76,44 +83,90 @@ export async function createSynastryFullReport(input: Readonly<{
loadSubjectBirth(input.supabase, input.userId, subjectB),
]);
if (!personA.chartable || !personB.chartable) {
return { status: 422, body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete } };
return { response: { status: 422, body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete } } };
}
rowA = personA.row;
rowB = personB.row;
} catch (error) {
if (error instanceof ConsultationSubjectError) {
const failure = consultationSubjectFailureResponse(error);
return { status: failure.status, body: failure.body };
return { response: { status: failure.status, body: failure.body } };
}
throw error;
}
return {
userId: input.userId,
subjectA,
subjectB,
pair: pairOf(subjectA, subjectB, marriageMatchFingerprint(rowA), marriageMatchFingerprint(rowB)),
};
}
const pair = pairOf(subjectA, subjectB, marriageMatchFingerprint(rowA), marriageMatchFingerprint(rowB));
const requestFingerprint = createHash("sha256").update(JSON.stringify({
...pair,
rules: MARRIAGE_MATCH_RULES_VERSION,
})).digest("hex");
const existing = await selectReports(input.admin, "id,request_id,status,created_at,completed_at")
.eq("user_id", input.userId)
function pairQuery(admin: unknown, pair: OpenedPair["pair"], userId: string) {
return selectReports(admin, "id,request_id,status,created_at,completed_at")
.eq("user_id", userId)
.eq("report_type", "synastry_full")
.eq("subject_low", pair.low)
.eq("subject_high", pair.high)
.eq("fingerprint_low", pair.fingerprintLow)
.eq("fingerprint_high", pair.fingerprintHigh)
.eq("synastry_rules_version", MARRIAGE_MATCH_RULES_VERSION)
.in("status", ["ready", "generating"])
.eq("synastry_rules_version", MARRIAGE_MATCH_RULES_VERSION);
}
export async function readSynastryPairState(input: Readonly<{
userId: string | null;
supabase: unknown;
admin: Parameters<typeof createSupabasePersonalReportService>[0];
subjectA: unknown;
subjectB: unknown;
}>): Promise<SynastryCreateResponse> {
const opened = await openSynastryPair(input);
if ("response" in opened) return opened.response;
const found = await pairQuery(input.admin, opened.pair, opened.userId).in("status", ["ready", "generating"]);
if (found.error) throw new Error(found.error.message ?? "synastry pair lookup failed");
const rows = Array.isArray(found.data) ? found.data as Row[] : [];
return {
status: 200,
body: {
readyCount: rows.filter((row) => row.status === "ready").length,
generating: rows.some((row) => row.status === "generating"),
},
};
}
export async function createSynastryFullReport(input: Readonly<{
userId: string | null;
body: unknown;
supabase: unknown;
admin: Parameters<typeof createSupabasePersonalReportService>[0];
}>): Promise<SynastryCreateResponse> {
const body = input.body && typeof input.body === "object" ? input.body as Record<string, unknown> : {};
const opened = await openSynastryPair({
userId: input.userId,
supabase: input.supabase,
subjectA: body.subjectA ?? "self",
subjectB: body.subjectB ?? body.with,
});
if ("response" in opened) return opened.response;
const { userId, subjectA, subjectB, pair } = opened;
const requestFingerprint = createHash("sha256").update(JSON.stringify({
...pair,
rules: MARRIAGE_MATCH_RULES_VERSION,
})).digest("hex");
const existing = await pairQuery(input.admin, pair, userId)
.eq("status", "generating")
.limit(1)
.maybeSingle();
if (existing.error) throw new Error(existing.error.message ?? "synastry lookup failed");
if (existing.data?.status === "ready" || existing.data?.status === "generating") {
if (existing.data?.status === "generating") {
return {
status: existing.data.status === "ready" ? 200 : 202,
body: { report: reportView(stubRecord(input.userId, existing.data)), reused: true },
status: 202,
body: { report: reportView(stubRecord(userId, existing.data)), reused: true },
};
}
const inFlight = await selectReports(input.admin, "id")
.eq("user_id", input.userId)
.eq("user_id", userId)
.eq("status", "generating")
.limit(1)
.maybeSingle();
@@ -123,7 +176,7 @@ export async function createSynastryFullReport(input: Readonly<{
}
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const today = await selectReports(input.admin, "created_at").eq("user_id", input.userId).gte("created_at", todayStart.toISOString());
const today = await selectReports(input.admin, "created_at").eq("user_id", userId).gte("created_at", todayStart.toISOString());
if (today.error) throw new Error(today.error.message ?? "synastry daily lookup failed");
if ((today.data ?? []).length >= readPersonalReportDailyLimit(process.env)) {
return { status: 429, body: { error: "今日报告生成次数已达上限", code: REPORT_STABLE_CODES.rateLimited } };
@@ -132,7 +185,7 @@ export async function createSynastryFullReport(input: Readonly<{
const catalog = await loadLanguageModelCatalog();
const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
if (!model) return { status: 502, body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable } };
const limited = consumeUserRateLimitSet(["reportGenerateHour"], input.userId);
const limited = consumeUserRateLimitSet(["reportGenerateHour"], userId);
if (!limited.ok) {
return {
status: 429,
@@ -146,7 +199,7 @@ export async function createSynastryFullReport(input: Readonly<{
try {
const pricing = await resolveFeaturePricing(input.admin as never, "synastry.full", model.id);
const authorization = await authorizeUsage(input.admin as never, {
userId: input.userId,
userId,
requestId,
featureKey: "synastry.full",
requestedModelId: model.id,
@@ -180,7 +233,7 @@ export async function createSynastryFullReport(input: Readonly<{
const release = async (reason: string) => {
if (!reserved) return;
reserved = false;
try { await releaseUsage(input.admin as never, input.userId as string, requestId, reason); } catch { /* the RPC is idempotent */ }
try { await releaseUsage(input.admin as never, userId, requestId, reason); } catch { /* the RPC is idempotent */ }
};
const persistence = createSupabasePersonalReportService(input.admin);
@@ -190,7 +243,7 @@ export async function createSynastryFullReport(input: Readonly<{
let created;
try {
created = await persistence.createGenerating({
userId: input.userId,
userId,
requestId,
requestFingerprint,
reportType: "synastry_full",
@@ -215,15 +268,33 @@ export async function createSynastryFullReport(input: Readonly<{
await release(REPORT_STABLE_CODES.generationInProgress);
return { status: 409, body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress } };
}
const orderSaved = await (input.admin as {
from(table: string): {
insert(row: Record<string, unknown>): Promise<{ error: { message?: string } | null }>;
};
}).from("personal_report_sections").insert({
user_id: userId,
request_id: requestId,
section_id: SYNASTRY_ORDER_SECTION,
status: "ready",
payload: { first: subjectA, second: subjectB },
attempt_count: 1,
max_attempts: 1,
});
if (orderSaved.error) {
await persistence.markFailed(userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
await release(REPORT_STABLE_CODES.generationFailed);
return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } };
}
try {
const enqueued = await createSupabasePersonalReportJobService(input.admin as never).enqueue({
userId: input.userId,
userId,
requestId,
requestFingerprint,
});
return { status: 202, body: { report: reportView(created.record), jobId: enqueued.job.id } };
} catch {
await persistence.markFailed(input.userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
await persistence.markFailed(userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
await release(REPORT_STABLE_CODES.generationFailed);
return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } };
}
+50 -1
View File
@@ -113,6 +113,36 @@ function deliveryFrom(parts: ReadonlyMap<SynastrySectionId, Record<string, unkno
return { sections: SYNASTRY_SECTION_IDS.map((id) => parts.get(id) ?? { id }) };
}
/** A body that opens with its own section title, including a repeated title. */
export function startsWithSectionTitle(text: string, title: string): boolean {
const heading = title.trim();
const body = text.replace(/^\s+/, "");
if (!heading || !body.startsWith(heading)) return false;
const rest = body.slice(heading.length);
return rest.length === 0 || /^[\s\u3000]/.test(rest) || rest.startsWith(heading);
}
export function stripLeadingSectionTitle(text: string, title: string): string {
const heading = title.trim();
if (!heading) return text;
let rest = text.replace(/^\s+/, "");
for (let guard = 0; guard < 4 && startsWithSectionTitle(rest, heading); guard += 1) {
rest = rest.slice(heading.length).replace(/^\s+/, "");
}
return rest;
}
function headingFailures(parts: ReadonlyMap<SynastrySectionId, Record<string, unknown>>): { section: string; code: string }[] {
const failures: { section: string; code: string }[] = [];
for (const id of SYNASTRY_PROSE_IDS) {
const row = parts.get(id);
const title = typeof row?.title === "string" ? row.title : "";
const text = typeof row?.text === "string" ? row.text : "";
if (startsWithSectionTitle(text, title)) failures.push({ section: id, code: "repeated_heading" });
}
return failures;
}
export async function writeSynastryLanguage(input: Readonly<{
language: "zh" | "en";
names: Readonly<{ a: string; b: string }>;
@@ -195,16 +225,35 @@ export async function writeSynastryLanguage(input: Readonly<{
await input.onProgress?.("checking_report");
let parts = materialize();
let gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
const firstHeadings = headingFailures(parts);
if (firstHeadings.length > 0) {
const seen = new Set(gated.failures.map((failure) => `${failure.section}:${failure.code}`));
gated = {
ok: false,
failures: [...gated.failures, ...firstHeadings.filter((failure) => !seen.has(`${failure.section}:${failure.code}`))],
};
}
if (!gated.ok) {
const failed = new Set(gated.failures.map((failure) => failure.section).filter((section) => SYNASTRY_SECTION_IDS.includes(section as SynastrySectionId)));
const targets = failed.size > 0 ? [...failed] as SynastrySectionId[] : [...SYNASTRY_SECTION_IDS];
await pool(targets, 3, (sectionId) => writeOne(sectionId, gated.failures.filter((failure) => failure.section === sectionId).map((failure) => failure.code)));
parts = materialize();
gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
const secondHeadings = headingFailures(parts);
if (secondHeadings.length > 0) {
const seen = new Set(gated.failures.map((failure) => `${failure.section}:${failure.code}`));
gated = {
ok: false,
failures: [...gated.failures, ...secondHeadings.filter((failure) => !seen.has(`${failure.section}:${failure.code}`))],
};
}
}
if (!gated.ok) throw new Error("synastry_gate_rejected");
const sections = SYNASTRY_PROSE_IDS.map((id) => parts.get(id) as { id: SynastrySectionId; title: string; text: string; reasons: SynastryReason[] });
const sections = SYNASTRY_PROSE_IDS.map((id) => {
const section = parts.get(id) as { id: SynastrySectionId; title: string; text: string; reasons: SynastryReason[] };
return { ...section, text: stripLeadingSectionTitle(section.text, section.title) };
});
const timeline = (parts.get("timeline")?.segments ?? []) as SynastrySnapshot["timeline"];
const practices = (parts.get("practices")?.items ?? []) as SynastrySnapshot["practices"];
const technical = buildTechnicalBasis({
+15 -2
View File
@@ -14,6 +14,7 @@ import { PersonalReportWorkerError } from "@/lib/personal-report-worker-core";
import type { PersonalReportRecord } from "@/lib/personal-report-service-core";
import { loadSubjectBirth } from "@/lib/subject-birth";
import { marriageMatchBirthBody } from "@/lib/marriage-match-service";
import { SYNASTRY_ORDER_SECTION, synastryDisplaySubjectIds } from "@/lib/synastry-display-order";
import { timelineForPair, writeSynastryLanguage, type SynastrySnapshot } from "@/lib/synastry-full-generate";
import type { TimingPeriod } from "@/lib/synastry-full-timeline";
@@ -160,9 +161,21 @@ export async function runSynastryFullReport(input: Readonly<{
}
await input.onProgress?.({ phase: "computing_charts", completed: 0, total: 9 });
const today = new Date().toISOString().slice(0, 10);
const orderRow = await input.admin.from("personal_report_sections")
.select("payload")
.eq("user_id", input.report.userId)
.eq("request_id", input.report.requestId)
.eq("section_id", SYNASTRY_ORDER_SECTION)
.maybeSingle();
const orderPayload = orderRow.error ? null : asRecord(orderRow.data)?.payload ?? null;
const [firstId, secondId] = synastryDisplaySubjectIds(
input.report.subjectLow,
input.report.subjectHigh,
orderPayload,
);
const [personA, personB] = await Promise.all([
loadSubjectBirth(input.admin, input.report.userId, input.report.subjectLow),
loadSubjectBirth(input.admin, input.report.userId, input.report.subjectHigh),
loadSubjectBirth(input.admin, input.report.userId, firstId),
loadSubjectBirth(input.admin, input.report.userId, secondId),
]);
const packet = asRecord(await postJson("/api/synastry_full_dossier", {
person_a: marriageMatchBirthBody(personA.row, today),
+12 -6
View File
@@ -197,7 +197,7 @@ const ZH_SYSTEM = `你在写一份婚恋合盘的关系画像。把盘面翻译
不要报分数。不要用「克 / 煞 / 凶 / 吉」贴标签。不要说「注定」「必然」「命中」。不要把一方说成问题源。不要替当事人做决定。
示例:${ZH_EXAMPLE}
每个判断给两到三条分析。盘面行必须逐字来自证据里的某个完整字段,不要改写。证据互相冲突时把 label 降为 parameter_sensitive 或 blocked,并写成条件,不要写成应期。不要点名补充方法。
结论、所以、时间线的阶段名和描述、共同练习里不得出现星曜、宫位、星座、分盘、大运、分数、Koota,也不得出现「克 / 吉 / 注定 / 不合 / 适合结婚」。不要写出生日期、时间、地点。不要写某年结婚、领证或分开。第一句不要写成「这段关系的核心不是……」。
结论、所以、时间线的阶段名和描述、共同练习里不得出现星曜、宫位、星座、分盘、大运、分数、Koota,也不得出现「克 / 吉 / 注定 / 不合 / 适合结婚」。不要写出生日期、时间、地点。不要写某年结婚、领证或分开。第一句不要写成「这段关系的核心不是……」,正文也不要以这一节的标题开头。
质检硬规则:label 只能是 ${EVIDENCE_LABELS.join("、")}。中文每一节结论至少 ${SYNASTRY_LENGTH.zhSection} 字,七节合计至少 ${SYNASTRY_LENGTH.zhTotal} 字。evidence 必须等于该节证据 JSON 里的某个完整字符串字段。`;
const EN_SYSTEM = `You write a relationship portrait of two people. Translate the evidence into how the relationship feels. Do not judge the people.
@@ -207,7 +207,7 @@ Five rules: speak about the pair, not about who someone is. Speak in tendencies,
Do not quote a score. Do not call one person the problem. Do not decide for them.
Example: ${EN_EXAMPLE}
Give two or three supports for each judgment. The chart line must copy one complete evidence field verbatim. When supports conflict, lower the label to parameter_sensitive or blocked and write a condition, not a dated event. Do not name supplementary methods.
The conclusion, the "so" line, the timeline phase and description, and the shared practices must not name planets, houses, signs, nakshatras, divisional charts, dashas, scores, koota, kuja, nadi, lagna, UL, or DK. Do not include birth dates, times, or places. Do not predict a wedding or a breakup in a named year. Do not open with "The core of this relationship is not".
The conclusion, the "so" line, the timeline phase and description, and the shared practices must not name planets, houses, signs, nakshatras, divisional charts, dashas, scores, koota, kuja, nadi, lagna, UL, or DK. Do not include birth dates, times, or places. Do not predict a wedding or a breakup in a named year. Do not open with "The core of this relationship is not". Do not start the body with the section title.
Gate: label must be one of ${EVIDENCE_LABELS.join(", ")}. Each English section is at least ${SYNASTRY_LENGTH.enSection} words, and the seven sections together are at least ${SYNASTRY_LENGTH.enTotal} words. evidence must equal one complete string field from that section's evidence JSON.`;
export function systemPrompt(language: "zh" | "en"): string {
@@ -223,14 +223,20 @@ export function userPrompt(input: Readonly<{
}>): string {
const title = SECTION_TITLES[input.sectionId][input.language];
const evidence = JSON.stringify(input.evidence);
const notes = (input.failures ?? []).map((code) => {
if (code !== "repeated_heading") return code;
return input.language === "en"
? "repeated_heading (do not start the body with the section title)"
: "repeated_heading(正文不要以节标题开头)";
});
if (input.language === "en") {
const repair = input.failures && input.failures.length > 0
? `\nThe previous draft failed this section only: ${input.failures.join("; ")}`
const repair = notes.length > 0
? `\nThe previous draft failed this section only: ${notes.join("; ")}`
: "";
return [`Names: ${input.names.a} and ${input.names.b}`, `Section: ${title}`, `Evidence: ${evidence}`, repair].filter(Boolean).join("\n");
}
const repair = input.failures && input.failures.length > 0
? `\n上次没有通过,只改这一节:${input.failures.join(";")}`
const repair = notes.length > 0
? `\n上次没有通过,只改这一节:${notes.join(";")}`
: "";
return [
`两人称呼:${input.names.a}、${input.names.b}`,
+34
View File
@@ -0,0 +1,34 @@
export type ReportSurface = "personal" | "synastry";
export function reportReaderHref(surface: ReportSurface, reportId: string, language: "zh" | "en"): string {
const path = surface === "synastry"
? `/synastry/${encodeURIComponent(reportId)}`
: `/reports/${encodeURIComponent(reportId)}`;
return language === "en" ? `${path}?lang=en` : path;
}
/**
* Old shared links still open /reports/:id. A synastry report there moves to
* /synastry/:id, and a personal report opened on the synastry URL moves back.
* Unknown types stay put. English keeps ?lang=en; Chinese keeps a clean path.
*/
export function reportSurfaceRedirect(input: {
surface: ReportSurface;
reportType: string | null;
reportId: string;
language: "zh" | "en";
}): string | null {
if (!input.reportType) return null;
const synastry = input.reportType === "synastry_full";
if (input.surface === "personal" && synastry) return reportReaderHref("synastry", input.reportId, input.language);
if (input.surface === "synastry" && !synastry) return reportReaderHref("personal", input.reportId, input.language);
return null;
}
export function reportTypeForSurface(state: { phase: string; reportType?: string }): string | null {
if (state.phase === "synastry-ready") return "synastry_full";
if (state.phase === "generating") return state.reportType ?? "personal_full";
if (state.phase === "markdown-ready" || state.phase === "legacy-unavailable") return "personal_full";
if (state.phase === "failed") return state.reportType ?? null;
return null;
}
@@ -0,0 +1,4 @@
-- A ready synastry report used to be unique per pair, so a second reading
-- could not be saved. Product decision K6 keeps the old report and allows
-- another one. Engine rows in synastry_reports stay unique and are reused.
drop index if exists public.personal_reports_synastry_pair_ready_uidx;
@@ -105,7 +105,7 @@ test("createSynastryFullReport reserves synastry.full and enqueues a job on the
});
catalog.invalidateLanguageModelCatalog();
const { createSynastryFullReport } = await import("../src/lib/synastry-full-create.ts");
const { createSynastryFullReport, readSynastryPairState } = await import("../src/lib/synastry-full-create.ts");
const user = createLocalPostgresDataClient(appUrl, { id: userId, email: `${userId}@example.invalid` });
const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role");
const created = await createSynastryFullReport({
@@ -123,6 +123,65 @@ test("createSynastryFullReport reserves synastry.full and enqueues a job on the
assert.equal(fixture.psql(`select status from public.personal_report_jobs where user_id = '${userId}'`), "queued");
assert.equal(fixture.psql(`select status from public.usage_reservations where user_id = '${userId}'`), "reserved");
assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "81");
assert.equal(fixture.psql(`select payload->>'first' from public.personal_report_sections where user_id = '${userId}' and section_id = 'synastry_order'`), "self");
assert.equal(fixture.psql(`select payload->>'second' from public.personal_report_sections where user_id = '${userId}' and section_id = 'synastry_order'`), personId);
assert.equal(fixture.psql(`select subject_low from public.personal_reports where id = '${report?.id}'`), personId);
assert.equal(fixture.psql(`select subject_high from public.personal_reports where id = '${report?.id}'`), "self");
const again = await createSynastryFullReport({
userId,
body: { reportType: "synastry_full", subjectA: "self", subjectB: personId },
supabase: user,
admin,
});
assert.equal(again.status, 202, JSON.stringify(again.body));
assert.equal(again.body.reused, true);
assert.equal((again.body.report as { id?: string }).id, report?.id);
assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "81");
const hash = "d".repeat(64);
fixture.psql(`
update public.personal_reports
set status = 'ready', completed_at = now(), report_document = '{}'::jsonb,
calculation_hash = '${hash}', evidence_hash = '${hash}'
where id = '${report?.id}';
update public.personal_report_jobs
set status = 'cancelled', finished_at = now()
where user_id = '${userId}' and status = 'queued';
`);
const readyState = await readSynastryPairState({
userId,
supabase: user,
admin,
subjectA: "self",
subjectB: personId,
});
assert.equal(readyState.status, 200, JSON.stringify(readyState.body));
assert.equal(readyState.body.readyCount, 1);
assert.equal(readyState.body.generating, false);
const second = await createSynastryFullReport({
userId,
body: { reportType: "synastry_full", subjectA: "self", subjectB: personId },
supabase: user,
admin,
});
assert.equal(second.status, 202, JSON.stringify(second.body));
assert.equal(second.body.reused, undefined);
const secondId = (second.body.report as { id?: string }).id;
assert.equal(typeof secondId, "string");
assert.notEqual(secondId, report?.id);
assert.equal(fixture.psql(`select status from public.personal_reports where id = '${secondId}'`), "generating");
assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "62");
const generatingState = await readSynastryPairState({
userId,
supabase: user,
admin,
subjectA: "self",
subjectB: personId,
});
assert.equal(generatingState.body.readyCount, 1);
assert.equal(generatingState.body.generating, true);
catalog.setModelCatalogIoForTests(null);
catalog.invalidateLanguageModelCatalog();
} finally {
@@ -81,7 +81,10 @@ test("synastry reports reuse a pair, drop with the person, and settle the synast
where id = '${reportId}';
${closeJob}
`);
assert.throws(() => fixture.psql(`
// 原值:第二份 ready 插入被 personal_reports_synastry_pair_ready_uidx 拒绝,count 为 1
// 新值:同一对可以再有一份 ready,count 为 2
// 原因:K6 推翻复用,旧报告保留(BUG-1408)
fixture.psql(`
begin;
${insertReport(duplicateReportId, secondRequestId, fingerprint)}
update public.personal_reports
@@ -89,14 +92,12 @@ test("synastry reports reuse a pair, drop with the person, and settle the synast
calculation_hash = '${skillHash}', evidence_hash = '${skillHash}'
where id = '${duplicateReportId}';
commit;
`), (error: unknown) => {
const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr?: unknown }).stderr) : "";
return /personal_reports_synastry_pair_ready_uidx/.test(`${stderr}\n${String(error)}`);
});
`);
assert.equal(lastLine(fixture.psql(`
select count(*) from public.personal_reports
where user_id = '${userId}' and fingerprint_low = '${fingerprint}' and status = 'ready';
`)), "1");
`)), "2");
fixture.psql(closeJob);
fixture.psql(insertReport(changedReportId, changedRequestId, changedFingerprint));
fixture.psql(`
+11 -1
View File
@@ -63,9 +63,13 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
assert.equal(readyV2.phase, "markdown-ready");
assert.deepEqual(classifyReportEnvelope(200, { report: view("generating") }), { phase: "generating" });
// 原值:失败态只有 phase 和 failureCode(以及摘要、附录错误码)
// 新值:失败态带上 reportType
// 原因:合盘地址要能把个人报告换回 /reports,失败的报告也要认出类型
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed", "model_unavailable") }), {
phase: "failed",
failureCode: "model_unavailable",
reportType: "personal_full",
});
assert.deepEqual(
classifyReportEnvelope(200, {
@@ -75,6 +79,7 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
phase: "failed",
failureCode: "report_schema_invalid",
failureSummary: "4 个主题中 4 个写作失败:输出被截断",
reportType: "personal_full",
},
);
assert.deepEqual(
@@ -90,9 +95,14 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
failureCode: "appendix_persist_failed",
failureSummary: "报告正文未能保存,请稍后重试",
appendixLastErrorCode: "appendix_persist_failed",
reportType: "personal_full",
},
);
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), { phase: "failed", failureCode: null });
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), {
phase: "failed",
failureCode: null,
reportType: "personal_full",
});
// Server-side error envelopes carry a stable code at the top level.
assert.deepEqual(classifyReportEnvelope(503, { error: "x", code: "report_persistence_unavailable" }), {
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { synastryDisplaySubjectIds } from "../src/lib/synastry-display-order.ts";
import { synastryFollowupTitle } from "../src/lib/synastry-report-session.ts";
test("picker order outranks the sorted pair key for the title and the chat", () => {
const ids = synastryDisplaySubjectIds("person-yi", "self", { first: "self", second: "person-yi" });
assert.deepEqual(ids, ["self", "person-yi"]);
const labels: Record<string, string> = { self: "甲", "person-yi": "乙" };
const names = { a: labels[ids[0]] ?? "", b: labels[ids[1]] ?? "" };
assert.equal(`${names.a} × ${names.b} · 婚恋合盘`, "甲 × 乙 · 婚恋合盘");
assert.equal(synastryFollowupTitle(names), "甲 × 乙 · 合盘追问");
assert.deepEqual(synastryDisplaySubjectIds("person-yi", "self", null), ["person-yi", "self"]);
const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8");
const run = readFileSync(new URL("../src/lib/synastry-full-run.ts", import.meta.url), "utf8");
assert.match(wait, /\$\{a\} × \$\{b\}/);
assert.match(run, /synastryDisplaySubjectIds\(/);
assert.match(run, /cardSummary: `\$\{names\.a\} × \$\{names\.b\} · 婚恋合盘`/);
});
@@ -31,7 +31,7 @@ function runRoute(script: string): { status: number; body: Record<string, unknow
return JSON.parse(line) as { status: number; body: Record<string, unknown>; charged: boolean };
}
function script(input: { product: boolean; same: boolean; missingOther: boolean; reused: boolean }): string {
function script(input: { product: boolean; same: boolean; missingOther: boolean; existing: "none" | "generating" }): string {
return String.raw`
import { mock } from "node:test";
import { pathToFileURL } from "node:url";
@@ -60,7 +60,7 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean;
const q = {
select() { return q; },
eq(column, value) { filters.push([column, value]); return q; },
in() { return q; },
in(column, values) { filters.push([column, values]); return q; },
gte() { return q; },
is() { return q; },
or() { return q; },
@@ -72,7 +72,11 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean;
return Promise.resolve({ data: ${input.missingOther ? "null" : "{ ...selfRow, id: otherId, user_id: 'user-1', role: 'other' }"}, error: null });
}
if (table === "personal_reports") {
return Promise.resolve({ data: ${input.reused ? "{ id: 'ready-1', request_id: 'req-1', status: 'ready', created_at: '2026-10-19T00:00:00Z' }" : "null"}, error: null });
const wantsGenerating = filters.some(([column, value]) => column === "status" && (value === "generating" || (Array.isArray(value) && value.includes("generating"))));
if (${input.existing === "generating" ? "true" : "false"} && wantsGenerating) {
return Promise.resolve({ data: { id: "gen-1", request_id: "req-1", status: "generating", created_at: "2026-10-19T00:00:00Z" }, error: null });
}
return Promise.resolve({ data: null, error: null });
}
return Promise.resolve({ data: null, error: null });
},
@@ -98,28 +102,31 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean;
}
test("the same person is rejected before billing", () => {
const result = runRoute(script({ product: true, same: true, missingOther: false, reused: false }));
const result = runRoute(script({ product: true, same: true, missingOther: false, existing: "none" }));
assert.equal(result.status, 400);
assert.equal(result.body.code, "same_subject");
assert.equal(result.charged, false);
});
test("someone else's id is rejected before billing", () => {
const result = runRoute(script({ product: true, same: false, missingOther: true, reused: false }));
const result = runRoute(script({ product: true, same: false, missingOther: true, existing: "none" }));
assert.equal(result.status, 409);
assert.equal(result.charged, false);
assert.match(JSON.stringify(result.body), /不会扣点/);
});
test("an unchanged pair is reused without a new reservation", () => {
const result = runRoute(script({ product: true, same: false, missingOther: false, reused: true }));
assert.equal(result.status, 200);
test("a generating pair is reused without a new reservation", () => {
// 原值:已有 ready 报告时 status 200、reused、不扣点
// 新值:只有 generating 复用并返回 202;ready 不再走这条
// 原因:K6 允许同一对人重新生成(BUG-1408)
const result = runRoute(script({ product: true, same: false, missingOther: false, existing: "generating" }));
assert.equal(result.status, 202);
assert.equal(result.body.reused, true);
assert.equal(result.charged, false);
});
test("a closed product switch rejects the report before billing", () => {
const result = runRoute(script({ product: false, same: false, missingOther: true, reused: false }));
const result = runRoute(script({ product: false, same: false, missingOther: true, existing: "none" }));
assert.equal(result.status, 503);
assert.equal(result.body.code, "compatibility_product_disabled");
assert.equal(result.charged, false);
+78 -2
View File
@@ -6,10 +6,10 @@ import { fileURLToPath } from "node:url";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
import { SynastryGoNotes, SynastryPicker } from "../src/components/synastry/synastry-picker.tsx";
import { SynastryReportView } from "../src/components/synastry/synastry-report-view.tsx";
import { SynastryPicker } from "../src/components/synastry/synastry-picker.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts";
import { stripLeadingSectionTitle, writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts";
import { EVIDENCE_LABELS, PRIVATE_KEY_NAMES, sectionEvidence, SYNASTRY_LENGTH, systemPrompt, userPrompt, SYNASTRY_SECTION_IDS, type SynastrySectionId } from "../src/lib/synastry-full-sections.ts";
import { buildTechnicalBasis } from "../src/lib/synastry-full-technical.ts";
import { buildTimelineSegments } from "../src/lib/synastry-full-timeline.ts";
@@ -98,6 +98,47 @@ test("a failed gate rewrites only the failed section once", async () => {
assert.match(result.snapshotParts.technical[5]?.lines.at(-1) ?? "", /外部资料与真实案例核对:未进行/);
});
test("a section that opens with its title is saved without the duplicate", async () => {
const calls: SynastrySectionId[] = [];
let checks = 0;
const clean = prose();
const result = await writeSynastryLanguage({
language: "zh",
names: { a: "甲", b: "乙" },
dossier: {},
segments: [
{ id: "s1", start: "2026-01", end: "2027-01", basis: "甲:木星大运 2026-01 至 2027-01。乙:金星大运 2026-01 至 2027-01。" },
{ id: "s2", start: "2027-01", end: "2028-01", basis: "甲:木星大运 2027-01 至 2028-01。乙:金星大运 2027-01 至 2028-01。" },
{ id: "s3", start: "2028-01", end: "2029-01", basis: "甲:木星大运 2028-01 至 2029-01。乙:金星大运 2028-01 至 2029-01。" },
],
signal: new AbortController().signal,
timeoutMs: 1000,
write: async ({ sectionId }) => {
calls.push(sectionId);
if (sectionId === "timeline") {
return { body: { segments: [{ id: "s1", phase: "靠近", text: "这段时间先把日常节奏说清楚。" }, { id: "s2", phase: "磨合", text: "这段时间把分歧说完再放下。" }, { id: "s3", phase: "协作", text: "这段时间一起把安排写下来。" }] }, inputTokens: 1, outputTokens: 1 };
}
if (sectionId === "practices") {
return { body: { items: [{ text: "每周留一次不被打断的谈话。", evidence: "证据甲" }] }, inputTokens: 1, outputTokens: 1 };
}
const titled = sectionId === "communication" && calls.filter((id) => id === "communication").length === 1;
return { body: titled ? { ...clean, text: `沟通与理解 沟通与理解 ${clean.text}` } : clean, inputTokens: 1, outputTokens: 1 };
},
gate: async () => {
checks += 1;
return { ok: true, failures: [] };
},
});
assert.equal(checks, 2);
assert.equal(calls.filter((id) => id === "communication").length, 2);
const saved = result.snapshotParts.sections.find((section) => section.id === "communication");
assert.equal(saved?.text, clean.text);
assert.equal(saved?.text.startsWith("沟通与理解"), false);
assert.equal(stripLeadingSectionTitle("沟通与理解 沟通与理解 沟通的天气很顺。", "沟通与理解"), "沟通的天气很顺。");
assert.equal(stripLeadingSectionTitle("Communication and Understanding The two of you talk.", "Communication and Understanding"), "The two of you talk.");
assert.equal(stripLeadingSectionTitle("沟通与理解力还在。", "沟通与理解"), "沟通与理解力还在。");
});
test("technical appendix has six program blocks and the fixed closing line", () => {
const blocks = buildTechnicalBasis({
dossier: { execution_receipt: { pair_execution_tasks: [{ task: "Kuja Dosha", status: "not_applicable" }] } },
@@ -131,6 +172,22 @@ test("the synastry page is a static shell and reads the flag in the browser", ()
assert.match(picker, /开始合盘/);
assert.match(picker, /+ 新增星盘/);
assert.match(picker, /生成一份完整合盘报告,用掉/);
const again = renderToStaticMarkup(createElement(SynastryGoNotes, {
creditCost: 19,
pricingNote: "",
readyCount: 2,
generating: false,
}));
assert.match(again, /这两人已经合过 2 次,再生成会另扣 19 点/);
assert.match(again, /生成一份完整合盘报告,用掉 19 点/);
const quiet = renderToStaticMarkup(createElement(SynastryGoNotes, {
creditCost: 19,
pricingNote: "",
readyCount: 2,
generating: true,
}));
assert.doesNotMatch(quiet, /再生成会另扣/);
assert.match(picker, /pair: "1"/);
assert.doesNotMatch(picker, /男方|女方|合不合|会不会在一起/);
const dialog = readFileSync(new URL("../src/components/synastry/synastry-add-dialog.tsx", import.meta.url), "utf8");
const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8");
@@ -555,3 +612,22 @@ test("a public pair dossier fills every section without birth keys", { timeout:
}
rmSync(output, { force: true });
});
test("the synastry report scrolls in a full-width outer container", () => {
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8");
const scroll = css.slice(css.indexOf(".synastry-report-scroll {"), css.indexOf(".synastry-report {"));
const column = css.slice(css.indexOf(".synastry-report {"), css.indexOf(".synastry-report-back"));
assert.match(scroll, /width:\s*100%/);
assert.match(scroll, /height:\s*100%/);
assert.match(scroll, /overflow-y:\s*auto/);
assert.match(scroll, /overflow-x:\s*hidden/);
assert.match(column, /width:\s*min\(720px, 100%\)/);
assert.match(column, /margin-inline:\s*auto/);
assert.doesNotMatch(column, /height:\s*100%/);
assert.doesNotMatch(column, /overflow:\s*auto/);
assert.match(page, /<div className="synastry-report-scroll">\s*<SynastryReportView /);
const printAt = css.indexOf(".synastry-report-scroll { width: auto !important;");
assert.ok(printAt > css.indexOf("@media print"));
assert.match(css.slice(printAt, printAt + 180), /overflow: visible !important/);
});
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PathnameContext } from "next/dist/shared/lib/hooks-client-context.shared-runtime";
import { AppSidebar } from "../src/components/app-sidebar.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { reportSurfaceRedirect } from "../src/lib/synastry-report-href.ts";
function renderSidebar(pathname: string) {
return renderToStaticMarkup(
createElement(
PathnameContext.Provider,
{ value: pathname },
createElement(
SidebarProvider,
null,
createElement(AppSidebar, {
sessions: [],
activeSessionId: null,
compatibilityEnabled: true,
account: {
name: "示例甲",
email: "fictional@example.invalid",
credits: 12,
initial: "示",
avatar: null,
},
}),
),
),
);
}
function navAnchor(markup: string, label: string) {
const anchors = markup.match(/<a\b[^>]*\breport-nav-button\b[^>]*>[\s\S]*?<\/a>/g) ?? [];
const hit = anchors.find((anchor) => anchor.includes(`>${label}<`));
assert.ok(hit, `sidebar nav link missing: ${label}`);
return hit;
}
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
test("an old synastry url moves to the synastry route and keeps the language", () => {
assert.equal(
reportSurfaceRedirect({ surface: "personal", reportType: "synastry_full", reportId: "abc", language: "en" }),
"/synastry/abc?lang=en",
);
assert.equal(
reportSurfaceRedirect({ surface: "synastry", reportType: "personal_full", reportId: "abc", language: "zh" }),
"/reports/abc",
);
assert.equal(
reportSurfaceRedirect({ surface: "synastry", reportType: "synastry_full", reportId: "abc", language: "zh" }),
null,
);
assert.equal(
reportSurfaceRedirect({ surface: "personal", reportType: "personal_full", reportId: "abc", language: "en" }),
null,
);
});
test("the synastry route reuses the reader and the sidebar stays on 婚恋合盘", () => {
const page = read("../src/app/(app)/synastry/[reportId]/page.tsx");
const reader = read("../src/components/personal-report/personal-report-page.tsx");
const sidebar = read("../src/components/app-sidebar.tsx");
const picker = read("../src/components/synastry/synastry-picker.tsx");
const center = read("../src/components/personal-report/personal-report-center.tsx");
assert.match(page, /title: "婚恋合盘 · Jyotisha"/);
assert.match(page, /readReportLanguage\(lang\)/);
assert.match(page, /surface="synastry"/);
assert.match(reader, /window\.location\.replace\(redirectTo\)/);
assert.match(sidebar, /\{ href: "\/synastry", label: "婚恋合盘"/);
assert.match(sidebar, /pathname === href \|\| pathname\.startsWith\(`\$\{href\}\/`\)/);
// 原值: assert.equal("/synastry/abc".startsWith("/synastry/"), true)
// 新值: pathname 为 /synastry/<id> 时渲染侧栏,当前项(data-active="true")是「婚恋合盘」,不是「我的报告」
// 原因: 原断言永远成立,没有渲染侧栏(issue #85 G3)
const markup = renderSidebar("/synastry/abc");
const synastry = navAnchor(markup, "婚恋合盘");
const reports = navAnchor(markup, "我的报告");
assert.match(synastry, /\bdata-active="true"/);
assert.doesNotMatch(reports, /\bdata-active="true"/);
assert.doesNotMatch(reports, /aria-current="page"/);
assert.match(picker, /window\.location\.assign\(`\/synastry\/\$\{payload\.report\.id\}`\)/);
assert.match(picker, /href=\{`\/synastry\/\$\{report\.id\}`\}/);
assert.doesNotMatch(picker, /\/reports\/\$\{/);
assert.match(center, /reportType === "synastry_full" \? `\/synastry\/\$\{id\}` : `\/reports\/\$\{id\}`/);
});
+137
View File
@@ -0,0 +1,137 @@
"""Goel's full Jaimini strength and special-point profile, not Bhava Bala.
Source: Predicting through Jaimini Astrology, printed pp10-17 (PDF pp14-21).
The published p15 example is the numeric regression. Dignity degree ranges
reuse the explicitly named Raman manual profile; no universal lineage claim.
"""
import math
from scripts.trikona_raman import CLASSICAL, DIGNITY_PROFILE, _dignity, _validate_positions
from scripts.narayana_dasha import _rasi_aspects
from scripts.sthira_brahma_goel_short_method import SIGNS, SIGN_LORDS
PROFILE = 'goel_full_jaimini_strength_ch1_v1'
SOURCE = 'V. P. Goel, Predicting through Jaimini Astrology'
KARAKA_POINTS = (60, 45, 30, 22.5, 15, 7.5, 3.75)
MALEFICS = ('Sun', 'Mars', 'Saturn', 'Rahu', 'Ketu')
def calculate_goel_strength(planet_longitudes, *, node_occupancy_policy='include_nodes_text'):
if node_occupancy_policy not in {'include_nodes_text', 'exclude_nodes_worked_example'}:
raise ValueError('explicit Goel node occupancy policy required')
_validate_positions(planet_longitudes)
signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
degrees = {p: planet_longitudes[p] % 30 for p in CLASSICAL}
ranked = sorted(CLASSICAL, key=degrees.get, reverse=True)
if any(math.isclose(degrees[a], degrees[b], rel_tol=0, abs_tol=1e-10)
for i, a in enumerate(ranked) for b in ranked[i + 1:]):
return dict(status='blocked', reason='equal_degree_seven_karaka_rank_requires_finer_positions', profile_id=PROFILE)
ak = ranked[0]
karakas = dict(zip(ranked, KARAKA_POINTS))
def contacts(sign, candidates):
return [p for p in candidates if signs[p] == sign or _rasi_aspects(signs[p], sign)]
planets = {}
for p in CLASSICAL:
sign = signs[p]
dignity, moola = _dignity(p, planet_longitudes[p])
kendra = (60, 30, 15)[(sign - signs[ak]) % 3]
before = [q for q, s in signs.items() if s == (sign - 1) % 12]
after = [q for q, s in signs.items() if s == (sign + 1) % 12]
kartari = 60 if sign % 2 == 0 and before and after else 0
malefic_contacts = contacts(sign, [q for q in MALEFICS if q != p])
adhipati = 60 if p != 'Moon' and malefic_contacts else 0
planets[p] = dict(sign_idx=sign, degree_in_sign=degrees[p],
dignity=dignity, mooltrikona=moola, karaka=karakas[p], kendriya=kendra,
kartari=kartari, adhipati=adhipati, malefic_contacts=malefic_contacts,
kartari_before=before, kartari_after=after,
total=moola + karakas[p] + kendra + kartari + adhipati)
rows = []
for sign in range(12):
lord = SIGN_LORDS[sign]
occupants = [p for p, s in signs.items() if s == sign]
permanent = (15, 30, 60)[sign % 3]
karaka = sum(karakas.get(p, 0) for p in occupants)
counted = occupants if node_occupancy_policy == 'include_nodes_text' else [p for p in occupants if p in CLASSICAL]
sthana = 45 + 15 * len(counted) if counted else 0
# Three roles, not distinct planets: Goel p103 explicitly explains
# double credit when Mercury/Jupiter is also the sign lord.
aspect_roles = [role for role, p in [('Jupiter', 'Jupiter'), ('Mercury', 'Mercury'), ('lord', lord)]
if contacts(sign, [p])]
aspect = 60 * len(aspect_roles)
rows.append(dict(sign_idx=sign, sign=SIGNS[sign], lord=lord,
permanent=permanent, karaka=karaka, sthana=sthana, occupants=occupants, counted_occupants=counted,
aspect=aspect, aspect_roles=aspect_roles, lord_strength=planets[lord]['total'],
total=permanent + karaka + sthana + aspect + planets[lord]['total']))
return dict(status='executed', confidence_status='parameter_sensitive', profile_id=PROFILE,
source=SOURCE, source_locator='PDF pp14-21; printed pp10-17; lines461-736',
dignity_profile=DIGNITY_PROFILE, malefic_profile='Sun_Mars_Saturn_Rahu_Ketu_excluding_self',
node_occupancy_policy=node_occupancy_policy,
aspect_profile='jaimini_rasi_drishti', aspect_role_policy='distinct_roles',
atmakaraka=ak, planets=planets, rashi_strengths=rows,
claim_boundary='Named Goel full method with declared dignity ranges; not PL9 or all-lineage parity.')
def build_goel_chart_evidence(planet_longitudes, ascendant_longitude):
if (type(ascendant_longitude) not in (int, float)
or not math.isfinite(ascendant_longitude)
or not 0 <= ascendant_longitude < 360):
raise ValueError('ascendant longitude must be a finite number in [0,360)')
strength = calculate_goel_strength(planet_longitudes)
result = dict(strength=strength, special_points={})
if strength['status'] != 'executed':
result['special_points'] = {
name: dict(status='blocked', reason=strength['reason'], profile_id=PROFILE)
for name in ('Brahma', 'Maheshwara', 'Rudra')
}
return result
strength['worked_example_node_exclusion'] = calculate_goel_strength(
planet_longitudes, node_occupancy_policy='exclude_nodes_worked_example')
strength['source_discrepancy'] = (
'Printed p14 includes nodes in Sthana; printed p15 worked totals omit them. '
'Both numeric alternatives are preserved; the explicit text is the active profile.')
lagna = int(float(ascendant_longitude) % 360 // 30)
seventh = (lagna + 6) % 12
planets = strength['planets']
rashis = strength['rashi_strengths']
ak_sign = planets[strength['atmakaraka']]['sign_idx']
def choose(candidates, rule):
candidates = list(dict.fromkeys(candidates))
scores = {p: planets[p]['total'] for p in candidates}
winners = [p for p in candidates if scores[p] == max(scores.values())]
return dict(status='executed' if len(winners) == 1 else 'blocked',
value=winners[0] if len(winners) == 1 else None,
candidates=scores, rule=rule,
reason=None if len(winners) == 1 else 'equal_Goel_strength_no_unique_selector',
profile_id=PROFILE, confidence_status='parameter_sensitive',
source=SOURCE, source_locator='PDF pp20-21; printed pp16-17; lines701-736')
eighth_ak = SIGN_LORDS[(ak_sign + 7) % 12]
if planets[eighth_ak]['sign_idx'] == (ak_sign + 7) % 12:
brahma = choose([eighth_ak], 'eighth_lord_from_AK_occupies_eighth_from_AK')
elif rashis[lagna]['total'] == rashis[seventh]['total']:
brahma = dict(status='blocked', reason='equal_Lagna_seventh_Goel_strength', profile_id=PROFILE)
else:
anchor = lagna if rashis[lagna]['total'] > rashis[seventh]['total'] else seventh
brahma = choose([SIGN_LORDS[(anchor + h - 1) % 12] for h in (6, 8, 12)
if SIGN_LORDS[(anchor + h - 1) % 12] != 'Saturn'],
'strongest_6_8_12_lord_from_stronger_Lagna_seventh_excluding_Saturn')
brahma['anchor_sign_idx'] = anchor
maheswara = choose([eighth_ak] + ([SIGN_LORDS[(ak_sign + 11) % 12]]
if planets[eighth_ak]['dignity'] == 'exalted' else []),
'eighth_lord_from_AK_compare_twelfth_if_exalted')
rudra_candidates = list(dict.fromkeys([SIGN_LORDS[(lagna + 7) % 12], SIGN_LORDS[(lagna + 1) % 12]]))
afflicted = [p for p in rudra_candidates if planets[p]['malefic_contacts']]
rudra = choose(afflicted if len(afflicted) == 1 else rudra_candidates,
'stronger_8th_2nd_lord_unless_only_weaker_has_malefic_contact')
result['special_points'] = dict(Brahma=brahma, Maheshwara=maheswara, Rudra=rudra)
result['lagna_seventh_strength'] = dict(lagna=rashis[lagna]['total'],
seventh=rashis[seventh]['total'], profile_id=PROFILE, source=SOURCE,
comparison='lagna_vs_seventh')
# Frozen-export compatibility only. PVR Niryaana is second/eighth Ayur
# comparison; callers must not route these Goel totals into that selector.
result['niryaana_shoola_strength'] = dict(result['lagna_seventh_strength'],
deprecated=True, replacement='lagna_seventh_strength',
not_for='pvr_niryaana_shoola_ayur_rasi_strength_v1')
return result
+39
View File
@@ -14339,6 +14339,39 @@ def cmd_aspects(args):
# ============================================================================
# 17. Jaimini系统(v3.7新增)
# ============================================================================
def _goel_jaimini_evidence(planet_longitudes, ascendant_longitude):
"""Shared CLI/full-reading view of the source-bounded Goel producer.
Failures stay inside this helper. A Goel error must not escape into the
full-reading ``except Exception`` that drops the whole jaimini module.
"""
try:
from scripts.goel_jaimini_strength import build_goel_chart_evidence
goel = build_goel_chart_evidence(planet_longitudes, ascendant_longitude)
fields = {
'goel_full_strength': goel['strength'],
'goel_special_points': goel['special_points'],
}
for key in ('lagna_seventh_strength', 'niryaana_shoola_strength'):
if key in goel:
fields[key] = goel[key]
return fields
except (ImportError, KeyError, TypeError, ValueError) as exc:
logger.warning(
"Goel Jaimini strength failed: %s: %s",
exc.__class__.__name__,
exc,
)
reason = f'{exc.__class__.__name__}: {exc}'
return {
'goel_full_strength': {'status': 'blocked', 'reason': reason},
'goel_special_points': {
name: {'status': 'blocked', 'reason': reason}
for name in ('Brahma', 'Maheshwara', 'Rudra')
},
}
def cmd_jaimini(args):
chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(args)
if chart is None:
@@ -14411,6 +14444,11 @@ def cmd_jaimini(args):
result['graha_padas'] = calc_graha_padas(planet_lons)
if mode in ('all', 'special'):
result['special_lagnas'] = calc_special_lagnas(asc_idx, args.hour, args.minute + _arg_second(args) / 60.0)
goel_lons = {}
for pn, pd in planets.items():
if isinstance(pd, dict) and 'degree' in pd:
goel_lons[pn] = pd.get('degree_raw', pd['degree'])
result.update(_goel_jaimini_evidence(goel_lons, asc_deg))
return _build_response_envelope('jaimini', result, args=args, execution_status='executed')
@@ -16283,6 +16321,7 @@ def cmd_full_reading(args):
except Exception as dk_e:
jaimini_result['darakaraka'] = {'error': str(dk_e)}
jaimini_result.update(_goel_jaimini_evidence(planet_lons, asc_deg))
report['modules']['jaimini'] = jaimini_result
except Exception as e:
report['errors'].append(f"jaimini: {e}")
@@ -225,6 +225,15 @@ def _scan_prose(text: str, language: str, section: str, *, opening: bool) -> lis
return failures
def _opens_with_title(text: str, title: str) -> bool:
heading = title.strip()
body = text.lstrip()
if not heading or not body.startswith(heading):
return False
rest = body[len(heading):]
return rest == "" or rest[:1].isspace() or rest.startswith(heading)
def validate_synastry_reader(
delivery: dict[str, Any],
section_inputs: dict[str, Any] | None = None,
@@ -254,6 +263,9 @@ def validate_synastry_reader(
failures.append(_fail(section_id, "missing_section", section_id))
continue
text = str(row.get("text") or "")
title = str(row.get("title") or "")
if _opens_with_title(text, title):
failures.append(_fail(section_id, "repeated_heading", title))
prose_characters += len(re.sub(r"\s+", "", text))
prose_words += len(re.findall(r"\b[\w']+\b", text))
minimum = ZH_SECTION_MINIMUM if language == "zh" else EN_SECTION_MINIMUM
+112
View File
@@ -0,0 +1,112 @@
"""Source-bounded Brahma selector for the Goel/Rao short-method profile.
This is intentionally explicit-input only. It implements the short method
described in the local Jaimini transcriptions: choose the stronger of Lagna
and the seventh sign, inspect the 6th/8th/12th lords from that anchor, exclude
Saturn, then rank the remaining candidates by declared strength and degree.
It does not implement the separate odd/back-rising or BPHS override clauses.
"""
from __future__ import annotations
from typing import Any
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
SIGN_LORDS = {
0: "Mars", 1: "Venus", 2: "Mercury", 3: "Moon",
4: "Sun", 5: "Mercury", 6: "Venus", 7: "Mars",
8: "Jupiter", 9: "Saturn", 10: "Saturn", 11: "Jupiter",
}
PROFILE_ID = "goel_rao_short_brahma_method_v1"
def _candidate(
*,
house: int,
anchor_sign_index: int,
planets: dict[str, dict[str, Any]],
strengths: dict[str, float],
) -> dict[str, Any]:
sign_index = (anchor_sign_index + house - 1) % 12
lord = SIGN_LORDS[sign_index]
planet = planets.get(lord) if isinstance(planets.get(lord), dict) else {}
degree = planet.get("degree_in_sign", planet.get("degree"))
return {
"source_house": house,
"source_sign_index": sign_index,
"source_sign": SIGNS[sign_index],
"planet": lord,
"planet_sign_index": planet.get("sign_index", planet.get("sign_idx")),
"planet_sign": planet.get("sign"),
"degree_in_sign": float(degree) if isinstance(degree, (int, float)) else None,
"declared_strength": strengths.get(lord),
"saturn_excluded": lord == "Saturn",
}
def select_brahma_goel_short_method(
*,
lagna_sign_index: int,
planets: dict[str, dict[str, Any]],
planet_strengths: dict[str, float],
lagna_strength: float,
seventh_strength: float,
) -> dict[str, Any]:
"""Select Brahma under the explicit Goel/Rao short-method profile."""
if not 0 <= int(lagna_sign_index) < 12:
return {"status": "blocked", "reason": "lagna_sign_index must be 0..11", "candidates": []}
if not isinstance(planets, dict) or not isinstance(planet_strengths, dict):
return {"status": "blocked", "reason": "planets and planet_strengths are required", "candidates": []}
if not isinstance(lagna_strength, (int, float)) or not isinstance(seventh_strength, (int, float)):
return {"status": "blocked", "reason": "lagna_strength and seventh_strength are required", "candidates": []}
anchor = int(lagna_sign_index) if float(lagna_strength) >= float(seventh_strength) else (int(lagna_sign_index) + 6) % 12
candidates = [
_candidate(house=house, anchor_sign_index=anchor, planets=planets, strengths=planet_strengths)
for house in (6, 8, 12)
]
eligible = [row for row in candidates if not row["saturn_excluded"] and row["declared_strength"] is not None]
if not eligible:
return {
"profile_id": PROFILE_ID,
"status": "blocked",
"confidence_status": "parameter_sensitive",
"reason": "No non-Saturn candidate has a declared strength.",
"anchor": {"sign_index": anchor, "sign": SIGNS[anchor], "selected_by": "lagna_vs_seventh_strength"},
"candidates": candidates,
}
eligible.sort(
key=lambda row: (
float(row["declared_strength"]),
float(row["degree_in_sign"]) if row["degree_in_sign"] is not None else -1.0,
),
reverse=True,
)
selected = eligible[0]
return {
"profile_id": PROFILE_ID,
"status": "executed",
"confidence_status": "parameter_sensitive",
"verification_status": "source_bounded_explicit_input",
"anchor": {
"sign_index": anchor,
"sign": SIGNS[anchor],
"selected_by": "stronger_of_lagna_and_seventh",
"lagna_strength": float(lagna_strength),
"seventh_strength": float(seventh_strength),
},
"candidates": candidates,
"selected": selected,
"source_rule": (
"Goel/Rao short method: strongest of 6th, 8th, and 12th lords from "
"the stronger Lagna/7th anchor, excluding Saturn."
),
"unresolved": [
"odd_sign_and_back_rising_filter_not_applied",
"BPHS_verse_172_karaka_and_saturn_node_override_not_applied",
"strength_source_is_declared_input_not_recomputed_here",
],
}
+166
View File
@@ -0,0 +1,166 @@
"""Separate Trikona profiles: Raghava Bhatta MD and Raman confirmed-parent AD."""
import argparse
from copy import deepcopy
from datetime import datetime, timedelta, timezone
import json
import math
from pathlib import Path
import sys
if __package__ in (None, ''):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.narayana_dasha import SIGNS, SIGN_LORDS, _NARAYANA_DIGNITY
PROFILE = 'trikona_raghava_bhatta_explicit_seed_md_v1'
SOURCE = 'https://saptarishisastrology.com/trikona-dasa-a-critical-review-by-iranganti-rangacharya/'
RAMAN_AD_PROFILE = 'trikona_raman_94_confirmed_full_md_ad_v1'
RAMAN_SOURCE = 'https://www.scribd.com/doc/127930441/Studies-in-Jaimini-Astrology-by-b-v-Raman'
def build_trikona_raman_antardasa_report(*, md_sign_idx, full_md_years,
full_md_start, parent_source, parent_profile, year_days, profile):
"""Raman section94 AD only; a confirmed, untruncated parent is input."""
if profile != RAMAN_AD_PROFILE or parent_profile != 'raman_studies_jaimini_92_93':
raise ValueError('explicit Raman AD and Raman sections92/93 parent profiles required')
if type(md_sign_idx) is not int or not 0 <= md_sign_idx < 12:
raise ValueError('md_sign_idx must be integer 0..11')
if not isinstance(parent_source, str) or not parent_source.strip():
raise ValueError('parent_source must identify the confirmed full MD, not birth balance')
for name, value, low, high in [('full_md_years', full_md_years, 0, 12),
('year_days', year_days, 300, 400)]:
if (isinstance(value, bool) or not isinstance(value, (int, float)) or
not math.isfinite(value) or not low <= value <= high or value == 0):
raise ValueError(f'{name} outside supported finite range')
if not isinstance(full_md_start, str):
raise ValueError('full_md_start must be aware ISO datetime')
anchor = datetime.fromisoformat(full_md_start.replace('Z', '+00:00'))
if anchor.tzinfo is None or anchor.utcoffset() is None:
raise ValueError('full_md_start must include UTC offset')
anchor = anchor.astimezone(timezone.utc)
# Raman section155 specifies these groups; neither parity nor ninth-from-parent.
direction = 1 if md_sign_idx in (0, 1, 2, 6, 7, 8) else -1
offsets = [group + trine for group in range(4) for trine in (0, 4, 8)]
try:
edges = [(anchor + timedelta(days=full_md_years * year_days * i / 12)).isoformat()
for i in range(13)]
except OverflowError as exc:
raise ValueError('calendar boundary outside supported datetime range') from exc
periods = [dict(sign_idx=(md_sign_idx + direction * offset) % 12,
sign=SIGNS[(md_sign_idx + direction * offset) % 12],
years=full_md_years / 12, start_utc=edges[i], end_utc=edges[i + 1],
start_age=full_md_years * i / 12, end_age=full_md_years * (i + 1) / 12)
for i, offset in enumerate(offsets)]
result = dict(status='parameter_sensitive', variant_profile_id=profile,
tradition_profile='bv_raman_studies_in_jaimini_astrology', source=RAMAN_SOURCE,
source_locator='section94 pp61-62; Savya groups section155 p127',
parent=dict(sign_idx=md_sign_idx, sign=SIGNS[md_sign_idx], years=full_md_years,
start_utc=edges[0], end_utc=edges[-1], source=parent_source,
profile=parent_profile, status='supplied_confirmed_full_parent'),
year_days=year_days, direction=direction, periods=periods,
child_periods=dict(level='AD', status='executed', count=12),
pratyantardasa=dict(status='blocked', reason='section94_does_not_establish_PD'),
limits=['Parent seed, duration and full start supplied, not calculated or independently verified',
'No birth-balance clipping; do not supply a residual MD as a full parent',
'Fixed year_days is explicit caller convention, not a source date oracle',
'Raman AD only; never attach to Raghava Bhatta or PyJHora parents automatically',
'No PD, event inference or default customer export'])
lines = ['# Trikona — Raman confirmed full MD / AD', '', f'Profile: {profile}',
f'Source: {RAMAN_SOURCE} — {result["source_locator"]}', '',
'| AD | Years | Start UTC | End UTC |', '| --- | --- | --- | --- |']
lines += [f"| {p['sign']} | {p['years']} | {p['start_utc']} | {p['end_utc']} |" for p in periods]
lines += ['', *result['limits'], '', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
return dict(status=result['status'], customer_export_admitted=False,
evidence_appendix=[result], exports=dict(markdown='\n'.join(lines),
ai_evidence_bundle=deepcopy(result)))
def build_trikona_native_report(*, lagna_sign_idx, seed_sign_idx, seed_source,
planet_longitudes, epoch, year_days, profile):
if profile != PROFILE:
raise ValueError('explicit Raghava Bhatta profile required')
if any(type(v) is not int or not 0 <= v < 12 for v in (lagna_sign_idx, seed_sign_idx)):
raise ValueError('sign indices must be integers 0..11')
if (seed_sign_idx - lagna_sign_idx) % 12 not in (0, 4, 8):
raise ValueError('seed must be Lagna, fifth or ninth')
if not isinstance(seed_source, str) or not seed_source.strip():
raise ValueError('seed_source must explain the supplied strength decision')
required = set(SIGN_LORDS.values())
if not isinstance(planet_longitudes, dict) or set(planet_longitudes) != required:
raise ValueError('exactly seven classical planetary longitudes required; no node co-lords')
if any(isinstance(v, bool) or not isinstance(v, (int, float)) or
not math.isfinite(v) or not 0 <= v < 360 for v in planet_longitudes.values()):
raise ValueError('planetary longitudes must be finite in [0,360)')
if isinstance(year_days, bool) or not isinstance(year_days, (int, float)) or not math.isfinite(year_days) or not 300 <= year_days <= 400:
raise ValueError('explicit year_days 300..400 required')
if not isinstance(epoch, str):
raise ValueError('aware ISO epoch required')
anchor = datetime.fromisoformat(epoch.replace('Z', '+00:00'))
if anchor.tzinfo is None or anchor.utcoffset() is None:
raise ValueError('epoch must include UTC offset')
anchor = anchor.astimezone(timezone.utc)
direction = 1 if seed_sign_idx % 2 == 0 else -1
if seed_sign_idx % 3 == 0:
offsets = list(range(12))
elif seed_sign_idx % 3 == 1:
offsets = [5 * i for i in range(12)]
else:
offsets = [group + trine for group in range(4) for trine in (0, 4, 8)]
periods = []
age = 0
for offset in offsets:
sign = (seed_sign_idx + direction * offset) % 12
lord = SIGN_LORDS[SIGNS[sign]]
lord_sign = int(planet_longitudes[lord] // 30)
count_direction = 1 if sign % 2 == 0 else -1
count = ((lord_sign - sign) * count_direction) % 12 + 1
dignity = _NARAYANA_DIGNITY[lord]
adjustment = int(lord_sign in dignity['exalted']) - int(lord_sign in dignity['debilitated'])
years = 12 if lord_sign == sign else min(12, count + adjustment)
end = age + years
try:
start_utc = (anchor + timedelta(days=age * year_days)).isoformat()
end_utc = (anchor + timedelta(days=end * year_days)).isoformat()
except OverflowError as exc:
raise ValueError('calendar boundary outside supported datetime range') from exc
periods.append(dict(sign_idx=sign, sign=SIGNS[sign], lord=lord,
lord_sign_idx=lord_sign, inclusive_count=count, dignity_adjustment=adjustment,
years=years, start_age=age, end_age=end, start_utc=start_utc, end_utc=end_utc))
age = end
result = dict(status='parameter_sensitive', variant_profile_id=PROFILE,
tradition_profile='raghava_bhatta_as_presented_by_rangacharya', source=SOURCE,
lagna_sign_idx=lagna_sign_idx, seed_sign_idx=seed_sign_idx,
seed_selection=dict(status='supplied_not_calculated', source=seed_source),
epoch_utc=anchor.isoformat(), year_days=year_days, periods=periods,
child_periods=dict(status='blocked', reason='method_specific_AD_PD_rules_not_established'),
limits=['Supplied strongest trine; strength selection is not calculated',
'Full first MD begins at explicit epoch; no fractional birth balance inferred',
'Fixed-day calendar is caller convention, not verified source dates',
'First cycle only; no automatic customer export or event certainty'])
lines = ['# Trikona — Raghava Bhatta / Rangacharya method 2', '',
'Status: parameter_sensitive', f'Source: {SOURCE}', f'Profile: {PROFILE}', '',
'| Sign | Years | Start UTC | End UTC |', '| --- | --- | --- | --- |']
lines += [f"| {p['sign']} | {p['years']} | {p['start_utc']} | {p['end_utc']} |" for p in periods]
lines += ['', *result['limits'], 'AD/PD: blocked — method-specific rules not established',
'', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
return dict(status=result['status'], customer_export_admitted=False,
evidence_appendix=[result], exports=dict(markdown='\n'.join(lines), ai_evidence_bundle=deepcopy(result)))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--request', type=Path, required=True)
parser.add_argument('--mode', choices=('method2-md', 'raman-confirmed-md-ad', 'raman-native'), default='method2-md')
parser.add_argument('--format', choices=('json', 'markdown'), default='json')
args = parser.parse_args()
builder = (build_trikona_raman_antardasa_report if args.mode == 'raman-confirmed-md-ad'
else build_trikona_native_report)
if args.mode == 'raman-native':
from scripts.trikona_raman import build_trikona_raman_report
builder = build_trikona_raman_report
report = builder(**json.loads(args.request.read_text()))
print(report['exports']['markdown'] if args.format == 'markdown' else json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
+233
View File
@@ -0,0 +1,233 @@
"""Raman's numerical Rasi strength and Trikona MD/AD, explicit conventions.
Studies in Jaimini Astrology sections62-72,79-86,92-94. This is Raman's
adaptation, not a universal Jaimini comparator or the Raghava Bhatta method.
"""
from copy import deepcopy
from datetime import datetime, timedelta, timezone
import json
import math
from scripts.jaimini import FRIENDSHIP
from scripts.narayana_dasha import SIGNS, SIGN_LORDS, _NARAYANA_DIGNITY, _rasi_aspects
from scripts.trikona_dasha import RAMAN_AD_PROFILE, RAMAN_SOURCE, build_trikona_raman_antardasa_report
PROFILE = 'trikona_raman_numeric_seed_md_ad_candidate_v1'
STRENGTH_PROFILE = 'raman_studies_62_72_numeric_v1'
DIGNITY_PROFILE = 'raman_manual_degree_mt_exaltation_first_v1'
CLASSICAL = ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn')
SAVYA = {0, 1, 2, 6, 7, 8}
AMSA = (60, 45, 30, 22.5, 15, 7.5, 3.75)
# Raman, A Manual of Hindu Astrology, section29 printed p12. These differ
# from the repository Shadbala table; never silently substitute that table.
MT = {'Sun': (4, 0, 20), 'Moon': (1, 4, 30), 'Mars': (0, 0, 18),
'Mercury': (5, 16, 20), 'Jupiter': (8, 0, 13), 'Venus': (6, 0, 10),
'Saturn': (10, 0, 20)}
def _validate_positions(positions):
if not isinstance(positions, dict) or set(positions) != set(CLASSICAL) | {'Rahu', 'Ketu'}:
raise ValueError('exactly seven classical planets plus Rahu and Ketu required')
if any(type(v) not in (int, float) or not math.isfinite(v) or not 0 <= v < 360
for v in positions.values()):
raise ValueError('longitudes must be finite numbers in [0,360)')
if not math.isclose((positions['Rahu'] - positions['Ketu']) % 360, 180, abs_tol=1e-7, rel_tol=0):
raise ValueError('Rahu and Ketu must be opposite in the same position profile')
def _dignity(planet, longitude):
sign, degree = int(longitude // 30), longitude % 30
if sign in _NARAYANA_DIGNITY[planet]['exalted']:
return 'exalted', 60
if sign in _NARAYANA_DIGNITY[planet]['debilitated']:
return 'debilitated', 3.75
mt_sign, lo, hi = MT[planet]
if sign == mt_sign and lo <= degree < hi:
return 'moolatrikona', 45
owner = SIGN_LORDS[SIGNS[sign]]
if owner == planet:
return 'own', 30
if owner in FRIENDSHIP[planet]['friend']:
return 'friend', 22.5
if owner in FRIENDSHIP[planet]['enemy']:
return 'enemy', 7.5
return 'neutral', 15
def calculate_raman_rasi_strength(*, planet_longitudes, strength_profile,
dignity_profile, druk_role_policy):
if strength_profile != STRENGTH_PROFILE or dignity_profile != DIGNITY_PROFILE:
raise ValueError('explicit Raman strength and dignity profiles required')
if druk_role_policy not in ('distinct_planets', 'distinct_roles'):
raise ValueError('druk_role_policy must be distinct_planets or distinct_roles')
_validate_positions(planet_longitudes)
signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
degrees = {p: planet_longitudes[p] % 30 for p in CLASSICAL}
ranked = sorted(CLASSICAL, key=degrees.get, reverse=True)
ties = [[p for p in CLASSICAL if degrees[p] == value] for value in set(degrees.values())]
ties = sorted((group for group in ties if len(group) > 1), key=lambda group: degrees[group[0]], reverse=True)
result = dict(profile=strength_profile, dignity_profile=dignity_profile,
druk_role_policy=druk_role_policy, source=RAMAN_SOURCE, locator='sections62-72',
status='blocked' if ties else 'executed', rank_ties=ties, planet_strengths={}, rasi_strengths=[])
# Section65 does not specify equal-rank scores. Preserve own-sign strengths
# below, but never fabricate a total/rank/Atmakaraka when a degree tie exists.
ak = None if ties else ranked[0]
result['atmakaraka'] = ak
if not ties:
for rank, planet in enumerate(ranked):
label, dignity = _dignity(planet, planet_longitudes[planet])
kendra = (60, 30, 15)[(signs[planet] - signs[ak]) % 3]
result['planet_strengths'][planet] = dict(dignity_label=label,
moolatrikona_bala=dignity, amsa_bala=AMSA[rank], kendra_bala=kendra,
total=dignity + AMSA[rank] + kendra)
for sign in range(12):
lord = SIGN_LORDS[SIGNS[sign]]
role_planets = ['Jupiter', 'Mercury', lord]
if druk_role_policy == 'distinct_planets':
role_planets = list(dict.fromkeys(role_planets))
hits = [p for p in role_planets if signs[p] == sign or _rasi_aspects(signs[p], sign)]
occupants = [p for p, position in signs.items() if position == sign]
chara = (15, 30, 60)[sign % 3]
sthira = 45 + 15 * len(occupants) if occupants else 0
own = chara + 60 * len(hits) + sthira
special = 60 if ak and signs[ak] == sign else (None if ties else 0)
lord_strength = None if ties else result['planet_strengths'][lord]['total']
result['rasi_strengths'].append(dict(sign_idx=sign, sign=SIGNS[sign], lord=lord,
chara_bala=chara, druk_bala=60 * len(hits), druk_contributors=hits,
sthira_bala=sthira, occupants=occupants, own_strength=own,
lord_strength=lord_strength, special_strength=special,
total=None if ties else own + lord_strength + special))
if ties:
result['reason'] = 'equal_degree_AMSA_rank_rule_not_established; provide finer positions or a sourced tie profile'
return result
def _duration(sign, signs, *, node_dignity_profile):
"""Sections79-86, retaining the selected lord and decision trace."""
direction = 1 if sign in SAVYA else -1
classical = SIGN_LORDS[SIGNS[sign]]
lords = [classical] + ({7: ['Ketu'], 10: ['Rahu']}.get(sign, []))
base = {p: ((signs[p] - sign) * direction) % 12 or 12 for p in lords}
adjustment = {p: (int(signs[p] in _NARAYANA_DIGNITY[p]['exalted']) -
int(signs[p] in _NARAYANA_DIGNITY[p]['debilitated']))
if p in CLASSICAL else 0 for p in lords}
trace = ['section79_savya_count_minus_one']
selected = lords[:]
if len(lords) == 2:
if signs[lords[0]] == signs[lords[1]]:
trace.append('section82_same_own' if signs[lords[0]] == sign else 'sections83_84_conjunction')
# Conjunction outside own sign explicitly refers to section79 only.
years = base[lords[0]]
return dict(status='executed', years=years, lords=selected, trace=trace,
count_direction=direction, candidates=base, node_dignity_profile=node_dignity_profile)
away = [p for p in lords if signs[p] != sign]
if len(away) == 1:
selected = away
trace.append('section85_prefer_lord_away_from_own')
else:
associates = {p: sum(s == signs[p] for name, s in signs.items() if name != p) for p in lords}
selected = [p for p in selected if associates[p] == max(associates.values())]
trace.append('section86_associate_count')
if len(selected) > 1:
modes = {p: signs[p] % 3 for p in selected}
selected = [p for p in selected if modes[p] == max(modes.values())]
trace.append('section86_dual_fixed_movable')
if len(selected) > 1:
selected = [p for p in selected if base[p] == max(base[q] for q in selected)]
trace.append('section86_larger_span')
if classical in selected and adjustment[classical] == -1:
return dict(status='blocked', reason='section86_over_debilitated_and_node_exaltation_exception_unresolved',
lords=selected, candidates=base, trace=trace)
years_by_lord = {p: (12 if signs[p] == sign else base[p] + adjustment[p]) for p in selected}
if len(set(years_by_lord.values())) != 1:
return dict(status='blocked', reason='equal_colord_rank_with_different_adjusted_years',
lords=selected, candidates=years_by_lord, trace=trace)
years = next(iter(years_by_lord.values()))
return dict(status='executed' if years > 0 else 'blocked', years=years,
reason=None if years > 0 else 'nonpositive_adjusted_span_no_clipping_rule',
lords=selected, trace=trace, candidates=base, dignity_adjustments=adjustment,
count_direction=direction, node_dignity_profile=node_dignity_profile)
def build_trikona_raman_report(*, lagna_sign_idx, planet_longitudes, epoch, year_days,
profile, strength_profile, dignity_profile, druk_role_policy, node_dignity_profile):
if profile != PROFILE:
raise ValueError('explicit Raman native profile required')
if type(lagna_sign_idx) is not int or not 0 <= lagna_sign_idx < 12:
raise ValueError('lagna_sign_idx must be integer 0..11')
if node_dignity_profile != 'unassigned_no_adjustment':
raise ValueError('node_dignity_profile must explicitly select unassigned_no_adjustment')
if type(year_days) not in (int, float) or not math.isfinite(year_days) or not 300 <= year_days <= 400:
raise ValueError('year_days must be finite in 300..400')
if not isinstance(epoch, str):
raise ValueError('aware ISO epoch required')
anchor = datetime.fromisoformat(epoch.replace('Z', '+00:00'))
if anchor.tzinfo is None or anchor.utcoffset() is None:
raise ValueError('epoch must include UTC offset')
anchor = anchor.astimezone(timezone.utc)
strength = calculate_raman_rasi_strength(planet_longitudes=planet_longitudes,
strength_profile=strength_profile, dignity_profile=dignity_profile, druk_role_policy=druk_role_policy)
signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
trines = [(lagna_sign_idx + offset) % 12 for offset in (0, 4, 8)]
candidates = [strength['rasi_strengths'][s] for s in trines]
seeds = [] if strength['status'] == 'blocked' else [r['sign_idx'] for r in candidates
if r['total'] == max(c['total'] for c in candidates)]
durations = [dict(sign_idx=s, sign=SIGNS[s], **_duration(s, signs,
node_dignity_profile=node_dignity_profile)) for s in range(12)]
branches = []
for seed in seeds:
age = 0
dated = True
periods = []
direction = 1 if seed in SAVYA else -1
for offset in [group + trine for group in range(4) for trine in (0, 4, 8)]:
sign = (seed + direction * offset) % 12
row = deepcopy(durations[sign])
if row['status'] != 'executed':
dated = False
if dated:
try:
start = (anchor + timedelta(days=age * year_days)).isoformat()
end = (anchor + timedelta(days=(age + row['years']) * year_days)).isoformat()
except OverflowError as exc:
raise ValueError('calendar boundary outside supported datetime range') from exc
row.update(start_age=age, end_age=age + row['years'], start_utc=start, end_utc=end,
calendar_status='calculated_with_caller_fixed_year')
child = build_trikona_raman_antardasa_report(md_sign_idx=sign,
full_md_years=row['years'], full_md_start=start,
parent_source='Native Raman sections79-86/92-93; see duration trace',
parent_profile='raman_studies_jaimini_92_93', year_days=year_days, profile=RAMAN_AD_PROFILE)
row['antardasas'] = child['evidence_appendix'][0]['periods']
# Parent and child formulas can round sub-microsecond floating
# days differently; share the exact already-calculated edge.
row['antardasas'][-1]['end_utc'] = end
age += row['years']
else:
row.update(calendar_status='blocked_by_unresolved_prior_or_current_duration', antardasas=[])
periods.append(row)
branches.append(dict(seed_sign_idx=seed, direction=direction,
status='parameter_sensitive' if dated else 'partial', periods=periods))
result = dict(status='blocked' if not seeds else 'parameter_sensitive', variant_profile_id=profile,
tradition_profile='bv_raman_studies_in_jaimini_astrology', source=RAMAN_SOURCE,
strength=strength, seed_selection=dict(status='blocked' if not seeds else 'parallel_tie' if len(seeds)>1 else 'calculated',
candidates=candidates, selected_signs=seeds), duration_evidence=durations, branches=branches,
epoch_utc=anchor.isoformat(), year_days=year_days, node_dignity_profile=node_dignity_profile,
pratyantardasa=dict(status='blocked', reason='Raman_section94_does_not_establish_PD'),
limits=['Raman numerical adaptation, not universal Jaimini strength',
'Druk role deduplication is explicit; source does not resolve duplicate roles',
'Degree MT follows Raman Manual section29; exaltation checked first by explicit profile',
'Node dignity left unassigned by caller convention, not a universal no-exaltation doctrine',
'Full first MD starts at caller epoch; no fractional birth-balance inferred',
'Fixed year_days not source-date verified; first cycle only; no customer admission'])
lines = ['# Trikona — Raman numerical seed / MD / AD', '', f'Profile: {profile}',
f'Source: {RAMAN_SOURCE}', '', '## Strength and seed',
'| Trine sign | Strength |', '| --- | --- |']
lines += [f"| {r['sign']} | {r['total']} |" for r in candidates]
for branch in branches:
lines += ['', f"## Seed {SIGNS[branch['seed_sign_idx']]}",
'| MD | Years | Start UTC | End UTC |', '| --- | --- | --- | --- |']
lines += [f"| {r['sign']} | {r.get('years', 'blocked')} | {r.get('start_utc', 'blocked')} | {r.get('end_utc', 'blocked')} |"
for r in branch['periods']]
lines += ['', *result['limits'], '', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
return dict(status=result['status'], customer_export_admitted=False, evidence_appendix=[result],
exports=dict(markdown='\n'.join(lines), ai_evidence_bundle=deepcopy(result)))
+4
View File
@@ -1643,6 +1643,10 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
# 新值: 中间加入 synastry
# 原因: 婚恋合盘页 app/(app)/synastry 进入页面扫描(issue 82,2026-10-19)
'synastry',
# 原值: synastry 后面直接是 terms
# 新值: 加入 synastry/[reportId]
# 原因: issue #85 T7 合盘报告独立地址
'synastry/[reportId]',
'terms',
]
# 原值: 期望集合含 'Case Validation'
+9
View File
@@ -63,3 +63,12 @@ def test_varga_cli_d9_uses_same_navamsa_mapping_as_varga_full() -> None:
assert short["ascendant"] == full["Ascendant"]["sign"]
for planet in ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]:
assert short[planet]["sign"] == full[planet]["sign"]
def test_full_reading_exposes_goel_jaimini_strength() -> None:
report = _run_engine("full-reading", *BASE_BIRTH_ARGS)
goel = report["modules"]["jaimini"]
assert goel["goel_full_strength"]["status"] == "executed"
assert len(goel["goel_full_strength"]["rashi_strengths"]) == 12
assert set(goel["goel_special_points"]) == {"Brahma", "Maheshwara", "Rudra"}
assert goel["niryaana_shoola_strength"]["deprecated"] is True
+209
View File
@@ -0,0 +1,209 @@
"""Hand-checked synthetic positions, plus Goel's printed p15 worked table.
No private birth data; these fixtures exercise supplied-position arithmetic.
"""
from copy import deepcopy
import importlib
import importlib.util
import math
import pytest
SYNTHETIC = dict(Sun=29., Moon=58., Mars=87., Mercury=116.,
Jupiter=145., Venus=174., Saturn=203., Rahu=245., Ketu=65.)
def module():
assert importlib.util.find_spec('scripts.goel_jaimini_strength'), 'Goel producer must be published'
return importlib.import_module('scripts.goel_jaimini_strength')
def test_synthetic_five_component_totals_and_special_points():
original = deepcopy(SYNTHETIC)
result = module().build_goel_chart_evidence(SYNTHETIC, 0.)
strength = result['strength']
assert strength['atmakaraka'] == 'Sun'
assert {p: row['total'] for p, row in strength['planets'].items()} == dict(
Sun=180, Moon=135, Mars=172.5, Mercury=90, Jupiter=187.5,
Venus=86.25, Saturn=123.75)
assert {p: row['value'] for p, row in result['special_points'].items()} == dict(
Brahma='Jupiter', Maheshwara='Mars', Rudra='Mars')
assert strength['rashi_strengths'][0]['total'] == 367.5
assert strength['rashi_strengths'][6]['total'] == 225
assert SYNTHETIC == original
def test_node_policy_only_changes_sthana_and_sign_total():
m = module()
text = m.calculate_goel_strength(SYNTHETIC)
example = m.calculate_goel_strength(SYNTHETIC, node_occupancy_policy='exclude_nodes_worked_example')
assert text['planets'] == example['planets']
# Gemini has Mars+Ketu; Sagittarius has only Rahu.
assert (text['rashi_strengths'][2]['sthana'], example['rashi_strengths'][2]['sthana']) == (75, 60)
assert (text['rashi_strengths'][8]['sthana'], example['rashi_strengths'][8]['sthana']) == (60, 0)
assert text['rashi_strengths'][2]['total'] - example['rashi_strengths'][2]['total'] == 15
assert text['rashi_strengths'][8]['total'] - example['rashi_strengths'][8]['total'] == 60
def test_goel_printed_p15_numeric_example_without_birth_identity():
positions = dict(Sun=20+18/60, Moon=210+20+15/60, Mars=210+23+2/60,
Mercury=30+11+12/60, Jupiter=150+3+13/60,
Venus=330+17+28/60, Saturn=7+7/60,
Rahu=330+5+58/60, Ketu=150+5+58/60)
result = module().calculate_goel_strength(positions, node_occupancy_policy='exclude_nodes_worked_example')
assert {p: r['total'] for p, r in result['planets'].items()} == dict(
Sun=240, Moon=93.75, Mars=210, Mercury=97.5, Jupiter=101.25,
Venus=172.5, Saturn=146.25)
assert [r['total'] for r in result['rashi_strengths']] == [
412.5, 337.5, 217.5, 228.75, 330, 281.25, 247.5, 465,
281.25, 221.25, 236.25, 363.75]
def test_equal_karaka_degrees_block_all_special_point_choices():
result = module().build_goel_chart_evidence(dict(SYNTHETIC, Moon=59.), 0.)
assert result['strength']['status'] == 'blocked'
assert all(result['special_points'][p]['status'] == 'blocked'
for p in ('Brahma', 'Maheshwara', 'Rudra'))
@pytest.mark.parametrize('bad', [math.nan, math.inf, -1., 360., True, '0'])
def test_invalid_ascendant_rejected(bad):
with pytest.raises(ValueError):
module().build_goel_chart_evidence(SYNTHETIC, bad)
def test_legacy_lagna_seventh_alias_does_not_replace_pvr_second_eighth():
result = module().build_goel_chart_evidence(SYNTHETIC, 0.)
legacy = result['niryaana_shoola_strength']
assert (legacy['lagna'], legacy['seventh']) == (367.5, 225)
assert legacy['deprecated'] is True
assert legacy['comparison'] == 'lagna_vs_seventh'
from scripts.rashi_dashas_native import select_niryaana_shoola_start_sign
pvr = select_niryaana_shoola_start_sign(ascendant_longitude=0., planet_longitudes=SYNTHETIC)
assert pvr['selected_sign_idx'] == 1
assert pvr['profile'] == 'pvr_niryaana_shoola_ayur_rasi_strength_v1'
def test_brahma_ak_eighth_occupancy_overrides_lagna_selector():
result = module().build_goel_chart_evidence(dict(SYNTHETIC, Sun=239., Mercury=86.), 0.)
assert result['special_points']['Brahma']['value'] == 'Mercury'
assert result['special_points']['Brahma']['rule'] == 'eighth_lord_from_AK_occupies_eighth_from_AK'
def test_exalted_ak_eighth_lord_compares_twelfth_for_maheshwara():
result = module().build_goel_chart_evidence(dict(SYNTHETIC, Mars=297.), 0.)
assert result['special_points']['Maheshwara']['value'] == 'Jupiter'
assert result['special_points']['Maheshwara']['candidates'] == {'Mars': 150, 'Jupiter': 187.5}
def test_rudra_weaker_malefic_contact_exception():
result = module().build_goel_chart_evidence(dict(SYNTHETIC, Sun=329., Rahu=125., Ketu=305.), 0.)
planets = result['strength']['planets']
assert planets['Mars']['total'] == 127.5
assert planets['Mars']['malefic_contacts'] == []
assert planets['Venus']['total'] == 101.25
assert planets['Venus']['malefic_contacts'] == ['Mars']
assert result['special_points']['Rudra']['value'] == 'Venus'
def test_incomplete_chart_entry_preserves_blocked_points():
from scripts.jyotish_engine import _goel_jaimini_evidence
result = _goel_jaimini_evidence({}, 0.)
assert result['goel_full_strength']['status'] == 'blocked'
assert all(r['status'] == 'blocked' for r in result['goel_special_points'].values())
assert 'niryaana_shoola_strength' not in result
@pytest.mark.parametrize('bad', [dict(SYNTHETIC, Sun=math.nan),
dict(SYNTHETIC, Ketu=66.), {p: v for p, v in SYNTHETIC.items() if p != 'Saturn'}])
def test_incomplete_or_inconsistent_positions_rejected(bad):
with pytest.raises(ValueError):
module().calculate_goel_strength(bad)
def test_jaimini_special_entry_exposes_real_goel_results(monkeypatch):
from types import SimpleNamespace
from scripts import jyotish_engine as engine
chart = {'planets': {p: {'degree': v} for p, v in SYNTHETIC.items()},
'ascendant': {'degree': 0.}}
monkeypatch.setattr(engine, '_compute_chart_from_args', lambda args: (chart, 0, 2451545., 24.))
result = engine.cmd_jaimini(SimpleNamespace(mode='special', hour=12, minute=0))
assert result.get('goel_full_strength', {}).get('atmakaraka') == 'Sun'
assert result['goel_special_points']['Brahma']['value'] == 'Jupiter'
assert result['niryaana_shoola_strength']['deprecated'] is True
def test_wired_special_entry_keeps_both_node_policies(monkeypatch):
from types import SimpleNamespace
from scripts import jyotish_engine as engine
chart = {'planets': {p: {'degree': round(v, 4), 'degree_raw': v} for p, v in SYNTHETIC.items()},
'ascendant': {'degree': 0., 'lon': 0.}}
chart['planets']['Sun']['degree'] = 1.0
monkeypatch.setattr(engine, '_compute_chart_from_args', lambda args: (chart, 0, 2451545., 24.))
result = engine.cmd_jaimini(SimpleNamespace(mode='special', hour=12, minute=0))
strength = result['goel_full_strength']
assert strength['status'] == 'executed'
assert strength['node_occupancy_policy'] == 'include_nodes_text'
exclusion = strength['worked_example_node_exclusion']
assert exclusion['node_occupancy_policy'] == 'exclude_nodes_worked_example'
assert strength['atmakaraka'] == 'Sun'
assert abs(strength['planets']['Sun']['degree_in_sign'] - (SYNTHETIC['Sun'] % 30)) <= 1e-6
def test_wired_equal_degrees_block_without_atmakaraka_or_rank(monkeypatch):
from types import SimpleNamespace
from scripts import jyotish_engine as engine
positions = dict(SYNTHETIC, Moon=59.)
chart = {'planets': {p: {'degree': v} for p, v in positions.items()},
'ascendant': {'degree': 0.}}
monkeypatch.setattr(engine, '_compute_chart_from_args', lambda args: (chart, 0, 2451545., 24.))
result = engine.cmd_jaimini(SimpleNamespace(mode='special', hour=12, minute=0))
strength = result['goel_full_strength']
assert strength['status'] == 'blocked'
assert strength['reason'] == 'equal_degree_seven_karaka_rank_requires_finer_positions'
assert 'atmakaraka' not in strength
assert 'planets' not in strength
for name in ('Brahma', 'Maheshwara', 'Rudra'):
point = result['goel_special_points'][name]
assert point['status'] == 'blocked'
assert 'value' not in point
assert 'candidates' not in point
def test_full_reading_feeds_goel_degree_raw(monkeypatch):
import scripts.goel_jaimini_strength as goel
from scripts.jyotish_engine import cmd_full_reading
captured = []
real = goel.build_goel_chart_evidence
def wrapped(longitudes, ascendant):
captured.append(dict(longitudes))
return real(longitudes, ascendant)
monkeypatch.setattr(goel, 'build_goel_chart_evidence', wrapped)
report = cmd_full_reading(type('Args', (), {
'name': 'fictional',
'year': 1990, 'month': 1, 'day': 1, 'hour': 12, 'minute': 0, 'second': 0,
'lat': 39.9, 'lon': 116.4, 'tz': 8.0,
'node_mode': 'mean', 'ayanamsa': 'lahiri',
'today': '2026-10-22', 'transit_date': '2026-10-22',
'target_year': 2026, 'age': 36,
'birth_time_accuracy': 'confirmed',
})())
assert captured, 'full-reading did not call Goel'
planets = report['modules']['chart']['planets']
expected = {
name: pdata['degree_raw']
for name, pdata in planets.items()
if isinstance(pdata, dict) and 'degree_raw' in pdata
}
assert expected
for longitudes in captured:
assert set(longitudes) == set(expected)
for name, raw in expected.items():
assert abs(float(longitudes[name]) - float(raw)) <= 1e-6
strength = report['modules']['jaimini']['goel_full_strength']
assert strength['status'] == 'executed'
assert strength['node_occupancy_policy'] == 'include_nodes_text'
assert strength['worked_example_node_exclusion']['node_occupancy_policy'] == 'exclude_nodes_worked_example'
+6 -5
View File
@@ -18,11 +18,12 @@ SCRIPTS = ROOT / "scripts"
# 本地新写的静默处理仍不得出现;允许的命中只有这一处。
SILENT_EXCEPT_BUDGET = 1
UPSTREAM_SILENT_EXCEPT = "scripts/classical_astrology.py:12558"
# 原值:446
# 新值:448
# 原因:产品 2026-10-18(issue #81 选 A):上游原样文件按上游路径放在 scripts/ 根目录,
# classical_astrology.py、unified_western_reading.py 两个;本地新写的文件仍不得增加。
ROOT_PY_BUDGET = 448
# 原值:448
# 新值:452
# 原因:产品 2026-10-18 #81 决策 A 先例,issue #94:上游原样文件 goel_jaimini_strength、
# trikona_raman、sthira_brahma_goel_short_method、trikona_dasha 按上游路径放根目录;
# 本地新写的文件仍不得增加。
ROOT_PY_BUDGET = 452
def _silent_handlers() -> list[str]:
+10
View File
@@ -180,6 +180,16 @@ def test_english_kuja_fails():
assert "banned_term" in _one(delivery, inputs, "en")
def test_a_body_that_opens_with_the_section_title_fails():
delivery, inputs = _passing("zh")
delivery["sections"][2]["text"] = "沟通与理解 沟通与理解 " + delivery["sections"][2]["text"]
assert "repeated_heading" in _one(delivery, inputs)
delivery, inputs = _passing("en")
delivery["sections"][2]["title"] = "Communication and Understanding"
delivery["sections"][2]["text"] = "Communication and Understanding " + delivery["sections"][2]["text"]
assert "repeated_heading" in _one(delivery, inputs, "en")
def test_english_banned_word_fails():
delivery, inputs = _passing("en")
delivery["sections"][1]["text"] += " Venus "