From 48eb7413527890975ea2bd338a128e196ffcf077 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Fri, 13 Nov 2026 06:03:31 +0800 Subject: [PATCH] Use saved profile gender in personal reports (#228) --- CHANGELOG.md | 5 +++ docs/BUG_HISTORY.md | 14 ++++++ docs/research/pre_work_error_ledger.md | 4 ++ docs/tasks/PROGRESS-report-gender-20261113.md | 45 +++++++++++++++++++ .../src/lib/personal-report-longform-birth.ts | 9 +++- frontend/src/lib/profile-gender.ts | 4 +- .../src/lib/server-owned-birth-profile.ts | 3 +- .../database-report-worker-subject.test.ts | 13 ++++-- .../tests/report-reading-20261027.test.tsx | 5 ++- .../tests/server-owned-birth-profile.test.ts | 5 ++- scripts/consultation_native_layers.py | 6 ++- scripts/jyotish_api_server.py | 12 ++--- scripts/professional_report_reference.py | 24 ++++++++-- scripts/report_render/reading_facts.py | 2 + scripts/report_render/yoga_status_words.py | 12 ++++- .../test_professional_report_reference_api.py | 28 ++++++++++++ tests/test_report_reading_facts.py | 23 +++++++++- tests/test_report_status_words_20261111.py | 23 ++++++++++ 18 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 docs/tasks/PROGRESS-report-gender-20261113.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9f18c1..d72da23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ - 出生分钟未确认时,个人报告用填报的时间范围核对各分盘上升。误差未知时按前后各 30 分钟。必需分盘在范围内换座的结论暂不能判断。 - 沿用当前 Skill 版本(BUG-1864)。 +## 2026-11-13 — 个人报告使用档案中已填的性别 + +- 新生成的个人报告把已填写的档案性别用于既有瑜伽形成条件。空值仍按缺项处理。同一张盘按性别分别缓存。 +- 沿用当前 Skill 版本(BUG-1866)。 + ## 2026-11-12 — 个人报告补充事实核对与依据清单 - 逐节核对宫主与落宫、标准 SAV 总和、阻碍宫及具体健康风险,并拦截没有依据的数值准确率说法。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 4742c8ce..67849e3d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -25172,3 +25172,17 @@ - 防复发:分别核对边界行的状态、两侧误差及来源,并验证临界分盘只影响依赖它的结论。 - 关联记录:#224、#144、BUG-1858、BUG-1848。 - 修复版本:`codex/reading-birth-window-20261113`,待发布。 + +## BUG-1866 | 个人报告遗漏档案性别 + +- 状态:fixed-pending-verify(待独立验收)。 +- 首次发现 / 最近更新:2026-11-13 / 2026-11-13。 +- 影响面:新生成的个人报告、候选瑜伽与解读事实包。 +- 现象:档案已经填写性别,报告仍缺少该输入,现有 Mahabhagya 规则无法判定相应分支。 +- 触发条件:为已填性别的本人或他人档案生成个人报告。 +- 根因:本人查询未选 `gender`,报告载荷与后端归一化均未转交。报告瑜伽表的复算也未把性别送入规则引擎。 +- 修复:接通规范化输入、报告专用的引擎上下文与事实包。相同性别可复用报告缓存,男女或缺省分开。空值保持缺项。 +- 验证:请求字段有值和省略、真实 PostgreSQL 的两条档案读取、同盘缓存隔离和公开盘的既有条件分支。详见 [#228 进度](tasks/PROGRESS-report-gender-20261113.md)。 +- 防复发:测试分别锁定档案权限、请求转交、缓存隔离与公开盘的候选瑜伽结果。 +- 关联记录:#228、#189、BUG-1063。 +- 修复版本:`codex/report-gender-20261113`,待发布。 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 9abaf3b7..4aea5efc 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -2,6 +2,10 @@ Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility. +## 2026-11-13 · Windows 报告预检目录审计超时(#228) + +- 在独立 worktree 上运行 `pre_work_check.py`,目录扫描及预检定向测试超时。延长外层超时后,目录扫描仍受 `preflight_fragment_scan.py` 调用 `audit_fragments.py --strict` 的 120 秒上限约束。Python 运行时、Gitea 远端可见性与外部引擎适配器诊断通过。记录见 [#228 进度](../tasks/PROGRESS-report-gender-20261113.md)。 + ## 2026-11-07 · 三方对照 parity3 产品侧修正(worktree `/workspace/wt-p3fix`,`codex/parity3-fixes-1107`,基于 f70ae94e) - 生时校正的冻结身份(`references/rectification_sealed_holdout.v1.json` 里的 `production_scoring_files`)已随 BUG-1644 退役:`scripts/research/sealed_holdout_rerun.py`、`reported_offset_sweep.py` 都删了,改 `functional_benefics.py` 等文件不再需要 ERR-110 重冻;不要照旧台账去找重冻脚本。 diff --git a/docs/tasks/PROGRESS-report-gender-20261113.md b/docs/tasks/PROGRESS-report-gender-20261113.md new file mode 100644 index 00000000..816c105e --- /dev/null +++ b/docs/tasks/PROGRESS-report-gender-20261113.md @@ -0,0 +1,45 @@ +# #228 执行进度 · 个人报告读取档案性别 + +2026-11-13,Codex。任务书提交 `3809f7f53`,执行基线为 staging `b8da77f43`。staging 的最大已入库编号为 BUG-1864,#227 的待验收分支已使用 BUG-1865,本单使用 BUG-1866。 + +## 读取、计算与缓存 + +- 本人的列是 `profiles.gender`,他人的列是 `chart_profiles.gender`。迁移 `20260927010000_profile_gender.sql` 定义两列为可空的 `text`,只接受 `female` 和 `male`。本人查询补齐 `gender`,他人查询已有该列,继续按属主读取。真实 PostgreSQL 用两份性别不同的虚构档案核对两条 worker 路径。 +- 报告请求用 `parseProfileGender`。缺省或不规范值省略该字段。专业参考版的输入归一化后把性别交给完整阅读与导出参数。事实包在 `subject.gender` 给出 `female`、`male` 或 `unknown`。报告响应缓存以含性别的规范化出生输入为键;档案图表的纯计算缓存沿用原有键。 +- 完整数据版的候选瑜伽表还会从本命图重新调用规则引擎。报告适配层把 D1、性别和既有昼夜计算交给同一个检测器,中文版、英文版与事实包共用其结果。空性别沿用候选状态。聊天调用沿用原参数。 +- Startrack 已启用且有性别时使用已填值,缺失时沿用桥接器的 `female` 后备。报告的默认入口继续关闭桥接。Chara 默认 `kn_rao`;已有选择为 `goel_gendered` 且有性别时才附带 `chara_gender`。没有增加报告入口的 profile 开关或新解读规则。 + +## 既有资料中的性别条件 + +| 位置 | 已有用法 | +| --- | --- | +| `references/strict-workflow-router.md:230` | 配偶指标星分析前核对性别和昼夜。 | +| `references/data-bridge-mapping.md:110` | 配偶征象星资料映射列出性别输入。 | +| `references/spouse-multi-layer-methodology.md:10`、`:284` | 对照配偶指标星的传统口径与昼夜分支,强调需综合多个依据。 | +| `references/dasa-convergence-methodology.md:158` | 婚恋推运资料列出性别区分。 | +| `references/yoga_rules.json:2732`、`scripts/yoga_engine.py:1364` | Mahabhagya 已有星座奇偶、性别和昼夜条件。 | +| `scripts/jaimini.py:522`、`:600` | Goel 性别版读取性别,其他 Chara profile 沿用各自规则。 | + +## 公开盘复验 + +使用仓库 `references/real_case_calibration/minute_rectification_holdout_v4.json` 的 Barack Obama 公开 AA 记录,Lahiri、mean node。同一盘分别传三个输入值,只观察 Mahabhagya 已有规则的输出。 + +| 输入 | 候选瑜伽规则输出 | +| --- | --- | +| `female` | 偶数星座、女性夜生一支按所选来源的形成条件成立。 | +| `male` | 不命中这一支,候选表不列该条。 | +| 省略 | 事实包为 `unknown`,候选的形成检查仍记录性别缺项。 | + +## 既有断言 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| `server-owned-birth-profile.test.ts` 的 `ACCOUNT_BIRTH_SELECT` 以 `birth_time_shichen` 结尾 | 末尾追加 `gender`,其余列与顺序相同 | 本人报告复用属主查询读取档案性别 | + +## 验证 + +- 前端定向 66 项与真实 PostgreSQL 3 项通过,取消 0。TypeScript 和 Next 构建通过,首页为 Static,首屏 JS gzip 515,860 字节、CSS gzip 90,906 字节,与开工基线相同。ESLint 0 错误、62 条既有警告。 +- 前端全量 3,683 项,3,656 通过、27 失败、0 取消。以 #227 合入 #219 后的同机报告去掉 #227 专属的五条用例作为对照,测试名称无增删,27 条失败逐项一致。 +- Python 定向 65 项通过,包括报告 API、事实包、缓存、瑜伽复算、咨询旧路径和 API 增长护栏。另通过完整数据版 API 分别生成上表三个输入的报告,核对事实包和候选瑜伽输出。Startrack 参数通过单元测试,未启动外部桥接器。 +- Python 快速门通过静态检查,pytest 1,461 项通过、2 跳过、5 失败。3 项 PyJHora 与基线一致,VedAstro 差异单项复跑通过。另有一项隐私检查因新增测试移动了已准入的合成路径样例行号而失败。将新增测试放至文件末尾并恢复原行号后,隐私 62 项和报告 API 15 项复跑通过。 +- 开工预检的远端可见性与外部适配器诊断通过;Windows 的目录审计及预检定向套件在延长超时后仍超时。 diff --git a/frontend/src/lib/personal-report-longform-birth.ts b/frontend/src/lib/personal-report-longform-birth.ts index 5773c3ff..73e76ad6 100644 --- a/frontend/src/lib/personal-report-longform-birth.ts +++ b/frontend/src/lib/personal-report-longform-birth.ts @@ -3,6 +3,8 @@ import { } from "./server-owned-birth-profile"; import { uncertaintyFromDeclaredRange } from "./birth-time-intake-model"; import { declaredClockRangeFromProfile, isBirthClockText, isShichenId, SHICHEN_RANGES } from "./declared-birth-window"; +import { asRecord } from "./guards"; +import { parseProfileGender } from "./profile-gender"; export function utcToday(now = new Date()): string { return now.toISOString().slice(0, 10); @@ -100,8 +102,10 @@ export function buildLongformBirthPayload( } const year = Number.parseInt(dateMatch[1], 10); const readingMinuteConfirmed = extras.readingMinuteConfirmed ?? extras.birthTimeAccuracy === "confirmed"; - const birthWindow = !readingMinuteConfirmed && row && typeof row === "object" - ? readingBirthWindow(row as Record, timeMatch[0]) + const profileRow = asRecord(row); + const gender = parseProfileGender(profileRow?.gender); + const birthWindow = !readingMinuteConfirmed && profileRow + ? readingBirthWindow(profileRow, timeMatch[0]) : undefined; return { year, @@ -113,6 +117,7 @@ export function buildLongformBirthPayload( lon: profile.longitude, tz: profile.timezoneOffset, ayanamsa: profile.ayanamsa, + ...(gender ? { gender } : {}), format: "markdown", packs: ["full"], today: extras.today, diff --git a/frontend/src/lib/profile-gender.ts b/frontend/src/lib/profile-gender.ts index f9d58b3b..10c3b47d 100644 --- a/frontend/src/lib/profile-gender.ts +++ b/frontend/src/lib/profile-gender.ts @@ -2,8 +2,8 @@ * Optional gender on a chart profile (TASK-consult-gender-optional-20260927). * * Stored as `female` / `male`; null (or a missing key) means "not filled". - * The value only decides which spouse significator the marriage evidence card - * and the condensed checklist name; it never changes the engine calculation. + * Consultation uses it for spouse significators. The report passes it to + * existing rules with gender prerequisites. It does not change chart positions. * Privacy: same level as birth data (never in logs, telemetry, feedback * records, BUG history or test fixtures with real data). */ diff --git a/frontend/src/lib/server-owned-birth-profile.ts b/frontend/src/lib/server-owned-birth-profile.ts index bb187679..d9bfe8f3 100644 --- a/frontend/src/lib/server-owned-birth-profile.ts +++ b/frontend/src/lib/server-owned-birth-profile.ts @@ -5,10 +5,11 @@ import { effectiveBirthDate } from "./effective-birth-date.ts"; const usableActiveStatuses = new Set(["accepted", "confirmed"]); export const ACCOUNT_BIRTH_SELECT = - "name,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,active_birth_time,birth_time,birth_time_status,birth_time_source,birth_time_period,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id,ayanamsa,birth_place_label,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_uncertainty_known,declared_window_start,declared_window_end,birth_time_clue,birth_time_shichen" as const; + "name,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,active_birth_time,birth_time,birth_time_status,birth_time_source,birth_time_period,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id,ayanamsa,birth_place_label,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_uncertainty_known,declared_window_start,declared_window_end,birth_time_clue,birth_time_shichen,gender" as const; export type AccountBirthRow = Readonly<{ name?: unknown; + gender?: unknown; birth_date?: unknown; active_birth_date?: unknown; active_birth_timezone_offset?: unknown; diff --git a/frontend/tests/database-report-worker-subject.test.ts b/frontend/tests/database-report-worker-subject.test.ts index 2d91043a..65f34ed4 100644 --- a/frontend/tests/database-report-worker-subject.test.ts +++ b/frontend/tests/database-report-worker-subject.test.ts @@ -37,14 +37,18 @@ test("service_role can read another person's chart profile for a report", { const subjectId = randomUUID(); fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at) values ('${owner}', 'Fictional owner', '${owner}@example.invalid', true, now());`); + fixture.psql(`update public.profiles set name = '虚构本人', birth_date = '1990-01-01', + reported_birth_time = '09:00', birth_time_source = 'family_exact', birth_time_status = 'reported', + latitude = 22.3, longitude = 114.1, timezone_id = 'Asia/Shanghai', timezone_offset = 8, gender = 'male' + where id = '${owner}';`); fixture.psql(`insert into public.chart_profiles ( id, user_id, role, profile, updated_at, name, birth_date, reported_birth_time, birth_time_source, latitude, longitude, timezone_id, timezone_offset, - birth_place_label, ayanamsa, birth_time_status + birth_place_label, ayanamsa, birth_time_status, gender ) values ( '${subjectId}', '${owner}', 'other', '{"name":"虚构乙"}'::jsonb, now(), '虚构乙', '1990-01-01', '08:00', 'family_exact', 22.3, 114.1, - 'Asia/Shanghai', 8, '虚构港', 'lahiri', 'reported' + 'Asia/Shanghai', 8, '虚构港', 'lahiri', 'reported', 'female' );`); const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role"); @@ -55,8 +59,11 @@ test("service_role can read another person's chart profile for a report", { .maybeSingle(); assert.equal(probe.error, null, probe.error?.message ?? "chart_profiles select failed"); - const loaded = await loadReportWorkerProfile(admin, owner, subjectId) as { name?: string }; + const loaded = await loadReportWorkerProfile(admin, owner, subjectId) as { name?: string; gender?: string }; assert.equal(loaded.name, "虚构乙"); + assert.equal(loaded.gender, "female"); + const self = await loadReportWorkerProfile(admin, owner, "self") as { gender?: string }; + assert.equal(self.gender, "male"); for (const column of CHART_SUBJECT_SELECT.split(",")) { assert.equal( diff --git a/frontend/tests/report-reading-20261027.test.tsx b/frontend/tests/report-reading-20261027.test.tsx index 734a3256..cdd89356 100644 --- a/frontend/tests/report-reading-20261027.test.tsx +++ b/frontend/tests/report-reading-20261027.test.tsx @@ -455,12 +455,15 @@ for (const mode of ["reading", "model-down", "no-writer"]) { if (mode === "model-down") throw new Error("model down"); return { body: draft, inputTokens: 1200, outputTokens: 800 }; }, { prompt: { system: "system", skill: ${JSON.stringify(SKILL)} } }); - const result = await generatePersonalReportLongform({ report, admin, profile: { birth_date: "2000-01-01", reported_birth_time: "12:00", birth_time_status: "reported", latitude: 0, longitude: 0, timezone_offset: 0, ayanamsa: "lahiri" }, candidateRange: null, displayName: "虚构测试", birthTimeStatus: "reported", now: () => new Date("2026-10-27T12:00:00Z"), + const gender = mode === "reading" ? "female" : mode === "model-down" ? "unknown" : null; + const result = await generatePersonalReportLongform({ report, admin, profile: { birth_date: "2000-01-01", reported_birth_time: "12:00", birth_time_status: "reported", latitude: 0, longitude: 0, timezone_offset: 0, ayanamsa: "lahiri", gender }, candidateRange: null, displayName: "虚构测试", birthTimeStatus: "reported", now: () => new Date("2026-10-27T12:00:00Z"), fetchImpl: async (_url, init) => { bodies.push(JSON.parse(init.body)); return Response.json({ format: "markdown", markdown: golden.markdown, fact_table_packet: packet, reader_dasha_applicability: golden.reader_dasha_applicability, ...(mode === "no-writer" ? {} : { reading_facts: facts }) }); }, ...(writeReading ? { writeReading } : {}) }); assert.equal(result.status, "ready"); assert.equal(bodies.length, 1); assert.equal(bodies[0].include_reading_facts, mode === "no-writer" ? undefined : true); + if (mode === "reading") assert.equal(bodies[0].gender, "female"); + else assert.equal(Object.hasOwn(bodies[0], "gender"), false); const stored = await readLongformSnapshot(admin, { reportId: report.id, userId: report.userId, requestId: report.requestId }); assert.equal(stored.status, "ready"); assert.equal(stored.snapshot.markdown, golden.markdown); diff --git a/frontend/tests/server-owned-birth-profile.test.ts b/frontend/tests/server-owned-birth-profile.test.ts index b3f3d28b..437249a3 100644 --- a/frontend/tests/server-owned-birth-profile.test.ts +++ b/frontend/tests/server-owned-birth-profile.test.ts @@ -87,9 +87,12 @@ test("daily and synastry routes share a literal account-birth select string", () // 原值(#165 前):…,declared_window_start,declared_window_end 结尾 // 新值:末尾多 birth_time_clue,birth_time_shichen 两列,其余列与顺序不变 // 原因:issue #165 §5,只记得时辰的人对「出生日期确定吗」的回答存在 birth_time_clue,星盘页据此提示先核实 + // 原值(#228 前):birth_time_shichen 结尾 + // 新值:末尾追加 gender,其余列与顺序不变 + // 原因:报告读取本人的档案性别,复用这里的属主查询 assert.equal( ACCOUNT_BIRTH_SELECT, - "name,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,active_birth_time,birth_time,birth_time_status,birth_time_source,birth_time_period,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id,ayanamsa,birth_place_label,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_uncertainty_known,declared_window_start,declared_window_end,birth_time_clue,birth_time_shichen", + "name,birth_date,active_birth_date,active_birth_timezone_offset,active_birth_provenance,reported_birth_time,active_birth_time,birth_time,birth_time_status,birth_time_source,birth_time_period,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,timezone_id,ayanamsa,birth_place_label,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_uncertainty_known,declared_window_start,declared_window_end,birth_time_clue,birth_time_shichen,gender", ); // 原值: the daily route selected ACCOUNT_BIRTH_SELECT inline. // 新值: subject-birth.ts selects it; the daily route calls loadSubjectBirth. diff --git a/scripts/consultation_native_layers.py b/scripts/consultation_native_layers.py index b5c95695..d5bc449f 100644 --- a/scripts/consultation_native_layers.py +++ b/scripts/consultation_native_layers.py @@ -760,7 +760,8 @@ def merge_detected_yogas(*groups: list) -> list[dict]: def collect_consultation_yogas(handler, chart: dict, birth_payload: dict, planets: dict, - ascendant: Any, compact_entry, evaluation_out: dict | None = None) -> list[dict]: + ascendant: Any, compact_entry, evaluation_out: dict | None = None, + *, rule_context: dict | None = None) -> list[dict]: """Every yoga the consultation sieve sees: the chart's own list plus the rule engine. BUG-1174 (TASK-consult-career-yoga-functional-20261002 T2): the rule engine @@ -772,7 +773,8 @@ def collect_consultation_yogas(handler, chart: dict, birth_payload: dict, planet raw = chart.get("yogas") if isinstance(chart.get("yogas"), list) else [] own = [entry for entry in (compact_entry(item) for item in raw) if entry] try: - api = handler._compute_yogas_api({**birth_payload, "planets": planets, "ascendant": ascendant}) + options = {"rule_context": rule_context} if rule_context is not None else {} + api = handler._compute_yogas_api({**birth_payload, "planets": planets, "ascendant": ascendant}, **options) except Exception: if not own: raise diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 0f68b961..ed09008d 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -1577,7 +1577,7 @@ def _consultation_dasha_sub_periods(chart: dict, body: dict) -> dict | None: } -def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, body: dict) -> dict: +def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, body: dict, *, yoga_rule_context=None) -> dict: """Attach locally-computable consultation layers before reports/evidence are assembled. VedAstro is an optional external cross-check for the web chat. D9/D10, Arudha @@ -1783,7 +1783,7 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, from scripts.consultation_native_layers import collect_consultation_yogas # BUG-1174 engine_evaluation: dict = {} compact = collect_consultation_yogas(handler, chart, birth_payload, planets, ascendant, _compact_yoga_entry, - evaluation_out=engine_evaluation) + evaluation_out=engine_evaluation, rule_context=yoga_rule_context) routes = _consultation_declared_routes(body) unevaluated = list(engine_evaluation.get('evaluation_failures') or []) + list( engine_evaluation.get('unimplemented_rules') or []) @@ -5937,7 +5937,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): except ValueError as e: raise BadRequest('Invalid birth date') from e - def _compute_full_reading_for_thematic(self, body): + def _compute_full_reading_for_thematic(self, body, *, engine_context=None): engine = _load_local_module('jyotish_engine') year = self._get_int(body, 'year', 1990, 1800, 2400) month = self._get_int(body, 'month', 6, 1, 12) @@ -5973,7 +5973,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): else: age = self._get_int(body, 'age', target_year - year, 0, 120) - args = type('Args', (), { + args = type('Args', (), {**(engine_context or {}), 'year': year, 'month': month, 'day': day, @@ -7725,7 +7725,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): except shadbala_api.ShadbalaInputError as exc: raise BadRequest(str(exc)) from exc - def _compute_yogas_api(self, body): + def _compute_yogas_api(self, body, *, rule_context=None): planets, _, asc_sign_idx = self._normalized_planets_from_body(body) if not planets: raise BadRequest('planets must include longitude data') @@ -7734,7 +7734,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): # 占星师第三轮审查 2026-11-07: a failed engine run or a rule that could # not be evaluated is reported as such, never as "no yoga formed". try: - status = _load_local_module('yoga_engine').detect_yogas_with_status(planets, asc_sign) + status = _load_local_module('yoga_engine').detect_yogas_with_status(planets, asc_sign, context=rule_context) engine_yogas = status['yogas'] engine_evaluation = {key: status[key] for key in ('evaluation_status', 'rules_evaluated', 'evaluation_failures', 'unimplemented_rules')} diff --git a/scripts/professional_report_reference.py b/scripts/professional_report_reference.py index fd2d3a93..43e4c7e4 100644 --- a/scripts/professional_report_reference.py +++ b/scripts/professional_report_reference.py @@ -26,6 +26,7 @@ try: from scripts.reader_dasha_applicability import render_dasha_applicability from scripts.report_density_packet import report_density_packet from scripts.report_render.birth_precision_window import report_birth_window + from scripts.report_render.yoga_status_words import report_yoga_conditions except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path from pl9_full_data_export import ( FULL_DATA_EDITION, @@ -36,6 +37,7 @@ except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path from reader_dasha_applicability import render_dasha_applicability from report_density_packet import report_density_packet from report_render.birth_precision_window import report_birth_window + from report_render.yoga_status_words import report_yoga_conditions try: from scripts.report_perf_flags import report_reference_cache_enabled @@ -194,6 +196,12 @@ def _export_args(birth: dict[str, Any]) -> SimpleNamespace: "visual_chart_observations": birth.get("visual_chart_observations"), "startrack_language_bridge": bool(birth.get("startrack_language_bridge")), } + gender = birth.get("gender") + if gender in ("female", "male"): + if payload["startrack_language_bridge"]: + payload["startrack_gender"] = gender + if payload.get("chara_profile") == "goel_gendered": + payload["chara_gender"] = gender return SimpleNamespace(**payload) @@ -266,12 +274,17 @@ def _assemble_professional_report_reference( engine, reading: dict[str, Any] | None = None, ) -> dict[str, Any]: - full_reading = handler._compute_full_reading_for_thematic(birth) + args = _export_args(birth) + engine_context = {key: value for key, value in vars(args).items() + if key in ("gender", "chara_profile", "chara_gender")} + full_reading = handler._compute_full_reading_for_thematic( + birth, **({"engine_context": engine_context} if engine_context else {}), + ) resolved_engine = engine or _load_engine() try: packet = resolved_engine.build_professional_report_reference_packet( full_reading, - _export_args(birth), + args, packs, include_annual_method_reference=edition == FULL_DATA_EDITION, ) @@ -283,6 +296,9 @@ def _assemble_professional_report_reference( packet["report_version"] = REPORT_VERSION_FULL if output_format == "markdown": if edition == FULL_DATA_EDITION: + if "gender" in birth: + # Prime the report's shared yoga projection before either edition renders. + report_yoga_conditions(packet, gender=birth["gender"]) # English renders from its own deep copy first, so nothing the Chinese # renderer does to the packet can reach it (and vice versa). english = _english_edition(packet, include_fact_tables) if "en" in languages else {} @@ -299,7 +315,7 @@ def _assemble_professional_report_reference( if include_fact_tables else {}), **(english if edition == FULL_DATA_EDITION and "en" in languages else {}), **({"reading_facts": _reading_facts( - packet, full_reading, **reading, + packet, full_reading, gender=birth.get("gender"), **reading, )} if reading is not None and edition == FULL_DATA_EDITION else {}), } return { @@ -343,6 +359,8 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine raise ProfessionalReportReferenceInputError("languages requires format markdown and edition full_data") birth = handler._high_rigor_birth_payload(body) + if body.get("gender") in ("female", "male"): + birth["gender"] = body["gender"] include_fact_tables = body.get("include_fact_tables") is True reading = _reading_request(body) if edition == FULL_DATA_EDITION and output_format == "markdown" else None sha = _report_cache_sha() diff --git a/scripts/report_render/reading_facts.py b/scripts/report_render/reading_facts.py index 286f5135..683c4519 100644 --- a/scripts/report_render/reading_facts.py +++ b/scripts/report_render/reading_facts.py @@ -855,6 +855,7 @@ def build_report_reading_facts( report_mode: str = "living_consultation", subject_life_status: str = "unknown", birth_time_window: object = None, + gender: str | None = None, ) -> dict[str, Any]: """Packet-sourced facts for the reading. Raises on any non-packet consult leaf.""" if report_mode not in ("living_consultation", "historical_replay"): @@ -907,6 +908,7 @@ def build_report_reading_facts( "minute_confirmed": minute_confirmed, "report_mode": report_mode, "subject_life_status": subject_life_status, + "subject": {"gender": gender if gender in ("female", "male") else "unknown"}, "current_advice_allowed": report_mode == "living_consultation", "observation_date": today or reference_day, "birth_parameters": _birth_parameters(working, minute_confirmed), diff --git a/scripts/report_render/yoga_status_words.py b/scripts/report_render/yoga_status_words.py index 6b5bb95d..5886d401 100644 --- a/scripts/report_render/yoga_status_words.py +++ b/scripts/report_render/yoga_status_words.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date, time +from types import SimpleNamespace from .english_gaps import HAN @@ -92,7 +93,7 @@ def _project_rows(rows, fields): return [{key: row[key] for key in fields if key in row} for row in rows or [] if isinstance(row, dict)] -def report_yoga_conditions(packet): +def report_yoga_conditions(packet, *, gender=None): cached = packet.get("report_yoga_conditions") if isinstance(cached, dict): return cached @@ -106,8 +107,15 @@ def report_yoga_conditions(packet): payload, body = inputs try: service = JyotishComputeService() + rule_context = None + if gender in ("female", "male"): + try: + from scripts.jyotish_engine import _yoga_birth_context + except ModuleNotFoundError: + from jyotish_engine import _yoga_birth_context + rule_context = {"chart_type": "D1", **_yoga_birth_context(SimpleNamespace(**payload, gender=gender))} chart = service._compute_chart_sync({**payload, "skip_vedastro_main_entry_overview": True}) - chart = _attach_local_consultation_layers(service, chart, payload, body) + chart = _attach_local_consultation_layers(service, chart, payload, body, yoga_rule_context=rule_context) layer = _record(_record(chart.get("consultation_native_layers")).get("yoga_conditions")) except Exception as error: layer = {"status": "blocked", "reason": type(error).__name__} diff --git a/tests/test_professional_report_reference_api.py b/tests/test_professional_report_reference_api.py index 4410113b..f4e84f3b 100644 --- a/tests/test_professional_report_reference_api.py +++ b/tests/test_professional_report_reference_api.py @@ -236,3 +236,31 @@ def test_http_endpoint_maps_invalid_format_to_bad_request() -> None: handler.do_POST() assert handler.status_code == 400 assert handler.payload()["error_code"] == "ERR_BAD_REQUEST" + + +@pytest.mark.parametrize(("value", "expected"), [("female", "female"), ("male", "male"), (None, None), ("unknown", None)]) +def test_report_uses_only_a_known_gender_for_both_engine_steps(value, expected) -> None: + class GenderHandler(_ReferenceHandler): + def _compute_full_reading_for_thematic(self, body, *, engine_context=None): + self.engine_context = engine_context + return super()._compute_full_reading_for_thematic(body) + + handler, engine = GenderHandler(), _ReferenceEngine() + build_professional_report_reference(handler, {**BIRTH, "gender": value}, engine=engine) + full_reading, args, _ = engine.calls[0] + assert getattr(args, "gender", None) == expected + assert full_reading["birth"].get("gender") == expected + assert handler.engine_context == ({"gender": expected} if expected else None) + assert not hasattr(args, "chara_gender") + assert not hasattr(args, "startrack_gender") + + +def test_report_gender_follows_the_selected_optional_engine_profiles() -> None: + args = _export_args({**BIRTH, "gender": "male", "startrack_language_bridge": True, + "chara_profile": "goel_gendered"}) + assert args.startrack_gender == "male" + assert args.chara_gender == "male" + unknown = _export_args({**BIRTH, "startrack_language_bridge": True, "chara_profile": "goel_gendered"}) + assert unknown.startrack_language_bridge + assert not hasattr(unknown, "startrack_gender") + assert not hasattr(unknown, "chara_gender") diff --git a/tests/test_report_reading_facts.py b/tests/test_report_reading_facts.py index 77a9350f..7f6da44c 100644 --- a/tests/test_report_reading_facts.py +++ b/tests/test_report_reading_facts.py @@ -155,7 +155,7 @@ class _Handler: payload["today"] = body["today"] return payload - def _compute_full_reading_for_thematic(self, _birth: dict) -> dict: + def _compute_full_reading_for_thematic(self, _birth: dict, *, engine_context=None) -> dict: self.calls += 1 return self.reading @@ -207,6 +207,27 @@ def test_flag_off_response_is_unchanged(stubbed) -> None: assert sorted(absent) == ["edition", "fact_table_packet", "format", "markdown", "reader_dasha_applicability", "report_version"] +def test_report_fact_cache_separates_the_same_chart_by_gender(stubbed, monkeypatch) -> None: + handler, engine = stubbed + monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "1") + monkeypatch.setenv("GITHUB_SHA", "report-facts-gender-test") + rendered_genders = [] + render_yoga_conditions = reference.report_yoga_conditions + + def capture_yoga_conditions(packet, *, gender): + rendered_genders.append(gender) + return render_yoga_conditions(packet, gender=gender) + + monkeypatch.setattr(reference, "report_yoga_conditions", capture_yoga_conditions) + for provided, expected in [("female", "female"), ("male", "male"), (None, "unknown"), ("female", "female")]: + result = build_professional_report_reference( + handler, _body(include_reading_facts=True, gender=provided), engine=engine, + ) + assert result["reading_facts"]["subject"]["gender"] == expected + assert handler.calls == 3 + assert rendered_genders == ["female", "male"] + + def test_flag_on_adds_reading_facts_and_keeps_the_report(stubbed) -> None: handler, engine = stubbed plain = build_professional_report_reference(handler, _body(), engine=engine) diff --git a/tests/test_report_status_words_20261111.py b/tests/test_report_status_words_20261111.py index 2d8bed44..78ed88e0 100644 --- a/tests/test_report_status_words_20261111.py +++ b/tests/test_report_status_words_20261111.py @@ -174,6 +174,29 @@ def _engine_yoga(packet, **route): return chart["consultation_native_layers"]["yoga_conditions"] +def test_report_yoga_reuses_the_gender_branch_on_a_public_chart(): + source = ROOT / "references/real_case_calibration/minute_rectification_holdout_v4.json" + cases = json.loads(source.read_text(encoding="utf-8"))["cases"] + birth = next(case["birth"] for case in cases if case["case_id"] == "barack_obama_1961_aa_v4_holdout") + packet = { + "birth_info": { + "date": birth["date"], "time": birth["time"], "lat": birth["latitude"], + "lon": birth["longitude"], "tz": birth["timezone_offset"], + }, + "calculation_profile": {"ayanamsa": "lahiri", "node_mode": "mean"}, + "generated_at": "2026-11-13", + } + + def mahabhagya(gender): + layer = report_yoga_conditions(deepcopy(packet), gender=gender) + assert layer["status"] == "executed" + return [row["formation"] for row in layer["rows"] if row["name"] == "Mahabhagya Yoga"] + + assert "gender" in mahabhagya(None)[0]["missing_conditions"] + assert mahabhagya("male") == [] + assert mahabhagya("female")[0]["status"] == "formed_under_source" + + @pytest.mark.parametrize("case", PUBLIC_REPORT_CASES) def test_public_reports_match_native_yoga_status_and_audit_axes(case): packet, chinese, english = public_chart_reports(case)