Compare commits

...

5 Commits

Author SHA1 Message Date
Jesse_Chen a594990a16 docs(report): record live four-theme claim-card verification
Fresh engine workflows (not the golden cache) close career/marriage/timing/wealth with empty blockedSections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 18:25:17 +08:00
Jesse_Chen 3e13cfd924 docs(report): record blocked-repairs quality gates and staging SHA
Task 4 evidence: local gates, Gitea 2308/2309, health gitCommit 7faf8555. Live personal_full and writer/token still need a logged-in 委托方.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 18:20:16 +08:00
Jesse_Chen 7faf8555a5 fix(report): unblock personal-full themes with real varga, transit, and karaka evidence
Independent Staging Quality Gate / validate (push) Successful in 9m15s
Independent Staging Quality Gate / publish (push) Successful in 1m50s
The extractor only read an obsolete varga object shape, never issued Transit receipts, and the consultation response omitted Chara Karakas, so career/marriage/timing/wealth all blocked. Supply the live engine shape, fail-closed receipts, and jaimini.chara_karakas without relaxing the evidence plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 18:03:23 +08:00
Jesse_Chen e37f8272ba docs(report): add blocked-repairs task brief — varga shape, transit receipt, karakas layer
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
2026-09-02 09:40:44 +00:00
Jesse_Chen 24a1cf3777 docs(rectification): add question-in-message task brief
Product decision 2026-09-02: every rectification question lives inside the
latest assistant message (body + agent-authored stem + server options);
the question slot is removed. Records which BUG-441/461/471/485/487/488
anti-recurrence clauses are revoked and which stay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-02 09:22:09 +00:00
11 changed files with 6412 additions and 32 deletions
+121
View File
@@ -0,0 +1,121 @@
# PROGRESS · 个人报告全主题 blocked 修复(2026-09-02
任务书:`TASK-report-blocked-repairs-20260902.md`
分支:`codex/report-blocked-repairs-20260902`
基线:`origin/staging` @ `e37f8272`(任务书提交;内容基线 `24a1cf37`
开工前置:已读 `docs/research/pre_work_error_ledger.md``frontend/AGENTS.md``docs/BUG_HISTORY.md` 检索 BUG-486 及相邻条目;`python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45``status=pass``remote_visibility_status=verified`)。按任务书从 `origin/staging` 建 worktree。
---
## 任务 0 · Golden fixture 与形状契约
捕获命令(虚构 smoke 出生,非真人):
```bash
.venv/bin/python scripts/capture_report_blocked_repairs_golden.py
```
脚本调用 `JyotishAPIHandler._compute_consultation_workflow`body 对齐 worker`entry_mode=direct_chart`question `请为个人报告计算 <theme> 主题证据``plan_version=consultation-plan-v2`,并设 `defer_optional_external_evidence=true` 以跳过 VedAstro(本轮修的是本地 varga / transits / jaimini 层)。主题:career / marriage / timing / wealth。出生:1993-06-15 10:30lat 36.42 / lon 114.21 / tz 8city Handan。
裁剪规则:
- 保留 `chart.ascendant/planets/houses/dasha`
- `modules.varga_full` 只留 `D2_Hora` / `D9_Navamsa` / `D10_Dasamsa` / `D11_Rudramsa` / `D24_Chaturvimsamsa` 的完整值(引擎 D24 名是 Chaturvimsamsa,不是任务书举例的 Siddhamsa
- 保留 `modules.transits``modules.jaimini`(含新增 `chara_karakas`)、`narayana_dasha``arudha_padas``yogas``ashtakavarga``dasha_sub_periods`
- 保留 `consumer_context` 的 route/core_status/available_layers/missing_route_layers/hard_blockers/answer_policy
- 保留 machine packet 中与主题闭合相关的 sections
- 删除 `ai_prompt_pack`、VedAstro、varga_spectrum、guided_topics、western、technique_audit_table,并压缩行星/宫位/dasha 字段
入库:`frontend/tests/fixtures/consultation-workflow-report-blocked-repairs-golden.json`(约 154KB)。入库前检索无邮箱、用户 id、真实姓名。
形状契约:`frontend/tests/report-blocked-repairs.test.ts` 直接读 golden,断言小写 `ascendant``sign_index``planets` 字典、`house_chart`,以及 `modules.transits.status``chara_karakas.AmK/DK`。该测试不依赖前端提取行为。
红线 9`report-interpretive-facts.test.ts``engineVarga()` 原值是 `{ _meta, Ascendant: {sign, sign_idx, degree_in_sign}, Sun/Moon/Mars: {sign, sign_idx} }`(643f6f7a 手造旧形状,锁住了本轮要修的缺陷)。已换成 golden 派生的 D2/D11/D24 真值;断言语义未放宽。
---
## 任务 1 · 分盘值形状
`deriveVargaHousesFromEngine` 现读 `ascendant ?? Ascendant`,索引 `sign_index ?? sign_idx ?? sign`,度数 `degree ?? degree_in_sign`。优先 `varga.planets`(有 `house` 用 house,否则整星座宫推导);无 `planets` 时退回顶层行星键。不复制 `house_chart` 与未知键。`VARGA_KEY_ALIASES.D24` 补上真实键 `D24_Chaturvimsamsa`
复现管线(golden 四主题):`charts` 含 D1/D2/D9/D10/D11/D24wealth 出 claim card`demoteThemesMissingRequiredCharts` 不触发;D2/D11 回执 executed。
---
## 任务 2 · Transit 回执
提取层读 `chart.modules.transits`:仅 `status === "executed"` 时 upsert `Transit``partial` + `executed: true`)。缺失或其他 status 不产回执。
单元覆盖:executed / 缺失 / `blocked` 三态。timing 在 executed 时出 claim card。
---
## 任务 3 · Chara Karaka
引擎 `_attach_local_consultation_layers` 调用既有 `jaimini.calc_chara_karaka_7`,把结果挂到 `chart.modules.jaimini.chara_karakas`。键同时保留全名(`Amatyakaraka`/`Darakaraka`)与任务书建议的短名(`AmK`/`DK`)。不改 `arudha_padas`
前端按 AmK/DK 行星存在性 upsert 回执。Python`test_local_consultation_layers_attach_chara_karakas_from_jaimini`
四主题复现:**claimCards 4blockedSections 0**ledger 中 AmK/DK/Transit/D2/D11 均 executed。
---
## 九主题逐项(golden career 图层复用到未单独捕获的主题)
| 主题 | 结果 |
|---|---|
| career | claim |
| marriage | claim |
| wealth | claim |
| education | claim |
| migration_home | claim |
| family | claim |
| health_pressure | claim |
| timing | claim |
| general | claim |
---
## 任务 4 · 质量门与部署
`requiredTechniqueGroups` 相对任务书基线 `e37f8272` 无 diff。未改 `.gitea/workflows/**`,未提升 main。
| 门 | 结果 |
|---|---|
| `frontend/node_modules/.bin/tsc --noEmit` | 通过(exit 0,无输出) |
| `eslint`(与 CI 相同,无 `--max-warnings` | 0 error / 73 既有 warningexit 0 |
| `tsx --test tests/*.test.ts` | tests 2515 / pass 2514 / fail 1 / skipped 0。相对 skill-parity 基线(2444 / 2410 / 24,本机当时无 Docker)本轮 Docker 可用,那 24 条环境失败已过。唯一失败是 `rectification-v9-database.test.ts`「v9 enforces profile gating, ownership and terminal read-only」:`database migration failed``1 !== 0`)。同文件单独重跑 **7/7 通过**。这是 `BLOCKED.md` 已记录的全量并发 Docker 争用 flake,与本轮 varga/Transit/karaka 改动无关。 |
| `next build --webpack` | exit 0Compiled 2.9minTS 2.3minstatic 62/62。两条 `Failed to copy traced files` 指向 `rishi-ai-mcp/.cursor` / `.vscode` 的 sandbox `EPERM`,不影响路由表。 |
| `pytest tests/test_consultation_consumer_context.py` | 28 passed |
| 相关测试聚焦 | `report-blocked-repairs` + generation / v2 / contract / interpretive-facts64 passed / 0 failed |
### Staging 部署
- 提交:`7faf8555a589a0bdf14a5b17323352470816a7ee` 已在 `origin/staging`
- Independent Staging Quality Gate[run 2308](https://git.copse.top/root/Jyotisha/actions/runs/2308) successvalidate 9m + publish 1m
- Deploy staging[run 2309](https://git.copse.top/root/Jyotisha/actions/runs/2309) success
- `GET https://staging.jyotisha.chat/api/health``.deployment.gitCommit` = `7faf8555a589a0bdf14a5b17323352470816a7ee`
### 复现管线(本轮当场,非 golden 缓存)
2026-09-02 18:26 用当前 worktree 引擎对虚构 smoke 出生重跑四主题 `_compute_consultation_workflow`(未覆盖 golden fixture),再喂给 `buildReportEvidenceBundleV2`
- 四主题均 `success=True``varga_full` 含 D2_Hora / D9_Navamsa / D10_Dasamsa / D11_Rudramsa / D24_Chaturvimsamsa`transits.status=executed``chara_karakas` 含 AmK/DK 及全名
- claimCards = career, marriage, timing, wealthblockedSections = []
- charts = D1/D2/D9/D10/D11/D24ledger 中 AmK/DK/Transit/D2/D11 均 executed
- 九主题全 claim
Staging 登录后的 writer 正文 / token 抽查仍需委托方:本环境无会话;浏览器代登被拦为未授权开户。`GET /api/reports` 仍为 401。health SHA 已对齐 `7faf8555`
委托方登录后:
1. 用虚构或自有资料生成一份 `personal_full`,主题 career / marriage / timing / wealth。
2. 四个主题应有正文,不再全部 blocked,摘要不应再是「报告主题尚未生成」。
3. 顺带完成上一轮欠账:≥3 处 writer 输出回溯;每章真实 `inputTokens` / 墙钟对照 `PROGRESS-report-skill-parity-20260901.md` 的 2 倍线裁决(产品已接受离线 2.28× 计费加权,以第一份实测为准)。
---
## 开工回执后的实现顺序
先落引擎 `chara_karakas` 再捕获 golden(使 fixture 含新层),再修提取层与测试。形状契约只读 JSON,不依赖提取实现。
@@ -0,0 +1,140 @@
# 任务书 · 题目进消息:一条助手消息承载正文+题干+选项,Agent 自己写题干,删除问题槽(2026-09-02)
基线:`origin/staging` @ `33d55b3a`(含 BUG-480488 全部)。
## 0. 决策记录(产品负责人 2026-09-02 拍板)
产品负责人的原话:"我要的是这部分是一条消息出来,而不是一条消息加下面的卡片;这些问题不能让 Agent 直接说出来吗?那是不是就不需要这个槽了。"
**结论:不需要槽。** 生时校正的每一道题(采集题、区分题、前事核对)都必须作为**最新一条助手消息本身**的一部分出现——正文(Agent 承接)→ 题干(Agent 用自己的话写)→ 选项(服务端,画在同一个气泡里)。已答之后这条消息原地保留(选项变灰、标出选的那个),刷新后从 turn 重建一模一样。`.rectification-question-slot` 整体删除。
这次显式**废止**下列防复发条款中与"槽"或"正文不得含题干"绑定的部分(其余条款保留):
| 条款 | 废止内容 | 保留内容 |
|---|---|---|
| BUG-441 | "不得为无选项的问题新造视觉容器" | 同一焦点在所有投影里形态一致;不得用正文判断"有没有问过" |
| BUG-461 | "题干只留在正文"/legend sr-only | 问题与已答卡共用 `--assistant-content-inset` |
| BUG-471 | "口述采集真源是 GET 直播槽,必须在问题槽可见" | 有选项时题干必须可见;不得用正文判断"有没有问过" |
| BUG-485 | "口述采集直播题干只由问题槽展示" | 开场正文不得并行提问;不得追加第二条 opening 题干消息 |
| BUG-487/488 | "仅在最新正文尚未带该题干时渲染采集槽"/"模型仍不自己提问" | 题干必须在同一条已完成助手消息里、刷新后仍在;不得用 `includes`/正则扫正文 |
| `agentic-rectification.ts` 指令第 4 条 | "题干与选项完全由结构化槽位和 UI 承担,正文不得提问" | 见 §3 新指令 |
**不变的红线**:问哪道题、年份、选项语义、答案→候选映射、计分,全部服务端所有;确认门恒 fail-closed;不得用正文字符串判断任何状态;Python 引擎不动;不 bump Skill 10.0.14。
## 1. 目标形态
```
┌ 助手气泡(一条消息,一个 <article>)────────────────┐
│ 2016 年 9 月上大学,记下了——这种带月份的节点对校正 │ ← 正文:Agent 承接(2-4 句,不提问)
│ 特别有用。接下来想核对一下感情这边。 │
│ │
│ 2023 年前后,你有没有一段认真开始或结束的关系? │ ← 题干:Agent 通过工具写的句子(focus.prompt
│ ┌────────────────────────────────────────┐ │
│ │ A 明确发生且时间吻合 │ │ ← 选项:服务端,choice 才有
│ │ B 发生过但时间偏了 │ │
│ │ C 没有这回事 D 记不清 │ │
│ └────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
👍 👎 📋 🔄 ← 动作图标在整条消息之下
```
- 采集题(`collect_spoken`)= 同一结构,没有选项框;输入框占位由 `current_question` 驱动,不变。
- 已答:同一条消息不移动、不复制;选项禁用,选中项高亮;用户点选的那行 "A. …" 仍不作为独立用户气泡出现(保留 `isStructuredChoiceUserText` 隐藏语义)。
- 流式期间:正文先流出;题干+选项在 `run.completed` 后随 Case 快照进入**同一条**消息(沿用 BUG-488 的 `bubbleMessage` 合成思路,扩展到 choice)。不允许在流式中出现第二个气泡或临时槽。
## 2. 数据合同:题目挂在 turn 上,刷新后可重建
现状:`agentic_rectification_conversation_focuses` 已持久化 `question_id / intent / expected_answer_schema(prompt, choice.options…) / status / asked_at`,但**没有与 turn 的链接,也没有记录用户选了哪个**。`agentic_rectification_turns` 只有 `user_message / assistant_message`。前端 `messagesFromTurns``rectification-agentic-chat.tsx:267`)只能重建纯文本。
新增(一条 migration`frontend/supabase/migrations/`):
1. focuses 表加 `asked_turn_id uuid null references agentic_rectification_turns(id) on delete set null``answer_option text null check (answer_option is null or answer_option in ('A','B','C','D','stop'))`
2. 创建 focus 的 RPC`set_v10_conversation_focus` 或现名,按符号找)接受 `p_asked_turn_id``resolve` RPC 接受 `p_answer_option`
3. dossier / GET `/api/rectification/cases/[caseId]``turns[]` 每条 assistant turn 增加 `question` 字段(null 或 `{ focus_id, question_id, kind: "choice"|"collect_spoken"|"reverse_verify", prompt, options[] | null, status, answer_option, probe_id }`),由服务端按 `asked_turn_id` 连接生成。**禁止**按 `asked_at``created_at` 时间关系推断归属。
4. `current_question` / `choice_card` 保留为**提交契约**`question_id``focus_id``probe_id``case_revision`),不再驱动任何独立 UI。
链接时机:
- Agent 路径:turn 行在 run 开始就已创建(`appendV9Turn` 返回 `turnId`),工具创建 focus 时直接带 `askedTurnId`;服务端兜底(§4)同样带当前 `turnId`
- 点选/确定性路径:先 `persistV9DeterministicTurn` 拿到 `turnId`,再创建下一问 focus 并链接;或创建后 update。二选一,PR 说明。
- 采用后前事核对(BUG-483 的 reverse_verify)走同一规则。
## 3. Agent 协议:题干由 Agent 写,但通过工具而不是正文
原则:**Agent 写题干,服务端出选项和计分;"是否问过"的信号是工具调用,不是正文。** 正文继续不提问——题干不是正文的一部分,而是同一条消息里正文之后的独立段落,其文本来自 focus。这样正文里永远不会出现"两句相近的问法",且不需要读正文。
1. `rectification-set-focus``mastra/rectification-v9-tools.ts:1173`)增加输入 `spokenPrompt: string`(必填,8–120 字,单句,可含一个问号)。服务端校验:不得含选项字面("A."、"选项")、不得含"唯一/确切的出生分钟"、不得含内部 tokenprobe/focus/candidate id 字样)与机器腔词表词条(词表在 `tests/agent-voice-copy-contract.test.ts`,抽成可复用常量)、choice 时年份/域必须与 `method_followup_plan.next_followup` 的探针一致(结构字段比对,不是正文)。校验失败 → 工具返回 `invalid_spoken_prompt`,Agent 重写一次;两次失败落服务端模板(§4)。
2. 通过校验的 `spokenPrompt` 写入 `expected_answer_schema.prompt`collect)或 `choice.prompt`choice)。`projectCurrentQuestion` / `choiceCardFromCaseDossier` 已优先读这里,不用改语义;`serverOwnedChoiceCopy` 只在 Agent 没写时使用。
3. 记证据类工具(`rectification-record-evidence-batch` / `confirm` / `revise`)的返回已带 `agentVisibleLatestProjection``current_question``method_followup_plan.next_followup`;确认返回里含**服务端已决定的下一问的结构信息**(探针 id、年份/期间、域、choice_frame 的选项文本、`why`),足够 Agent 写题干。缺什么补什么,不新造第二套投影。
4. 系统指令(`mastra/agentic-rectification.ts` 第 4 条)改写为:
> 每轮在记录证据后,用 `rectification-set-focus` 的 `spokenPrompt` 写出服务端给你的下一问:用自己的话、结合用户刚说的事,问出**同一个年份/期间和同一个事件家族**;不得改年份、不得改选项含义、不得合并两道题。正文只做承接(2-4 句),不提问、不复述题干、不预告选项——题干会作为同一条消息的下一段自动出现。开场轮:先 set-focus 写采集题的 `spokenPrompt`,正文只打招呼。没有下一问(服务端返回 `next_followup=null`)时不要自拟问题。
`frontend/docs/VOICE.md` 同步:加 3 组"服务端探针 → Agent 题干"好/坏对照(坏:逐字复读模板;坏:改了年份;好:接着用户上一句、同年份同家族)。
5. 只读重写(regenerate)不得改 focus;重生成后的消息仍挂同一个 `question``regenerate-turn.ts` 已有回贴逻辑,改为按 `asked_turn_id` 回贴)。
## 4. 服务端兜底:模板题干,同一条消息
Agent 没调 set-focus(或两次校验失败)时:轮末 `persistNextInterviewIfIdle` 照常用 `USER_COLLECT_QUESTION` / `serverOwnedChoiceCopy` 创建 focus**带当前 `turnId`**。消息渲染规则与 Agent 写的题干完全相同(都是"这条 turn 的 question"),所以兜底也在同一个气泡里。
删除:
- `shouldPersistFocusPromptTurn``turn-exit.ts:82-107` 把题干写成**第二条** assistant turn 的整段逻辑。题干不再是 turn 文本,而是 turn 的 `question`
- `composeCollectSpokenAssistantText` / `detachCollectSpokenAssistantText``agent-run.ts:507-531` 的"把题干拼进 `p_assistant_message`"BUG-487)。`assistant_message` 只存正文;已存在的历史 turn 里拼过题干的文本按精确后缀一次性 detach(读取时,`asked_turn_id` 命中且文本以 `\n\n${prompt}` 结尾才剥,仅此一处允许精确字符串比较,且只作用于本次迁移前的旧数据)。
顺带修掉 `39c30b55` 引入的回归:`finalizeSuccessfulTurnExit``focusCreated` 早退,导致 agent-run 预先建好的 choice focus 在 message 路径上从不写题干 turn。新合同下这个问题自然消失(题干不依赖 turn 文本),但要加不变量测试锁住(§7 第 2 条)。
## 5. 点选路径
点选是"immediate"确定性执行,没有模型轮。本轮**保持确定性**:`applyRectificationChoice``persistV9DeterministicTurn`(正文 = 现有 `hostNarration` 里的承接/进度句)→ 下一问 focus 带 `askedTurnId` + 模板题干。`resolve` 时写 `answer_option`
用户可见效果:点 A 之后,上一条消息原地变成已答态,紧接着出现一条新消息"记下了…(进度)+ 下一题干 + 选项"。
**可选升级(本轮不做,留接口)**:点选后跑一次小预算模型轮只写 `spokenPrompt`,让区分题链也带 Agent 口吻。数据合同与 UI 不需要为此再改。PR 里记为 follow-up。
## 6. 前端
`rectification-agentic-chat.tsx`
1. 删除 `.rectification-question-slot`(三处渲染 `:1253/:1281``showQuestionSlot``showMissingQuestion`/`showUnavailableQuestion` 文案)、`collectSpokenPrompt` 合成、`attachCollectSpokenStem``bubbleText` 拼接。
2. `RenderMessage` 增加 `question?: MessageQuestion`(形状同 §2.3)。`messagesFromTurns``turn.question` 直接重建;`choiceAttachment` 并入 `question``answer_option` 非空即已答)。
3. 气泡:`ChatMessageRow` 之内(同一个 `<article>`)渲染 `message.question`:题干段落 + 可选 `RectificationChoiceCard`(改为无外框/无 legend 的"选项组"变体,题干由段落承担,不再重复)。动作图标在整条消息之下。
4. 流式结束:`run.completed``loadCaseSnapshot()` → 用返回的 `turns[last].question` 写入最新消息(不是拼文本)。`send()``submitStructuredChoice` 共用一个 `markQuestionAnswered(focusId, option | "typed")`:打字回答选择题时,上一条消息的 question 标 `answer_option: null, status: "resolved"`(灰掉选项,不高亮),不得让它消失。
5. 缺题状态:"当前没有可回答的问题,正在等待服务端更新"只在**输入框上方**以一行状态显示(`role=status`),不再是消息列表里的块。
6. 复制:复制文本 = 正文 + 空行 + 题干(+ 选项行)。
7. `globals.css`:删除 `.rectification-question-slot*`;已答/未答选项组共用 `--assistant-content-inset`BUG-461 保留条款)。
## 7. 不变量与测试(fail=0
1. **一条消息**:任意 active focus ⇒ 最新 assistant turn 的 `question.focus_id` 等于它;DOM 中题干与选项在同一个 `article` 内;不存在 `.rectification-question-slot`
2. **历史必有题**opening / message / answer_choice / accept 后建立的每个 focus 都有 `asked_turn_id`,且 GET `turns[]` 里恰有一条 assistant turn 带该 `question`(覆盖 `39c30b55` 回归形状:message 路径下 agent-run 预建 choice focus)。
3. **刷新等价**:以走查 case `a17efc37` 形状跑完整链(opening → 2 采集 → 3 区分点选 → 1 打字答区分 → 采用 → 前事核对),`messagesFromTurns(GET.turns)` 渲染结果与在线会话的消息列表结构相同(题干、选项、已答标记、条数)。
4. **Agent 题干校验**:年份/域与探针不符、含选项字面、含"唯一出生分钟"、超长 → `invalid_spoken_prompt`;两次失败落模板且仍挂在同一 turn。
5. **点选行隐藏不变**`isStructuredChoiceUserText` 语义保留;已答消息里选中项可见。
6. **正文不提问**`agent-voice-copy-contract` 的开场/承接锁保留(正文样本不得含问号结尾的题干句——这是对**模型输出样本**的测试断言,不是运行时判定)。
7. 既有 `rectification-spoken-collect` / `rectification-collect-prompt` / `rectification-walkthrough-polish` 中锁槽位与拼接的断言:逐条三栏(旧→新→保留语义)迁移到新合同,不得整文件删除。
## 8. 硬红线
1. 问哪道题、年份、选项、计分、候选/采用/确认门语义不变;确认门恒 fail-closed。
2. 运行时不得读正文字符串做任何判定(§4 的旧数据 detach 是唯一例外,且只在读取旧 turn 时)。
3. Python 引擎不动;Skill 10.0.14 不 bumpskill 文本"正文只做承接与解释"与本合同兼容)。
4. `./node_modules/.bin/tsc --noEmit` 通过(不要用 `npx tsc`);`npm run lint --prefix frontend` 0 error`rectification-*` / `consultation-*` / `consult-*` / `chat-*` fail=0。
5. migration 必须可在已有数据上执行(新列可空);旧 focus 无 `asked_turn_id` 的历史消息退化为纯文本,不报错。
6. 无凭据不得声称真实环境验证;PR 贴走查 case 形状的前后对照(在线截图形状 + 刷新后重建)。
## 9. 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/rectification-question-in-message-20260902 \
../.worktrees/rectification-question-in-message-20260902 origin/staging
```
`frontend/AGENTS.md``frontend/docs/VOICE.md``docs/BUG_HISTORY.md` 的 BUG-441/449/456/461/469/471/480/485/487/488 链(本任务书 §0 已列出各条废止/保留)。**行号是线索,按符号名定位。** BUG_HISTORY 新条目编号从 BUG-489 起(先 grep 确认最大号);本任务建议拆两条:一条记"题目进消息+删槽"的结构变更(引用 §0 决策记录),一条记 `39c30b55` 的 message 路径题干丢失回归。
## 10. 验收标准
1. §7 七条不变量全绿,改动断言逐条三栏说明。
2. 走查 case 形状前后对照:改后每一道题都在助手气泡内;刷新后历史与在线一致;打字回答区分题后上一题不消失。
3. Agent 题干样本(PR 贴 ≥5 道:2 采集、2 区分、1 前事核对):同年份同家族、接着用户上一句、无模板复读。
4. tsc + lint + 四组测试 fail=0BUG_HISTORY 两条落库。
5. 真实环境人工清单(部署后):开场一条消息(打招呼+采集题);答一件事后一条消息(承接+区分题+选项);点 A 后上一条原地变已答;打字答选择题后上一条仍在;刷新后全程一致;复制文本含题干。
+171
View File
@@ -0,0 +1,171 @@
# 任务书 · 个人报告全主题 blocked 修复(2026-09-02
基线:`origin/staging` @ `24a1cf37`
## 事故实证
2026-09-02 15:45 staging 真实生成的 personal_full 报告(请求主题 career/marriage/timing/wealth)四个主题**全部 blocked**,摘要退化为"报告主题尚未生成"。blocked 理由:
- career:缺少最低证据组 AmK、Transit
- marriage:缺少最低证据组 DK
- timing:缺少最低证据组 Transit
- wealth:缺少结构化分盘 D2、D11
已在本地用 `origin/staging @ 24a1cf37` 的前端提取层 + 同版本 Python 引擎(`scripts/jyotish_api_server.py`,虚构 smoke 出生数据,按 worker 的方式每主题一次 `/api/consultation_workflow`**完整复现**:blocked 主题与理由逐字一致。这是确定性缺陷,不是线上环境问题。
## 根因(已逐项定位,不需要重新猜)
**根因 1(wealth):分盘值形状不匹配,所有分盘图提取失败。**
引擎 `chart.modules.varga_full` 的值形状是:
```json
{ "ascendant": { "sign": "Gemini", "sign_index": 2, "degree": 21.027 },
"planets": { "Sun": { "sign": "Libra", "sign_index": 6, "degree": 15.23, "house": 5 }, ... },
"house_chart": [["Ketu"], [], ...], "division": ..., "name": ..., "meaning": ... }
```
`frontend/src/lib/personal-report-generation.ts``deriveVargaHousesFromEngine`(约 :336)读的是**大写 `Ascendant` + `sign_idx` + 顶层行星键** → 每张分盘都返回 null → bundle `charts` 只剩 D1。昨日 `45f190a1` 修了键名别名(`D2_Hora``D2`)并新增"claim card 主题缺结构化分盘图则降 blocked"的兜底(`demoteThemesMissingRequiredCharts`),但没修值形状——于是 wealth 从"write 后文档校验失败"变成"bundle 层 blocked"。`643f6f7a` 的 fixture 是按**旧形状手造**的,所以测试全绿、线上照挂。(2026-08-30 观察到的 `final_parse_rejected` 大概率同源。)
**根因 2timing、career 之一半):Transit 回执从不生成。**
引擎响应 `chart.modules.transits` 存在且 `status: "executed"`(含 sade_sati、triggers、search_period、boundary),但 `consumer_context.available_layers` 与 machine packet sections 里都没有 transit 名目,前端提取层从不为它 upsert 执行回执 → 要求 Transit 的 timing/career 自这套 claim gate 上线起从未通过。存量问题。
**根因 3career:AmK、marriage:DK):consultation 响应没有 Chara Karaka 数据。**
整个响应中无 Amatyakaraka/Darakaraka 数值层:`chart.modules.jaimini` 只有 `arudha_padas`guided topics 里 DK 的值是空占位符 `- H-`。引擎的 full-reading 路径有现成 karaka 计算(`scripts/jaimini.py`),但 `scripts/jyotish_api_server.py``_attach_local_consultation_layers` 没有把这层挂进 consultation 响应。**前端修不了,必须引擎响应新增字段。**
## 决策记录
产品负责人 2026-09-02 确认修复方向并授权:
- **(a)** 引擎侧允许在 consultation 响应中**新增** Chara Karaka 字段(这是 `TASK-report-skill-parity-20260901.md` 任务 0 预留的"响应新增字段"止损分支,正式启用)。仍然只许新增、不许改动既有字段的键名、形状或语义。
- **(b)** `643f6f7a` / 既有测试中按旧分盘形状手造的 fixture 与断言,属于"锁住本轮要修的缺陷本身",允许按红线 9 的程序修改(注明原值与原因)。
- **(c)** 主题最低证据组(`report-theme-evidence-plan.ts`)**不在授权范围内**:不得为了让主题通过而删除或放宽 AmK/DK/Transit/D2/D11 要求。修法只能是把证据真实供给上。
## 硬红线
1. **最低证据组一条不得放宽**(见决策记录 c)。`demoteThemesMissingRequiredCharts` 兜底保留,不得删除——修好值形状后它自然不触发。
2. **引擎改动只许响应新增字段**:不改既有字段、不改排盘算法、不改 full-reading 路径;新增层必须复用 `scripts/jaimini.py` 既有实现,不得重写 karaka 算法。
3. **fixture 必须来自真实引擎响应**(golden),不得再手造形状。任务 0 是门控:先落 golden fixture 与形状契约测试,任务 1–3 的实现测试必须建立在它之上。
4. 上一轮任务书(`TASK-report-skill-parity-20260901.md`)的红线继续有效:输入隔离不放松、writer 输出 schema 只可收紧、`assertionLevel` 推导与 consensus ≥2 verified 防升级校验一字不改、本轮不改数据库结构、章节串行、telemetry 只记指标名与数值。
5. 隐私红线:fixture 与测试只用虚构 smoke 出生数据;golden fixture 入库前确认不含任何真实用户资料。
6. 推 staging 前 `./node_modules/.bin/tsc --noEmit` 必须通过(**不要用 `npx tsc`**,会装到空包 `tsc@2.0.4`)。不得改 `.gitea/workflows/**`,不得自行提升 main。
7. Python 侧改动必须带单元测试(`.venv` + unittest/pytest,按仓内既有测试形式),并真实跑过。
让步顺序:数据不损坏 > 证据纪律不回退 > 功能与测试不回归 > 可验证的修复 > 成本 > 代码整洁。
## 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/report-blocked-repairs-20260902 \
../.worktrees/report-blocked-repairs-20260902 origin/staging
```
`pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`,在 `docs/BUG_HISTORY.md` 检索(BUG-486 及相邻条目与本轮直接相关)。
**先读这几处再动手**
- `frontend/src/lib/personal-report-generation.ts` —— `deriveVargaHousesFromEngine`:336 附近)、`readAllVargaCharts` / `readVargaHouses``VARGA_KEY_ALIASES` 已存在)、`demoteThemesMissingRequiredCharts`、receipt upsert 逻辑
- `frontend/src/lib/report-theme-evidence-plan.ts` —— 各主题 `requiredTechniqueGroups`(只读,不改)
- `scripts/jyotish_api_server.py` —— `_attach_local_consultation_layers``execute_consultation_workflow`
- `scripts/jaimini.py` —— 既有 Chara Karaka 实现
- `git show 45f190a1``git show 643f6f7a` —— 昨日两次治标修复的全部内容
## 复现方法(验收也用它)
起本地引擎:`.venv/bin/python scripts/jyotish_api_server.py --port 5200`。用虚构出生数据(如 1993-06-15 10:30lat 36.42 / lon 114.21 / tz 8)按 worker 的调用方式(`entryMode: "direct_chart"`question `请为个人报告计算 <theme> 主题证据`)对 career/marriage/timing/wealth 各调一次 `runConsultationWorkflow`,把 4 个响应喂给 `buildReportEvidenceBundleV2`,检查 `claimCards` / `blockedSections` / `executionLedger` / `charts`。修复前的预期输出(用于确认复现成功):claimCards 为空、四主题全在 blockedSections、charts 只有 D1。
---
## 任务 0P0,门控)· Golden fixture 与形状契约
### 做法
- 用上述复现方法捕获 4 个主题的真实响应,裁剪成 fixture(保留 `chart.modules.varga_full`(至少 D2/D9/D10/D11/D24 各一张完整值)、`modules.transits``modules.jaimini``consumer_context`、machine packet sections;删除与提取无关的大块字段以控制体积),入库为测试资产。
- 新增形状契约测试:断言 varga_full 值的真实形状(小写 `ascendant``sign_index``planets` 字典、`house_chart`),并断言 `modules.transits.status` 字段存在。**这个测试在修复前就应该通过**(它锁的是引擎真实形状,不是前端行为)。
-`643f6f7a` 手造的旧形状 fixture 替换为 golden 派生数据;按红线 9 在改动处注明原值与原因。
### 验收
- 形状契约测试直接对 golden fixture 通过;PROGRESS 记录 fixture 的捕获命令与裁剪规则。
---
## 任务 1(P0)· 修分盘值形状,复活结构化分盘图
### 做法
`deriveVargaHousesFromEngine` 兼容真实形状,规则:
- 上升:`varga.ascendant ?? varga.Ascendant`;星座索引 `sign_index ?? sign_idx ?? signIndex(sign)`;度数 `degree ?? degree_in_sign`
- 行星:优先读 `varga.planets` 字典(每行星有 `sign_index``house`,宫位可直接用 `house`,无 `house` 时按整星座宫从上升推导);`planets` 不存在时退回旧的顶层行星键路径(保持对旧形状的兼容,两种形状都要有测试)。
- 不吸收 `house_chart` 以外来源不明的字段;未知键一律不复制。
### 验收
- 单元测试双形状覆盖(golden 新形状 + 既有旧形状)。
- 复现管线跑通后:`charts` 至少含 D1/D2/D9/D10/D11/D24wealth 出 claim card`demoteThemesMissingRequiredCharts` 不触发;D2/D11 回执 executed。
- education / migration_home 等依赖分盘的主题在补充复现(9 主题全请求)中同样解锁,PROGRESS 记录逐主题结果。
---
## 任务 2P0)· Transit 执行回执
### 做法
提取层读 `chart.modules.transits``status === "executed"` 时 upsert `Transit` 回执(`status: "partial"``executed: true`,与 ashtakavarga/dasha_sub_periods 的既有读取方式同风格);`status` 缺失或非 executed 时不产回执(fail closed,不得默认 executed)。
### 验收
- 单元测试:executed / 缺失 / 其他 status 三种输入。
- 复现管线:timing 出 claim cardcareer 的 blocked 理由只剩 AmK(在任务 3 完成前)。
---
## 任务 3P0)· 引擎挂 Chara Karaka + 前端 AmK/DK 回执
### 做法
- 引擎:`_attach_local_consultation_layers` 调用 `scripts/jaimini.py` 既有实现,把 Chara Karaka 挂到 `chart.modules.jaimini.chara_karakas`(结构建议:`{ "AmK": { "planet": ..., ... }, "DK": { ... }, ... }`,具体键名以 jaimini.py 现有输出为准,**新增字段不改既有 `arudha_padas`**)。带 Python 单元测试。
- 前端:从 `modules.jaimini.chara_karakas` 中 AmK/DK 的存在性 upsert `AmK` / `DK` 回执(`partial` + `executed: true`);数据缺失时不产回执。
- golden fixture 用引擎修复后的响应重新捕获(或叠加一份含 karakas 的 golden),形状契约测试同步覆盖新层。
### 验收
- Python 测试:consultation 响应含 `chara_karakas` 且 AmK/DK 数据来自 jaimini.py 现有输出。
- 复现管线:**career/marriage/timing/wealth 四主题全部出 claim cardblockedSections 为空**`executionLedger` 中 AmK/DK/Transit/D2/D11 均 executed。
### 止损
-`jaimini.py` 现有实现无法在 consultation 输入上直接复用(缺参数、口径冲突),**停下**写 `BLOCKED.md`,不得重写算法、不得放宽最低证据组来绕过。
---
## 任务 4(P1)· 端到端与既有欠账
- 全套质量门:frontend `tsc --noEmit` / `eslint`0 error/ `tsx --test`(失败清单与基线逐条比对)/ `next build`Python 相关测试真实跑过。
- 部署后(委托方核对 health gitCommit)真实生成一份 personal_full 报告,验收:四主题有正文、不再全 blocked;顺带补上一轮欠的交付物——真实 writer 输出回溯抽查(≥3 处)与每章实测 `inputTokens`/墙钟对照(对照基线与 2 倍线裁决记录见 `PROGRESS-report-skill-parity-20260901.md``BLOCKED.md` 对应条目)。
## 不在本轮范围
- 主题最低证据组的增删改(决策记录 c)。
- 引擎排盘算法、full-reading 路径、varga_full 既有形状的任何改动。
- 知识包内容、writer prompt、成本优化(沿用已裁决结论)。
- 章节并发、模型更换、计费、数据库结构。
## 收尾
- PROGRESS 文件名写 `PROGRESS-report-blocked-repairs-20260902.md`
- `docs/BUG_HISTORY.md` 新增条目(编号先对远端确认;根因 1 与 BUG-486 关联,注明 45f190a1/643f6f7a 是治标、本轮治本)。
- 建议两次推送:任务 0+1+2(纯前端)一次;任务 3(引擎+前端)一次。staging push 触发全量构建+部署,全链路约 20 分钟,推送后核对 `https://staging.jyotisha.chat/api/health``.deployment.gitCommit`
- 不自行提升 main。
## 交付物清单
1. Golden fixture(真实响应派生)+ 形状契约测试;643f6f7a 旧 fixture 的替换说明
2. `deriveVargaHousesFromEngine` 双形状兼容 + 测试;复现管线中 charts/D2/D11 复活证据
3. Transit 回执提取 + 三态测试
4. 引擎 `chara_karakas` 新增层 + Python 测试;前端 AmK/DK 回执 + 测试
5. 复现管线修复前后对照:blockedSections 从 4 → 0claimCards 从 0 → 4(9 主题全请求的逐主题表)
6. 部署后真实报告验收 + 上一轮欠账(writer 输出回溯抽查、实测 tokens/墙钟对照)
7. `docs/BUG_HISTORY.md` 条目、`BLOCKED.md`(任何触发止损的项)、`PROGRESS-report-blocked-repairs-20260902.md`
8. 全套质量门实际输出(frontend 四件 + Python 测试)
+17 -1
View File
@@ -7485,7 +7485,7 @@
- 修复:把文档允许的分盘键归一成 D2/D9/D10/D11/D24(含 `D2_Hora` 等别名),占据星体走 celestial allowlist。write 主题若缺少对应结构化分盘,降成 blocked disclosure,不再组装成会解析失败的文档。终态 parse 失败时把缺分盘与 hash 失配从笼统的 `final_parse_rejected` 分出来;日志仍只写 allowlist token,不含用户或模型原文。不放宽 `.strict()` 文档 schema,也不改 assertionLevel 规则。
- 验证:`frontend/tests/personal-report-generation.test.ts``D2_Hora` / `D11_Rudramsa` 抽出 D2/D11 且财富章成为 claim`frontend/tests/personal-report-generation-v2.test.ts` 财富 write 只有 D1 时 ready 且财富进 blocked`frontend/tests/personal-report-contract.test.ts` 财富主题缺 D2/D11 仍拒绝。Gitea run 2298 在 `report-interpretive-facts.test.ts` 6 fail:夹具只有 D2/D11/D24 回执、没有 `varga_full`,降级后 wealth/education 卡被拿掉。补上结构化 D2/D11/D24 后该文件本地绿,并锁「仅回执则 demote」。未用登录会话在 staging 重生。
- 防复发:文档分盘 id 必须从引擎 `varga_full` 别名归一,不得只认 `D2` 这种短键。财富/事业/婚恋/教育 write 主题缺对应结构化分盘时必须降级 blocked,不得把 `final_parse_rejected` 当正常完成。新增分盘别名必须同时覆盖提取与文档 `CHART_IDS`。改 `REQUIRED_THEME_CHARTS` 或 demote 后必须跑 `frontend/tests/report-interpretive-facts.test.ts`;期望 write 主题 claim card 的夹具必须带对应结构化分盘,不能只标 machine section used。
- 相关记录:BUG-352、BUG-451、BUG-467
- 相关记录:BUG-352、BUG-451、BUG-467、BUG-489
- 复发自:无
- 修复版本:待发布
@@ -7520,3 +7520,19 @@
- 相关记录:BUG-471、BUG-485、BUG-487
- 复发自:BUG-487(服务端拼接后槽位兜底仍可见)
- 修复版本:待发布
## BUG-489 | 个人报告四主题全部 blocked:分盘值形状、Transit 回执与 Chara Karaka 层缺失
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`deriveVargaHousesFromEngine``buildReportEvidenceBundleV2``_attach_local_consultation_layers`、personal_full `career/marriage/timing/wealth`
- 用户现象:staging 真实 personal_full 报告四个请求主题全部 blocked,摘要退化为「报告主题尚未生成」。blocked 理由分别为缺 AmK+Transit、缺 DK、缺 Transit、缺结构化 D2/D11。
- 触发条件:个人报告按主题调用 `/api/consultation_workflow`,提取层组 `ReportEvidenceBundleV2`。与出生数据或线上环境无关,本地 smoke 出生可逐字复现。
- 根因:三层叠加。`modules.varga_full` 真值是小写 `ascendant` / `sign_index` / `planets` 字典,提取层只读旧形状 `Ascendant` / `sign_idx` / 顶层行星键,所有分盘图提取失败;`modules.transits.status=executed` 存在但从不 upsert Transit 回执;consultation 响应的 `modules.jaimini` 只有 `arudha_padas`,没有 Chara KarakaAmK/DK 回执无法闭合。BUG-486 的 `45f190a1` / `643f6f7a` 只修了键名别名、demote 兜底和手造旧形状 fixture,测试绿、线上仍挂。
- 修复:提取层双形状兼容真实 varga;`transits.status==="executed"` 时 upsert Transit`partial`+executed,缺或非 executed 不产回执);引擎把 `jaimini.calc_chara_karaka_7` 挂到 `chart.modules.jaimini.chara_karakas`(不改 `arudha_padas`),前端按 AmK/DK 存在性 upsert 回执。不放宽主题最低证据组,不改既有字段形状。
- 验证:golden fixture 形状契约;`frontend/tests/report-blocked-repairs.test.ts` 锁双形状、Transit 三态、AmK/DK、四主题 claimCards 4 / blocked 0、九主题全 claim`tests/test_consultation_consumer_context.py::test_local_consultation_layers_attach_chara_karakas_from_jaimini`;既有 personal-report generation / interpretive / contract / v2 回归绿。本地 `tsc --noEmit` / eslint 0 error / `next build --webpack` / pytest 28。全量 `tsx --test` 2514/2515,唯一失败是既有 Docker 争用 flake,同文件单独 7/7。Gitea gate [2308](https://git.copse.top/root/Jyotisha/actions/runs/2308) 与 Deploy staging [2309](https://git.copse.top/root/Jyotisha/actions/runs/2309) 成功;`https://staging.jyotisha.chat/api/health``deployment.gitCommit` = `7faf8555a589a0bdf14a5b17323352470816a7ee`。登录后的 staging 重生与 writer/token 抽查仍需委托方(本环境 401,无模型凭据)。
- 防复发:分盘 fixture 必须来自真实引擎响应,不得再手造 `Ascendant/sign_idx` 形状当 live 契约。Transit / AmK / DK 回执必须来自模块数据存在性,不得用 `available_layers` 名目冒充已执行。新增 `chara_karakas` 不得改写既有 `arudha_padas`。改提取形状后必须跑 `report-blocked-repairs``report-interpretive-facts`
- 相关记录:BUG-486、BUG-467、BUG-352
- 复发自:BUG-486(键名别名与 demote 治标,值形状与缺失层未修)
- 修复版本:`7faf8555`staging;未提升 main
+86 -21
View File
@@ -316,7 +316,7 @@ const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]
D9: ["D9_Navamsa", "D9"],
D10: ["D10_Dasamsa", "D10"],
D11: ["D11_Rudramsa", "D11"],
D24: ["D24_Siddhamsa", "D24"],
D24: ["D24_Chaturvimsamsa", "D24_Siddhamsa", "D24"],
};
function canonicalDocumentVargaChartId(rawId: string): DocumentVargaChartId | null {
@@ -324,14 +324,53 @@ function canonicalDocumentVargaChartId(rawId: string): DocumentVargaChartId | nu
return match ? match[1] as DocumentVargaChartId : null;
}
const VARGA_ENGINE_META_KEYS = new Set([
"Ascendant",
"ascendant",
"planets",
"house_chart",
"division",
"name",
"meaning",
]);
function vargaPlanetHouse(row: JsonRecord, ascIndex: number): number | null {
const declared = finiteNumber(row.house);
if (declared !== null && Number.isInteger(declared) && declared >= 1 && declared <= 12) {
return declared;
}
const planetIndex = signIndex(row.sign_index ?? row.sign_idx ?? row.sign);
if (planetIndex === null) return null;
return (((planetIndex - ascIndex) % 12) + 12) % 12 + 1;
}
function collectVargaOccupants(
entries: Iterable<readonly [string, unknown]>,
ascIndex: number,
): string[][] {
const occupants: string[][] = Array.from({ length: 12 }, () => []);
for (const [name, item] of entries) {
const row = record(item);
if (!row) continue;
const house = vargaPlanetHouse(row, ascIndex);
if (house === null) continue;
const safeName = safeCelestialName(name);
if (safeName) occupants[house - 1].push(safeName);
}
return occupants;
}
/**
* Divisional charts in the real engine live under modules.varga_full with keys
* such as D2_Hora / D9_Navamsa / D10_Dasamsa / D11_Rudramsa (or D2 / D9 / D10 /
* D11). Each varga is {Ascendant: {sign_idx|sign}, <planet>: {sign_idx|sign},
* _meta, _dignity, ...} — there are no house arrays. House signs are derived
* whole-sign from the divisional ascendant and occupants from each planet's
* sign index. Missing or non-document vargas are omitted; occupant names go
* through the celestial allowlist.
* Divisional charts live under modules.varga_full with keys such as D2_Hora /
* D9_Navamsa / D10_Dasamsa / D11_Rudramsa (or D2 / D9 / D10 / D11).
*
* The live engine shape is {ascendant: {sign, sign_index, degree}, planets:
* {Sun: {sign, sign_index, house, degree}, ...}, house_chart, division, name,
* meaning}. An older fixture shape used {Ascendant: {sign_idx|sign}, <planet>:
* {sign_idx|sign}, _meta, ...}. Both are accepted. House signs are whole-sign
* from the divisional ascendant. Occupants come from planets.house when
* present, otherwise from sign index. house_chart and unknown keys are not
* copied. Occupant names go through the celestial allowlist.
*/
function deriveVargaHousesFromEngine(
varga: JsonRecord,
@@ -340,22 +379,23 @@ function deriveVargaHousesFromEngine(
degree: number;
houses: ReportEvidencePacket["chart"]["houses"];
}> | null {
const ascendant = record(varga.Ascendant);
const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null;
const ascendant = record(varga.ascendant) ?? record(varga.Ascendant);
const ascIndex = ascendant
? signIndex(ascendant.sign_index ?? ascendant.sign_idx ?? ascendant.sign)
: null;
if (ascIndex === null) return null;
const occupants: string[][] = Array.from({ length: 12 }, () => []);
for (const [name, item] of Object.entries(varga)) {
if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue;
const row = record(item);
const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null;
if (planetIndex === null) continue;
const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1;
const safeName = safeCelestialName(name);
if (safeName) occupants[house - 1].push(safeName);
}
const planets = record(varga.planets);
const occupants = planets
? collectVargaOccupants(Object.entries(planets), ascIndex)
: collectVargaOccupants(
Object.entries(varga).filter(([name]) => (
!name.startsWith("_") && !VARGA_ENGINE_META_KEYS.has(name)
)),
ascIndex,
);
return {
ascIndex,
degree: finiteNumber(ascendant?.degree_in_sign ?? ascendant?.degree) ?? 0,
degree: finiteNumber(ascendant?.degree ?? ascendant?.degree_in_sign) ?? 0,
houses: Array.from({ length: 12 }, (_, index) => ({
number: index + 1,
sign: SIGNS[((ascIndex + index) % 12 + 12) % 12],
@@ -1130,6 +1170,17 @@ function readChartModules(workflow: JsonRecord): JsonRecord {
return record(record(workflow.chart)?.modules) ?? {};
}
function charaKarakaPlanetPresent(
karakas: JsonRecord | null,
keys: readonly string[],
): boolean {
if (!karakas) return false;
return keys.some((key) => {
const planet = text(record(karakas[key])?.planet);
return Boolean(planet && safeCelestialName(planet));
});
}
/**
* Functional benefic/malefic table. `yogakarakas` wins over the benefic and
* malefic lists so a planet is never reported twice.
@@ -1621,6 +1672,20 @@ export function buildReportEvidenceBundleV2(
}
}
for (const { workflow } of packets) {
const modules = readChartModules(workflow);
if (text(record(modules.transits)?.status)?.toLowerCase() === "executed") {
upsertReceipt("Transit", "partial", true);
}
const karakas = record(record(modules.jaimini)?.chara_karakas);
if (charaKarakaPlanetPresent(karakas, ["AmK", "Amatyakaraka"])) {
upsertReceipt("AmK", "partial", true);
}
if (charaKarakaPlanetPresent(karakas, ["DK", "Darakaraka"])) {
upsertReceipt("DK", "partial", true);
}
}
// --- interpretive facts -------------------------------------------------
// Bound to receipts that already exist in the ledger; a layer without a
// receipt is skipped rather than given a synthetic ref.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildReportEvidenceBundleV2 } from "../src/lib/personal-report-generation.ts";
import { REPORT_THEME_IDS } from "../src/lib/report-theme-evidence-plan.ts";
type JsonRecord = Record<string, unknown>;
const GOLDEN = JSON.parse(readFileSync(
new URL("./fixtures/consultation-workflow-report-blocked-repairs-golden.json", import.meta.url),
"utf8",
)) as {
themes: Record<string, JsonRecord>;
};
const SKILL = {
name: "jyotish-vedic-astrology",
version: "6.9.14",
sha256: "a".repeat(64),
sourceCommit: "b".repeat(40),
} as const;
const SUBJECT = {
displayName: "冒烟用户",
birthTimeStatus: "accepted" as const,
birthPlaceLabel: "冒烟市",
};
const FOUR_THEMES = ["career", "marriage", "timing", "wealth"] as const;
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function goldenWorkflow(theme: string): JsonRecord {
return clone(GOLDEN.themes[theme] ?? GOLDEN.themes.career);
}
function buildBundle(themes: readonly string[], workflows?: readonly JsonRecord[]) {
return buildReportEvidenceBundleV2({
workflows: themes.map((theme, index) => ({
theme,
workflow: workflows?.[index] ?? goldenWorkflow(theme),
})),
subject: SUBJECT,
requestedThemes: [...themes],
reportType: "personal_full",
presentationMode: "default",
skillSnapshot: SKILL,
});
}
function chartModules(workflow: JsonRecord): JsonRecord {
const chart = workflow.chart as JsonRecord;
return chart.modules as JsonRecord;
}
function executedTechniques(bundle: ReturnType<typeof buildBundle>): string[] {
return bundle.executionLedger
.filter((receipt) => receipt.executed && receipt.status !== "blocked")
.map((receipt) => receipt.technique)
.sort();
}
test("golden fixture locks the live engine varga and transits shape", () => {
const vargaFull = chartModules(GOLDEN.themes.career).varga_full as JsonRecord;
assert.ok(vargaFull.D2_Hora);
assert.ok(vargaFull.D9_Navamsa);
assert.ok(vargaFull.D10_Dasamsa);
assert.ok(vargaFull.D11_Rudramsa);
assert.ok(vargaFull.D24_Chaturvimsamsa);
for (const key of ["D2_Hora", "D9_Navamsa", "D10_Dasamsa", "D11_Rudramsa", "D24_Chaturvimsamsa"] as const) {
const varga = vargaFull[key] as JsonRecord;
const ascendant = varga.ascendant as JsonRecord;
const planets = varga.planets as JsonRecord;
assert.equal(typeof ascendant.sign, "string", `${key} ascendant.sign`);
assert.equal(typeof ascendant.sign_index, "number", `${key} ascendant.sign_index`);
assert.equal(typeof ascendant.degree, "number", `${key} ascendant.degree`);
assert.equal(varga.Ascendant, undefined, `${key} must not use the old Ascendant key as the live shape`);
assert.ok(planets.Sun && typeof (planets.Sun as JsonRecord).sign_index === "number", `${key} planets.Sun`);
assert.ok(Array.isArray(varga.house_chart));
assert.equal((varga.house_chart as unknown[]).length, 12);
}
const transits = chartModules(GOLDEN.themes.career).transits as JsonRecord;
assert.equal(typeof transits.status, "string");
const karakas = (chartModules(GOLDEN.themes.career).jaimini as JsonRecord).chara_karakas as JsonRecord;
assert.equal(typeof (karakas.AmK as JsonRecord).planet, "string");
assert.equal(typeof (karakas.DK as JsonRecord).planet, "string");
});
test("deriveVargaHousesFromEngine reads the live planets-dict shape and the old top-level shape", () => {
const goldenBundle = buildBundle(["wealth"], [goldenWorkflow("wealth")]);
const required = ["D1", "D2", "D9", "D10", "D11", "D24"];
for (const id of required) {
assert.ok(goldenBundle.charts.some((chart) => chart.id === id), `golden missing ${id}`);
}
const d2 = goldenBundle.charts.find((chart) => chart.id === "D2")!;
assert.equal(d2.houses.length, 12);
assert.equal(d2.houses[0].sign, "Leo");
assert.ok(d2.houses[0].occupants.includes("Sun"));
const oldShape = goldenWorkflow("wealth");
const modules = chartModules(oldShape);
// Keep the old 643f6f7a-era shape as a compatibility path: Ascendant/sign_idx
// plus top-level planet keys. Live engine responses no longer look like this.
modules.varga_full = {
D2_Hora: {
_meta: { fixture: "legacy-shape" },
Ascendant: { sign: "Cancer", sign_idx: 3, degree_in_sign: 8.25 },
Sun: { sign: "Leo", sign_idx: 4 },
Moon: { sign: "Virgo", sign_idx: 5 },
},
D11_Rudramsa: {
Ascendant: { sign: "Scorpio", sign_idx: 7 },
Sun: { sign: "Sagittarius", sign_idx: 8 },
},
};
const oldBundle = buildBundle(["wealth"], [oldShape]);
const oldD2 = oldBundle.charts.find((chart) => chart.id === "D2");
const oldD11 = oldBundle.charts.find((chart) => chart.id === "D11");
assert.ok(oldD2);
assert.ok(oldD11);
assert.equal(oldD2.houses[0].sign, "Cancer");
assert.ok(oldD2.houses[1].occupants.includes("Sun"));
assert.equal(oldD11.houses[0].sign, "Scorpio");
});
test("Transit receipt upserts only when modules.transits.status is executed", () => {
const executed = buildBundle(["timing"], [goldenWorkflow("timing")]);
assert.ok(executed.claimCards.some((card) => card.theme === "timing"));
assert.equal(executed.blockedSections.some((section) => section.theme === "timing"), false);
const transit = executed.executionLedger.find((receipt) => receipt.technique === "Transit");
assert.ok(transit);
assert.equal(transit.executed, true);
assert.equal(transit.status, "partial");
const missing = goldenWorkflow("timing");
delete chartModules(missing).transits;
const missingBundle = buildBundle(["timing"], [missing]);
assert.equal(missingBundle.claimCards.some((card) => card.theme === "timing"), false);
assert.ok(missingBundle.blockedSections.some((section) => (
section.theme === "timing" && section.reason.includes("Transit")
)));
assert.equal(
missingBundle.executionLedger.some((receipt) => receipt.technique === "Transit" && receipt.executed),
false,
);
const blocked = goldenWorkflow("timing");
(chartModules(blocked).transits as JsonRecord).status = "blocked";
const blockedBundle = buildBundle(["timing"], [blocked]);
assert.equal(blockedBundle.claimCards.some((card) => card.theme === "timing"), false);
assert.equal(
blockedBundle.executionLedger.some((receipt) => receipt.technique === "Transit" && receipt.executed),
false,
);
});
test("AmK and DK receipts require chara_karakas planet rows", () => {
const full = buildBundle(["career", "marriage"]);
assert.ok(full.claimCards.some((card) => card.theme === "career"));
assert.ok(full.claimCards.some((card) => card.theme === "marriage"));
assert.ok(executedTechniques(full).includes("AmK"));
assert.ok(executedTechniques(full).includes("DK"));
const noKarakas = goldenWorkflow("career");
delete (chartModules(noKarakas).jaimini as JsonRecord).chara_karakas;
const careerOnly = buildBundle(["career"], [noKarakas]);
const blocked = careerOnly.blockedSections.find((section) => section.theme === "career");
assert.ok(blocked);
assert.ok(blocked.reason.includes("AmK"));
assert.equal(blocked.reason.includes("Transit"), false);
});
test("golden four-theme pipeline emits claim cards and no blocked sections", () => {
const bundle = buildBundle(FOUR_THEMES);
assert.deepEqual(bundle.claimCards.map((card) => card.theme).sort(), [...FOUR_THEMES].sort());
assert.deepEqual(bundle.blockedSections.map((section) => section.theme), []);
const chartIds = bundle.charts.map((chart) => chart.id).sort();
for (const id of ["D1", "D2", "D9", "D10", "D11", "D24"]) {
assert.ok(chartIds.includes(id), `charts missing ${id}: ${chartIds.join(",")}`);
}
const executed = executedTechniques(bundle);
for (const technique of ["AmK", "DK", "Transit", "D2", "D11"]) {
assert.ok(executed.includes(technique), `ledger missing executed ${technique}`);
}
assert.equal(
bundle.claimCards.some((card) => card.theme === "wealth")
&& !bundle.blockedSections.some((section) => section.reason.includes("结构化分盘")),
true,
);
});
test("nine requested themes close from the same golden chart layers", () => {
const bundle = buildBundle(REPORT_THEME_IDS);
const claimed = new Set(bundle.claimCards.map((card) => card.theme));
const blocked = Object.fromEntries(
bundle.blockedSections.map((section) => [section.theme, section.reason]),
);
const rows = REPORT_THEME_IDS.map((theme) => ({
theme,
status: claimed.has(theme) ? "claim" : "blocked",
reason: blocked[theme] ?? null,
}));
for (const row of rows) {
assert.equal(row.status, "claim", `${row.theme} still blocked: ${row.reason}`);
}
});
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildReportEvidenceBundleV2,
@@ -91,14 +92,28 @@ function machineSections(): JsonRecord {
};
}
function engineVarga(ascIndex: number): JsonRecord {
return {
_meta: { fixture: true },
Ascendant: { sign: SIGNS[ascIndex], sign_idx: ascIndex, degree_in_sign: 10.5 },
Sun: { sign: SIGNS[ascIndex], sign_idx: ascIndex },
Moon: { sign: SIGNS[(ascIndex + 1) % 12], sign_idx: (ascIndex + 1) % 12 },
Mars: { sign: SIGNS[(ascIndex + 2) % 12], sign_idx: (ascIndex + 2) % 12 },
// Previous 643f6f7a fixture used the old engine-incompatible shape:
// `{ _meta, Ascendant: { sign, sign_idx, degree_in_sign }, Sun/Moon/Mars: { sign, sign_idx } }`.
// That locked the defect this round repairs (frontend only read Ascendant/sign_idx).
// Replaced with golden-derived live `modules.varga_full` values (lowercase
// ascendant, sign_index, planets dict, house_chart).
const GOLDEN_VARGA = (
JSON.parse(readFileSync(new URL("./fixtures/consultation-workflow-report-blocked-repairs-golden.json", import.meta.url), "utf8")) as {
themes: { career: { chart: { modules: { varga_full: Record<string, JsonRecord> } } } };
}
).themes.career.chart.modules.varga_full;
function engineVargaFromGolden(id: "D2" | "D11" | "D24"): JsonRecord {
const aliases: Record<typeof id, readonly string[]> = {
D2: ["D2_Hora", "D2"],
D11: ["D11_Rudramsa", "D11"],
D24: ["D24_Chaturvimsamsa", "D24_Siddhamsa", "D24"],
};
for (const key of aliases[id]) {
const value = GOLDEN_VARGA[key];
if (value) return value;
}
throw new Error(`golden varga ${id} missing`);
}
type FixtureOptions = Readonly<{
@@ -128,9 +143,9 @@ function workflowFixture(options: FixtureOptions = {}): JsonRecord {
// education claim cards (Gitea run 2298).
if (options.structuredVargas !== false) {
modules.varga_full = {
D2: engineVarga(ASC_INDEX),
D11: engineVarga((ASC_INDEX + 2) % 12),
D24: engineVarga((ASC_INDEX + 4) % 12),
D2: engineVargaFromGolden("D2"),
D11: engineVargaFromGolden("D11"),
D24: engineVargaFromGolden("D24"),
};
}
if (interpretive) {
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""Capture trimmed consultation_workflow responses for report blocked-repairs fixtures.
Smoke birth only: 1993-06-15 10:30, lat 36.42 / lon 114.21 / tz 8.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from consultation_plan_contract import PLAN_VERSION, _ROUTE_CONTRACTS # noqa: E402
from jyotish_api_server import JyotishAPIHandler # noqa: E402
BIRTH = {
"year": 1993,
"month": 6,
"day": 15,
"hour": 10,
"minute": 30,
"lat": 36.42,
"lon": 114.21,
"tz": 8,
"city": "Handan",
}
THEMES = ("career", "marriage", "timing", "wealth")
VARGA_KEEP = (
"D2_Hora", "D2",
"D9_Navamsa", "D9",
"D10_Dasamsa", "D10",
"D11_Rudramsa", "D11",
"D24_Chaturvimsamsa", "D24_Siddhamsa", "D24",
)
MODULE_KEEP = (
"varga_full",
"transits",
"jaimini",
"narayana_dasha",
"arudha_padas",
"yogas",
"ashtakavarga",
"dasha_sub_periods",
)
CONSUMER_KEEP = (
"route",
"core_status",
"available_layers",
"missing_route_layers",
"hard_blockers",
"answer_policy",
)
CHART_KEEP = ("success", "ascendant", "planets", "houses", "dasha")
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _body(theme: str) -> dict:
contract = _ROUTE_CONTRACTS[theme]
return {
**BIRTH,
"question": f"请为个人报告计算 {theme} 主题证据",
"question_text": f"请为个人报告计算 {theme} 主题证据",
"theme": list(contract.themes),
"entry_mode": "direct_chart",
"plan_version": PLAN_VERSION,
"strict_workflow_route": contract.resolved_routes[0],
"required_layers": list(contract.required_layers),
"claim_boundary": contract.claim_boundary,
"plan_depth": "standard",
"requested_domains": list(contract.requested_domains),
"timing_horizon": "next_12_months" if theme == "timing" else None,
"precision_boundary": "server_evidence_required",
"required_evidence_categories": list(contract.required_evidence_categories),
"defer_optional_external_evidence": True,
}
def _trim_varga(varga_full: dict) -> dict:
trimmed = {}
for key in VARGA_KEEP:
value = varga_full.get(key)
if isinstance(value, dict):
trimmed[key] = value
if trimmed:
return trimmed
# Fall back to prefix match if the live keys drifted.
for key, value in varga_full.items():
if not isinstance(key, str) or not isinstance(value, dict):
continue
if any(key == keep or key.startswith(f"{keep.split('_')[0]}_") for keep in ("D2", "D9", "D10", "D11", "D24")):
if key.startswith(("D2", "D9", "D10", "D11", "D24")):
trimmed[key] = value
return trimmed
def _trim_workflow(raw: dict) -> dict:
chart = raw.get("chart") if isinstance(raw.get("chart"), dict) else {}
modules = chart.get("modules") if isinstance(chart.get("modules"), dict) else {}
kept_modules = {}
for name in MODULE_KEEP:
value = modules.get(name)
if name == "varga_full" and isinstance(value, dict):
kept_modules[name] = _trim_varga(value)
elif value is not None:
kept_modules[name] = value
kept_chart = {key: chart[key] for key in CHART_KEEP if key in chart}
if chart.get("success") is not None:
kept_chart["success"] = chart.get("success")
kept_chart["modules"] = kept_modules
consumer = raw.get("consumer_context") if isinstance(raw.get("consumer_context"), dict) else {}
machine = raw.get("machine_evidence_packet") if isinstance(raw.get("machine_evidence_packet"), dict) else {}
workflow = {
"success": bool(raw.get("success")),
"chart": kept_chart,
"consumer_context": {key: consumer[key] for key in CONSUMER_KEEP if key in consumer},
"machine_evidence_packet": {
"sections": machine.get("sections") if isinstance(machine.get("sections"), dict) else {},
},
}
return _compact_workflow(workflow)
def _pick(row: dict, keys: tuple[str, ...]) -> dict:
return {key: row[key] for key in keys if key in row}
def _compact_workflow(workflow: dict) -> dict:
"""Drop bulky unused fields after the extraction-layer keep list."""
chart = workflow["chart"]
planets = chart.get("planets")
if isinstance(planets, dict):
chart["planets"] = {
name: _pick(row, ("sign", "sign_idx", "sign_index", "lon", "degree", "degree_in_sign", "house", "retrograde"))
if isinstance(row, dict) else row
for name, row in planets.items()
}
houses = chart.get("houses")
if isinstance(houses, dict):
chart["houses"] = {
key: _pick(row, ("cusp_sign", "sign", "sign_idx", "lord")) if isinstance(row, dict) else row
for key, row in houses.items()
}
dasha = chart.get("dasha")
if isinstance(dasha, dict):
periods = [
_pick(item, ("lord", "start", "end"))
for item in (dasha.get("periods") or [])
if isinstance(item, dict)
]
compact_dasha = {}
if periods:
compact_dasha["periods"] = periods
compact_dasha["mahadashas"] = periods
for key in ("current_md", "start_date"):
if key in dasha:
compact_dasha[key] = dasha[key]
chart["dasha"] = compact_dasha
modules = chart["modules"]
transits = modules.get("transits")
if isinstance(transits, dict):
modules["transits"] = _pick(transits, ("status", "sade_sati", "search_period", "trigger_count", "boundary"))
sav = (modules.get("ashtakavarga") or {}).get("sav") if isinstance(modules.get("ashtakavarga"), dict) else None
if isinstance(sav, dict):
modules["ashtakavarga"] = {"sav": _pick(sav, ("scores", "total"))}
yogas = modules.get("yogas")
if isinstance(yogas, dict):
rows = [
_pick(item, ("name", "category", "hit", "planets"))
for item in (yogas.get("yogas") or [])
if isinstance(item, dict)
]
modules["yogas"] = {"status": yogas.get("status"), "count": yogas.get("count"), "yogas": rows}
nara = modules.get("narayana_dasha")
if isinstance(nara, dict):
modules["narayana_dasha"] = _pick(nara, ("current_dasha", "lagna_sign"))
arudha = modules.get("arudha_padas")
if isinstance(arudha, dict):
padas = arudha.get("padas") if isinstance(arudha.get("padas"), dict) else {}
modules["arudha_padas"] = {"padas": {key: padas[key] for key in ("A7", "A10", "UL") if key in padas}}
jaimini = modules.get("jaimini")
if isinstance(jaimini, dict):
compact_jaimini = {}
if isinstance(jaimini.get("arudha_padas"), dict):
padas = jaimini["arudha_padas"].get("padas") if isinstance(jaimini["arudha_padas"].get("padas"), dict) else {}
compact_jaimini["arudha_padas"] = {"padas": {key: padas[key] for key in ("A7", "A10", "UL") if key in padas}}
karakas = jaimini.get("chara_karakas")
if isinstance(karakas, dict):
compact_jaimini["chara_karakas"] = {
key: _pick(row, ("planet", "degree_in_sign", "rank", "domain", "cn_name")) if isinstance(row, dict) else row
for key, row in karakas.items()
}
modules["jaimini"] = compact_jaimini
sub = modules.get("dasha_sub_periods")
if isinstance(sub, dict) and isinstance(sub.get("current"), dict):
modules["dasha_sub_periods"] = {"current": sub["current"]}
sections = workflow["machine_evidence_packet"].get("sections")
if isinstance(sections, dict):
keep = {
"D1", "D2", "D4", "D6", "D7", "D9", "D10", "D11", "D12", "D24", "D30",
"A7", "A10", "UL", "dasha_boundaries", "narayana_dasha", "yogas", "ashtakavarga",
"planet_degrees", "house_degrees", "dasha_sub_periods",
}
workflow["machine_evidence_packet"]["sections"] = {key: value for key, value in sections.items() if key in keep}
return workflow
def main() -> int:
handler = _handler()
out_dir = ROOT / "frontend/tests/fixtures"
out_dir.mkdir(parents=True, exist_ok=True)
payload = {
"source": "scripts/jyotish_api_server.py _compute_consultation_workflow",
"smoke_birth": {**BIRTH, "note": "fictional capture identity; not a real person"},
"trim": {
"keep_varga_keys": list(VARGA_KEEP),
"keep_modules": list(MODULE_KEEP),
"drop": [
"ai_prompt_pack",
"vedastro",
"varga_spectrum",
"guided_topics",
"western_spectrum",
"technique_audit_table",
],
},
"themes": {},
}
for theme in THEMES:
raw = handler._compute_consultation_workflow(_body(theme))
payload["themes"][theme] = _trim_workflow(raw)
varga = payload["themes"][theme]["chart"]["modules"].get("varga_full") or {}
transits = payload["themes"][theme]["chart"]["modules"].get("transits") or {}
jaimini = payload["themes"][theme]["chart"]["modules"].get("jaimini") or {}
print(
theme,
"success=",
payload["themes"][theme]["success"],
"varga_keys=",
sorted(varga),
"transits=",
transits.get("status"),
"karakas=",
sorted((jaimini.get("chara_karakas") or {})),
)
dest = out_dir / "consultation-workflow-report-blocked-repairs-golden.json"
dest.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print("wrote", dest, "bytes", dest.stat().st_size)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -1502,6 +1502,38 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict,
except Exception as exc:
diagnostics.append({'layer': 'arudha_padas', 'status': 'unavailable', 'reason': exc.__class__.__name__})
existing_jaimini = modules.get('jaimini') if isinstance(modules.get('jaimini'), dict) else {}
if planets and not isinstance(existing_jaimini.get('chara_karakas'), dict):
try:
jaimini = _load_local_module('jaimini')
planet_degs = {
name: lon % 30
for name, lon in _planet_longitudes(planets).items()
}
ck7 = jaimini.calc_chara_karaka_7(planet_degs) if planet_degs else {}
table = ck7.get('karaka_table') if isinstance(ck7, dict) else None
if isinstance(table, dict) and table:
short_names = {
'Atmakaraka': 'AK',
'Amatyakaraka': 'AmK',
'Bhratrikaraka': 'BK',
'Matrikaraka': 'MK',
'Putrakaraka': 'PK',
'Gnatikaraka': 'GK',
'Darakaraka': 'DK',
}
chara_karakas = {}
for full_name, row in table.items():
if not isinstance(row, dict):
continue
chara_karakas[full_name] = row
alias = short_names.get(full_name)
if alias:
chara_karakas[alias] = row
modules.setdefault('jaimini', {})['chara_karakas'] = chara_karakas
except Exception as exc:
diagnostics.append({'layer': 'chara_karakas', 'status': 'unavailable', 'reason': exc.__class__.__name__})
if planets and ascendant and not isinstance(modules.get('narayana_dasha'), dict):
try:
narayana = _load_local_module('narayana_dasha')
@@ -160,6 +160,31 @@ def test_local_consultation_layers_include_yogas_transits_and_route_chara() -> N
assert 'chara_dasha' not in wealth['local_consultation_layers']['available']
def test_local_consultation_layers_attach_chara_karakas_from_jaimini() -> None:
from jaimini import calc_chara_karaka_7
chart = _attach_local_consultation_layers(
_handler(),
_base_chart(),
dict(_BIRTH),
{'current_date': _REFERENCE_DATE, 'strict_workflow_route': 'career'},
)
jaimini_mod = chart['modules']['jaimini']
assert jaimini_mod['arudha_padas']['padas']['A10']['name'] == 'Karma Pada (A10)'
karakas = jaimini_mod['chara_karakas']
planet_degs = {
name: float(data['lon']) % 30
for name, data in chart['planets'].items()
}
expected = calc_chara_karaka_7(planet_degs)['karaka_table']
assert karakas['Amatyakaraka']['planet'] == expected['Amatyakaraka']['planet']
assert karakas['AmK']['planet'] == expected['Amatyakaraka']['planet']
assert karakas['Darakaraka']['planet'] == expected['Darakaraka']['planet']
assert karakas['DK']['planet'] == expected['Darakaraka']['planet']
assert karakas['AmK']['degree_in_sign'] == expected['Amatyakaraka']['degree_in_sign']
assert karakas['DK']['rank'] == expected['Darakaraka']['rank']
def test_consultation_computes_transit_trigger_windows(monkeypatch) -> None:
class FakeTransit:
@staticmethod