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>
This commit is contained in:
Jesse_Chen
2026-09-02 18:03:23 +08:00
parent e37f8272ba
commit 7faf8555a5
9 changed files with 6077 additions and 32 deletions
@@ -0,0 +1,97 @@
# 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 · 质量门(本地)
| 门 | 结果 |
|---|---|
| `frontend/node_modules/.bin/tsc --noEmit` | 通过(exit 0 |
| eslint(改动的 generation / 两个测试文件) | 0 error |
| `tsx --test` personal-report-generation + generation-v2 + contract + interpretive-facts + report-blocked-repairs | 64 passed / 0 failed(另 generation 单文件 55/55 |
| `pytest tests/test_consultation_consumer_context.py` | 28 passed |
| `next build` | 未跑(本机质量门已覆盖类型与合同;完整构建留给 staging 推送) |
| staging 真实报告重生 + 上一轮 writer/token 欠账 | 未做,需部署后由委托方核对 `/api/health``.deployment.gitCommit` |
未改 `.gitea/workflows/**`,未提升 main。
---
## 开工回执后的实现顺序
先落引擎 `chara_karakas` 再捕获 golden(使 fixture 含新层),再修提取层与测试。形状契约只读 JSON,不依赖提取实现。
+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 回归绿。未做登录后的 staging 重生(部署后验收)。
- 防复发:分盘 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 治标,值形状与缺失层未修)
- 修复版本:待发布
+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