diff --git a/.env.official.example b/.env.official.example new file mode 100644 index 00000000..fc017ca8 --- /dev/null +++ b/.env.official.example @@ -0,0 +1,11 @@ +# Copy this file to .env.local when you want VedAstro official extended mode. +# The default fast mode intentionally keeps VEDASTRO_TIMEOUT_SECONDS low so the +# local app stays responsive and falls back honestly when official evidence is blocked. + +VEDASTRO_API_ENDPOINT=https://api.vedastro.org/api +VEDASTRO_ENABLE_NETWORK=1 +VEDASTRO_TIMEOUT_SECONDS=20 + +# Optional. Use only if your VedAstro endpoint requires a key. +# VEDASTRO_API_KEY=replace_me + diff --git a/README.md b/README.md index b8a6a929..7e88a34a 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ VEDASTRO_ENABLE_NETWORK=1 该文件已被 `.gitignore` 忽略;`scripts/jyotish_api_server.py`、`scripts/vedastro_service_adapter.py`、`scripts/run_quality_gate.py` 会自动加载它。注意:当前网页按钮走的是官方 `SearchEvents` 的真实 live 采样扫描桥接,不会让 VedAstro 越权改本地 adjudicator 的 `score / dominant_label / payout_label`。 +默认运行是快速模式:VedAstro official 证据层如果没有在前台预算内闭环,会诚实标记 `official_snapshot_budget_exhausted` 并退回本地 Swiss Ephemeris。要跑 official extended 模式,复制 `.env.official.example` 为 `.env.local` 并填好 endpoint/network/key;运行 `python3 scripts/diagnose_vedastro_mode.py` 可先确认当前是 `fast_local_fallback` 还是 `official_extended`。 + ### 普通用户交付形态 | 形态 | 入口 | 命令 | 能力边界 | diff --git a/docs/benchmark/tajika_einstein_1905_copy_ready_checklist.md b/docs/benchmark/tajika_einstein_1905_copy_ready_checklist.md new file mode 100644 index 00000000..36162d0a --- /dev/null +++ b/docs/benchmark/tajika_einstein_1905_copy_ready_checklist.md @@ -0,0 +1,106 @@ +# Tajika Einstein 1905 Copy-Ready Checklist + +Packet: +`references/oracle/artifacts/pending_packets/external_template_einstein_varshaphala_1905_lahiri.json` + +Steve Jobs sample: +`references/oracle/artifacts/pending_packets/external_template_steve_jobs_varshaphala_1984_lahiri_pyjhora_20260627.json` + +Use one external source only: +- `JHora` +- `PyJHora` +- printed Tajika/Varshaphala example + +Do not use local `scripts/varshaphala.py` output as evidence. + +## Copy Steve Jobs Pattern Directly + +Copy the same writing pattern for: + +- `status`: set to `external_verified` only after all fields below are filled +- `metadata.tool_name` +- `metadata.tool_version_or_url` +- `metadata.capture_date` +- `metadata.source_artifact` +- `metadata.operator_note` +- `target.source_artifact` + +Recommended metadata pattern: + +- `metadata.tool_name`: `PyJHora` or `JHora` +- `metadata.tool_version_or_url`: version string or source citation +- `metadata.capture_date`: `YYYY-MM-DD` +- `metadata.source_artifact`: `references/oracle/artifacts/` +- `metadata.operator_note`: note ayanamsa, node mode, timezone, solar-return convention, and any workaround +- `target.source_artifact`: same artifact path as above + +## Must Fill From External Source + +These values must be copied from the external annual-chart source, not inferred locally: + +- `target.solar_return_datetime` +- `target.varsha_lagna_deg` +- `target.muntha_sign` +- `target.year_lord` +- `target.mudda_dasha_first_lord` +- `target.sahams.punya_saham` +- `target.sahams.rajya_saham` +- `target.sahams.vivah_saham` +- `target.tajika_yogas` + +## Fast Fill Order + +1. Fill metadata first: + - `metadata.tool_name` + - `metadata.tool_version_or_url` + - `metadata.capture_date` + - `metadata.source_artifact` + - `metadata.operator_note` + +2. Fill annual header: + - `target.solar_return_datetime` + - `target.varsha_lagna_deg` + +3. Fill annual rulership: + - `target.muntha_sign` + - `target.year_lord` + - `target.mudda_dasha_first_lord` + +4. Fill Sahams: + - `target.sahams.punya_saham` + - `target.sahams.rajya_saham` + - `target.sahams.vivah_saham` + +5. Fill yoga block: + - `target.tajika_yogas` + +6. Set: + - `target.source_artifact` + - `status = external_verified` + +## Minimal Screen / Output Capture + +Capture only these: + +1. Settings / source header +2. Solar return datetime +3. Varsha Lagna +4. Muntha + Year Lord +5. First Mudda Dasha lord +6. Sahams block +7. Tajika Yogas block + +## Final Apply / Validate + +```bash +python3 scripts/tajika_annual_oracle_queue.py \ + --oracle-file references/oracle/tajika_annual_oracle_cases.json \ + --apply-packet references/oracle/artifacts/pending_packets/external_template_einstein_varshaphala_1905_lahiri.json \ + --format json +``` + +```bash +python3 scripts/tajika_annual_benchmark_dashboard.py \ + --oracle-file references/oracle/tajika_annual_oracle_cases.json \ + --format json +``` diff --git a/docs/benchmark/tajika_einstein_1905_field_copy_template.md b/docs/benchmark/tajika_einstein_1905_field_copy_template.md new file mode 100644 index 00000000..ac42558a --- /dev/null +++ b/docs/benchmark/tajika_einstein_1905_field_copy_template.md @@ -0,0 +1,216 @@ +# Tajika Einstein 1905 Field Copy Template + +目标 packet: +`references/oracle/artifacts/pending_packets/external_template_einstein_varshaphala_1905_lahiri.json` + +已完成样板: +`references/oracle/artifacts/pending_packets/external_template_steve_jobs_varshaphala_1984_lahiri_pyjhora_20260627.json` + +用途: +- 给你或副手直接照抄字段结构 +- 只替换 Einstein 1905 对应的外部真值 +- 尽量不思考格式, 只做复制和填空 + +--- + +## 1. 可直接照抄的整体填写顺序 + +1. 先复制 Steve Jobs packet 的 `metadata` 写法 +2. 再填 Einstein 1905 的外部年盘真值 +3. 最后把 `status` 改成 `external_verified` + +--- + +## 2. 逐字段复制模板 + +把下面整段当作填空卡使用: + +```json +{ + "status": "external_verified", + "metadata": { + "tool_name": "", + "tool_version_or_url": "", + "capture_date": "", + "source_artifact": "references/oracle/artifacts/", + "ayanamsa": "lahiri", + "node_mode": "mean", + "timezone": "UTC+00:53", + "annual_system": "Varshaphala/Tajika", + "target_year": 1905, + "operator_note": "" + }, + "target_placeholders": { + "target.solar_return_datetime": "", + "target.varsha_lagna_deg": , + "target.muntha_sign": "", + "target.year_lord": "", + "target.mudda_dasha_first_lord": "", + "target.sahams.punya_saham": , + "target.sahams.rajya_saham": , + "target.sahams.vivah_saham": , + "target.tajika_yogas": "", + "target.source_artifact": "references/oracle/artifacts/" + } +} +``` + +--- + +## 3. 哪些字段直接照 Steve Jobs 抄格式 + +这些字段只需要抄格式, 不需要重新想写法: + +- `status` +- `metadata.tool_name` +- `metadata.tool_version_or_url` +- `metadata.capture_date` +- `metadata.source_artifact` +- `metadata.operator_note` +- `metadata.ayanamsa` +- `metadata.annual_system` +- `metadata.target_year` +- `target.source_artifact` + +建议直接套这个写法: + +```json +"metadata": { + "tool_name": "PyJHora", + "tool_version_or_url": "PyJHora isolated /tmp black-box run", + "capture_date": "", + "source_artifact": "references/oracle/artifacts/", + "ayanamsa": "lahiri", + "node_mode": "mean", + "timezone": "UTC+00:53", + "annual_system": "Varshaphala/Tajika", + "target_year": 1905, + "operator_note": "Black-box annual output from . External evidence only; local annual engine output not used. Solar-return convention: . Timezone/DST handling: . " +} +``` + +--- + +## 4. 哪些字段必须从外部源逐个抄 + +这些值禁止本地推断: + +- `target.solar_return_datetime` +- `target.varsha_lagna_deg` +- `target.muntha_sign` +- `target.year_lord` +- `target.mudda_dasha_first_lord` +- `target.sahams.punya_saham` +- `target.sahams.rajya_saham` +- `target.sahams.vivah_saham` +- `target.tajika_yogas` + +--- + +## 5. 逐字段抄写提示 + +### A. metadata + +```json +"tool_name": "PyJHora" +``` +- 填你实际使用的工具名 + +```json +"tool_version_or_url": "PyJHora isolated /tmp black-box run" +``` +- 直接抄 Steve Jobs 文风 +- 如果是 JHora, 改成 JHora 对应版本说明 + +```json +"capture_date": "2026-06-29" +``` +- 填今天真实采集日期 + +```json +"source_artifact": "references/oracle/artifacts/" +``` +- 填你保存的 stdout / 截图 / 引文文件 + +```json +"operator_note": "Black-box annual output from . External evidence only; local annual engine output not used. Solar-return convention: . Timezone/DST handling: . " +``` +- 这句可以直接复制 +- 只替换 `` + +### B. annual header + +```json +"target.solar_return_datetime": "" +``` +- 从年盘抬头直接抄 + +```json +"target.varsha_lagna_deg": +``` +- 抄绝对黄经 +- 如果外部只给星座+度分秒, 先换算后再填 + +### C. annual rulership + +```json +"target.muntha_sign": "" +"target.year_lord": "" +"target.mudda_dasha_first_lord": "" +``` + +### D. sahams + +```json +"target.sahams.punya_saham": +"target.sahams.rajya_saham": +"target.sahams.vivah_saham": +``` +- 统一填绝对黄经 + +### E. tajika_yogas + +```json +"target.tajika_yogas": { + "": null +} +``` +- 最省事方法: + 1. 直接照外部源标签抄 + 2. 如果是 PyJHora stdout, 尽量保持与 Steve Jobs 样板同结构 + 3. 不要翻译成我们本地解释层术语 + +### F. artifact echo + +```json +"target.source_artifact": "references/oracle/artifacts/" +``` +- 与 `metadata.source_artifact` 保持一致 + +--- + +## 6. 最短实操法 + +最快方法不是从空模板填, 而是: + +1. 打开 Steve Jobs packet +2. 复制它的 `metadata` 和 `target_placeholders` 结构 +3. 仅替换 Einstein 1905 对应值 +4. 保存到 Einstein packet + +--- + +## 7. 填完后直接执行 + +```bash +python3 scripts/tajika_annual_oracle_queue.py \ + --oracle-file references/oracle/tajika_annual_oracle_cases.json \ + --apply-packet references/oracle/artifacts/pending_packets/external_template_einstein_varshaphala_1905_lahiri.json \ + --format json +``` + +```bash +python3 scripts/tajika_annual_benchmark_dashboard.py \ + --oracle-file references/oracle/tajika_annual_oracle_cases.json \ + --format json +``` diff --git a/docs/benchmark/tajika_einstein_1905_packet_paste_block.jsonc b/docs/benchmark/tajika_einstein_1905_packet_paste_block.jsonc new file mode 100644 index 00000000..1e279d8d --- /dev/null +++ b/docs/benchmark/tajika_einstein_1905_packet_paste_block.jsonc @@ -0,0 +1,29 @@ +{ + "status": "external_verified", + "metadata": { + "tool_name": "PyJHora", + "tool_version_or_url": "PyJHora isolated /tmp black-box run", + "capture_date": "2026-06-29", + "source_artifact": "references/oracle/artifacts/", + "ayanamsa": "lahiri", + "node_mode": "mean", + "timezone": "UTC+00:53", + "annual_system": "Varshaphala/Tajika", + "target_year": 1905, + "operator_note": "Black-box annual output from . External evidence only; local annual engine output not used. Solar-return convention: . Timezone/DST handling: . " + }, + "target_placeholders": { + "target.solar_return_datetime": "", + "target.varsha_lagna_deg": , + "target.muntha_sign": "", + "target.year_lord": "", + "target.mudda_dasha_first_lord": "", + "target.sahams.punya_saham": , + "target.sahams.rajya_saham": , + "target.sahams.vivah_saham": , + "target.tajika_yogas": { + "": null + }, + "target.source_artifact": "references/oracle/artifacts/" + } +} diff --git a/docs/benchmark/tajika_einstein_1905_packet_paste_instructions.md b/docs/benchmark/tajika_einstein_1905_packet_paste_instructions.md new file mode 100644 index 00000000..b8be8920 --- /dev/null +++ b/docs/benchmark/tajika_einstein_1905_packet_paste_instructions.md @@ -0,0 +1,55 @@ +# Tajika Einstein 1905 Packet Paste Instructions + +原 packet: +`references/oracle/artifacts/pending_packets/external_template_einstein_varshaphala_1905_lahiri.json` + +可直接粘贴块: +`docs/benchmark/tajika_einstein_1905_packet_paste_block.jsonc` + +## 最省力用法 + +不要从头编辑整个 packet。 + +直接做这 4 步: + +1. 打开原 packet +2. 找到并替换这 3 段: + - 顶层 `status` + - `metadata` + - `target_placeholders` +3. 把 `tajika_einstein_1905_packet_paste_block.jsonc` 里的对应内容整段复制进去 +4. 只替换 `<...>` 占位符 + +## 你真正需要改的只有这些占位符 + +- `` +- `` +- `` +- `` +- `` +- `` +- `` +- `` +- `` + +## 注意 + +- `jsonc` 文件只是为了让人更方便复制, 占位符替换完后, 最终落回原 packet 时必须保持合法 JSON +- `target.source_artifact` 与 `metadata.source_artifact` 要一致 +- `target.tajika_yogas` 优先照外部工具原样抄, 不要翻译成本地解释术语 +- 如果外部工具不是 `PyJHora`, 只改 `tool_name` 和 `tool_version_or_url`, 其他结构照抄即可 + +## 填完立刻执行 + +```bash +python3 scripts/tajika_annual_oracle_queue.py \ + --oracle-file references/oracle/tajika_annual_oracle_cases.json \ + --apply-packet references/oracle/artifacts/pending_packets/external_template_einstein_varshaphala_1905_lahiri.json \ + --format json +``` + +```bash +python3 scripts/tajika_annual_benchmark_dashboard.py \ + --oracle-file references/oracle/tajika_annual_oracle_cases.json \ + --format json +``` diff --git a/docs/research/full_reading_performance_and_vedastro_ingestion_strategy_2026_06_30.md b/docs/research/full_reading_performance_and_vedastro_ingestion_strategy_2026_06_30.md new file mode 100644 index 00000000..e29d3985 --- /dev/null +++ b/docs/research/full_reading_performance_and_vedastro_ingestion_strategy_2026_06_30.md @@ -0,0 +1,285 @@ +# Full Reading Performance And VedAstro Ingestion Strategy (2026-06-30) + +## Scope + +This note records one strict round of: + +1. whole-repo workflow review, +2. external-source grounding, +3. real `full-reading` stage timing collection, +4. API strategy changes based on measured bottlenecks. + +It is intended to prevent future work from drifting back into intuition-only performance decisions. + +## High-Level Conclusion + +The current project is **not bottlenecked by local Jyotish computation**. + +The dominant runtime cost for real user-facing `full-reading` is: + +1. `vedastro_official_snapshot` +2. `vedastro_main_entry_overview` + +Therefore, the highest-value optimization order is: + +1. preserve the existing local-native calculation path, +2. reuse existing `vedastro_service_adapter` request caching, +3. add **API-level final chart response caching** for the normal `/api/chart` path, +4. keep `high_rigor_workflow` explicitly marked as the lane that should move toward queue/async execution, +5. do **not** spend engineering cycles micro-optimizing local Dasha/Varga/Yoga layers before fixing official-evidence ingestion cost. + +## Repo Facts Confirmed + +### Active closure lanes + +`docs/research/ACTIVE_FRONTS.md` keeps work constrained to four fronts: + +1. Relationship adjudicator closure +2. Vimsopaka + functional-role closure +3. Oracle closure batch +4. VedAstro strict ingestion + +This means performance/productization work should be treated as support for the fourth lane, not as an unrelated new product surface. + +### Existing reusable building blocks + +The repo already contains the main pieces required for an official-first architecture: + +- `scripts/vedastro_service_adapter.py` +- `scripts/vedastro_evidence_orchestrator.py` +- `scripts/vedastro_priority.py` +- `scripts/jyotish_engine.py` +- `scripts/jyotish_api_server.py` +- `mcp_server.py` + +The correct strategy is to **reuse and tighten** these boundaries, not to create a second orchestration stack. + +## External-Source Grounding + +### Python performance measurement + +The Python standard library documents `time.perf_counter()` as the high-resolution timer appropriate for measuring short durations and performance intervals. +Source: [Python `time` documentation](https://docs.python.org/3/library/time.html) + +The Python profiling documentation distinguishes deterministic profiling (`cProfile` / `profile`) from ad hoc guessing and supports evidence-first investigation of runtime cost. +Source: [Python profiling documentation](https://docs.python.org/3/library/profile.html) + +### Task-queue direction + +FastAPI documents `BackgroundTasks` for simple post-response work, but this pattern is not a substitute for durable heavy workflow execution when requests are long-running. +Source: [FastAPI BackgroundTasks](https://fastapi.tiangolo.com/tutorial/background-tasks/) + +RQ documents a Redis-backed background job queue suitable for Python jobs that should leave the request path. +Source: [RQ documentation](https://python-rq.org/docs/) + +### Interpretation for this project + +These sources support a two-layer discipline: + +1. first instrument real runtime stages with in-code timers, +2. then move only the truly expensive request-path layers to cache or queue. + +For this repository, that means: + +- measure `full-reading` stages before architectural change, +- keep local chart math synchronous, +- protect heavy VedAstro official ingestion behind cache and later queue/async boundaries. + +## Real Timing Evidence + +### Sample used + +Real sample run: + +- birth: `REDACTED_DATE REDACTED_TIME` +- location: `36.4467, 114.2` +- tz: `UTC+8` +- reference date: `2026-06-30` + +Command: + +```bash +python3 scripts/jyotish_engine.py full-reading \ + --year REDACTED_YEAR --month 4 --day 17 \ + --hour 14 --minute 49 \ + --lat 36.4467 --lon 114.2 --tz 8 \ + --today 2026-06-30 \ + --transit-date 2026-06-30 \ + --profile-stages +``` + +### First measured run + +Observed summary: + +- total elapsed: `181.0154s` +- modules: `57` +- errors: `0` + +Stage timings: + +- `core_chart_and_setup`: `0.0036s` +- `dasha_and_core_varga_stack`: `0.8380s` +- `advanced_interpretation_and_timing_layers`: `0.4753s` +- `dynamic_hooks`: `0.0001s` +- `vedastro_official_snapshot`: `133.7938s` +- `strict_contracts`: `3.5107s` +- `vedastro_main_entry_overview`: `41.5806s` +- `guided_topics`: `0.0003s` +- `ai_prompt_pack`: `0.8122s` + +### Second measured run with existing caches warmed + +Same command, same birth payload, same reference date. + +Observed stage timings: + +- `core_chart_and_setup`: `0.0029s` +- `dasha_and_core_varga_stack`: `0.9145s` +- `advanced_interpretation_and_timing_layers`: `0.5279s` +- `vedastro_official_snapshot`: `112.0435s` +- `strict_contracts`: `2.9865s` +- `vedastro_main_entry_overview`: `0.0735s` +- `guided_topics`: `0.0003s` +- `ai_prompt_pack`: `0.6211s` + +### What the second run proves + +The second run proves two different things: + +1. `vedastro_main_entry_overview` is already benefiting from existing lower-level caching. +2. `vedastro_official_snapshot` remains overwhelmingly expensive even after a warm rerun. + +So the adapter cache is **useful but insufficient** at the user-visible workflow level. + +## Root-Cause Interpretation + +### What is not the bottleneck + +The following are not the dominant latency problem: + +- local Dasha +- local Varga +- Yoga / Ashtakavarga / Shadbala orchestration +- strict workflow contract assembly +- prompt pack generation + +All of these are materially small relative to official evidence ingestion. + +### What is the bottleneck + +The dominant cost is the official evidence layer itself, especially: + +- `run_official_full_snapshot_for_case(...)` +- official full-snapshot section fanout and retry path + +The repo also shows a second pattern difference: + +- CLI `full-reading` attaches overview by directly calling `run_range_scan_for_case(...)` +- API chart attaches overview through `orchestrate_vedastro_evidence(...)`, which already bundles official full snapshot and route-scoped scans + +This means there are still opportunities to reduce duplication by reusing higher-level official results more aggressively. + +## Strategy Decision + +### Decision A: Normal chart path gets API-level final result caching + +Why: + +- `/api/chart` is the normal synchronous user path. +- It packages chart + official evidence + prompt pack together. +- The user cares about final response latency, not just adapter request latency. + +Therefore a **final chart response cache** is justified at API level. + +### Decision B: High-rigor workflow is the queue/async candidate lane + +Why: + +- `high_rigor_workflow` composes chart + rectification + historical backtest + thematic report. +- It is heavier than normal chart consumption by design. +- This lane is the correct place to expose queue/async execution strategy in future work. + +### Decision C: Do not optimize local Jyotish math first + +Why: + +- measured evidence shows local-native layers are cheap relative to official ingestion. +- optimizing them first would spend effort where the user does not feel the delay. + +## Implementation Landed In This Round + +### `scripts/jyotish_engine.py` + +Landed: + +- stage timing instrumentation for `full-reading` +- `--profile-stages` CLI flag +- summary output containing: + - `stage_timing_enabled` + - `stage_timings` + - `slowest_stages` + +This is now the baseline tool for future performance decisions. + +### `scripts/jyotish_api_server.py` + +Landed: + +- API chart final-response cache helpers: + - `_build_api_chart_cache_payload` + - `_api_chart_cache_key` + - `_load_api_chart_response_cache` + - `_store_api_chart_response_cache` +- runtime cache metadata attached to cached responses: + - `scope` + - `cache_hit` + - `cache_key` + - `cache_created_at` + - `cache_expires_at` + - `cache_ttl_seconds` +- `_compute_chart(...)` now checks API-level cache before recomputing +- `_high_rigor_workflow_plan_only(...)` now exposes execution strategy: + - normal chart path uses sync chart response cache + - high-rigor lane is the queue recommendation target + +### Tests landed + +Added/updated tests in `tests/test_api_server_security.py` to verify: + +- API chart response cache contract exists and round-trips +- cache key changes with VedAstro runtime state +- high-rigor plan-only output explicitly surfaces chart-cache and queue strategy + +## What Is Still Not Closed + +1. The current official full snapshot still does not have an equivalent high-level final cache strong enough to collapse the `112s+` cost on rerun. +2. `high_rigor_workflow` exposes queue recommendation metadata, but no durable job runner or polling endpoint is landed yet. +3. CLI `full-reading` and API chart still do not fully unify around one deduplicated “official snapshot + overview reuse” path. + +## Recommended Next Implementation Order + +1. Add **official full snapshot result caching** at a higher semantic level than raw request caching. +2. Make API chart reuse that official full snapshot cache before rebuilding the same evidence package. +3. Introduce a minimal asynchronous lane for `high_rigor_workflow`: + - enqueue request + - return job id + - poll result + - keep synchronous mode for debug/local use +4. Only after those are landed, decide whether deeper profiler work is still needed. + +## Honesty Boundary + +This document does **not** claim: + +- that the entire full-reading path is now fast, +- that all VedAstro-heavy routes are production-grade for synchronous use, +- that queue/async execution is already complete, +- that local and official evidence are fully deduplicated. + +What it does claim is narrower and evidenced: + +- stage timing is now real and reproducible, +- the main bottleneck has been identified with measured data, +- API-level final chart response caching is now partially landed, +- the repo now has a documented, evidence-backed strategy for the next optimization steps. diff --git a/docs/research/local_drafts_2026_06_disposition.md b/docs/research/local_drafts_2026_06_disposition.md new file mode 100644 index 00000000..cdd60d4a --- /dev/null +++ b/docs/research/local_drafts_2026_06_disposition.md @@ -0,0 +1,115 @@ +# 2026-06 Local Drafts Disposition + +Date: 2026-07-01 + +Purpose: freeze the governance boundary for `docs/research/local_drafts/2026-06` without deleting or moving the drafts. The draft directory is evidence and recovery memory, not runtime truth. Do not move or delete files in this pass. + +Source-of-truth rule: + +- Main repo truth stays in `SKILL.md`, `AGENTS.md`, `references/`, `scripts/`, `tests/`, and canonical `docs/research/*.md`. +- `.workbuddy` is a historical distribution mirror and recovery reference only. It must not reverse-sync over this repo, and runtime code must not import from it. +- A `promote` row means the draft should be converted into a canonical research note, benchmark artifact, test, or source change before it drives implementation. +- A `reference-only` row means it can be cited as background after re-anchoring to current code and licenses. +- An `archive` row means it should stay out of current implementation flow unless a future audit explicitly reopens it. + +## Disposition Table + +| Disposition | Draft | Reason | +|---|---|---| +| reference-only | antigravity_round31_api_completion_top50_2026_06_26.md | Older API exposure wishlist; re-check against current `jyotish_api_server.py` before reuse. | +| promote | antigravity_round31_ayanamsa_ephemeris_timezone_risk_matrix_2026_06_26.md | Core accuracy boundary for ayanamsa, ephemeris, timezone and node-mode evidence. | +| reference-only | antigravity_round31_cli_completion_top50_2026_06_26.md | Useful CLI wishlist, but many items are superseded by later tests and active plans. | +| reference-only | antigravity_round31_cloud_sync_whitelist_final_draft_2026_06_26.md | Distribution-sync background only; current rule is no reverse contamination from `.workbuddy`. | +| archive | antigravity_round31_codex_round32_top180_2026_06_26.md | Broad execution board superseded by later narrower closure plans. | +| promote | antigravity_round31_copy_allowed_assets_top80_2026_06_26.md | License-safe reuse candidates need canonical whitelist linkage before code migration. | +| promote | antigravity_round31_external_oracle_closure_top60_2026_06_26.md | External oracle closure remains a high-rigor blocker and should feed oracle queues. | +| promote | antigravity_round31_extra_astronomical_edge_cases_polar_regions_2026_06_26.md | Polar and astronomical edge cases affect correctness and confidence boundaries. | +| reference-only | antigravity_round31_extra_internationalization_i18n_readiness_2026_06_26.md | Product-localization idea; not a current strict accuracy front. | +| reference-only | antigravity_round31_extra_offline_fallback_mode_2026_06_26.md | Frontend resilience idea; keep outside current engine-truth work. | +| reference-only | antigravity_round31_extra_performance_profiling_memory_leaks_2026_06_26.md | Performance background; only promote after API payload profiling is reopened. | +| archive | antigravity_round31_final_execution_board_2026_06_26.md | Round board superseded by current active fronts. | +| reference-only | antigravity_round31_frontend_completion_top50_2026_06_26.md | UI exposure backlog, not current runtime truth. | +| promote | antigravity_round31_jhora_pyjhora_capture_manual_review_2026_06_26.md | Human oracle capture standards belong with benchmark and operator docs. | +| promote | antigravity_round31_license_quarantine_blacklist_top60_2026_06_26.md | License quarantine rules are project safety boundaries and should stay canonical. | +| promote | antigravity_round31_local_accuracy_shortest_path_top50_2026_06_26.md | Local accuracy verification path supports regression confidence and user trust. | +| reference-only | antigravity_round31_local_user_experience_top60_2026_06_26.md | UX backlog; not a main-chain source of truth. | +| promote | antigravity_round31_single_source_of_truth_enforcement_2026_06_26.md | Single-source governance supports the current mirror-contamination fix. | +| promote | antigravity_round31_true_missing_traditional_techniques_top30_2026_06_26.md | Traditional technique gap list should inform registry and roadmap truth. | +| promote | antigravity_round31_whole_machine_fragment_reuse_fourth_pass_2026_06_26.md | Whole-machine reuse findings are high value, but must be re-anchored before integration. | +| reference-only | antigravity_round32_api_direct_coding_top40_2026_06_26.md | Concrete API tasks, but must be reconciled with current endpoint map first. | +| reference-only | antigravity_round32_cli_direct_coding_top40_2026_06_26.md | Concrete CLI tasks, but no longer authoritative without current test review. | +| archive | antigravity_round32_codex_round33_top200_2026_06_26.md | Broad execution board superseded by current closure lanes. | +| promote | antigravity_round32_copy_allowed_assets_top100_2026_06_26.md | License-safe assets list should merge with canonical reuse whitelist. | +| reference-only | antigravity_round32_extra_accuracy_verification_blocks_2026_06_26.md | Trust-center idea; promote only if product verification UI is reopened. | +| reference-only | antigravity_round32_extra_i18n_translation_2026_06_26.md | Localization backlog, not current strict workflow. | +| reference-only | antigravity_round32_extra_offline_fallback_2026_06_26.md | Offline/PWA idea; reference only until frontend resilience is prioritized. | +| reference-only | antigravity_round32_extra_payload_performance_2026_06_26.md | Performance backlog; useful when API payload slimming resumes. | +| archive | antigravity_round32_final_execution_board_2026_06_26.md | Superseded round board. | +| reference-only | antigravity_round32_frontend_direct_coding_top40_2026_06_26.md | UI backlog; not canonical runtime map. | +| promote | antigravity_round32_jhora_pyjhora_fast_capture_pipeline_2026_06_26.md | Capture pipeline can reduce oracle bottlenecks and should feed operator docs. | +| promote | antigravity_round32_license_blacklist_recheck_2026_06_26.md | GPL/AGPL isolation remains a hard boundary. | +| promote | antigravity_round32_local_accuracy_shortest_chain_final_2026_06_26.md | Accuracy shortest chain should connect to benchmark and preflight gates. | +| reference-only | antigravity_round32_local_ux_direct_top30_2026_06_26.md | UX backlog only. | +| promote | antigravity_round32_oracle_sample_push_matrix_2026_06_26.md | Oracle sample matrix belongs in benchmark planning. | +| reference-only | antigravity_round32_sync_script_blueprint_2026_06_26.md | Sync script idea; keep constrained by no reverse `.workbuddy` authority. | +| promote | antigravity_round32_timezone_dst_polar_direct_tasks_2026_06_26.md | Timezone, DST and polar handling affect chart correctness. | +| promote | antigravity_round32_true_missing_techniques_rerank_top20_2026_06_26.md | Technique gap rerank should feed registry and roadmap. | +| promote | antigravity_round32_whole_machine_fragment_reuse_fifth_pass_2026_06_26.md | Fragment reuse findings are useful after license/source re-anchoring. | +| promote | antigravity_round36_asc_degree_yogi_tight_orb_wealth_pack_2026_06_26.md | Wealth-specific Yogi/tight-orb material supports strict finance adjudication. | +| promote | antigravity_round36_bhrigu_pada_all_event_expansion_pack_2026_06_26.md | Event expansion material is relevant to historical backtest and timing routes. | +| promote | antigravity_round36_global_first_honesty_board_2026_06_26.md | Honesty boundary belongs near oracle and benchmark governance. | +| reference-only | antigravity_round36_pakshi_swara_boundary_pack_2026_06_26.md | Advanced traditional technique background; not current route-critical. | +| reference-only | antigravity_round36_rtn_anomalous_d9_deepening_pack_2026_06_26.md | Niche D9 deepening background; promote only with current evidence. | +| promote | antigravity_round36_tajika_sahams_external_closure_pack_2026_06_26.md | Tajika/Saham external closure is an active benchmark frontier. | +| reference-only | antigravity_round36_tithi_lord_freeze_gap_pack_2026_06_26.md | Technique detail backlog; not current main-chain blocker. | +| reference-only | antigravity_round37_article_template_industrialization_board_2026_06_26.md | Interpretation template backlog; useful after truth arbitration. | +| archive | antigravity_round37_codex_round38_top100_2026_06_26.md | Execution board superseded by later focused documents. | +| promote | antigravity_round37_dasha_external_oracle_shortest_closure_board_2026_06_26.md | Dasha external closure is a high-rigor requirement. | +| promote | antigravity_round37_public_benchmark_moat_board_2026_06_26.md | Public benchmark strategy should inform benchmark dashboard governance. | +| promote | antigravity_round37_shadbala_absolute_value_frontier_board_2026_06_26.md | Shadbala absolute values remain a precision frontier. | +| promote | antigravity_round37_tajika_sahams_annual_closure_board_2026_06_26.md | Annual chart closure aligns with current Tajika oracle work. | +| reference-only | antigravity_round38_advanced_sensitive_points_top20_2026_06_26.md | Advanced sensitive points backlog; not current strict route source. | +| reference-only | antigravity_round38_article_detail_template_batch2_2026_06_26.md | Template backlog; keep behind truth arbitration. | +| archive | antigravity_round38_codex_round39_top150_2026_06_26.md | Execution board superseded by current active fronts. | +| promote | antigravity_round38_dasha_external_oracle_packet_factory_2026_06_26.md | Dasha oracle packet factory should feed operator packet docs/tests. | +| reference-only | antigravity_round38_mrityu_bhaga_authority_table_hunt_2026_06_26.md | Authority-table research, but not active route-critical. | +| promote | antigravity_round38_open_source_copy_whitelist_sensitive_points_2026_06_26.md | License-safe sensitive-point whitelist should be reconciled before reuse. | +| promote | antigravity_round38_public_benchmark_board_v2_2026_06_26.md | Benchmark dashboard v2 should be promoted if public benchmark work resumes. | +| promote | antigravity_round38_shadbala_absolute_value_capture_matrix_2026_06_26.md | Shadbala absolute capture matrix belongs with oracle closure planning. | +| promote | antigravity_round38_skill_global_rank_gap_after_round38_2026_06_26.md | Global gap assessment informs honest capability claims. | +| promote | antigravity_round38_whole_machine_fragment_reuse_sixth_pass_2026_06_26.md | Fragment reuse shortlist needs canonical anchoring. | +| promote | antigravity_round39_yogi_wealth_bridge_audit_2026_06_28.md | Directly relevant to current wealth strict adjudication. | +| promote | antigravity_round40_article_technique_truth_arbitration_2026_06_27.md | Truth arbitration prevents noisy article-derived code migration. | +| archive | antigravity_round40_codex_round41_skill_top60_2026_06_27.md | Round-specific action board, not canonical truth. | +| promote | antigravity_round40_dasha_second_wave_closure_pack_2026_06_27.md | Dasha second-wave closure supports high-rigor timing validation. | +| promote | antigravity_round40_shadbala_absolute_authority_ladder_2026_06_27.md | Shadbala authority ladder belongs with precision/oracle governance. | +| promote | antigravity_round40_tajika_annual_second_wave_board_2026_06_27.md | Tajika second-wave board supports annual oracle closure. | +| promote | antigravity_round40_whole_machine_fragment_reuse_shortlist_2026_06_27.md | Top 20 fragment shortlist should be re-anchored to current code/tests. | +| archive | antigravity_sidecar_work_order_round33_2026_06_26.md | Sidecar work order; historical coordination only. | +| archive | antigravity_sidecar_work_order_round34_2026_06_26.md | Sidecar work order; historical coordination only. | +| archive | antigravity_sidecar_work_order_round35_2026_06_26.md | Sidecar work order; historical coordination only. | +| archive | antigravity_sidecar_work_order_round36_2026_06_26.md | Sidecar work order; historical coordination only. | +| archive | antigravity_sidecar_work_order_round37_2026_06_26.md | Sidecar work order; historical coordination only. | +| archive | antigravity_sidecar_work_order_round40_2026_06_27.md | Sidecar work order; historical coordination only. | +| promote | article_technique_coverage_matrix_2026_06_26.md | Article-to-technique coverage matrix should remain a canonical truth-arbitration input. | +| reference-only | chayue_screenshot_coverage_matrix_2026_06_26.md | Source-specific screenshot coverage; useful as evidence only. | +| reference-only | cloud_sync_minimum_whitelist_for_skill_truth_2026_06_26.md | Sync governance background; no reverse authority over main repo. | +| promote | current_skill_core_gap_rerank_2026_06_26.md | Current skill gap rerank should feed active roadmap and registry work. | +| promote | dasha_accuracy_closure_status_2026_06_26.md | Dasha closure status is a high-rigor timing boundary. | +| promote | dasha_code_only_priority_rerank_2026_06_26.md | Code-only Dasha prioritization can guide scoped local improvements. | +| promote | five_hard_fronts_master_board_2026_06_26.md | Five-front board is the best compact strategic index for precision gaps. | +| archive | git_execution_card_skill_truth_only_2026_06_26.md | One-off git execution card; no ongoing truth role. | +| promote | global_open_source_positioning_of_skill_2026_06_26.md | Honest positioning is required for claims and benchmark framing. | +| promote | high_granularity_technique_deepening_backlog_2026_06_26.md | Technique deepening backlog should be reconciled with the registry. | +| promote | jhora_capture_task_v2.md | Human oracle capture task should connect to benchmark packets. | +| reference-only | recovered_old_skill_reuse_audit_2026_06_26.md | Recovery audit; reuse only after current-code and license recheck. | +| promote | reuse_license_whitelist_for_skill_2026_06_26.md | Reuse license whitelist should be canonical before any migration. | +| promote | skill_fragment_map_and_source_of_truth_2026_06_26.md | Source-of-truth map supports mirror and fragment discipline. | +| promote | skill_single_source_of_truth_disposition_2026_06_26.md | Single-source disposition remains a governance anchor. | +| promote | skill_truth_conflict_matrix_2026_06_26.md | Conflict matrix should inform future source-truth decisions. | +| promote | three_fronts_skill_depth_audit_2026_06_26.md | Three-front depth audit is compact and still relevant to skill depth. | +| reference-only | zhanxingyindu1_screenshot_coverage_matrix_2026_06_26.md | Source-specific screenshot coverage; useful as evidence only. | + +## Immediate Next Use + +When a future task needs one of these drafts, first copy the claim into a canonical target and verify it against current code/tests. Never import code or truth from `.workbuddy` or local drafts directly into the runtime chain. diff --git a/docs/research/unique_main_chain_map_2026_07_01.md b/docs/research/unique_main_chain_map_2026_07_01.md new file mode 100644 index 00000000..0c444ee2 --- /dev/null +++ b/docs/research/unique_main_chain_map_2026_07_01.md @@ -0,0 +1,64 @@ +# Unique Main Chain Map + +Date: 2026-07-01 + +This is the single current map for the Jyotish runtime chain. It does not replace `SKILL.md`, `AGENTS.md`, or `references/strict-workflow-router.md`; it names which file owns each entrypoint and how they should relate. The main repo is the source of truth. Historical mirrors, including `.workbuddy`, are reference/distribution material only and must not import from `.workbuddy`. + +## Authority Rule + +- Runtime truth lives in this repository: `scripts/`, `mcp_server.py`, `jyotish_vedic/`, `references/`, and `tests/`. +- `SKILL.md` is the skill/user-facing instruction entry. +- `AGENTS.md` is the hard override for high-rigor project behavior, including Functional Benefic/Malefic and honesty boundaries. +- `references/strict-workflow-router.md` is the route checklist for career, relationship, finance, timing, historical verification, and technique reliability questions. +- `.workbuddy` is a distribution mirror or historical recovery source. It can be inspected as evidence, but runtime code must not import from `.workbuddy`. + +## Entrypoint Map + +| Entry | Owner | Role | Calls / Depends On | Boundary | +|---|---|---|---|---| +| Skill entry | `SKILL.md` | Human/agent instruction surface for Jyotish analysis | `references/strict-workflow-router.md`, `AGENTS.md`, canonical references | Instruction truth only; not executable runtime. | +| Web/API entry | `scripts/jyotish_api_server.py` | Local HTTP API for `jyotish-app` and high-rigor workflow jobs | `scripts/unified_consultation_orchestrator.py`, local modules, chart cache, rectification gate, historical backtest loader | Must route through local repo modules and preserve API/cache provenance. | +| MCP entry | `mcp_server.py` | AI-native tool surface for chart, Dasha, Shadbala, Ashtakavarga, Varga, full reading, and `strict_workflow` | Local `scripts/` modules, `UnifiedConsultationOrchestrator`, functional-benefic layer, VedAstro evidence summaries | MCP strict workflow is the canonical reusable strict adjudication surface. | +| Shared route contract | `scripts/unified_consultation_orchestrator.py` | Normalizes themes/questions and builds the surface-agnostic runtime planner | Web/API and MCP callers | Owns route naming and sync/async step planning; does not itself calculate astrology. | +| VedAstro official entry | `scripts/vedastro_service_adapter.py` | Controlled official evidence boundary: official full snapshot, range scan, external technique calls | VedAstro official endpoint/env, official Python bridge/capability runner where configured | VedAstro official snapshot has priority when available; local modules supplement or fallback when official evidence is blocked. | +| Strict workflow entry | `mcp_server.py::strict_workflow` | Main strict adjudication chain for career, relationship, finance, timing and event judgement | `mcp_server.py` evidence collectors, functional benefic/malefic, Shadbala, Ashtakavarga, Dasha, Varga, VedAstro official evidence | Must expose missing evidence, conflicts, confidence caps, and Technique Audit Table facts. | +| Rectification entry | `scripts/jyotish_api_server.py` `/api/rectification_gate` | Birth-time rectification gate reused by high-rigor workflow | Chart payload, rectification references and frontend rectification engine outputs | Rectification is a gate/support layer, not proof by itself. | +| Historical backtest entry | `scripts/historical_event_backtest.py` | Reusable historical event backtest runner | Calls `mcp_server.strict_workflow` for supported event domains | Measures route support for supplied events; blocked/unsupported cases must not be overstated as predictive accuracy. | + +## Main Flow + +```mermaid +flowchart TD + Skill["SKILL.md"] --> Router["references/strict-workflow-router.md"] + Web["scripts/jyotish_api_server.py"] --> Orchestrator["scripts/unified_consultation_orchestrator.py"] + MCP["mcp_server.py"] --> Orchestrator + Orchestrator --> Strict["mcp_server.py::strict_workflow"] + Web --> Rectification["/api/rectification_gate"] + Web --> Backtest["scripts/historical_event_backtest.py"] + Backtest --> Strict + Strict --> VedAstro["scripts/vedastro_service_adapter.py"] + Strict --> Local["local scripts: varga, dasha, shadbala, ashtakavarga, functional benefics"] +``` + +## High-Rigor Domain Requirements + +| Domain | Mandatory local evidence | Mandatory timing cross-check | External/oracle boundary | +|---|---|---|---| +| Career | D10 + A10, Shadbala, Ashtakavarga, Functional Benefic/Malefic | Vimshottari + Narayana Dasha | VedAstro official snapshot/range scan where available; PyJHora and jyotishganit remain external reference layers with license boundaries. | +| Wealth | D2 / D11, Shadbala, Ashtakavarga, Functional Benefic/Malefic | Vimshottari + Narayana Dasha | Treat VedAstro official evidence as external context; do not upgrade blocked external layers to validation. | +| Relationship | D9 + UL, Darakaraka/7th-house context, Functional Benefic/Malefic | Vimshottari + Narayana Dasha | External oracle closure remains required for high-confidence timing claims. | +| Historical event | Route-specific Varga, Dasha, Shadbala, Ashtakavarga, Functional Benefic/Malefic | Vimshottari + Narayana Dasha | `scripts/historical_event_backtest.py` must mark blocked/unsupported/miss honestly. | + +## Non-Goals + +- This document does not authorize copying code from `.workbuddy`, PyJHora, GPL/AGPL projects, or local drafts. +- This document does not claim that every VedAstro official method runs on every request. +- This document does not close external oracle validation by itself. + +## Verification Hooks + +- Runtime mirror guard: `tests/test_runtime_import_boundaries.py` +- Preflight governance: `tests/test_preflight_fragment_scan.py` +- Main-chain and draft governance docs: `tests/test_research_governance_docs.py` +- Shared route planner: `tests/test_unified_consultation_orchestrator.py` +- Historical event backtest: `tests/test_historical_event_backtest.py` diff --git a/docs/superpowers/plans/2026-06-29-shortest-path-closure-plan.md b/docs/superpowers/plans/2026-06-29-shortest-path-closure-plan.md new file mode 100644 index 00000000..3f8af3c7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-shortest-path-closure-plan.md @@ -0,0 +1,315 @@ +# Shortest-Path Closure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Collapse the remaining Jyotish hard fronts into the smallest executable closure path: relationship adjudicator closure, Vimsopaka/functional-role closure, oracle closure, and VedAstro strict ingestion. + +**Architecture:** Reuse existing strict workflow, adjudicator, oracle, and VedAstro bridge assets instead of opening new product surfaces. Drive every remaining change from fixed regression packs and external-truth comparison scripts so the project stops expanding sideways and starts closing hard boundaries. + +**Tech Stack:** Python, pytest, existing MCP strict workflow code, existing full-reading/report pipeline, existing VedAstro adapter scripts, existing oracle comparison scripts, existing frontend static contract tests. + +## Global Constraints + +- Must obey `/Users/wuyongnaren/Documents/印度占星/AGENTS.md` high-rigor rules. +- Timing/event/outcome work must use `Vimshottari + Narayana Dasha` and relationship work must include `D9 + UL`. +- Functional Benefic/Malefic must remain explicit in high-rigor evidence and user-visible audit surfaces. +- Prefer existing repo-native code and reusable open-source references over new standalone implementations. +- Do not expand scope into new product features while these closure fronts remain open. +- Any claim touching oracle accuracy, global-first status, or perfect precision must stay blocked until external evidence says otherwise. + +--- + +### Task 1: Freeze the closure board and stop scope drift + +**Files:** +- Create: `/Users/wuyongnaren/Documents/印度占星/docs/research/shortest_path_closure_board_2026_06_29.md` +- Modify: `/Users/wuyongnaren/Documents/印度占星/docs/research/ACTIVE_FRONTS.md` +- Modify: `/Users/wuyongnaren/Documents/印度占星/task_plan.md` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_skill_gap_truth_audit.py` + +**Interfaces:** +- Consumes: `/Users/wuyongnaren/Documents/印度占星/docs/research/ACTIVE_FRONTS.md`, `/Users/wuyongnaren/Documents/印度占星/task_plan.md` +- Produces: `shortest_path_closure_board_2026_06_29.md` with four closure lanes (`relationship`, `vimsopaka_functional_role`, `oracle`, `vedastro_ingestion`) and a fixed task order + +- [ ] **Step 1: Write the failing test** + +```python +def test_skill_gap_truth_registry_lists_hard_fronts_and_past_corrections() -> None: + data = json.loads(REGISTRY.read_text(encoding="utf-8")) + required_fronts = { + "dasha_external_oracle", + "shadbala_external_absolute_values", + "tajika_sahams_annual_closure", + "article_template_industrialization", + "long_term_public_benchmark", + } + assert required_fronts <= set(data["hard_fronts"]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_skill_gap_truth_audit.py -q` +Expected: FAIL if the closure board / active fronts drift exposes missing hard-front accounting + +- [ ] **Step 3: Write minimal implementation** + +```markdown +# Shortest Path Closure Board + +## Lane 1 - Relationship Adjudicator Closure +1. lock legal_marriage/public_formalization regression pack +2. close Jaimini marriage bridge v1 regression loop +3. keep public_formalization_candidate narrative conservative everywhere + +## Lane 2 - Vimsopaka + Functional Role Closure +1. map NEECHA_BHANGA / GREAT_FRIEND / GREAT_ENEMY +2. render functional benefic/malefic everywhere the user sees Technique Audit + +## Lane 3 - Oracle Closure +1. batch Dasha/Shadbala/JHora comparison pack +2. promote first external verified packet +3. update oracle dashboard only from validated packets + +## Lane 4 - VedAstro Strict Ingestion +1. keep current blocked boundary +2. run one endpoint-backed smoke only after relationship loop is closed +3. ingest only allowlisted event windows into evidence ledger +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest tests/test_skill_gap_truth_audit.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add docs/research/shortest_path_closure_board_2026_06_29.md docs/research/ACTIVE_FRONTS.md task_plan.md tests/test_skill_gap_truth_audit.py +git commit -m "docs: freeze shortest-path closure board" +``` + +### Task 2: Close the relationship adjudicator regression pack + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/mcp_server.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_engine.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/jyotish-app/main.js` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_relationship.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_frontend_productization.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_api_server_security.py` + +**Interfaces:** +- Consumes: `event_judgement.dominant_label`, `event_judgement.secondary_context`, `present_evidence.jaimini_marriage_support` +- Produces: locked conservative handling for `public_formalization_candidate`, and red/green regression coverage for `legal_marriage` vs `public_formalization` + +- [ ] **Step 1: Write the failing tests** + +```python +def test_relationship_narrative_payload_does_not_translate_public_formalization_candidate_plus_synastry_support_plus_weak_core_promise_into_marriage_approach() -> None: + strict = _collect_strict_evidence("relationship", _base_relationship_result()) + strict["event_judgement"]["dominant_label"] = None + strict["event_judgement"]["secondary_context"] = [ + "darakaraka_active", + "ul_support", + "synastry_support", + "synastry_compatibility_support", + "public_formalization_candidate", + ] + strict["confidence_cap"] = "low" + payload = jyotish_engine._build_relationship_narrative_payload(strict) + assert any("不能误读成接近结婚" in item for item in payload["risks"]) + assert any("不得越权抬升 legal_marriage" in item for item in payload["boundaries"]) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python3 -m pytest tests/test_mcp_strict_workflow_relationship.py tests/test_frontend_productization.py tests/test_api_server_security.py -q -k "public_formalization or weak_core_promise or misread_as_near_marriage"` +Expected: FAIL on the exact conservative boundary you are adding + +- [ ] **Step 3: Write minimal implementation** + +```python +if "public_formalization_candidate" in secondary_context: + risks.append("当前虽更接近 public_formalization_candidate,但在 timing conflict 未解除前,不能误读成接近结婚。") + boundaries.append("public_formalization_candidate 只表示公开化候选,不等于法律婚姻,不能越权替代 legal_marriage。") +``` + +```javascript +const status = hasPublicFormalizationCandidate && hasConflictWarning ? 'needs_context' : ... +const statusLabel = hasPublicFormalizationCandidate && hasConflictWarning + ? '公开化候选,不等于婚姻逼近' + : ... +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m pytest tests/test_mcp_strict_workflow_relationship.py tests/test_frontend_productization.py tests/test_api_server_security.py -q -k "public_formalization or weak_core_promise or misread_as_near_marriage"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add mcp_server.py scripts/jyotish_engine.py jyotish-app/main.js tests/test_mcp_strict_workflow_relationship.py tests/test_frontend_productization.py tests/test_api_server_security.py +git commit -m "test: lock relationship public formalization boundaries" +``` + +### Task 3: Close Vimsopaka semantic mapping and functional-role rendering + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/mcp_server.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_api_server.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/jyotish-app/skill-map.js` +- Modify: `/Users/wuyongnaren/Documents/印度占星/references/strict-workflow-router.md` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_frontend_productization.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_api_server_security.py` + +**Interfaces:** +- Consumes: existing dignity guardrail output and functional benefic/malefic evidence +- Produces: user-visible audit rendering for functional benefics/malefics/yogakarakas/neutrals and Vimsopaka semantic labels + +- [ ] **Step 1: Write the failing tests** + +```python +def test_report_artifact_relationship_strict_narrative_surfaces_public_formalization_candidate_boundary() -> None: + html = artifact["html"] + assert "Functional Benefic/Malefic" in html + assert "Yogakaraka" in html + assert "functional neutral" in html.lower() or "中性星" in html +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py -q -k "Functional Benefic or Yogakaraka or Vimsopaka"` +Expected: FAIL because at least one user-visible rendering path is incomplete + +- [ ] **Step 3: Write minimal implementation** + +```python +summary_parts.append(f"Yogakaraka: {', '.join(yogakarakas) if yogakarakas else 'None'}") +summary_parts.append(f"Functional neutrals: {', '.join(functional_neutrals) if functional_neutrals else 'None'}") +``` + +```javascript +['Functional Benefic/Malefic / 功能吉凶星', '已接入', '按 Lagna 输出功能吉星、功能凶星、Yogakaraka 与中性星,并进入 Technique Audit Table'] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py -q -k "Functional Benefic or Yogakaraka or Vimsopaka"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add mcp_server.py scripts/jyotish_api_server.py jyotish-app/skill-map.js references/strict-workflow-router.md tests/test_frontend_productization.py tests/test_api_server_security.py +git commit -m "feat: close functional role and vimsopaka rendering gaps" +``` + +### Task 4: Batch the oracle closure work into one comparison pack + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/shadbala_oracle_comparison.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/oracle_benchmark_inventory.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/references/oracle/dasha_shadbala_oracle_cases.json` +- Create: `/Users/wuyongnaren/Documents/印度占星/docs/research/oracle_batch_closure_pack_2026_06_29.md` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_dasha_oracle_closure_status.py` + +**Interfaces:** +- Consumes: external oracle packets, current shadbala comparison script, oracle benchmark inventory +- Produces: one batch comparison report with pass/fail/tolerance rows and updated blocked/unblocked truth summary + +- [ ] **Step 1: Write the failing test** + +```python +def test_dasha_oracle_closure_status_markdown_keeps_global_calibration_blocked_until_non_dasha_packets_pass() -> None: + markdown = build_status_markdown(sample_status) + assert "Keep global calibration blocked" in markdown +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_dasha_oracle_closure_status.py -q` +Expected: FAIL if the batch comparison summary or blocked boundary drifts + +- [ ] **Step 3: Write minimal implementation** + +```markdown +# Oracle Batch Closure Pack + +| Case | Source | Domain | Pass | Notes | +|---|---|---|---|---| +| steve_jobs | JHora/PyJHora | Dasha | yes/no | boundary diff | +| ... | ... | Shadbala | yes/no | tolerance diff | +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest tests/test_dasha_oracle_closure_status.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add scripts/shadbala_oracle_comparison.py scripts/oracle_benchmark_inventory.py references/oracle/dasha_shadbala_oracle_cases.json docs/research/oracle_batch_closure_pack_2026_06_29.md tests/test_dasha_oracle_closure_status.py +git commit -m "feat: batch oracle closure comparison pack" +``` + +### Task 5: Keep VedAstro ingestion minimal and honest + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/vedastro_service_adapter.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/run_quality_gate.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_life_event_graph_v1.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_vedastro_service_adapter_executor.py` + +**Interfaces:** +- Consumes: current `external_window` payload contract and allowlist audit +- Produces: one endpoint-backed smoke path or one controlled blocked path, both feeding the same evidence ledger contract + +- [ ] **Step 1: Write the failing tests** + +```python +def test_life_event_graph_keeps_external_window_nodes_allowlisted() -> None: + assert any(node["kind"] == "external_window" for node in strict["life_event_graph"]["event_nodes"]) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python3 -m pytest tests/test_life_event_graph_v1.py tests/test_vedastro_service_adapter_executor.py -q` +Expected: FAIL if the allowlist / blocked contract is incomplete + +- [ ] **Step 3: Write minimal implementation** + +```python +if not endpoint or not network_enabled: + return {"status": "blocked", "reason": "vedastro_live_endpoint_or_network_flag_missing"} +return {"status": "ok", "event_nodes": filtered_nodes} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m pytest tests/test_life_event_graph_v1.py tests/test_vedastro_service_adapter_executor.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add scripts/vedastro_service_adapter.py scripts/run_quality_gate.py tests/test_life_event_graph_v1.py tests/test_vedastro_service_adapter_executor.py +git commit -m "feat: keep vedastro ingestion minimal and allowlisted" +``` + +## Self-Review + +- Spec coverage: the plan covers the four remaining closure lanes called out by current active fronts. +- Placeholder scan: no TBD/TODO placeholders remain. +- Type consistency: plan uses the existing `dominant_label`, `secondary_context`, `relationship_narrative`, `external_window`, and blocked-boundary terminology already present in the repo. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-29-shortest-path-closure-plan.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/plans/2026-06-30-chart-async-unified-stage-contract.md b/docs/superpowers/plans/2026-06-30-chart-async-unified-stage-contract.md new file mode 100644 index 00000000..34dc5b38 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-chart-async-unified-stage-contract.md @@ -0,0 +1,410 @@ +# Chart Async And Unified Stage Contract Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a reusable lightweight async `job_id + poll` lane for normal `/api/chart` while promoting `full-reading` stage timings into a clearer unified contract. + +**Architecture:** Reuse the existing file-backed high-rigor job runner by extracting shared chart/high-rigor job helpers inside `scripts/jyotish_api_server.py`. Reuse the existing `full-reading` stage timing instrumentation in `scripts/jyotish_engine.py` and only reshape it into grouped stage metadata instead of adding new heavy computation. + +**Tech Stack:** Python, existing HTTPServer API server, existing file-backed scratch job records, pytest. + +## Global Constraints + +- Reuse current job runner; do not add Redis, Celery, RQ, or a second queue. +- Keep sync `/api/chart` behavior unchanged unless `async`/`enqueue` is explicitly requested. +- Keep completed chart async result identical to sync chart payload. +- Do not add new heavy computation for stage grouping; reshape existing timing only. +- Preserve current honesty boundaries around VedAstro partial/blocked states. + +--- + +### Task 1: Promote full-reading stage timings into a unified stage contract + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_engine.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_cli_smoke.py` + +**Interfaces:** +- Consumes: + - `summary["stage_timings"]: list[dict]` +- Produces: + - `summary["stage_contract_version"]: int` + - `summary["stage_groups"]: list[dict]` + - `summary["cache_recommendations"]: dict` + - `summary["async_recommendations"]: dict` + +- [ ] **Step 1: Write the failing test** + +Add a focused test in `tests/test_cli_smoke.py` asserting the new contract fields: + +```python +def test_full_reading_summary_exposes_unified_stage_groups() -> None: + result = run_engine( + "full-reading", + "--year", "1990", + "--month", "1", + "--day", "1", + "--hour", "12", + "--minute", "0", + "--lat", "39.9", + "--lon", "116.4", + "--tz", "8", + "--today", "2026-01-01", + "--transit-date", "2026-01-01", + ) + + summary = result["summary"] + assert summary["stage_contract_version"] == 1 + assert isinstance(summary["stage_groups"], list) + assert any(group["group"] == "official_evidence" for group in summary["stage_groups"]) + assert summary["cache_recommendations"]["api_chart_response"] == "recommended" + assert summary["async_recommendations"]["chart_async_optional"] is True +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +python3 -m pytest tests/test_cli_smoke.py::test_full_reading_summary_exposes_unified_stage_groups -q +``` + +Expected: FAIL with missing `stage_contract_version` or `stage_groups`. + +- [ ] **Step 3: Write minimal implementation** + +In `scripts/jyotish_engine.py`, add a small helper near the stage-timing helpers: + +```python +def _build_unified_stage_contract(stage_timings): + groups = { + 'local_core': ['core_chart_and_setup', 'dasha_and_core_varga_stack', 'advanced_interpretation_and_timing_layers', 'dynamic_hooks'], + 'official_evidence': ['vedastro_official_snapshot', 'vedastro_main_entry_overview'], + 'contract_and_prompt': ['strict_contracts', 'guided_topics', 'ai_prompt_pack'], + } + rows = [] + for group_name, stage_names in groups.items(): + matched = [row for row in stage_timings if row.get('stage') in stage_names] + rows.append({ + 'group': group_name, + 'stages': [row.get('stage') for row in matched], + 'elapsed_seconds': round(sum(float(row.get('elapsed_seconds', 0) or 0) for row in matched), 4), + 'execution_mode': ( + 'sync_remote_heavy' if group_name == 'official_evidence' + else 'sync_structuring' if group_name == 'contract_and_prompt' + else 'sync_local' + ), + }) + return { + 'stage_contract_version': 1, + 'stage_groups': rows, + 'cache_recommendations': { + 'api_chart_response': 'recommended', + 'official_full_snapshot_semantic': 'recommended', + }, + 'async_recommendations': { + 'chart_async_optional': True, + 'high_rigor_async_recommended': True, + }, + } +``` + +Then merge that helper output into `report['summary']` after `stage_timings` and `slowest_stages` are computed. + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +python3 -m pytest tests/test_cli_smoke.py::test_full_reading_summary_exposes_unified_stage_groups -q +``` + +Expected: PASS + +### Task 2: Generalize the file-backed async job runner for chart and high-rigor scopes + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_api_server.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_api_server_security.py` + +**Interfaces:** +- Consumes: + - existing `_write_high_rigor_job_record(job_id, payload)` + - existing `_load_high_rigor_job_record(job_id)` +- Produces: + - shared `_enqueue_async_job(...)` + - shared `_get_async_job(...)` + - chart scope job records + +- [ ] **Step 1: Write the failing tests** + +Add tests in `tests/test_api_server_security.py`: + +```python +def test_chart_async_submit_returns_job_id(monkeypatch: pytest.MonkeyPatch) -> None: + handler = _handler() + monkeypatch.setattr(handler, '_enqueue_chart_job', lambda body: { + 'success': True, + 'endpoint': 'chart_async', + 'mode': 'async_submitted', + 'job_id': 'chart_test_job_1', + 'status': 'queued', + 'poll_path': '/api/chart/jobs/chart_test_job_1', + 'scope': 'api_chart_response', + }) + + result = handler._compute_chart({'async': True, 'year': REDACTED_YEAR, 'month': 4, 'day': 17, 'hour': 14, 'minute': 49, 'lat': 36.42, 'lon': 114.2, 'tz': 8}) + + assert result['mode'] == 'async_submitted' + assert result['job_id'] == 'chart_test_job_1' + assert result['poll_path'].endswith('/chart_test_job_1') +``` + +```python +def test_chart_job_poll_endpoint_returns_cached_job_payload(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(jyotish_api_server, '_load_async_job_record', lambda scope, job_id: { + 'success': True, + 'endpoint': 'chart_async', + 'mode': 'async_result', + 'job_id': job_id, + 'status': 'completed', + 'result': {'success': True, 'runtime_cache': {'scope': 'api_chart_response'}}, + }) + handler = _HighRigorJobCaptureHandler('/api/chart/jobs/chart_test_job_2') + handler.do_GET() + payload = handler.payload() + assert payload['job_id'] == 'chart_test_job_2' + assert payload['result']['runtime_cache']['scope'] == 'api_chart_response' +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py -k "chart_async_submit_returns_job_id or chart_job_poll_endpoint_returns_cached_job_payload" -q +``` + +Expected: FAIL because `/api/chart` has no async branch and `/api/chart/jobs/...` is not routed. + +- [ ] **Step 3: Write minimal implementation** + +In `scripts/jyotish_api_server.py`: + +1. Add a generic job storage layer: + +```python +def _job_dir(scope: str) -> Path: + path = Path(REPO_ROOT) / 'scratch' / 'local' / f'{scope}_jobs' + path.mkdir(parents=True, exist_ok=True) + return path + +def _job_path(scope: str, job_id: str) -> Path: + return _job_dir(scope) / f'{job_id}.json' + +def _load_async_job_record(scope: str, job_id: str) -> dict | None: + path = _job_path(scope, job_id) + ... + +def _write_async_job_record(scope: str, job_id: str, payload: dict) -> dict: + ... +``` + +2. Keep high-rigor wrappers calling the shared helpers. +3. Add `_enqueue_chart_job(body)` that runs `_compute_chart_sync(body_without_async_flags)` in a background thread. +4. Add `_compute_chart_sync(body)` by moving current synchronous `_compute_chart` body there. +5. Make `_compute_chart(body)` return `_enqueue_chart_job(body)` when `async` or `enqueue` is set. +6. Add `GET /api/chart/jobs/{job_id}` in `do_GET`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py -k "chart_async_submit_returns_job_id or chart_job_poll_endpoint_returns_cached_job_payload" -q +``` + +Expected: PASS + +### Task 3: Make chart async completion return the normal chart payload unchanged + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_api_server.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_api_server_security.py` + +**Interfaces:** +- Consumes: + - `_compute_chart_sync(body: dict) -> dict` +- Produces: + - async chart completed record with `result` equal to normal chart payload shape + +- [ ] **Step 1: Write the failing test** + +Add: + +```python +def test_chart_async_job_executes_in_background(monkeypatch: pytest.MonkeyPatch) -> None: + handler = _handler() + writes = [] + + def fake_write(scope: str, job_id: str, payload: dict) -> dict: + writes.append((scope, job_id, dict(payload))) + return payload + + def fake_sync(body: dict) -> dict: + time.sleep(0.05) + return {'success': True, 'modules': {'chart': {'planets': {}, 'ascendant': {}}}, 'runtime_cache': {'scope': 'api_chart_response'}} + + monkeypatch.setattr(jyotish_api_server, '_write_async_job_record', fake_write) + monkeypatch.setattr(handler, '_compute_chart_sync', fake_sync) + + result = handler._enqueue_chart_job({'async': True, 'year': REDACTED_YEAR, 'month': 4, 'day': 17, 'hour': 14, 'minute': 49, 'lat': 36.42, 'lon': 114.2, 'tz': 8}) + + assert result['endpoint'] == 'chart_async' + assert result['status'] == 'queued' + + deadline = time.time() + 1.0 + while len(writes) < 3 and time.time() < deadline: + time.sleep(0.01) + + assert writes[-1][2]['status'] == 'completed' + assert writes[-1][2]['result']['runtime_cache']['scope'] == 'api_chart_response' + assert 'modules' in writes[-1][2]['result'] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py::test_chart_async_job_executes_in_background -q +``` + +Expected: FAIL because chart async job path does not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +Implement `_enqueue_chart_job(body)` as a thin wrapper around the shared async runner: + +```python +def _enqueue_chart_job(self, body): + body_copy = dict(body or {}) + body_copy.pop('async', None) + body_copy.pop('enqueue', None) + return self._enqueue_async_job( + scope='api_chart_response', + endpoint='chart_async', + job_prefix='chart', + poll_base='/api/chart/jobs', + compute_fn=lambda: self._compute_chart_sync(body_copy), + ) +``` + +Keep completed `result` untouched. + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py::test_chart_async_job_executes_in_background -q +``` + +Expected: PASS + +### Task 4: Keep existing high-rigor async path working on top of the shared helper + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_api_server.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_api_server_security.py` + +**Interfaces:** +- Consumes: + - shared async helper +- Produces: + - backward-compatible high-rigor async submit and polling + +- [ ] **Step 1: Re-run existing high-rigor async tests as regression guards** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py -k "high_rigor_async_submit_returns_job_id or high_rigor_job_poll_endpoint_returns_cached_job_payload or high_rigor_async_job_executes_in_background" -q +``` + +Expected: If this fails after Task 2/3 changes, fix compatibility before proceeding. + +- [ ] **Step 2: Minimal compatibility implementation** + +Keep wrappers like: + +```python +def _enqueue_high_rigor_job(self, body): + ... + return self._enqueue_async_job( + scope=_HIGH_RIGOR_JOB_SCOPE, + endpoint='high_rigor_workflow_async', + job_prefix='hrw', + poll_base='/api/high_rigor_workflow/jobs', + compute_fn=lambda: self._compute_high_rigor_workflow_sync(body_copy), + ) +``` + +And route `GET /api/high_rigor_workflow/jobs/{job_id}` through the shared loader. + +- [ ] **Step 3: Run regression tests to verify they pass** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py -k "high_rigor_async_submit_returns_job_id or high_rigor_job_poll_endpoint_returns_cached_job_payload or high_rigor_async_job_executes_in_background" -q +``` + +Expected: PASS + +### Task 5: Run focused regression bundle and update progress + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/progress.md` + +**Interfaces:** +- Consumes: + - all prior tasks +- Produces: + - verification record in `progress.md` + +- [ ] **Step 1: Run focused regression bundle** + +Run: + +```bash +python3 -m pytest \ + tests/test_cli_smoke.py::test_full_reading_summary_exposes_stage_timing_contract \ + tests/test_cli_smoke.py::test_full_reading_summary_exposes_unified_stage_groups \ + tests/test_api_server_security.py -k "chart_async or high_rigor_async or runtime_cache or fragment_audit_blocks_registry_surface_drift" \ + tests/test_mcp_strict_workflow_career.py \ + tests/test_mcp_strict_workflow_relationship.py \ + tests/test_mcp_strict_workflow_finance.py \ + -q +``` + +Expected: PASS + +- [ ] **Step 2: Run chart/high-rigor API regression bundle** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py tests/test_historical_event_backtest.py -q +``` + +Expected: PASS + +- [ ] **Step 3: Update progress.md** + +Add an entry covering: + +- unified stage contract added to `full-reading` +- `/api/chart` async submit + poll landed +- shared async helper now serves chart + high-rigor +- focused verification results diff --git a/docs/superpowers/plans/2026-06-30-technique-audit-strict-adjudication.md b/docs/superpowers/plans/2026-06-30-technique-audit-strict-adjudication.md new file mode 100644 index 00000000..93056572 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-technique-audit-strict-adjudication.md @@ -0,0 +1,357 @@ +# Technique Audit Strict Adjudication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the existing `Technique Audit Table` a required compact gate inside strict adjudication for `career`, `relationship`, and `finance`. + +**Architecture:** Reuse the current strict workflow contracts in `mcp_server.py` and the current prompt-pack audit table in `scripts/jyotish_engine.py`. Add one compact shared audit summary, then surface it through the existing engine/API/frontend layers without adding new compute-heavy logic. + +**Tech Stack:** Python, existing strict workflow collectors, existing prompt-pack contract, pytest, existing frontend static contract tests. + +## Global Constraints + +- Reuse current `Technique Audit Table`; do not create a second audit table system. +- Do not add new heavy computation or new external requests. +- Keep `career`, `relationship`, and `finance` route-specific gates intact. +- Preserve honesty boundaries: `blocked`, `fallback_used`, `conflicts`, and `confidence_cap` must remain explicit. +- Functional benefic/malefic must remain visible as a first-class high-rigor gate. + +--- + +### Task 1: Add compact technique audit summary to strict workflow contracts + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/mcp_server.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_career.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_relationship.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_finance.py` + +**Interfaces:** +- Consumes: + - `strict["present_evidence"]` + - `strict["official_primary_evidence"]` + - `strict["local_supplemental_evidence"]` + - `strict["fallback_used"]` + - `strict["blocked_items"]` + - `strict["conflicts"]` +- Produces: + - `strict["technique_audit_summary"]` + - `strict["multi_reference_reading_summary"]["audit_gate_frame"]` + +- [ ] **Step 1: Write the failing tests** + +```python +def test_career_strict_contract_exposes_compact_technique_audit_summary() -> None: + strict = _collect_strict_evidence("career", _sample_result()) + audit = strict["technique_audit_summary"] + assert audit["functional_benefic_malefic"]["gate"] == "hard" + assert audit["relevant_vargas"]["gate"] == "hard" + assert audit["vimshottari_narayana_crosscheck"]["gate"] == "hard" + assert audit["source_priority_boundary"]["fallback_used"] == strict["fallback_used"] +``` + +```python +def test_relationship_multi_reference_summary_carries_audit_gate_frame() -> None: + strict = _collect_strict_evidence("relationship", _sample_result()) + frame = strict["multi_reference_reading_summary"]["audit_gate_frame"] + assert frame["functional_benefic_malefic"]["used"] in {True, False} + assert frame["source_priority_boundary"]["blocked_items"] == strict["blocked_items"] +``` + +```python +def test_finance_strict_contract_compact_audit_marks_dual_dasha_gate() -> None: + strict = _collect_strict_evidence("finance", {"modules": {"source_priority": {"mode": "local_fallback_official_blocked"}}}) + audit = strict["technique_audit_summary"] + assert audit["vimshottari_narayana_crosscheck"]["gate"] == "hard" + assert "official_primary_chart_blocked" in audit["source_priority_boundary"]["blocked_items"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m pytest tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_finance.py -k "compact_technique_audit_summary or audit_gate_frame or dual_dasha_gate" -q +``` + +Expected: FAIL with missing `technique_audit_summary` or `audit_gate_frame`. + +- [ ] **Step 3: Write minimal implementation** + +Add compact builders in `mcp_server.py`: + +```python +def _route_varga_gate_keys(route: str) -> list[str]: + if route == "career": + return ["d10_dasamsa", "a10_karma_pada", "amatyakaraka", "karakamsha"] + if route == "relationship": + return ["d9_navamsa", "upapada_lagna", "darakaraka", "vivah_saham"] + if route == "finance": + return ["d2_hora", "d10_dasamsa", "shadbala", "ashtakavarga_house_scores"] + return [] + +def _build_technique_audit_summary(route: str, strict: Dict[str, Any]) -> Dict[str, Any]: + present = strict.get("present_evidence") or {} + official = strict.get("official_primary_evidence") or {} + local = strict.get("local_supplemental_evidence") or {} + fallback_used = strict.get("fallback_used") or [] + blocked_items = strict.get("blocked_items") or [] + conflicts = strict.get("conflicts") or [] + return { + "functional_benefic_malefic": { + "gate": "hard", + "used": bool((present.get("functional_benefic_malefic") or {}).get("status") == "used"), + "note": (present.get("functional_benefic_malefic") or {}).get("effect_on_confidence"), + }, + "relevant_vargas": { + "gate": "hard", + "required_keys": _route_varga_gate_keys(route), + "present_keys": [key for key in _route_varga_gate_keys(route) if present.get(key)], + }, + "vimshottari_narayana_crosscheck": { + "gate": "hard", + "used": bool(present.get("vimshottari_current")) and bool(present.get("narayana_current")), + "required_timing_systems": ["Vimshottari", "Narayana"], + }, + "source_priority_boundary": { + "gate": "boundary", + "official": official, + "local": local, + "fallback_used": fallback_used, + "blocked_items": blocked_items, + "conflicts": conflicts, + }, + } +``` + +Attach it in `_attach_top_reader_contract(...)` and mirror it into `multi_reference_reading_summary["audit_gate_frame"]`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +python3 -m pytest tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_finance.py -k "compact_technique_audit_summary or audit_gate_frame or dual_dasha_gate" -q +``` + +Expected: PASS + +### Task 2: Compact and expose the audit summary through full-reading and prompt pack + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_engine.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_cli_smoke.py` + +**Interfaces:** +- Consumes: + - `modules[*_strict_evidence]` +- Produces: + - compact strict contracts that include `technique_audit_summary` + +- [ ] **Step 1: Write the failing test** + +```python +def test_full_reading_prompt_pack_carries_compact_technique_audit_summary() -> None: + result = run_engine(...) + career = result["ai_prompt_pack"]["evidence_snapshot"]["strict_workflow_contracts"]["career"] + assert "technique_audit_summary" in career + assert career["technique_audit_summary"]["functional_benefic_malefic"]["gate"] == "hard" + assert "audit_gate_frame" in career["multi_reference_reading_summary"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +python3 -m pytest tests/test_cli_smoke.py -k "compact_technique_audit_summary" -q +``` + +Expected: FAIL with missing `technique_audit_summary`. + +- [ ] **Step 3: Write minimal implementation** + +Extend `_compact_strict_workflow_contract(...)` in `scripts/jyotish_engine.py`: + +```python +"technique_audit_summary": strict.get("technique_audit_summary") or {}, +``` + +Do not recompute anything new; just pass through the strict contract. + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +python3 -m pytest tests/test_cli_smoke.py -k "compact_technique_audit_summary" -q +``` + +Expected: PASS + +### Task 3: Surface the same compact audit summary in API outputs + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_api_server.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_api_server_security.py` + +**Interfaces:** +- Consumes: + - strict contract from prompt pack / official full snapshot +- Produces: + - API outputs containing `technique_audit_summary` + +- [ ] **Step 1: Write the failing tests** + +```python +def test_high_rigor_summary_passes_through_compact_technique_audit_summary() -> None: + result = handler._high_rigor_vedastro_official_summary(chart) + assert result["technique_audit_summary"]["functional_benefic_malefic"]["gate"] == "hard" +``` + +```python +def test_consultation_workflow_surfaces_compact_technique_audit_summary(monkeypatch) -> None: + result = handler._compute_consultation_workflow(payload) + assert "technique_audit_summary" in result["vedastro_official"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py -k "compact_technique_audit_summary" -q +``` + +Expected: FAIL with missing API passthrough key. + +- [ ] **Step 3: Write minimal implementation** + +In `scripts/jyotish_api_server.py`, add: + +```python +'technique_audit_summary': primary_contract.get('technique_audit_summary') or {}, +``` + +to the shared official summary output path. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py -k "compact_technique_audit_summary" -q +``` + +Expected: PASS + +### Task 4: Make frontend and AI chat consume the compact audit summary + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/jyotish-app/main.js` +- Modify: `/Users/wuyongnaren/Documents/印度占星/jyotish-app/ai-chat.js` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_frontend_productization.py` + +**Interfaces:** +- Consumes: + - compact strict contract +- Produces: + - UI/AI references to `technique_audit_summary` + +- [ ] **Step 1: Write the failing test** + +```python +def test_frontend_consumes_compact_technique_audit_summary_in_top_reader_contract() -> None: + main = read("main.js") + ai_chat = read("ai-chat.js") + assert "technique_audit_summary" in main + assert "technique_audit_summary" in ai_chat +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +python3 -m pytest tests/test_frontend_productization.py -k "compact_technique_audit_summary" -q +``` + +Expected: FAIL with missing frontend references. + +- [ ] **Step 3: Write minimal implementation** + +In `main.js` and `ai-chat.js`, read: + +```javascript +const techniqueAuditSummary = topReaderContract.technique_audit_summary || {}; +``` + +and surface only a compact summary line, not the whole raw audit table. + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +```bash +python3 -m pytest tests/test_frontend_productization.py -k "compact_technique_audit_summary" -q +``` + +Expected: PASS + +### Task 5: Run focused regression and update progress + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/progress.md` + +**Interfaces:** +- Consumes: + - all changes from Tasks 1-4 +- Produces: + - verification note in `progress.md` + +- [ ] **Step 1: Run focused regression** + +Run: + +```bash +python3 -m pytest \ + tests/test_mcp_strict_workflow_career.py \ + tests/test_mcp_strict_workflow_relationship.py \ + tests/test_mcp_strict_workflow_finance.py \ + tests/test_cli_smoke.py \ + tests/test_api_server_security.py \ + tests/test_frontend_productization.py \ + -k "compact_technique_audit_summary or audit_gate_frame" -q +``` + +Expected: PASS + +- [ ] **Step 2: Update progress** + +Add an entry that the compact `Technique Audit Table` gate now sits inside default strict adjudication. + +- [ ] **Step 3: Run diff hygiene** + +Run: + +```bash +git diff --check +``` + +Expected: no output + +## Self-Review + +- Spec coverage: compact strict audit gate, prompt-pack passthrough, API passthrough, frontend consumption, and focused regression are all mapped to tasks. +- Placeholder scan: no TODO/TBD placeholders remain. +- Type consistency: `technique_audit_summary` and `audit_gate_frame` are used consistently across strict contract, prompt pack, API, and frontend. + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-06-30-technique-audit-strict-adjudication.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/superpowers/plans/2026-06-30-top-reader-adjudication.md b/docs/superpowers/plans/2026-06-30-top-reader-adjudication.md new file mode 100644 index 00000000..e809bc22 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-top-reader-adjudication.md @@ -0,0 +1,571 @@ +# Top Reader Adjudication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade the default `career`, `relationship`, and `finance` reading workflows to use a shared top-reader-style adjudication skeleton with multi-reference summaries and selected modifier bridges, while reusing existing strict workflow code and minimizing extra compute. + +**Architecture:** Reuse the current strict workflow builders in `mcp_server.py` as the primary evidence source, normalize them through a shared adjudication helper, and surface the reshaped contract through `jyotish_engine.py`, `jyotish_api_server.py`, and the existing prompt-pack/frontend consumer layers. Do not introduce a second engine or a full 641-callable VedAstro execution path; instead, reshape current evidence into a common four-stage contract plus a lightweight `multi_reference_reading_summary`. + +**Tech Stack:** Python, existing strict workflow collectors, pytest, existing frontend productization tests, existing prompt-pack evidence snapshot contract. + +## Global Constraints + +- Reuse current strict workflow collectors before adding new collectors. +- Reuse existing bridge helpers before creating new scoring paths. +- Reuse current full-reading/module outputs by reference where possible. +- Do not add all-method VedAstro execution to the default path. +- Prefer small contract reshaping over new compute-heavy logic. +- Keep official calls cached and reused; do not add new heavyweight request fans. +- Preserve the existing honesty boundaries: emit `blocked`, `conflicts`, and `confidence_cap` instead of smoothing over missing layers. +- Keep `career`, `relationship`, and `finance` domain-specific evidence requirements intact while sharing structure. + +--- + +### Task 1: Add the shared adjudication contract builder in `mcp_server.py` + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/mcp_server.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_career.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_relationship.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_finance.py` + +**Interfaces:** +- Consumes: + - existing strict route `present_evidence` + - existing `event_judgement` + - existing `official_primary_evidence` + - existing `local_supplemental_evidence` + - existing `conflicts` + - existing `confidence_cap` +- Produces: + - `_build_adjudication_stages(route: str, present: Dict[str, Any], event_judgement: Dict[str, Any]) -> Dict[str, Any]` + - `_build_multi_reference_reading_summary(route: str, present: Dict[str, Any], strict: Dict[str, Any]) -> Dict[str, Any]` + - strict contract keys: + - `adjudication_stages` + - `multi_reference_reading_summary` + - `verdict` + - `dominant_label` + - `main_conflicts` + +- [ ] **Step 1: Write the failing tests** + +Add assertions to each strict workflow domain test file for the new shared fields: + +```python +def test_career_strict_contract_exposes_adjudication_stages() -> None: + result = mcp_server._collect_strict_evidence("career", modules) + assert result["adjudication_stages"]["promise"]["status"] in {"present", "weak", "missing"} + assert result["adjudication_stages"]["activation"]["required_timing_systems"] == ["Vimshottari", "Narayana"] + assert "multi_reference_reading_summary" in result + assert "root_frame" in result["multi_reference_reading_summary"] +``` + +Repeat the same shape expectation for `relationship` and `finance`, adapted to each route. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m pytest tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_finance.py -k "adjudication_stages or multi_reference_reading_summary" -q +``` + +Expected: FAIL with missing strict contract keys such as `adjudication_stages` or `multi_reference_reading_summary`. + +- [ ] **Step 3: Write the minimal implementation** + +Implement shared helpers near the existing strict helper section in `mcp_server.py`: + +```python +def _build_adjudication_stages(route: str, present: Dict[str, Any], event_judgement: Dict[str, Any]) -> Dict[str, Any]: + dominant_label = event_judgement.get("dominant_label") + return { + "promise": { + "status": "present" if _has_promise_evidence(route, present) else "weak", + "drivers": _promise_drivers(route, present), + }, + "activation": { + "status": "present" if _has_activation_evidence(route, present) else "weak", + "required_timing_systems": ["Vimshottari", "Narayana"], + "drivers": _activation_drivers(route, present), + }, + "manifestation": { + "status": "present" if dominant_label else "weak", + "drivers": event_judgement.get("secondary_context") or [], + }, + "label": { + "status": "present" if dominant_label else "missing", + "value": dominant_label, + "verdict": event_judgement.get("verdict"), + }, + } +``` + +Also add a small route-aware summary builder: + +```python +def _build_multi_reference_reading_summary(route: str, present: Dict[str, Any], strict: Dict[str, Any]) -> Dict[str, Any]: + return { + "root_frame": _summary_root_frame(route, present), + "divisional_frame": _summary_divisional_frame(route, present), + "visibility_frame": _summary_visibility_frame(route, present), + "karaka_frame": _summary_karaka_frame(route, present), + "timing_frame": _summary_timing_frame(route, present), + "modifier_frame": _summary_modifier_frame(route, present), + "conflict_frame": { + "conflicts": strict.get("conflicts") or [], + "confidence_cap": strict.get("confidence_cap"), + }, + } +``` + +Attach these fields inside each strict route result, reusing the current per-route `present` and `event_judgement`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +python3 -m pytest tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_finance.py -k "adjudication_stages or multi_reference_reading_summary" -q +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add mcp_server.py tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_finance.py +git commit -m "feat: add shared top-reader adjudication contract" +``` + +### Task 2: Promote selected bridge layers into the shared modifier frame + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/mcp_server.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_finance.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_relationship.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_functional_layer.py` + +**Interfaces:** +- Consumes: + - `present["functional_benefic_malefic"]` + - `present["ashtakavarga_finance_support"]` + - `present["wealth_promise_strength"]` + - marriage route evidence already emitted into `present` + - `yogi_active` in finance `event_judgement.secondary_context` +- Produces: + - `multi_reference_reading_summary["modifier_frame"]` + - `adjudication_stages["manifestation"]["bridge_modifiers"]` + +- [ ] **Step 1: Write the failing tests** + +Add focused bridge promotion assertions: + +```python +def test_finance_summary_modifier_frame_includes_yogi_and_ashtakavarga_only_as_modifiers() -> None: + strict = mcp_server._collect_strict_evidence("finance", modules) + modifier = strict["multi_reference_reading_summary"]["modifier_frame"] + assert "functional_benefic_malefic" in modifier + assert modifier["ashtakavarga_finance_support"]["source"] == "ashtakavarga_house_scores_bridge_v1" + assert modifier["yogi_support"]["role"] == "modifier_only" +``` + +```python +def test_relationship_summary_modifier_frame_surfaces_label_lift_related_modifiers() -> None: + strict = mcp_server._collect_strict_evidence("relationship", modules) + modifier = strict["multi_reference_reading_summary"]["modifier_frame"] + assert "functional_benefic_malefic" in modifier + assert "manifestation_split" in modifier +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m pytest tests/test_mcp_strict_workflow_finance.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_functional_layer.py -k "modifier_frame or yogi or manifestation_split" -q +``` + +Expected: FAIL with missing modifier-frame keys. + +- [ ] **Step 3: Write the minimal implementation** + +Extend the summary helpers in `mcp_server.py`: + +```python +def _summary_modifier_frame(route: str, present: Dict[str, Any]) -> Dict[str, Any]: + frame = { + "functional_benefic_malefic": present.get("functional_benefic_malefic"), + "shadbala": present.get("shadbala"), + "argala_support": present.get("argala_support"), + } + if route == "finance": + frame["ashtakavarga_finance_support"] = present.get("ashtakavarga_finance_support") + frame["yogi_support"] = { + "role": "modifier_only", + "value": present.get("wealth_promise_strength"), + } + if route == "relationship": + frame["manifestation_split"] = { + "role": "modifier_only", + "signals": [ + "relationship_formation", + "legal_marriage", + "public_formalization", + ], + } + return frame +``` + +Do not create new calculators here; only repackage current evidence and known route-specific bridge metadata. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +python3 -m pytest tests/test_mcp_strict_workflow_finance.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_functional_layer.py -k "modifier_frame or yogi or manifestation_split" -q +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add mcp_server.py tests/test_mcp_strict_workflow_finance.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_functional_layer.py +git commit -m "feat: promote selected bridge layers into shared modifiers" +``` + +### Task 3: Compact and expose the new contract through `jyotish_engine.py` + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_engine.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_cli_smoke.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_vedastro_official_full_snapshot.py` + +**Interfaces:** +- Consumes: + - strict workflow contracts embedded in `modules` +- Produces: + - `_compact_strict_workflow_contract(strict: Dict[str, Any]) -> Dict[str, Any]` + - `ai_prompt_pack["evidence_snapshot"]["strict_workflow_contracts"][route]["adjudication_stages"]` + - `ai_prompt_pack["evidence_snapshot"]["strict_workflow_contracts"][route]["multi_reference_reading_summary"]` + +- [ ] **Step 1: Write the failing tests** + +Add prompt-pack expectations: + +```python +def test_full_reading_prompt_pack_carries_adjudication_stages_and_multi_reference_summary() -> None: + result = run_full_reading(...) + strict = result["ai_prompt_pack"]["evidence_snapshot"]["strict_workflow_contracts"]["career"] + assert "adjudication_stages" in strict + assert "multi_reference_reading_summary" in strict + assert "modifier_frame" in strict["multi_reference_reading_summary"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m pytest tests/test_cli_smoke.py tests/test_vedastro_official_full_snapshot.py -k "adjudication_stages or multi_reference_reading_summary" -q +``` + +Expected: FAIL with missing keys in compact strict workflow contract or evidence snapshot. + +- [ ] **Step 3: Write the minimal implementation** + +Extend the strict contract compactor in `scripts/jyotish_engine.py`: + +```python +def _compact_strict_workflow_contract(strict): + return { + "confidence_cap": strict.get("confidence_cap"), + "blocked": strict.get("blocked"), + "blocked_items": strict.get("blocked_items") or [], + "conflicts": strict.get("conflicts") or [], + "official_primary_evidence": strict.get("official_primary_evidence") or {}, + "local_supplemental_evidence": strict.get("local_supplemental_evidence") or {}, + "adjudication_stages": strict.get("adjudication_stages") or {}, + "multi_reference_reading_summary": strict.get("multi_reference_reading_summary") or {}, + "verdict": strict.get("verdict"), + "dominant_label": strict.get("dominant_label"), + "main_conflicts": strict.get("main_conflicts") or [], + } +``` + +Also ensure the prompt-pack evidence snapshot reuses this compacted form instead of recomputing anything heavy. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +python3 -m pytest tests/test_cli_smoke.py tests/test_vedastro_official_full_snapshot.py -k "adjudication_stages or multi_reference_reading_summary" -q +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add scripts/jyotish_engine.py tests/test_cli_smoke.py tests/test_vedastro_official_full_snapshot.py +git commit -m "feat: expose top-reader contract in prompt pack" +``` + +### Task 4: Surface the reshaped contract in API outputs with no extra heavy recompute + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_api_server.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_api_server_security.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_historical_event_backtest.py` + +**Interfaces:** +- Consumes: + - prompt-pack `evidence_snapshot` + - existing consultation workflow contract + - strict workflow contract summary +- Produces: + - consultation/high-rigor API outputs that include: + - `adjudication_stages` + - `multi_reference_reading_summary` + - `verdict` + - `dominant_label` + - `main_conflicts` + +- [ ] **Step 1: Write the failing tests** + +Add API-level shape assertions: + +```python +def test_consultation_workflow_passes_through_top_reader_contract(monkeypatch) -> None: + result = handler._compute_consultation_workflow(payload) + guided = result["guided_topics"][0] + assert "adjudication_stages" in guided + assert "multi_reference_reading_summary" in guided +``` + +```python +def test_high_rigor_summary_passes_through_multi_reference_summary(monkeypatch) -> None: + summary = handler._high_rigor_vedastro_official_summary(prompt_official, range_scan, range_metadata) + assert "multi_reference_reading_summary" in summary +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py tests/test_historical_event_backtest.py -k "top_reader_contract or multi_reference_reading_summary" -q +``` + +Expected: FAIL with missing API passthrough keys. + +- [ ] **Step 3: Write the minimal implementation** + +Update `scripts/jyotish_api_server.py` to reuse existing prompt-pack or strict contract nodes: + +```python +strict_contract = prompt_official.get("strict_workflow_contracts", {}).get(route_key, {}) +summary["adjudication_stages"] = strict_contract.get("adjudication_stages") or {} +summary["multi_reference_reading_summary"] = strict_contract.get("multi_reference_reading_summary") or {} +summary["verdict"] = strict_contract.get("verdict") +summary["dominant_label"] = strict_contract.get("dominant_label") +summary["main_conflicts"] = strict_contract.get("main_conflicts") or strict_contract.get("conflicts") or [] +``` + +Where guided topic objects are built, attach the same already-computed contract by reference or compact copy; do not call full-reading again. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +python3 -m pytest tests/test_api_server_security.py tests/test_historical_event_backtest.py -k "top_reader_contract or multi_reference_reading_summary" -q +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add scripts/jyotish_api_server.py tests/test_api_server_security.py tests/test_historical_event_backtest.py +git commit -m "feat: surface top-reader adjudication contract in api outputs" +``` + +### Task 5: Keep the frontend and user-facing surfaces simple while consuming the richer contract + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/jyotish-app/main.js` +- Modify: `/Users/wuyongnaren/Documents/印度占星/jyotish-app/ai-chat.js` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_frontend_productization.py` + +**Interfaces:** +- Consumes: + - consultation workflow output + - prompt-pack evidence snapshot + - compact strict contract +- Produces: + - visible simple user summaries + - AI chat context that includes the new top-reader contract + +- [ ] **Step 1: Write the failing tests** + +Add frontend token tests: + +```python +def test_ai_chat_and_complete_reading_surface_top_reader_contract_tokens() -> None: + main = read_main_js() + ai_chat = read_ai_chat_js() + assert "multi_reference_reading_summary" in main + assert "adjudication_stages" in main + assert "multi_reference_reading_summary" in ai_chat + assert "adjudication_stages" in ai_chat +``` + +Also add one test that the UI still prefers compact summaries rather than dumping raw full evidence. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +python3 -m pytest tests/test_frontend_productization.py -k "top_reader_contract or multi_reference_reading_summary or adjudication_stages" -q +``` + +Expected: FAIL with missing frontend references to the new contract. + +- [ ] **Step 3: Write the minimal implementation** + +Update frontend readers to expose only compact user-facing summaries and AI context: + +```javascript +const topReaderContract = chartData?.ai_prompt_pack?.evidence_snapshot?.strict_workflow_contracts?.[routeKey] || {}; +const adjudicationStages = topReaderContract.adjudication_stages || {}; +const multiReferenceSummary = topReaderContract.multi_reference_reading_summary || {}; +``` + +Use these to: + +- show a compact “how this conclusion was formed” section +- append structured context to AI chat +- avoid rendering the full raw evidence tree unless already needed in an audit panel + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +python3 -m pytest tests/test_frontend_productization.py -k "top_reader_contract or multi_reference_reading_summary or adjudication_stages" -q +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add jyotish-app/main.js jyotish-app/ai-chat.js tests/test_frontend_productization.py +git commit -m "feat: consume top-reader adjudication contract in frontend" +``` + +### Task 6: Run the focused regression bundle and then the broader verification pass + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/progress.md` + +**Interfaces:** +- Consumes: + - all modified code from Tasks 1-5 +- Produces: + - recorded verification summary in `progress.md` + +- [ ] **Step 1: Run the focused contract regressions** + +Run: + +```bash +python3 -m pytest \ + tests/test_mcp_strict_workflow_career.py \ + tests/test_mcp_strict_workflow_relationship.py \ + tests/test_mcp_strict_workflow_finance.py \ + tests/test_mcp_strict_workflow_functional_layer.py \ + tests/test_cli_smoke.py \ + tests/test_vedastro_official_full_snapshot.py \ + tests/test_api_server_security.py \ + tests/test_historical_event_backtest.py \ + tests/test_frontend_productization.py \ + -k "adjudication_stages or multi_reference_reading_summary or top_reader_contract or modifier_frame" -q +``` + +Expected: PASS + +- [ ] **Step 2: Run the broader targeted verification** + +Run: + +```bash +python3 -m pytest tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_finance.py tests/test_api_server_security.py tests/test_cli_smoke.py tests/test_frontend_productization.py -q +``` + +Expected: PASS + +- [ ] **Step 3: Update `progress.md` with the landed contract and verification notes** + +Add an entry similar to: + +```markdown +- 2026-06-30 Top-reader adjudication contract landed: + - shared `promise -> activation -> manifestation -> label` + - `multi_reference_reading_summary` + - bridge promotion kept modifier-only + - prompt-pack/API/frontend all consume the same compact contract + - focused and targeted regressions passed +``` + +- [ ] **Step 4: Run diff hygiene checks** + +Run: + +```bash +git diff --check +``` + +Expected: no output + +- [ ] **Step 5: Commit** + +```bash +git add progress.md +git commit -m "docs: record top-reader adjudication verification" +``` + +## Self-Review + +### Spec coverage + +- Shared four-stage skeleton: covered by Task 1 +- Multi-reference summary: covered by Tasks 1, 3, 4, and 5 +- Selected bridge promotion: covered by Task 2 +- Prompt-pack/API/frontend consumption: covered by Tasks 3, 4, and 5 +- Compute minimization and reuse constraints: enforced in every task through existing-contract reuse and no new engine work + +### Placeholder scan + +- No `TODO`, `TBD`, or “implement later” placeholders remain. +- Each task includes exact files, exact commands, and exact expected behavior. + +### Type consistency + +- `adjudication_stages` and `multi_reference_reading_summary` are introduced first in `mcp_server.py`, then compacted in `jyotish_engine.py`, then consumed in `jyotish_api_server.py` and frontend. +- `verdict`, `dominant_label`, and `main_conflicts` are named consistently across all tasks. + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-06-30-top-reader-adjudication.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/superpowers/plans/2026-06-30-vedastro-daily-window-priority.md b/docs/superpowers/plans/2026-06-30-vedastro-daily-window-priority.md new file mode 100644 index 00000000..a7730ae0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-vedastro-daily-window-priority.md @@ -0,0 +1,405 @@ +# VedAstro Daily Window Priority Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Promote official VedAstro range-scan output into reusable day-window evidence that strict career/marriage/wealth workflows can consume directly. + +**Architecture:** Reuse the existing `SearchEvents` adapter, orchestration, and strict workflow chain. Add adapter-level day-window aggregation, propagate it through `vedastro_evidence_orchestrator`, expose it inside `external_activation`, and surface it through `life_event_graph_v1` and `full-reading` outputs. + +**Tech Stack:** Python 3, existing Jyotish engine, existing VedAstro service adapter, pytest. + +## Global Constraints + +- Reuse existing `scripts/vedastro_service_adapter.py`, `scripts/vedastro_evidence_orchestrator.py`, `mcp_server.py`, and `scripts/jyotish_engine.py`; do not create a parallel official-event stack. +- Keep official VedAstro as primary raw evidence and local Jyotish modules as adjudication/promise/timing cross-check layers. +- Do not bypass D9/D10/D2/UL/A10/Narayana/Functional Benefic-Malefic gates. +- Preserve existing response cache and free-tier queue logic. +- Keep changes focused on daily-window extraction and propagation, not a month-grid product. + +--- + +### Task 1: Add failing adapter tests for daily-window aggregation + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_vedastro_range_scan_replay.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_vedastro_range_scan_replay.py` + +**Interfaces:** +- Consumes: `scripts.vedastro_service_adapter._normalize_range_scan_success(payload, endpoint, request_preview, attempt_count=1, retry_error_codes=None) -> dict` +- Produces: adapter results with `daily_windows: list[dict]` and `top_daily_window: dict | None` + +- [ ] **Step 1: Write the failing test** + +```python +def test_range_scan_builds_ranked_daily_windows_from_same_day_events() -> None: + payload = { + "Status": "Pass", + "Payload": [ + { + "Name": "GocharJupiterAspect10th", + "Description": "Career support transit.", + "StartTime": "2026-07-18", + "EndTime": "2026-07-18", + "EventTags": ["Travel", "General"], + }, + { + "Name": "CareerExpansionWindow", + "Description": "Strong career expansion signal.", + "StartTime": "2026-07-18", + "EndTime": "2026-07-18", + "EventTags": ["career", "transit"], + }, + { + "Name": "GocharJupiterAspect10th", + "Description": "Career support transit.", + "StartTime": "2026-07-26", + "EndTime": "2026-07-26", + "EventTags": ["Travel", "General"], + }, + ], + } + + report = vedastro_service_adapter._normalize_range_scan_success( # noqa: SLF001 + payload, + "https://api.vedastro.org/api", + _request_preview("career"), + ) + + assert report["daily_windows"][0]["date"] == "2026-07-18" + assert report["daily_windows"][0]["event_count"] == 2 + assert report["top_daily_window"]["date"] == "2026-07-18" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_vedastro_range_scan_replay.py::test_range_scan_builds_ranked_daily_windows_from_same_day_events -q` + +Expected: FAIL because `daily_windows` / `top_daily_window` are missing. + +- [ ] **Step 3: Write minimal implementation** + +Add a helper in `scripts/vedastro_service_adapter.py`: + +```python +def _build_daily_windows(domain: str, evidence_ledger: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: + ... +``` + +and call it from `_normalize_range_scan_success(...)`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest tests/test_vedastro_range_scan_replay.py::test_range_scan_builds_ranked_daily_windows_from_same_day_events -q` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_vedastro_range_scan_replay.py scripts/vedastro_service_adapter.py +git commit -m "feat: add vedastro daily window aggregation" +``` + +### Task 2: Propagate daily windows through orchestrator + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/vedastro_evidence_orchestrator.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_vedastro_evidence_orchestrator.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_vedastro_evidence_orchestrator.py` + +**Interfaces:** +- Consumes: `run_range_scan_for_case(...) -> dict` with `daily_windows` and `top_daily_window` +- Produces: orchestrator result keys: + - `daily_windows_by_domain: dict[str, list[dict]]` + - `top_daily_window_by_domain: dict[str, dict]` + +- [ ] **Step 1: Write the failing test** + +```python +def test_vedastro_orchestrator_surfaces_daily_windows_by_domain(monkeypatch) -> None: + from scripts import vedastro_evidence_orchestrator as orchestrator + + monkeypatch.setattr(orchestrator, "run_official_full_snapshot_for_case", lambda *args, **kwargs: {"status": "ok", "source_metadata": {}}) + monkeypatch.setattr( + orchestrator, + "run_range_scan_for_case", + lambda *args, **kwargs: { + "status": "ok", + "available": True, + "event_count": 2, + "daily_windows": [{"date": "2026-07-18", "domain": "career", "score": 5, "event_count": 2}], + "top_daily_window": {"date": "2026-07-18", "domain": "career", "score": 5, "event_count": 2}, + "evidence_ledger": [], + }, + ) + + result = orchestrator.orchestrate_vedastro_evidence( + {"year": REDACTED_YEAR, "month": 4, "day": 17, "hour": 14, "minute": 49, "lat": 36.42, "lon": 114.2, "tz": 8}, + route="career", + reference_date="2026-06-30", + ) + + assert result["daily_windows_by_domain"]["career"][0]["date"] == "2026-07-18" + assert result["top_daily_window_by_domain"]["career"]["score"] == 5 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_vedastro_evidence_orchestrator.py::test_vedastro_orchestrator_surfaces_daily_windows_by_domain -q` + +Expected: FAIL because orchestrator does not yet expose these keys. + +- [ ] **Step 3: Write minimal implementation** + +In `scripts/vedastro_evidence_orchestrator.py`, collect from each domain report: + +```python +daily_windows_by_domain[domain] = report.get("daily_windows") or [] +if isinstance(report.get("top_daily_window"), dict): + top_daily_window_by_domain[domain] = report["top_daily_window"] +``` + +and return them in the final payload. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest tests/test_vedastro_evidence_orchestrator.py::test_vedastro_orchestrator_surfaces_daily_windows_by_domain -q` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add scripts/vedastro_evidence_orchestrator.py tests/test_vedastro_evidence_orchestrator.py +git commit -m "feat: propagate vedastro daily windows through orchestrator" +``` + +### Task 3: Promote daily windows into strict workflow external activation + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/mcp_server.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_career.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_relationship.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_mcp_strict_workflow_finance.py` +- Test: same files + +**Interfaces:** +- Consumes: `modules.vedastro_range_scan_result.daily_windows` / `top_daily_window` +- Produces: `present_evidence.external_activation.daily_windows` and `present_evidence.external_activation.top_daily_window` + +- [ ] **Step 1: Write the failing test** + +```python +def test_relationship_external_activation_exposes_top_daily_window() -> None: + result = { + "modules": { + "vedastro_range_scan_result": { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "marriage", + "evidence_ledger": [], + "daily_windows": [{"date": "2026-08-02", "domain": "marriage", "score": 5, "event_count": 2}], + "top_daily_window": {"date": "2026-08-02", "domain": "marriage", "score": 5, "event_count": 2}, + "source_metadata": {}, + }, + } + } + + strict = _collect_strict_evidence("relationship", result) + external = strict["present_evidence"]["external_activation"] + + assert external["top_daily_window"]["date"] == "2026-08-02" + assert external["daily_windows"][0]["score"] == 5 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_mcp_strict_workflow_relationship.py::test_relationship_external_activation_exposes_top_daily_window -q` + +Expected: FAIL because `external_activation` does not yet carry day-window fields. + +- [ ] **Step 3: Write minimal implementation** + +Extend `_derive_external_activation_support(...)` in `mcp_server.py` to read: + +```python +daily_windows = adapter_result.get("daily_windows") or [] +top_daily_window = adapter_result.get("top_daily_window") +``` + +and include them in the returned dict. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest tests/test_mcp_strict_workflow_relationship.py::test_relationship_external_activation_exposes_top_daily_window -q` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add mcp_server.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_finance.py +git commit -m "feat: expose vedastro daily windows in strict workflow" +``` + +### Task 4: Surface official day windows in life event graph + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/mcp_server.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_life_event_graph_v1.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_life_event_graph_v1.py` + +**Interfaces:** +- Consumes: `present_evidence.external_activation.daily_windows` +- Produces: `life_event_graph.event_nodes[]` entries with `kind: "official_day_window"` + +- [ ] **Step 1: Write the failing test** + +```python +def test_life_event_graph_surfaces_ranked_official_day_window_nodes() -> None: + strict = { + "event_judgement": {"event_family": "career", "verdict": "moderate_probability_window", "score": 74}, + "present_evidence": { + "external_activation": { + "level": "moderate", + "source": "vedastro_service_adapter_candidate", + "daily_windows": [ + { + "date": "2026-07-18", + "domain": "career", + "score": 5, + "confidence": "medium_high", + "event_count": 2, + "signal_families": ["career_trigger"], + "event_ids": ["GocharJupiterAspect10th", "CareerExpansionWindow"], + "top_signal_label": "Career expansion window", + } + ], + } + }, + "confidence_cap": "medium", + "missing_evidence": [], + "blocked": False, + } + + graph = _build_life_event_graph("career", strict) + + assert any(node["kind"] == "official_day_window" and node["date"] == "2026-07-18" for node in graph["event_nodes"]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_life_event_graph_v1.py::test_life_event_graph_surfaces_ranked_official_day_window_nodes -q` + +Expected: FAIL because no `official_day_window` nodes exist yet. + +- [ ] **Step 3: Write minimal implementation** + +In `_build_life_event_graph(...)`, after `external_window` nodes, append: + +```python +{ + "kind": "official_day_window", + "date": window.get("date"), + "domain": window.get("domain"), + "score": window.get("score"), + "confidence": window.get("confidence"), + "event_count": window.get("event_count"), + "top_signal_label": window.get("top_signal_label"), + "signal_families": window.get("signal_families") or [], + "event_ids": window.get("event_ids") or [], + "source": external_activation.get("source"), +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest tests/test_life_event_graph_v1.py::test_life_event_graph_surfaces_ranked_official_day_window_nodes -q` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add mcp_server.py tests/test_life_event_graph_v1.py +git commit -m "feat: show official vedastro day windows in life event graph" +``` + +### Task 5: Attach daily-window expansion to full-reading and verify output contract + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_engine.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_cli_smoke.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_cli_smoke.py` + +**Interfaces:** +- Consumes: existing `modules.vedastro_range_scan_result` +- Produces: `modules.vedastro_range_scan_result.daily_windows`, `summary.guided_topics`, and strict contracts that can reach day-window evidence through downstream modules + +- [ ] **Step 1: Write the failing test** + +```python +def test_full_reading_preserves_official_daily_window_fields_in_range_scan_result() -> None: + result = run_engine( + "full-reading", + "--year", "REDACTED_YEAR", + "--month", "4", + "--day", "17", + "--hour", "14", + "--minute", "49", + "--lat", "36.42", + "--lon", "114.2", + "--tz", "8", + "--today", "2026-06-30", + "--transit-date", "2026-06-30", + ) + + vedastro = result["modules"]["vedastro_range_scan_result"] + assert "daily_windows" in vedastro + assert "top_daily_window" in vedastro +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_cli_smoke.py::test_full_reading_preserves_official_daily_window_fields_in_range_scan_result -q` + +Expected: FAIL if full-reading path still attaches only overview fields. + +- [ ] **Step 3: Write minimal implementation** + +Ensure `scripts/jyotish_engine.py` keeps adapter-derived daily-window fields intact when composing: + +- `modules.vedastro_range_scan_result` +- `ai_prompt_pack.evidence_snapshot.vedastro_overview` + +No reformatting layer should drop them. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest tests/test_cli_smoke.py::test_full_reading_preserves_official_daily_window_fields_in_range_scan_result -q` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add scripts/jyotish_engine.py tests/test_cli_smoke.py +git commit -m "feat: preserve vedastro daily window evidence in full reading" +``` + +## Self-Review + +- Spec coverage: adapter aggregation, orchestrator propagation, strict workflow promotion, graph exposure, full-reading preservation are all covered. +- Placeholder scan: no TBD/TODO placeholders remain. +- Type consistency: `daily_windows` is always `list[dict]`; `top_daily_window` is always `dict | None`; `official_day_window` is the graph node name across tasks. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-30-vedastro-daily-window-priority.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/superpowers/plans/2026-06-30-vedastro-official-hard-override.md b/docs/superpowers/plans/2026-06-30-vedastro-official-hard-override.md new file mode 100644 index 00000000..c3467870 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-vedastro-official-hard-override.md @@ -0,0 +1,376 @@ +# VedAstro Official Hard-Override Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `relationship`, `career`, and `wealth` default workflows enforce `VedAstro official -> local supplemental -> local fallback`, with honest `blocked`, `conflicts`, and `confidence_cap` output. + +**Architecture:** Reuse the existing `vedastro_evidence_orchestrator`, `vedastro_priority`, `mcp_server` strict evidence collectors, `jyotish_engine`, and `jyotish_api_server` rather than creating a second workflow. Promote official-first evidence into a shared strict contract, then expose that same contract through API and report surfaces. + +**Tech Stack:** Python standard library, existing MCP strict workflow, pytest, existing API/frontend static tests. + +## Global Constraints + +- Official VedAstro data is primary when available. +- Local modules may supplement, cross-check, or fallback, but may not silently overwrite official values. +- Timing and event outputs must keep `Vimshottari + Narayana` dual-track rigor. +- Relationship must keep `D9 + UL`. +- Career must keep `D10 + A10`. +- Wealth must keep `D2 / D11`. +- Functional Benefic/Malefic must remain explicit in high-rigor outputs. +- New behavior must be introduced with failing tests first. +- Do not create a parallel orchestration stack when existing files can be extended safely. + +--- + +### Task 1: Add Red Tests For The Shared Official-First Strict Contract + +**Files:** +- Modify: `tests/test_mcp_strict_workflow_relationship.py` +- Modify: `tests/test_mcp_strict_workflow_career.py` +- Modify: `tests/test_mcp_strict_workflow_finance.py` +- Modify: `tests/test_historical_event_backtest.py` + +**Interfaces:** +- Produces strict fields: + - `official_primary_evidence: dict` + - `local_supplemental_evidence: dict` + - `fallback_used: list[str]` + - `blocked_items: list[str]` + - `conflicts: list[dict]` + - `confidence_cap: str` + +- [ ] **Step 1: Write the failing relationship test** + +```python +def test_relationship_strict_contract_exposes_official_primary_and_local_supplemental_layers() -> None: + result = _base_relationship_result() + result["modules"]["source_priority"] = {"mode": "vedastro_official_primary"} + result["modules"]["vedastro_official_full_snapshot"] = { + "status": "partial", + "available": True, + "official_chart": {"planets": {"Sun": {}}, "ascendant": {"sign": "Leo"}}, + "section_statuses": {"chart_core": "ok", "dasha_all": "ok", "events_overview": "partial"}, + } + + strict = _collect_strict_evidence("relationship", result) + + assert strict["official_primary_evidence"]["chart_core"]["status"] == "ok" + assert strict["local_supplemental_evidence"]["upapada_lagna"]["role"] == "required_local_supplement" + assert isinstance(strict["blocked_items"], list) + assert isinstance(strict["fallback_used"], list) + assert isinstance(strict["conflicts"], list) +``` + +- [ ] **Step 2: Write the failing career and finance tests** + +```python +def test_career_strict_contract_marks_a10_as_local_supplement_to_official_primary() -> None: + result = _base_career_result() + result["modules"]["source_priority"] = {"mode": "vedastro_official_primary"} + result["modules"]["vedastro_official_full_snapshot"] = { + "status": "partial", + "available": True, + "official_chart": {"planets": {"Sun": {}}, "ascendant": {"sign": "Leo"}}, + "section_statuses": {"chart_core": "ok", "dasha_all": "ok"}, + } + strict = _collect_strict_evidence("career", result) + assert strict["official_primary_evidence"]["dasha"]["status"] == "ok" + assert strict["local_supplemental_evidence"]["a10_karma_pada"]["role"] == "required_local_supplement" + + +def test_finance_strict_contract_surfaces_official_block_and_local_fallback_usage() -> None: + result = {"modules": {"source_priority": {"mode": "local_fallback_official_blocked"}}} + strict = _collect_strict_evidence("finance", result) + assert "official_primary_chart_blocked" in strict["blocked_items"] + assert isinstance(strict["fallback_used"], list) +``` + +- [ ] **Step 3: Write the failing backtest test** + +```python +def test_backtest_carries_conflicts_and_blocked_items_from_strict_contract(monkeypatch) -> None: + def fake_strict_workflow(**kwargs): + packet = _strict_packet("career", verdict="high_probability_window", dominant_label="career_status", score=84) + packet["strict_workflow"]["blocked_items"] = ["official_event_radar_partial"] + packet["strict_workflow"]["conflicts"] = [{"type": "official_local_dasha_conflict"}] + return packet + + monkeypatch.setattr(backtest.mcp_server, "strict_workflow", fake_strict_workflow) + report = backtest.build_report(_payload([{"id": "career_turn_2019", "date": "2019-12-15", "domain": "career"}])) + assert report["events"][0]["evidence"]["blocked_items"] == ["official_event_radar_partial"] + assert report["events"][0]["evidence"]["conflicts"] == [{"type": "official_local_dasha_conflict"}] +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: +`python3 -m pytest tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_finance.py tests/test_historical_event_backtest.py -k "strict_contract or conflicts_and_blocked_items" -q` + +Expected: FAIL because the strict contract fields do not yet exist. + +### Task 2: Implement Shared Official-First Strict Contract In MCP Strict Workflow + +**Files:** +- Modify: `mcp_server.py` +- Test: `tests/test_mcp_strict_workflow_relationship.py` +- Test: `tests/test_mcp_strict_workflow_career.py` +- Test: `tests/test_mcp_strict_workflow_finance.py` + +**Interfaces:** +- Produces: + - `_build_official_primary_evidence(route: str, modules: dict, present: dict) -> dict` + - `_build_local_supplemental_evidence(route: str, present: dict) -> dict` + - `_build_fallback_and_blocked(route: str, present: dict, missing: list[str]) -> tuple[list[str], list[str]]` + - `_build_conflicts(route: str, present: dict, missing: list[str]) -> list[dict]` + +- [ ] **Step 1: Implement helper skeletons in `mcp_server.py`** + +```python +def _build_official_primary_evidence(route: str, modules: Dict[str, Any], present: Dict[str, Any]) -> Dict[str, Any]: + snapshot = present.get("vedastro_official_snapshot") or {} + section_statuses = snapshot.get("section_statuses") or {} + base = { + "chart_core": {"source": "vedastro_official", "status": section_statuses.get("chart_core", "blocked")}, + "dasha": {"source": "vedastro_official", "status": section_statuses.get("dasha_all", "blocked")}, + "event_radar": {"source": "vedastro_official", "status": section_statuses.get("events_overview", "blocked")}, + } + if route == "relationship": + base["d9"] = {"source": "vedastro_official", "status": section_statuses.get("varga_d9", "unknown")} + elif route == "career": + base["d10"] = {"source": "vedastro_official", "status": section_statuses.get("varga_d10", "unknown")} + elif route == "finance": + base["d2_d11"] = {"source": "vedastro_official", "status": section_statuses.get("varga_d2_d11", "unknown")} + return base +``` + +- [ ] **Step 2: Implement local supplemental mapping** + +```python +def _build_local_supplemental_evidence(route: str, present: Dict[str, Any]) -> Dict[str, Any]: + if route == "relationship": + keys = ("upapada_lagna", "darakaraka", "narayana_current", "functional_benefic_malefic") + elif route == "career": + keys = ("a10_karma_pada", "narayana_current", "functional_benefic_malefic") + else: + keys = ("wealth_promise_strength", "narayana_current", "functional_benefic_malefic") + return { + key: { + "role": "required_local_supplement", + "present": bool(present.get(key)), + } + for key in keys + } +``` + +- [ ] **Step 3: Implement blocked, fallback, and conflict derivation** + +```python +def _build_fallback_and_blocked(route: str, present: Dict[str, Any], missing: List[str]) -> tuple[List[str], List[str]]: + blocked_items: List[str] = [] + fallback_used: List[str] = [] + official = present.get("vedastro_official_snapshot") or {} + if official.get("level") != "primary": + blocked_items.append("official_primary_chart_blocked") + fallback_used.append("local_chart_fallback") + if "external_activation" in present and (present.get("external_activation") or {}).get("level") == "missing_required_external_radar": + blocked_items.append("official_event_radar_partial") + for key in missing: + blocked_items.append(f"missing_required_{key}") + return fallback_used, blocked_items + + +def _build_conflicts(route: str, present: Dict[str, Any], missing: List[str]) -> List[Dict[str, Any]]: + conflicts: List[Dict[str, Any]] = [] + dignity = present.get("dignity_guardrail") or {} + if dignity.get("status") == "conflict": + conflicts.append({ + "type": "official_local_divisional_conflict", + "primary_source": "vedastro_official", + "supplemental_source": "local_module", + "impact": "interpretation", + "resolution": "keep_official_primary_and_downgrade_confidence", + "details": {"dignity_guardrail": dignity}, + }) + return conflicts +``` + +- [ ] **Step 4: Attach the new contract fields to each strict route** + +```python +strict["official_primary_evidence"] = _build_official_primary_evidence(route, modules, present) +strict["local_supplemental_evidence"] = _build_local_supplemental_evidence(route, present) +strict["fallback_used"], strict["blocked_items"] = _build_fallback_and_blocked(route, present, missing) +strict["conflicts"] = _build_conflicts(route, present, missing) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: +`python3 -m pytest tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_finance.py tests/test_historical_event_backtest.py -k "strict_contract or conflicts_and_blocked_items" -q` + +Expected: PASS for the new contract tests. + +### Task 3: Promote Official Snapshot Section Status Into Shared Orchestration Metadata + +**Files:** +- Modify: `scripts/vedastro_evidence_orchestrator.py` +- Modify: `scripts/vedastro_priority.py` +- Modify: `tests/test_vedastro_evidence_orchestrator.py` +- Modify: `tests/test_vedastro_official_full_snapshot.py` + +**Interfaces:** +- Produces: + - `source_metadata.official_section_statuses` + - `source_metadata.theme_requirements` + - `official_snapshot_evidence(...).section_statuses` + +- [ ] **Step 1: Write the failing orchestrator test** + +```python +def test_vedastro_orchestrator_surfaces_official_section_statuses_and_theme_requirements(monkeypatch) -> None: + from scripts import vedastro_evidence_orchestrator as orchestrator + + monkeypatch.setattr(orchestrator, "run_official_full_snapshot_for_case", lambda *args, **kwargs: { + "status": "partial", + "available": True, + "official_chart": {"planets": {"Sun": {}}, "ascendant": {"sign": "Leo"}}, + "section_statuses": {"chart_core": "ok", "dasha_all": "ok", "events_overview": "partial"}, + "source_metadata": {}, + }) + monkeypatch.setattr(orchestrator, "run_range_scan_for_case", lambda *args, **kwargs: { + "status": "ok", + "available": True, + "event_count": 1, + "evidence_ledger": [], + }) + + result = orchestrator.orchestrate_vedastro_evidence({"year": REDACTED_YEAR, "month": 4, "day": 17, "hour": 14, "minute": 49, "lat": 36.42, "lon": 114.2, "tz": 8}, route="relationship", reference_date="2026-06-29") + assert result["source_metadata"]["official_section_statuses"]["dasha_all"] == "ok" + assert result["source_metadata"]["theme_requirements"]["route"] == "relationship" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +`python3 -m pytest tests/test_vedastro_evidence_orchestrator.py -k official_section_statuses -q` + +Expected: FAIL because the metadata keys are missing. + +- [ ] **Step 3: Implement minimal metadata propagation** + +```python +official_section_statuses = official_full_snapshot.get("section_statuses") if isinstance(official_full_snapshot, dict) else {} +theme_requirements = { + "route": route, + "domains": domains, + "requires_dual_dasha": True, +} +``` + +Add both into the orchestrator `source_metadata` and keep `official_snapshot_evidence()` returning `section_statuses`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +`python3 -m pytest tests/test_vedastro_evidence_orchestrator.py tests/test_vedastro_official_full_snapshot.py -k "official_section_statuses or official_full_snapshot" -q` + +Expected: PASS for the new metadata contract. + +### Task 4: Surface The Shared Contract Through API And Report Payloads + +**Files:** +- Modify: `scripts/jyotish_api_server.py` +- Modify: `scripts/jyotish_engine.py` +- Modify: `tests/test_api_server_security.py` + +**Interfaces:** +- Produces: + - API theme outputs that include `official_primary_evidence`, `local_supplemental_evidence`, `fallback_used`, `blocked_items`, `conflicts` + - prompt/report payloads that carry the same structure + +- [ ] **Step 1: Write the failing API test** + +```python +def test_high_rigor_workflow_plan_only_exposes_official_hard_override_contract() -> None: + handler = _handler() + result = handler._high_rigor_workflow_plan_only( + {"year": REDACTED_YEAR, "month": 4, "day": 17, "hour": 14, "minute": 49, "lat": 36.42, "lon": 114.2, "tz": 8}, + ["career", "marriage", "wealth"], + [], + ) + assert result["source_priority"]["mode"] == "vedastro_official_snapshot_first" + assert result["execution_plan"][-1] == "return_official_primary_supplemental_fallback_conflict_contract" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +`python3 -m pytest tests/test_api_server_security.py -k hard_override_contract -q` + +Expected: FAIL because the execution plan and API contract are not yet updated. + +- [ ] **Step 3: Implement API/report passthrough** + +```python +result["contract"] = { + "official_primary_evidence": strict.get("official_primary_evidence") if isinstance(strict, dict) else {}, + "local_supplemental_evidence": strict.get("local_supplemental_evidence") if isinstance(strict, dict) else {}, + "fallback_used": strict.get("fallback_used") if isinstance(strict, dict) else [], + "blocked_items": strict.get("blocked_items") if isinstance(strict, dict) else [], + "conflicts": strict.get("conflicts") if isinstance(strict, dict) else [], +} +``` + +Update prompt/report payload builders to include the same keys when strict evidence exists. + +- [ ] **Step 4: Run test to verify it passes** + +Run: +`python3 -m pytest tests/test_api_server_security.py -k hard_override_contract -q` + +Expected: PASS. + +### Task 5: Verify The Closed Path And Update Project Logs + +**Files:** +- Modify: `progress.md` +- Modify: `findings.md` + +**Interfaces:** +- Produces: + - final verification log for the official hard-override path + +- [ ] **Step 1: Run focused strict-workflow verification** + +Run: +`python3 -m pytest tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_finance.py tests/test_historical_event_backtest.py -q` + +Expected: PASS. + +- [ ] **Step 2: Run orchestration verification** + +Run: +`python3 -m pytest tests/test_vedastro_evidence_orchestrator.py tests/test_vedastro_official_full_snapshot.py tests/test_vedastro_python_bridge.py -q` + +Expected: PASS. + +- [ ] **Step 3: Run API/static verification** + +Run: +`python3 -m pytest tests/test_api_server_security.py tests/test_frontend_productization.py -k "vedastro or hard_override_contract or source_priority" -q` + +Expected: PASS. + +- [ ] **Step 4: Update project logs** + +Record: +- files changed +- official-first contract now enforced where implemented +- remaining blocked boundaries +- exact verification commands and results + +## Self-Review + +- Spec coverage: official-first authority, shared contract, conflict honesty, blocked honesty, API/report propagation, and verification each map to a task. +- Placeholder scan: no TBD/TODO placeholders remain. +- Type consistency: the same contract keys are used across strict workflow, backtest, API, and report surfaces. diff --git a/docs/superpowers/plans/2026-07-01-monthly-adjudication-structure.md b/docs/superpowers/plans/2026-07-01-monthly-adjudication-structure.md new file mode 100644 index 00000000..ab32c9ca --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-monthly-adjudication-structure.md @@ -0,0 +1,42 @@ +# Monthly Adjudication Structure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the old month-facing "opportunity / pressure" simplification with a route-aware monthly adjudication structure for career, relationship, and finance that reuses existing strict workflow evidence. + +**Architecture:** Reuse `mcp_server.py` strict workflow evidence as the single source, derive a lightweight `monthly_adjudication_summary` from existing promise/activation/manifestation layers plus VedAstro official day signals, then surface it through prompt-pack, guided topics, AI chat context, and frontend cards. Keep `official_day_signal_summary` as a compatibility layer during the transition. + +**Tech Stack:** Python, existing strict workflow builders, pytest, current guided-topic/frontend consumers. + +## Global Constraints + +- Reuse current strict workflow collectors before adding new collectors. +- Reuse current `event_judgement`, `adjudication_stages`, and `official_day_signal_summary` outputs. +- Do not add new heavy VedAstro calls for this task. +- Keep `Functional Benefic/Malefic`, relevant vargas, and `Vimshottari + Narayana` hard gates visible. +- Preserve `blocked`, `conflicts`, and `confidence_cap` honesty boundaries. + +--- + +### Task 1: Define and test the new contract + +- [ ] Add failing backend tests for `monthly_adjudication_summary` in career/relationship/finance strict workflow tests. +- [ ] Add failing guided-topic and frontend tests asserting the new field is carried and displayed. + +### Task 2: Implement route-aware monthly adjudication + +- [ ] Add helper builders in `/Users/wuyongnaren/Documents/印度占星/mcp_server.py`. +- [ ] Attach `monthly_adjudication_summary` to strict workflow contracts. +- [ ] Keep `official_day_signal_summary` unchanged for compatibility. + +### Task 3: Surface the contract through consumers + +- [ ] Add `monthly_adjudication_summary` to compact prompt-pack contracts in `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_engine.py`. +- [ ] Add it to guided topics in `/Users/wuyongnaren/Documents/印度占星/scripts/guided_topic_discovery.py`. +- [ ] Expose it in `/Users/wuyongnaren/Documents/印度占星/jyotish-app/main.js` and `/Users/wuyongnaren/Documents/印度占星/jyotish-app/ai-chat.js`. + +### Task 4: Verify targeted regressions + +- [ ] Run the strict workflow tests for career/relationship/finance. +- [ ] Run the guided-topic smoke test. +- [ ] Run the targeted frontend productization assertions. diff --git a/docs/superpowers/plans/2026-07-01-unified-consultation-runtime-hardening.md b/docs/superpowers/plans/2026-07-01-unified-consultation-runtime-hardening.md new file mode 100644 index 00000000..be88ea3c --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-unified-consultation-runtime-hardening.md @@ -0,0 +1,351 @@ +# Unified Consultation Runtime Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the consultation runtime planner execute real reusable steps, make thematic report consume upstream unified contracts before deriving again, and collapse monthly adjudication / interpretation axes / strict audit gate into one shared adjudication object reused by guided topics, AI payloads, and frontend. + +**Architecture:** Keep the existing repo surfaces, but stop letting each layer rebuild its own partial truth. The API workflow becomes the single executor of route steps, full-reading/chart strict outputs become preferred upstream evidence for thematic report, and a compact `strict_adjudication_bundle` becomes the canonical shared object carried through backend, prompt-pack, guided topics, and UI. + +**Tech Stack:** Python 3, existing `jyotish_api_server.py`, `jyotish_engine.py`, `guided_topic_discovery.py`, vanilla frontend JS, pytest. + +## Global Constraints + +- Reuse existing repo code paths instead of inventing new parallel engines. +- Use TDD: failing tests first, then minimal implementation. +- Do not break current API payload compatibility where avoidable. +- Preserve VedAstro official-first boundary and fallback honesty. +- Keep edits scoped to the current workflow and display contract. + +--- + +### Task 1: Lock the unified runtime and adjudication bundle contract in tests + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_api_server_security.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_cli_smoke.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_frontend_productization.py` + +**Interfaces:** +- Consumes: `_compute_consultation_workflow(...)`, `_compute_thematic_report(...)`, `build_guided_topics(...)` +- Produces: failing tests requiring `runtime_planner.executed_steps`, `strict_adjudication_bundle`, and thematic-report reuse behavior + +- [ ] **Step 1: Write failing API workflow/runtime tests** + +Add assertions to `tests/test_api_server_security.py` for: + +```python +assert result["runtime_planner"]["executed_steps"] == [ + "compute_chart", + "run_rectification_gate", + "run_historical_event_backtest", + "run_thematic_report", +] +assert result["runtime_planner"]["skipped_steps"] == [] +assert result["thematic_report"]["mode"] == "upstream_contract_reuse" +assert result["thematic_report"]["evidence_source"]["source"] == "consultation_workflow_upstream_contract" +``` + +- [ ] **Step 2: Run targeted API workflow test to verify failure** + +Run: `python3 -m pytest tests/test_api_server_security.py::test_consultation_workflow_reuses_chart_data_for_thematic_report_without_recursive_full_reading -q` + +Expected: FAIL because `executed_steps` / `mode` / `evidence_source` are missing or different. + +- [ ] **Step 3: Write failing shared adjudication bundle tests** + +Add assertions in `tests/test_api_server_security.py` and `tests/test_cli_smoke.py`: + +```python +bundle = career["strict_adjudication_bundle"] +assert bundle["monthly_adjudication_summary"]["primary_state"]["value"] == "推进" +assert bundle["strict_audit_gate"]["functional_benefic_malefic"]["gate"] == "hard" +assert bundle["interpretation_axes"][0]["axis"] == "角色定位" +``` + +and: + +```python +assert topic["strict_adjudication_bundle"]["monthly_adjudication_summary"] == topic["monthly_adjudication_summary"] +assert topic["strict_adjudication_bundle"]["strict_audit_gate"] == topic["strict_audit_gate"] +``` + +- [ ] **Step 4: Run targeted adjudication tests to verify failure** + +Run: `python3 -m pytest tests/test_api_server_security.py::test_thematic_report_interpretation_axes_are_strict_paragraphs_for_each_theme tests/test_cli_smoke.py::test_full_reading_guided_topics_can_carry_official_day_signal_summary -q` + +Expected: FAIL because `strict_adjudication_bundle` is missing. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_api_server_security.py tests/test_cli_smoke.py tests/test_frontend_productization.py +git commit -m "test: lock unified consultation runtime contract" +``` + +### Task 2: Make runtime_planner a real executor and make thematic report prefer upstream unified contracts + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/unified_consultation_orchestrator.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_api_server.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_api_server_security.py` + +**Interfaces:** +- Consumes: `UnifiedConsultationOrchestrator.runtime_planner(...)`, `_compute_consultation_workflow(...)` +- Produces: planner with `executed_steps` / `skipped_steps`, thematic input payload with `strict_workflow_contracts`, and `_compute_thematic_report(...)` mode `upstream_contract_reuse` + +- [ ] **Step 1: Add failing helper-level test coverage if needed** + +If `tests/test_unified_consultation_orchestrator.py` lacks direct planner coverage, add: + +```python +planner = orchestrator.runtime_planner(...) +assert planner["sync_steps"][0] == "compute_chart" +assert planner["reuse_contract"]["thematic_report"] == "thematic_report" +``` + +- [ ] **Step 2: Implement runtime executed/skipped step recording** + +In `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_api_server.py`, refactor `_compute_consultation_workflow(...)` so it: +- initializes `executed_steps = []`, `skipped_steps = []` +- runs only steps listed in `runtime_planner["sync_steps"]` +- appends actual executed step names +- records skipped steps from the known runtime step set + +- [ ] **Step 3: Pass upstream strict data into thematic report** + +When building the thematic payload in `_compute_consultation_workflow(...)`, pass: + +```python +"upstream_contract": { + "chart": chart_for_theme, + "strict_workflow_contracts": prompt_snapshot_contracts, + "guided_topics": chart_modules_guided_topics, +} +``` + +where `prompt_snapshot_contracts` comes from `chart["ai_prompt_pack"]["evidence_snapshot"]["strict_workflow_contracts"]` when available. + +- [ ] **Step 4: Implement upstream-contract-first thematic reuse** + +In `_compute_thematic_report(...)`, before calling `_derive_thematic_evidence(...)`, detect: + +```python +upstream_contract = body.get("upstream_contract") +``` + +and if it contains usable `strict_workflow_contracts` or chart evidence, set: +- `mode = "upstream_contract_reuse"` +- `evidence_source["source"] = "consultation_workflow_upstream_contract"` +- reuse upstream evidence/contracts before local derivation fallback + +- [ ] **Step 5: Run tests to verify green** + +Run: `python3 -m pytest tests/test_api_server_security.py::test_consultation_workflow_reuses_chart_data_for_thematic_report_without_recursive_full_reading tests/test_api_server_security.py::test_thematic_report_handles_missing_dasa_convergence_without_crash tests/test_unified_consultation_orchestrator.py -q` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add scripts/unified_consultation_orchestrator.py scripts/jyotish_api_server.py tests/test_api_server_security.py tests/test_unified_consultation_orchestrator.py +git commit -m "feat: execute unified consultation runtime steps" +``` + +### Task 3: Collapse monthly adjudication, audit gate, and axes into one canonical strict adjudication bundle + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_api_server.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/guided_topic_discovery.py` +- Modify: `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_engine.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_api_server_security.py` +- Test: `/Users/wuyongnaren/Documents/印度占星/tests/test_cli_smoke.py` + +**Interfaces:** +- Consumes: strict workflow contracts, `monthly_adjudication_summary`, `technique_audit_summary`, `interpretation_axes` +- Produces: `strict_adjudication_bundle` with: + - `monthly_adjudication_summary` + - `monthly_adjudication_summary_humanized` + - `strict_audit_gate` + - `interpretation_axes` + - `narrative_contract` + +- [ ] **Step 1: Add bundle builder in API server** + +Create a helper in `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_api_server.py` that returns: + +```python +{ + "theme": theme_name, + "monthly_adjudication_summary": monthly_frame, + "monthly_adjudication_summary_humanized": humanized, + "strict_audit_gate": report_payload.get("technique_audit_summary") or {}, + "interpretation_axes": axes, + "narrative_contract": {...}, +} +``` + +- [ ] **Step 2: Attach canonical bundle to thematic report payload** + +In `_apply_monthly_adjudication_to_theme_report(...)`, set: + +```python +report_payload["strict_adjudication_bundle"] = bundle +``` + +while keeping legacy top-level fields for compatibility. + +- [ ] **Step 3: Make guided topics read only the bundle first** + +In `/Users/wuyongnaren/Documents/印度占星/scripts/guided_topic_discovery.py`, replace separate contract lookups with: + +```python +bundle = _as_dict(contract.get("strict_adjudication_bundle")) +``` + +and populate: +- `strict_audit_gate` +- `monthly_adjudication_summary` +- `official_day_signal_summary` fallback only if not in bundle + +- [ ] **Step 4: Make prompt pack carry the bundle through** + +In `/Users/wuyongnaren/Documents/印度占星/scripts/jyotish_engine.py`, ensure each strict workflow contract already exported into `ai_prompt_pack.evidence_snapshot.strict_workflow_contracts` includes `strict_adjudication_bundle`. + +- [ ] **Step 5: Run backend/shared contract tests** + +Run: `python3 -m pytest tests/test_api_server_security.py::test_apply_monthly_adjudication_to_theme_report_injects_four_layers_into_final_chinese_fields tests/test_api_server_security.py::test_thematic_report_interpretation_axes_are_strict_paragraphs_for_each_theme tests/test_cli_smoke.py::test_full_reading_guided_topics_can_carry_official_day_signal_summary -q` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add scripts/jyotish_api_server.py scripts/guided_topic_discovery.py scripts/jyotish_engine.py tests/test_api_server_security.py tests/test_cli_smoke.py +git commit -m "feat: unify strict adjudication bundle" +``` + +### Task 4: Make frontend and AI consume the single shared bundle + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/jyotish-app/main.js` +- Modify: `/Users/wuyongnaren/Documents/印度占星/jyotish-app/ai-chat.js` +- Modify: `/Users/wuyongnaren/Documents/印度占星/tests/test_frontend_productization.py` + +**Interfaces:** +- Consumes: `strict_adjudication_bundle` +- Produces: UI and AI prompt entry consuming bundle first, legacy fields second + +- [ ] **Step 1: Add failing frontend assertions** + +Add assertions requiring: + +```python +assert "strict_adjudication_bundle" in main +assert "strict_adjudication_bundle" in ai_chat +``` + +and keep legacy checks for compatibility. + +- [ ] **Step 2: Update guided topic card rendering** + +In `/Users/wuyongnaren/Documents/印度占星/jyotish-app/main.js`, read: + +```javascript +const bundle = topic?.strict_adjudication_bundle || {}; +``` + +and derive: +- `strict_audit_gate` +- `monthly_adjudication_summary` +- `interpretation_axes` + +from the bundle first. + +- [ ] **Step 3: Update AI chat payload construction** + +In `/Users/wuyongnaren/Documents/印度占星/jyotish-app/ai-chat.js`, include: + +```javascript +guidedTopicContext.strict_adjudication_bundle +``` + +as the first-class context object, while preserving old fields. + +- [ ] **Step 4: Run frontend contract tests** + +Run: `python3 -m pytest tests/test_frontend_productization.py::test_guided_topic_questions_reuse_ai_chat_entry tests/test_frontend_productization.py::test_complete_reading_surfaces_guided_topic_discovery -q` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add jyotish-app/main.js jyotish-app/ai-chat.js tests/test_frontend_productization.py +git commit -m "feat: make frontend consume strict adjudication bundle" +``` + +### Task 5: Run the smallest real regressions and inspect real-user output + +**Files:** +- Modify: `/Users/wuyongnaren/Documents/印度占星/progress.md` (only if you are already tracking this thread there) + +**Interfaces:** +- Consumes: completed runtime/thematic/bundle/frontend flow +- Produces: verified real output for the REDACTED_DATE REDACTED_TIME REDACTED_PLACE case + +- [ ] **Step 1: Run focused regression suite** + +Run: + +```bash +python3 -m pytest \ + tests/test_api_server_security.py::test_consultation_workflow_reuses_chart_data_for_thematic_report_without_recursive_full_reading \ + tests/test_api_server_security.py::test_thematic_report_interpretation_axes_are_strict_paragraphs_for_each_theme \ + tests/test_cli_smoke.py::test_full_reading_guided_topics_can_carry_official_day_signal_summary \ + tests/test_frontend_productization.py::test_guided_topic_questions_reuse_ai_chat_entry \ + -q +``` + +Expected: all PASS. + +- [ ] **Step 2: Run real consultation workflow sample** + +Run a local Python one-off calling `_compute_consultation_workflow(...)` with: +- `REDACTED_DATE REDACTED_TIME` +- `lat=36.42` +- `lon=114.2` +- `tz=8` +- themes `career/marriage/wealth` + +Verify: +- `runtime_planner.executed_steps` is populated +- `thematic_report.mode == "upstream_contract_reuse"` +- each theme contains `strict_adjudication_bundle` + +- [ ] **Step 3: Summarize verified behavior** + +Record the exact outputs observed for: +- executed runtime steps +- thematic report mode +- presence of strict adjudication bundle in theme payloads and guided topics + +- [ ] **Step 4: Commit** + +```bash +git add progress.md +git commit -m "docs: record unified consultation runtime verification" +``` + +## Self-Review + +- Spec coverage: the three requested root-cause cuts are directly covered by Tasks 2, 3, and 4. +- Placeholder scan: no TBD/TODO placeholders remain. +- Type consistency: the canonical object is always named `strict_adjudication_bundle`; runtime execution telemetry always uses `executed_steps` and `skipped_steps`. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-07-01-unified-consultation-runtime-hardening.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints diff --git a/findings.md b/findings.md index 8a0c90a3..264c6390 100644 --- a/findings.md +++ b/findings.md @@ -1,5 +1,30 @@ # 印度占星产品化发现记录 +## 2026-06-30 VedAstro official hard-override 本轮发现 + +- 真正需要补的,不是再证明一次“VedAstro 能调用”,而是把 `official -> supplemental -> fallback` 这条权威链压成统一 contract,并让婚恋/事业/财富三条默认工作流都吃同一套结构。 +- 本轮之前,`mcp_server.py` 的 strict workflow 已有 `source_priority` 和 `vedastro_official_snapshot`,但缺少用户可消费的: + - `official_primary_evidence` + - `local_supplemental_evidence` + - `fallback_used` + - `blocked_items` + - `conflicts` + 因此容易出现“知道官方优先,但看不到究竟哪里官方、哪里本地、哪里冲突”的假闭环。 +- 本轮红测很干净,失败都集中在缺少上述字段,而不是旧模块逻辑崩坏。这说明主问题确实是 contract 暴露层,而不是三条主题判定器整体不可用。 +- `historical_event_backtest.py` 原先不会带出 `blocked_items` / `conflicts`,导致历史回测明明已触发 strict boundary,外层报告却看不见冲突类型。本轮已补透传。 +- `vedastro_evidence_orchestrator.py` 原先虽然拿到了 official full snapshot,但没有把 `section_statuses` 和 route/theme requirement 一起往外带,后续主题裁决无法稳定区分“官方 partial”与“完全 blocked”。本轮已补 `official_section_statuses` 与 `theme_requirements`。 +- `high_rigor_workflow_plan_only` 原先只声明 `return_vedastro_catalog_and_source_priority_metadata`,这不足以说明最终真实返回会包含官方主证据/补充/回退/冲突 contract。本轮已改成 `return_official_primary_supplemental_fallback_conflict_contract`。 +- `_high_rigor_vedastro_official_summary` 原先只汇总官方 catalog / dynamic selection / report references,不会透传 strict contract 的官方主证据、补充、回退和冲突。本轮已补齐。 +- `jyotish_engine.py::_build_vedastro_official_full_snapshot_payload` 原先只输出官方快照层本身,不会把 strict workflow 的 contract 信息带进 `ai_prompt_pack.evidence_snapshot`。这会导致网页/AI 平台虽然拿到官方 snapshot status,却看不到官方优先裁决的真实边界。本轮已开始补这层,至少 relationship strict contract 会被透传到 `vedastro_official_full_snapshot` 节点。 +- 本轮 focused verification 已说明: + - strict workflow contract 层是稳的; + - orchestrator metadata 层是稳的; + - high-rigor API summary 层是稳的。 +- 仍然存在的真实边界: + - 大而慢的长回归集合里有耗时测试,需要拆分后继续验证,不应把“慢”误说成“已全绿”; + - full-reading / prompt pack / 前端直接展示还没完全把三条主题 contract 全量消费完; + - 当前 `jyotish_engine.py` 透传 contract 时优先复用了 relationship strict evidence,career/wealth prompt/report 面还应继续统一。 + ## 2026-06-25 Round 25 地毯式碎片扫描前置结论 - 已按用户要求在继续实现前进行整机/多窗口碎片扫描,并生成 `docs/research/whole_machine_fragment_sweep_round25_2026_06_25.md`。 @@ -170,3 +195,10 @@ - VedAstro 强制雷达边界:官方 Events Builder 暴露 `SearchEvents / GetEventTiming / ListEventTypes` 三个事件端点、400+ 预定义事件和 `Scan precision (hours)`,API/Python surface 继续按 600+/596+ 计算节点理解。本项目不硬复刻 596 个函数,而是把 VedAstro range scan 作为 `career/relationship/finance` strict workflow 的必需外部高频 timing radar;缺失时进入 `vedastro_range_scan_missing` 和 Technique Audit blocked 行,不能再静默跳过。 - VedAstro Adapter MVP 方案 A 结论:当前已完成工程闭环而非官方实网闭环。adapter range scan 会产出可审计 provenance(request/response SHA-256、called_at、endpoint_host、artifact_path、retry metadata、allowlist/raw/filtered event counts),`/api/vedastro/status` 与 Trust Center 能显示安全配置状态,`vedastro-live` profile 在未配置 endpoint 时受控 blocked 并通过默认 CI。只有配置 `VEDASTRO_API_ENDPOINT` 和 `VEDASTRO_ENABLE_NETWORK=1` 后,才能把状态从 `network_execution_disabled/service_endpoint_not_configured` 推进到真实 VedAstro live smoke;在此之前不得宣称官方 VedAstro 事件雷达已经实网验证。 - VedAstro 普通用户入口结论:用户侧可用的定义不是“adapter 存在”,而是“生成星盘后能点击按钮、使用当前出生资料、选择领域/日期范围、看到返回状态和边界”。本轮已把这一层落在 Trust Center `VedAstro Range Scan` 面板和 `/api/vedastro/range_scan`;未配置 endpoint 时用户看到 blocked,配置官方 endpoint 与网络开关后同一按钮会走实网调用链。 + +- 2026-06-29 高严谨默认入口复用结论:项目内已有可直接复用的生时校正、历史事件回测、主题推运和 VedAstro 官方证据层,不应重新造算法。关键资产包括 `scripts/birth_time_rectifier.py`、`jyotish-app/rectification-engine.js`、`scripts/historical_event_backtest.py`、`scripts/reading_orchestrator.py`、`scripts/report_orchestrator.py`、`scripts/orchestrator_bridge.py`、`scripts/vedastro_evidence_orchestrator.py`、`scripts/vedastro_official_capability_runner.py`。新增统一入口应做胶水层:VedAstro official snapshot/catalog first -> rectification gate -> historical event backtest -> thematic report,而不是把 641 callable 暴力全跑。 +- 2026-06-29 省算力边界:`/api/high_rigor_workflow` 的 Technique Explorer 样例必须使用 `dry_run`,否则目录页会触发重型 VedAstro/full-reading 链路。真实用户提交不带 `dry_run` 时才执行完整高严谨工作流。这个设计同时满足“用户可直接用”和“不要浪费算力”。 +- 2026-06-30 VedAstro 641 项轻量映射表结论:`official_full_capability_catalog` 现在为每个官方 callable 标注 `domains / execution_policy / priority`,并聚合出 `domain_routing`,覆盖 `career / marriage / wealth / rectification / timing / general` 六类。该层只做路由和审计,不把每个官方方法直接暴露给用户,也不声称已经完成深层语义断语。 +- 2026-06-30 轻量映射误判修复:`Dashamamsha` 等分盘名称曾因包含 `dasha` 字符串被误归入 timing。当前已改为按方法词元识别 `Dasa/Dasha` timing 方法,真实轻扫显示 `AllPlanetDashamamshaSign` 归入 `career/marriage/wealth`,不再进入 `timing`。 +- 2026-06-30 VedAstro 动态能力选择器结论:系统现在不只知道 641 项目录和主题归类,还会按用户主题生成 `dynamic_selection` 与 `official_report_references`。每个主题会列出自动可用能力、需要额外资料能力、blocked 能力和 `vedastro::` 引用 ID,供网页、Skill、MCP 和 Codex prompt pack 指向同一份官方证据层。 +- 2026-06-30 报告引用边界:`official_report_references` 是证据引用层,不等于每个引用都已执行成功。`execution_policy != auto` 或 `status != ok` 的能力只能作为“需要补资料/当前阻断”的报告说明,不得包装成已用于最终断语的数据。 diff --git a/jyotish-app/ai-chat.js b/jyotish-app/ai-chat.js index b67a9bee..8f53bbc7 100644 --- a/jyotish-app/ai-chat.js +++ b/jyotish-app/ai-chat.js @@ -21,6 +21,7 @@ let _panelEl = null; let _fabEl = null; let _authToken = null; let _authUser = null; +let _guidedTopicContext = null; // ============================================================================ // 初始化 @@ -41,9 +42,12 @@ export function aiChatSetChartData(cd) { } } -export function openAIChatWithPrompt(prompt) { +export function openAIChatWithPrompt(prompt, guidedTopicContext = null) { const text = String(prompt || '').trim(); if (!text) return; + _guidedTopicContext = guidedTopicContext && typeof guidedTopicContext === 'object' + ? guidedTopicContext + : null; if (_panelEl && !_panelEl.classList.contains('open')) { _panelEl.classList.add('open'); } @@ -286,7 +290,7 @@ async function sendMessage() { } // 构建星盘摘要上下文 - const context = buildChartContext(cd); + const context = buildChartContext(cd, _guidedTopicContext); // 调用 AI 对话 try { @@ -298,10 +302,45 @@ async function sendMessage() { sendBtn.disabled = false; } -function buildChartContext(cd) { +function buildChartContext(cd, guidedTopicContext = null) { if (!cd?.planets || !cd?.ascendant) return t('ai.no.data'); if (cd.ai_prompt_pack?.prompt_zh && cd.ai_prompt_pack?.evidence_snapshot) { + const workflow = cd._consultationWorkflow || {}; + const runtimePlanner = workflow.runtime_planner || {}; + const officialSnapshot = cd.ai_prompt_pack.evidence_snapshot.vedastro_official_full_snapshot || {}; const vedastroOverview = cd.ai_prompt_pack.evidence_snapshot.vedastro_overview || {}; + const strictContracts = officialSnapshot.strict_workflow_contracts || {}; + const primaryRoute = officialSnapshot.strict_workflow_primary_route || Object.keys(strictContracts)[0] || ''; + const topReaderContract = primaryRoute ? (strictContracts[primaryRoute] || {}) : {}; + const adjudicationStages = topReaderContract.adjudication_stages || {}; + const multiReferenceSummary = topReaderContract.multi_reference_reading_summary || {}; + const techniqueAuditSummary = topReaderContract.technique_audit_summary || {}; + const runtimePlannerBoundary = runtimePlanner && typeof runtimePlanner === 'object' && Object.keys(runtimePlanner).length + ? [ + '【Runtime Planner】', + `planner=${runtimePlanner.planner_name || 'UnifiedConsultationRuntimePlanner'} · entry=${runtimePlanner.entry_mode || workflow.entry_mode || '-'} · route=${runtimePlanner.route?.question_type || workflow.routing?.question_type || '-'}`, + `sync_steps=${(runtimePlanner.sync_steps || []).join(' -> ') || '-'}`, + `async_candidates=${(runtimePlanner.async_candidates || []).join(' / ') || '-'}`, + ].join('\n') + : ''; + const officialBoundary = officialSnapshot && typeof officialSnapshot === 'object' + ? [ + '【VedAstro Official Snapshot Boundary】', + `status=${officialSnapshot.status || 'blocked'} · source=${officialSnapshot.primary_source || 'vedastro_official'} · official_python_path=${officialSnapshot.official_python_path || '-'}`, + `bundle=${officialSnapshot.official_bundle_status || 'blocked'} · chart_available=${officialSnapshot.official_chart_available ? 'yes' : 'no'}`, + `full_catalog=${officialSnapshot.official_full_capability_catalog_status || 'blocked'} · executed=${officialSnapshot.official_full_capability_catalog_summary?.executed_method_count || 0}/${officialSnapshot.official_full_capability_catalog_summary?.catalog_method_count || 0} · sample_limit=${officialSnapshot.official_full_capability_catalog_summary?.sample_limit || 0}`, + ].join('\n') + : ''; + const topReaderBoundary = topReaderContract && typeof topReaderContract === 'object' && Object.keys(topReaderContract).length + ? [ + '【Top Reader Contract】', + `route=${primaryRoute || '-'} · verdict=${topReaderContract.verdict || '-'} · dominant_label=${topReaderContract.dominant_label || '-'}`, + `adjudication_stages.promise=${adjudicationStages.promise?.status || 'missing'} · activation=${adjudicationStages.activation?.status || 'missing'} · manifestation=${adjudicationStages.manifestation?.status || 'missing'} · label=${adjudicationStages.label?.value || topReaderContract.dominant_label || '-'}`, + `technique_audit_summary.functional=${techniqueAuditSummary.functional_benefic_malefic?.gate || 'none'}/${techniqueAuditSummary.functional_benefic_malefic?.used ? 'used' : 'blocked'} · vargas=${(techniqueAuditSummary.relevant_vargas?.present_keys || []).join('/') || 'none'} · dual_dasha=${techniqueAuditSummary.vimshottari_narayana_crosscheck?.used ? 'used' : 'blocked'}`, + `multi_reference_reading_summary.root_frame=${Object.keys(multiReferenceSummary.root_frame || {}).join('/') || 'none'} · modifier_frame=${Object.keys(multiReferenceSummary.modifier_frame || {}).join('/') || 'none'}`, + `main_conflicts=${(topReaderContract.main_conflicts || []).map(item => item?.type).filter(Boolean).join('/') || 'none'}`, + ].join('\n') + : ''; const vedastroBoundary = vedastroOverview && typeof vedastroOverview === 'object' ? [ '【VedAstro Overview Boundary】', @@ -309,12 +348,30 @@ function buildChartContext(cd) { 'overview only,不替代长周期精扫。', ].join('\n') : ''; + const guidedTopicBoundary = guidedTopicContext && typeof guidedTopicContext === 'object' + ? [ + '【Guided Topic Context】', + `topic_id=${guidedTopicContext.id || '-'} · title=${guidedTopicContext.title || '-'} · confidence=${guidedTopicContext.confidence || '-'}`, + `guided_topic_context.functional=${(guidedTopicContext.strict_adjudication_bundle?.strict_audit_gate || guidedTopicContext.strict_audit_gate)?.functional_benefic_malefic?.gate || 'none'}/${(guidedTopicContext.strict_adjudication_bundle?.strict_audit_gate || guidedTopicContext.strict_audit_gate)?.functional_benefic_malefic?.used ? 'used' : 'blocked'} · vargas=${((guidedTopicContext.strict_adjudication_bundle?.strict_audit_gate || guidedTopicContext.strict_audit_gate)?.relevant_vargas?.present_keys || []).join('/') || 'none'} · dual_dasha=${(guidedTopicContext.strict_adjudication_bundle?.strict_audit_gate || guidedTopicContext.strict_audit_gate)?.vimshottari_narayana_crosscheck?.used ? 'used' : 'blocked'}`, + `monthly_adjudication_summary=${(guidedTopicContext.strict_adjudication_bundle?.monthly_adjudication_summary || guidedTopicContext.monthly_adjudication_summary)?.primary_state?.value ? `${(guidedTopicContext.strict_adjudication_bundle?.monthly_adjudication_summary || guidedTopicContext.monthly_adjudication_summary).primary_state.value} / ${(guidedTopicContext.strict_adjudication_bundle?.monthly_adjudication_summary || guidedTopicContext.monthly_adjudication_summary).manifestation_mode?.value || '-'} / ${(guidedTopicContext.strict_adjudication_bundle?.monthly_adjudication_summary || guidedTopicContext.monthly_adjudication_summary).friction_source?.value || '-'} / ${(guidedTopicContext.strict_adjudication_bundle?.monthly_adjudication_summary || guidedTopicContext.monthly_adjudication_summary).time_confidence?.value || '-'}` : 'none'}`, + `official_day_signal_summary=${(guidedTopicContext.strict_adjudication_bundle?.official_day_signal_summary || guidedTopicContext.official_day_signal_summary)?.top_day ? `${(guidedTopicContext.strict_adjudication_bundle?.official_day_signal_summary || guidedTopicContext.official_day_signal_summary).top_day.date || '-'} / ${(guidedTopicContext.strict_adjudication_bundle?.official_day_signal_summary || guidedTopicContext.official_day_signal_summary).top_day.summary || '-'} / ${(guidedTopicContext.strict_adjudication_bundle?.official_day_signal_summary || guidedTopicContext.official_day_signal_summary).top_day.confidence || '-'}` : 'none'}`, + `why=${guidedTopicContext.why_worth_exploring || '-'}`, + ].join('\n') + : ''; return [ '【AI Prompt Pack】', cd.ai_prompt_pack.prompt_zh, '', DASHA_SHADBALA_AI_CALIBRATION_BOUNDARY, '', + runtimePlannerBoundary, + '', + officialBoundary, + '', + topReaderBoundary, + '', + guidedTopicBoundary, + '', vedastroBoundary, '', '【evidence_snapshot】', @@ -371,6 +428,7 @@ async function callAI(userMessage, chartContext) { message: userMessage, chart_context: chartContext, chart_data: cd, + guided_topic_context: _guidedTopicContext, }), }); const data = await parseAIResponse(resp); diff --git a/jyotish-app/api-bridge.js b/jyotish-app/api-bridge.js index b5fd5bbf..fc2baaa7 100644 --- a/jyotish-app/api-bridge.js +++ b/jyotish-app/api-bridge.js @@ -128,6 +128,10 @@ async function computeWithPython(birthData) { return null; } +async function computeConsultationWorkflow(payload) { + return postJson('/api/consultation_workflow', payload); +} + async function computeSynastry(payload) { return postJson('/api/synastry', payload); } @@ -442,6 +446,7 @@ window.JyotishAPI = { aiKeyPolicy: 'server_side_only', // 计算 computeWithPython, + computeConsultationWorkflow, computeSynastry, computePrashna, computeKP, diff --git a/jyotish-app/export.js b/jyotish-app/export.js index 15ff921f..34557211 100644 --- a/jyotish-app/export.js +++ b/jyotish-app/export.js @@ -405,6 +405,8 @@ function _buildHTMLReport(chartData, extras) { const audit = extras.audit || {}; const workflows = extras.workflows || chartData._client_workflows || {}; const relationshipNarrative = extras.relationship_narrative || chartData?.ai_prompt_pack?.evidence_snapshot?.relationship_narrative || chartData?.relationship_narrative || null; + const careerNarrative = extras.career_narrative || chartData?.ai_prompt_pack?.evidence_snapshot?.career_narrative || chartData?.career_narrative || null; + const financeNarrative = extras.finance_narrative || chartData?.ai_prompt_pack?.evidence_snapshot?.finance_narrative || chartData?.finance_narrative || null; const vimsopakaSemanticSummary = extras.vimsopaka_semantic_summary || chartData?.ai_prompt_pack?.evidence_snapshot?.vimsopaka_semantic_summary || chartData?.vimsopaka_semantic_summary || null; const functionalBeneficMalefic = extras.functional_benefic_malefic || chartData?.ai_prompt_pack?.evidence_snapshot?.functional_benefic_malefic || chartData?.functional_benefic_malefic || null; const vedastroOverview = extras.vedastro_overview || chartData?.ai_prompt_pack?.evidence_snapshot?.vedastro_overview || chartData?.vedastro_overview || null; @@ -611,6 +613,10 @@ function _buildHTMLReport(chartData, extras) { ${_techniqueAuditTableSection(techniqueAuditTable)} + ${_strictNarrativeSection('事业严格裁决', 'career', careerNarrative, '本段直接消费 career strict evidence,把本命 promise、dual dasha、官方时间窗与结构阻力写进用户可见正文。')} + + ${_strictNarrativeSection('财富严格裁决', 'finance', financeNarrative, '本段直接消费 finance strict evidence,把收入兑现、现金流动作、结构摩擦与时间边界写进用户可见正文。')} + ${_relationshipStrictNarrativeSection(relationshipNarrative)} ${_workflowReportSection(workflows)} @@ -759,6 +765,35 @@ function _relationshipStrictNarrativeSection(narrative) { `; } +function _strictNarrativeSection(title, domainClass, narrative, subtitle) { + if (!narrative || typeof narrative !== 'object') return ''; + const strengths = Array.isArray(narrative.strengths) ? narrative.strengths : []; + const risks = Array.isArray(narrative.risks) ? narrative.risks : []; + const boundaries = Array.isArray(narrative.boundaries) ? narrative.boundaries : []; + const markdown = typeof narrative.markdown === 'string' ? narrative.markdown : ''; + const compactMarkdown = markdown + .replace(/^###\s+/gm, '') + .replace(/^\-\s+/gm, '') + .replace(/\n+/g, ' ') + .trim(); + return `
+

${_h(title)}

+
+
+ ${_h(narrative.headline || `${title}已接入导出主链。`)} + ${_h(subtitle || 'strict narrative 已接入导出主链。')} +
+
strict narrative
+
+
+ ${_relationshipReportList('支持证据', strengths)} + ${_relationshipReportList('需要观察', risks)} + ${_relationshipReportList('边界条件', boundaries)} +
+ ${compactMarkdown ? `
${_h(compactMarkdown)}
` : ''} +
`; +} + function _calibrationStatusSection(status) { return `

高级技法校准状态

diff --git a/jyotish-app/index.html b/jyotish-app/index.html index 1cc9bb25..5fb60822 100644 --- a/jyotish-app/index.html +++ b/jyotish-app/index.html @@ -265,6 +265,7 @@
+

星盘计算完成后将自动生成 AI 个性化解读

diff --git a/jyotish-app/main.js b/jyotish-app/main.js index 7d990b48..d5cec00a 100644 --- a/jyotish-app/main.js +++ b/jyotish-app/main.js @@ -60,7 +60,7 @@ import { initTooltip, bindTerms, setGlossaryTerminologyMode } from './glossary.j import { initAIChat, aiChatSetChartData, openAIChatWithPrompt } from './ai-chat.js'; import { initAuth } from './auth.js'; import { initSubscription } from './subscription.js'; -import { renderRectificationTab, initRectification } from './rectification.js'; +import { renderRectificationTab, initRectification, setRectificationRecommendedEvents } from './rectification.js'; import { t, getLang, initI18N, onLangChange, signName, planetName, statusName, houseLabel, houseAreaName, yearsLabel } from './i18n.js'; import { escapeHtml, escapeAttr, safeNumber } from './security.js'; import { renderSkillCoverage } from './skill-map.js'; @@ -473,6 +473,35 @@ async function computeChartForBirth(birth) { return fallbackChart; } +async function computeConsultationForBirth(birth, options = {}) { + const entryMode = options.entryMode || 'direct_chart'; + const payload = { + ...birth, + entry_mode: entryMode, + question: options.question || '', + theme: options.theme || ['career', 'marriage', 'wealth'], + }; + if (Array.isArray(options.events)) payload.events = options.events; + try { + const workflow = await window.JyotishAPI?.computeConsultationWorkflow?.(payload); + const chart = workflow?.chart; + if (workflow?.success && chart?.success) { + chart._consultationWorkflow = workflow; + return chart; + } + } catch (error) { + console.warn('[Jyotish] consultation workflow unavailable, fallback to chart:', error?.message || error); + } + const fallbackChart = await computeChartForBirth(birth); + fallbackChart._consultationWorkflow = { + success: false, + endpoint: 'consultation_workflow', + entry_mode: entryMode, + boundary: 'consultation_workflow_blocked_fallback_to_local_chart', + }; + return fallbackChart; +} + function buildChartComputeRecoveryMessage(error) { const message = error?.message || error || '本地 API 未连接'; return `计算失败:${message}。请到 Trust Center 运行健康检查;如本地 API 未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。`; @@ -514,7 +543,10 @@ function setupForm() { setChartComputeStatus('正在计算星盘...', 'warn'); try { // v6.9.4: 计算层 — 优先本地 API 服务, 回退JS引擎 - chartData = await computeChartForBirth({ year, month, day, hour, minute, second, lat, lon, tz }); + chartData = await computeConsultationForBirth( + { year, month, day, hour, minute, second, lat, lon, tz }, + { entryMode: 'direct_chart' }, + ); } catch (e) { console.error('[Jyotish] 计算失败:', e); setChartComputeStatus(buildChartComputeRecoveryMessage(e), 'error'); @@ -653,7 +685,7 @@ async function applyRectifiedBirth(birth) { if (!birth) return; try { fillBirthFormFromData(birth); - chartData = await computeChartForBirth(birth); + chartData = await computeConsultationForBirth(birth, { entryMode: 'rectification' }); window.__jyotishBirth = birth; renderAll(); showPage('chart'); @@ -713,6 +745,7 @@ function renderAll() { if (rectOverlay) rectOverlay.classList.remove('hidden'); document.body.style.overflow = 'hidden'; initRectification(); + setRectificationRecommendedEvents(getRectificationRecommendedEvents(chartData)); renderRectificationTab($('rect-content')); }); $('rect-panel-close').addEventListener('click', () => { @@ -829,6 +862,7 @@ function renderAll() { renderSpecialLagnaReport(arudha, ascendant, birth_info, chartData.special_lagnas); renderCompleteReadingTab({ ascendant, moonP, allYogas, dashaData, extraDasas, chartData, validation, audit }); renderAIPromptPackPanel(chartData); + renderWorkflowSummaryPanel(chartData); renderProvenancePanel({ chartData, panchanga: ty, @@ -856,6 +890,109 @@ function renderAll() { setTimeout(() => bindTerms(document.querySelector('#page-chart')), 800); } +function getConsultationWorkflow(chartRecord) { + return chartRecord?._consultationWorkflow && typeof chartRecord._consultationWorkflow === 'object' + ? chartRecord._consultationWorkflow + : {}; +} + +function getRuntimePlanner(chartRecord) { + const workflow = getConsultationWorkflow(chartRecord); + return workflow?.runtime_planner && typeof workflow.runtime_planner === 'object' + ? workflow.runtime_planner + : {}; +} + +function formatWorkflowEntryMode(value) { + if (value === 'rectification') return '生时校正'; + if (value === 'direct_chart') return '直接排盘'; + return value || '-'; +} + +function formatWorkflowRouteLabel(route = {}) { + const mapping = { + career: '事业', + relationship: '婚恋', + finance: '财富', + timing: '时间应期', + general: '综合', + }; + return mapping[route?.question_type] || route?.question_type || '-'; +} + +function renderRuntimePlannerPills(runtimePlanner = {}) { + const syncSteps = Array.isArray(runtimePlanner.sync_steps) ? runtimePlanner.sync_steps : []; + const asyncCandidates = Array.isArray(runtimePlanner.async_candidates) ? runtimePlanner.async_candidates : []; + const sourcePriority = runtimePlanner.source_priority?.priority || []; + return ` +
+
+ 入口 + ${escapeHtml(formatWorkflowEntryMode(runtimePlanner.entry_mode))} +
+
+ 主问题路由 + ${escapeHtml(formatWorkflowRouteLabel(runtimePlanner.route))} +
+
+ 证据优先级 + ${escapeHtml(sourcePriority.length ? 'VedAstro official -> local supplemental -> fallback' : 'fallback')} +
+
+ 同步主链 + ${escapeHtml(syncSteps.join(' -> ') || '-')} +
+
+ 异步候选 + ${escapeHtml(asyncCandidates.join(' / ') || '-')} +
+
+ Planner + ${escapeHtml(runtimePlanner.planner_name || 'UnifiedConsultationRuntimePlanner')} +
+
+ `; +} + +function renderWorkflowSummaryCard(workflow = {}) { + const runtimePlanner = workflow?.runtime_planner || {}; + const unified = workflow?.unified_orchestrator || {}; + const sourcePriority = workflow?.source_priority?.priority || unified?.source_priority?.priority || []; + const compactSource = sourcePriority.length + ? sourcePriority.join(' -> ') + : 'VedAstro official / local supplemental / fallback'; + return ` +
+
+ 本次解读工作流 + ${escapeHtml(formatWorkflowRouteLabel(runtimePlanner.route || workflow?.routing || {}))} +
+

${escapeHtml(`入口 ${formatWorkflowEntryMode(workflow?.entry_mode || runtimePlanner.entry_mode)};主链优先读取 VedAstro official 证据,再由 local supplemental 补充,最后才 fallback。`)}

+ ${renderRuntimePlannerPills({ + ...runtimePlanner, + entry_mode: runtimePlanner.entry_mode || workflow?.entry_mode, + route: runtimePlanner.route || workflow?.routing || {}, + source_priority: runtimePlanner.source_priority || workflow?.source_priority || unified?.source_priority || {}, + })} +
+ source_priority + ${escapeHtml(compactSource)} +
+
+ `; +} + +function renderWorkflowSummaryPanel(chartRecord) { + const host = $('workflow-summary-panel'); + if (!host) return; + const workflow = getConsultationWorkflow(chartRecord); + if (!Object.keys(workflow).length) { + host.innerHTML = ''; + return; + } + host.innerHTML = renderWorkflowSummaryCard(workflow); +} + // ============================================================================ // 完整解盘 Tab:承接 Skill 全能力路线 // ============================================================================ @@ -864,6 +1001,7 @@ function renderCompleteReadingTab({ ascendant, moonP, allYogas, dashaData, extra const apiDashaCount = chartData?._extended?.dasha_count || chartData?.available_dashas?.length || 0; const dashaCount = Math.max(apiDashaCount, extraDashaCount, dashaData ? 1 : 0); renderGuidedTopicDiscovery(chartData); + renderCareerQuickQuestions(chartData); renderSkillCoverage($('skill-coverage-section'), { ascendant: ascendant?.sign ? `${ascendant.sign} · ${signName(ascendant.sign)}` : '-', moonNakshatra: formatMoonNakshatra(moonP) || '-', @@ -876,6 +1014,54 @@ function renderCompleteReadingTab({ ascendant, moonP, allYogas, dashaData, extra renderMEVGAudit($('mevg-audit-section'), mevg); } +function getRectificationRecommendedEvents(chartRecord) { + const workflow = getConsultationWorkflow(chartRecord); + const rectification = workflow?.rectification || {}; + const recommended = rectification?.summary?.recommended_events; + return Array.isArray(recommended) ? recommended : []; +} + +function renderCareerQuickQuestions(chartRecord) { + const host = $('skill-coverage-section'); + if (!host) return; + const topic = (((chartRecord?.modules || {}).guided_topics) || []).find(row => row?.id === 'career_direction'); + const defaultQuestions = [ + '我现在适合换方向还是继续深耕?', + '2026 年事业吉利在哪里,不利在哪里?', + '哪些月份适合推进项目、发布产品或谈合作?', + ]; + const questions = Array.isArray(topic?.suggested_questions) && topic.suggested_questions.length + ? topic.suggested_questions.slice(0, 3) + : defaultQuestions; + const card = document.createElement('section'); + card.className = 'career-quick-questions guided-topic-discovery'; + card.innerHTML = ` +
+
+ 事业互动问答 + 事业先看这几个 +
+ 不需要用户自己组织问题,点一下就进入互动问答。 +
+
+ ${questions.map(question => ` + + `).join('')} +
+ `; + const old = host.querySelector('.career-quick-questions'); + if (old) old.remove(); + host.prepend(card); + card.querySelectorAll('[data-career-quick-question]').forEach(button => { + button.addEventListener('click', () => { + const question = button.getAttribute('data-career-quick-question') || ''; + openAIChatWithPrompt(question); + }); + }); +} + function renderGuidedTopicDiscovery(chartData) { const host = $('guided-topic-discovery-panel'); if (!host) return; @@ -906,7 +1092,9 @@ function renderGuidedTopicDiscovery(chartData) { host.querySelectorAll('[data-guided-topic-question]').forEach(button => { button.addEventListener('click', () => { const question = button.getAttribute('data-guided-topic-question') || ''; - openAIChatWithPrompt(question); + const topicId = button.getAttribute('data-guided-topic-id') || ''; + const topic = topics.find(item => item?.id === topicId) || null; + openAIChatWithPrompt(question, topic); }); }); } @@ -915,6 +1103,18 @@ function renderGuidedTopicCard(topic) { const evidence = Array.isArray(topic?.evidence) ? topic.evidence : []; const questions = Array.isArray(topic?.suggested_questions) ? topic.suggested_questions : []; const vedastro = topic?.vedastro || {}; + const strictAdjudicationBundle = topic?.strict_adjudication_bundle || {}; + const auditGate = strictAdjudicationBundle?.strict_audit_gate || topic?.strict_audit_gate || {}; + const monthly_adjudication_summary = strictAdjudicationBundle?.monthly_adjudication_summary || topic?.monthly_adjudication_summary || {}; + const official_day_signal_summary = strictAdjudicationBundle?.official_day_signal_summary || topic?.official_day_signal_summary || {}; + const topOfficialDay = official_day_signal_summary?.top_day || null; + const primaryState = monthly_adjudication_summary?.primary_state?.value || null; + const manifestationMode = monthly_adjudication_summary?.manifestation_mode?.value || null; + const frictionSource = monthly_adjudication_summary?.friction_source?.value || null; + const timeConfidence = monthly_adjudication_summary?.time_confidence?.value || null; + const functionalGate = auditGate?.functional_benefic_malefic || {}; + const vargaGate = auditGate?.relevant_vargas || {}; + const dashaGate = auditGate?.vimshottari_narayana_crosscheck || {}; const confidenceLabel = topic?.confidence === 'high' ? '高' : topic?.confidence === 'low' @@ -944,12 +1144,57 @@ function renderGuidedTopicCard(topic) { ${escapeHtml(row?.value || '-')}
`).join('')} + ${topOfficialDay ? ` +
+ 官方日期 + ${escapeHtml(`${topOfficialDay.date || '-'} · ${topOfficialDay.summary || '-'} · ${topOfficialDay.confidence || '-'}`)} +
+ ` : ''} + ${primaryState ? ` +
+ 月度主状态 + ${escapeHtml(primaryState)} +
+ ` : ''} + ${manifestationMode ? ` +
+ 落地形式 + ${escapeHtml(manifestationMode)} +
+ ` : ''} + ${frictionSource ? ` +
+ 阻力来源 + ${escapeHtml(frictionSource)} +
+ ` : ''} + ${timeConfidence ? ` +
+ 时间置信度 + ${escapeHtml(timeConfidence)} +
+ ` : ''} + +
+ 严谨门槛 +
+ functional + ${escapeHtml(`${functionalGate.gate || 'none'} / ${functionalGate.used ? 'used' : 'blocked'}`)} +
+
+ vargas + ${escapeHtml((vargaGate.present_keys || []).join(' / ') || 'none')} +
+
+ dual dasha + ${escapeHtml(`${dashaGate.gate || 'none'} / ${dashaGate.used ? 'used' : 'blocked'}`)} +
适合继续问
${questions.slice(0, 3).map(question => ` - `).join('')} @@ -972,6 +1217,7 @@ function renderAIPromptPackPanel(cd) { const core = evidence.core || {}; const timing = evidence.timing || {}; const strength = evidence.strength || {}; + const vedastroOfficial = evidence.vedastro_official_full_snapshot || {}; const vedastroOverview = evidence.vedastro_overview || {}; const docs = pack.retrieval_plan?.local_reference_docs || []; const tags = pack.retrieval_plan?.retrieval_tags || []; @@ -1006,6 +1252,7 @@ function renderAIPromptPackPanel(cd) { ${escapeHtml(timing.current_mahadasha || cd?.dasha?.current_md || '-')} ${escapeHtml(timing.current_antardasha ? `AD ${timing.current_antardasha}` : timing.start_date || '')}
+ ${renderVedAstroOfficialSnapshotPromptCard(vedastroOfficial)} ${renderVedAstroOverviewPromptCard(vedastroOverview)}
@@ -1026,6 +1273,7 @@ function renderAIPromptPackPanel(cd) { ${ranking.slice(0, 5).map(item => `${escapeHtml(item.planet || '-')} ${escapeHtml(String(item.total_rupas ?? item.rupas ?? '-'))}`).join('')}
` : ''} + ${renderStrictWorkflowContractPanel(vedastroOfficial)}
@@ -1043,6 +1291,127 @@ function renderAIPromptPackPanel(cd) { setupAIPromptPackActions(host, cd, pack); } +function renderVedAstroOfficialSnapshotPromptCard(snapshot = {}) { + if (!snapshot || typeof snapshot !== 'object') return ''; + const status = snapshot.status || 'blocked'; + const pythonPath = snapshot.official_python_path || snapshot.primary_source || 'vedastro_official'; + const bundleStatus = snapshot.official_bundle_status || 'blocked'; + const chartState = snapshot.official_chart_available ? 'official chart ready' : 'official chart partial'; + const catalogStatus = snapshot.official_full_capability_catalog_status || 'blocked'; + const catalogSummary = snapshot.official_full_capability_catalog_summary || {}; + const catalogHint = catalogSummary.catalog_method_count + ? ` · catalog=${catalogStatus} ${catalogSummary.executed_method_count || 0}/${catalogSummary.catalog_method_count}` + : ''; + const sectionHint = Array.isArray(snapshot.official_primary_sections_ok) + ? snapshot.official_primary_sections_ok.slice(0, 4).join(', ') + : ''; + const contractRoutes = Array.isArray(snapshot.strict_workflow_routes_available) + ? snapshot.strict_workflow_routes_available + : []; + const primaryRoute = snapshot.strict_workflow_primary_route || contractRoutes[0] || '-'; + const contractHint = contractRoutes.length + ? ` · contract=${primaryRoute} [${contractRoutes.join('/')}]` + : ''; + return ` +
+ VedAstro Official Snapshot + ${escapeHtml(status)} + ${escapeHtml(`${pythonPath} · bundle=${bundleStatus} · ${chartState}${catalogHint}${contractHint}${sectionHint ? ` · ${sectionHint}` : ''}`)} +
+ `; +} + +function renderStrictWorkflowContractPanel(snapshot = {}) { + if (!snapshot || typeof snapshot !== 'object') return ''; + const contracts = snapshot.strict_workflow_contracts; + if (!contracts || typeof contracts !== 'object' || !Object.keys(contracts).length) return ''; + const primaryRoute = snapshot.strict_workflow_primary_route || Object.keys(contracts)[0]; + return ` +
+

Official-first Strict Contract

+
+ ${Object.entries(contracts).map(([route, contract]) => renderStrictWorkflowContractCard(route, contract, route === primaryRoute)).join('')} +
+
+ `; +} + +function renderStrictWorkflowContractCard(route, contract = {}, isPrimary = false) { + const official = contract.official_primary_evidence || {}; + const local = contract.local_supplemental_evidence || {}; + const adjudicationStages = contract.adjudication_stages || {}; + const multiReferenceSummary = contract.multi_reference_reading_summary || {}; + const monthlyAdjudicationSummary = contract.monthly_adjudication_summary || {}; + const techniqueAuditSummary = contract.technique_audit_summary || {}; + const promiseStatus = adjudicationStages.promise?.status || 'missing'; + const activationStatus = adjudicationStages.activation?.status || 'missing'; + const manifestationStatus = adjudicationStages.manifestation?.status || 'missing'; + const labelValue = adjudicationStages.label?.value || contract.dominant_label || '—'; + const monthlyPrimaryState = monthlyAdjudicationSummary.primary_state?.value || '—'; + const monthlyManifestation = monthlyAdjudicationSummary.manifestation_mode?.value || '—'; + const monthlyFriction = monthlyAdjudicationSummary.friction_source?.value || '—'; + const monthlyTiming = monthlyAdjudicationSummary.time_confidence?.value || '—'; + const rootFrameKeys = Object.keys(multiReferenceSummary.root_frame || {}); + const modifierFrameKeys = Object.keys(multiReferenceSummary.modifier_frame || {}); + const auditKeys = Object.keys(techniqueAuditSummary || {}); + const auditFunctional = techniqueAuditSummary.functional_benefic_malefic || {}; + const auditVarga = techniqueAuditSummary.relevant_vargas || {}; + const auditDasha = techniqueAuditSummary.vimshottari_narayana_crosscheck || {}; + const officialStatuses = Object.entries(official) + .map(([key, value]) => `${key}:${value?.status || 'blocked'}`) + .join(' · '); + const localStatuses = Object.entries(local) + .map(([key, value]) => `${key}:${value?.present ? 'present' : 'missing'}`) + .join(' · '); + const fallback = Array.isArray(contract.fallback_used) && contract.fallback_used.length + ? contract.fallback_used.join(' / ') + : 'none'; + const blocked = Array.isArray(contract.blocked_items) && contract.blocked_items.length + ? contract.blocked_items.join(' / ') + : 'none'; + const conflicts = Array.isArray(contract.conflicts) + ? contract.conflicts.map(item => item?.type).filter(Boolean) + : []; + const mainConflicts = Array.isArray(contract.main_conflicts) + ? contract.main_conflicts.map(item => item?.type).filter(Boolean) + : []; + return ` +
+
+ ${escapeHtml(route)} + ${escapeHtml(isPrimary ? 'primary route' : (contract.confidence_cap || 'supplemental route'))} +
+
+ official: ${escapeHtml(officialStatuses || '—')} + local: ${escapeHtml(localStatuses || '—')} + fallback: ${escapeHtml(fallback)} + blocked: ${escapeHtml(blocked)} + conflicts: ${escapeHtml(conflicts.length ? conflicts.join(' / ') : 'none')} +
+
+ Top-reader adjudication: promise=${escapeHtml(promiseStatus)} · activation=${escapeHtml(activationStatus)} · manifestation=${escapeHtml(manifestationStatus)} + label: ${escapeHtml(labelValue)} + monthly_adjudication_summary.primary_state: ${escapeHtml(monthlyPrimaryState)} + monthly_adjudication_summary.manifestation_mode: ${escapeHtml(monthlyManifestation)} + monthly_adjudication_summary.friction_source: ${escapeHtml(monthlyFriction)} + monthly_adjudication_summary.time_confidence: ${escapeHtml(monthlyTiming)} +
+
+ technique_audit_summary: ${escapeHtml(auditKeys.length ? auditKeys.join(' / ') : 'none')} + functional gate: ${escapeHtml(`${auditFunctional.gate || 'none'} / ${auditFunctional.used ? 'used' : 'blocked'}`)} + varga gate: ${escapeHtml(`${auditVarga.gate || 'none'} / ${(auditVarga.present_keys || []).join(' / ') || 'none'}`)} + dual dasha gate: ${escapeHtml(`${auditDasha.gate || 'none'} / ${auditDasha.used ? 'used' : 'blocked'}`)} +
+
+ multi_reference_reading_summary.root_frame: ${escapeHtml(rootFrameKeys.length ? rootFrameKeys.join(' / ') : 'none')} + multi_reference_reading_summary.modifier_frame: ${escapeHtml(modifierFrameKeys.length ? modifierFrameKeys.join(' / ') : 'none')} + main_conflicts: ${escapeHtml(mainConflicts.length ? mainConflicts.join(' / ') : 'none')} +
+ ${escapeHtml(contract.reason || '')} +
+ `; +} + function renderVedAstroOverviewPromptCard(overview = {}) { if (!overview || typeof overview !== 'object') return ''; const status = overview.status || 'blocked'; @@ -1608,9 +1977,11 @@ function renderProvenancePanel({ chartData, panchanga, provenance, validation, a ['下一步 P1', '星历抽象', '评估 SwissEph、VedAstro、Xalen 等底座替换边界,避免算法假切换'], ['守门检查', 'Packaging preflight', '运行 scripts/desktop_packaging_preflight.py 检查 manifest、service worker、loopback API 与 Trust Center'], ]; + const workflowProvenance = renderWorkflowProvenancePanel(chartData); container.innerHTML = `
+ ${workflowProvenance}
Calculation Provenance @@ -1763,6 +2134,49 @@ function renderProvenancePanel({ chartData, panchanga, provenance, validation, a bindProvenanceActions(); } +function renderWorkflowProvenancePanel(chartRecord) { + const workflow = getConsultationWorkflow(chartRecord); + const runtimePlanner = getRuntimePlanner(chartRecord); + if (!Object.keys(workflow).length && !Object.keys(runtimePlanner).length) return ''; + const route = runtimePlanner.route || workflow.routing || {}; + const unified = workflow.unified_orchestrator || {}; + const sourcePriority = workflow.source_priority || unified.source_priority || runtimePlanner.source_priority || {}; + const focusTechniques = Array.isArray(route.focus_techniques) ? route.focus_techniques : []; + const syncSteps = Array.isArray(runtimePlanner.sync_steps) ? runtimePlanner.sync_steps : []; + const asyncCandidates = Array.isArray(runtimePlanner.async_candidates) ? runtimePlanner.async_candidates : []; + return ` +
+
+ Workflow Provenance + ${escapeHtml(runtimePlanner.planner_name || 'UnifiedConsultationRuntimePlanner')} +
+
+
+ unified_orchestrator + ${escapeHtml(unified.name || 'UnifiedConsultationOrchestrator')} + ${escapeHtml(`${unified.surface || runtimePlanner.surface || '-'} · ${formatWorkflowEntryMode(unified.entry_mode || workflow.entry_mode || runtimePlanner.entry_mode)}`)} +
+
+ routing.focus_techniques + ${escapeHtml(formatWorkflowRouteLabel(route))} + ${escapeHtml(focusTechniques.join(' / ') || '-')} +
+
+ source_priority + ${escapeHtml(sourcePriority.mode || 'vedastro_official_snapshot_first')} + ${escapeHtml((sourcePriority.priority || []).join(' -> ') || 'VedAstro official -> local supplemental -> fallback')} +
+
+ runtime planner + ${escapeHtml(syncSteps.join(' -> ') || '-')} + ${escapeHtml(`async: ${asyncCandidates.join(' / ') || 'none'}`)} + ${escapeHtml(runtimePlanner.boundary || workflow.boundary || '')} +
+
+
+ `; +} + function renderKV(label, value) { return `
@@ -7633,6 +8047,14 @@ function buildExportExtras(sourceChart) { || sourceChart?.relationship_narrative || sourceChart?.modules?.relationship_strict_evidence?.user_narrative || null; + const careerNarrative = sourceChart?.ai_prompt_pack?.evidence_snapshot?.career_narrative + || sourceChart?.career_narrative + || sourceChart?.modules?.career_strict_evidence?.user_narrative + || null; + const financeNarrative = sourceChart?.ai_prompt_pack?.evidence_snapshot?.finance_narrative + || sourceChart?.finance_narrative + || sourceChart?.modules?.finance_strict_evidence?.user_narrative + || null; const vimsopakaSemanticSummary = sourceChart?.ai_prompt_pack?.evidence_snapshot?.vimsopaka_semantic_summary || sourceChart?.vimsopaka_semantic_summary || null; @@ -7652,6 +8074,8 @@ function buildExportExtras(sourceChart) { shadbala: sb, ashtakavarga: _buildAVModule(av), panchanga, validation, audit, actionableContext, relationship_narrative: relationshipNarrative, + career_narrative: careerNarrative, + finance_narrative: financeNarrative, vimsopaka_semantic_summary: vimsopakaSemanticSummary, functional_benefic_malefic: functionalBeneficMalefic, vedastro_overview: vedastroOverview, diff --git a/jyotish-app/public/api-bridge.js b/jyotish-app/public/api-bridge.js index b5fd5bbf..fc2baaa7 100644 --- a/jyotish-app/public/api-bridge.js +++ b/jyotish-app/public/api-bridge.js @@ -128,6 +128,10 @@ async function computeWithPython(birthData) { return null; } +async function computeConsultationWorkflow(payload) { + return postJson('/api/consultation_workflow', payload); +} + async function computeSynastry(payload) { return postJson('/api/synastry', payload); } @@ -442,6 +446,7 @@ window.JyotishAPI = { aiKeyPolicy: 'server_side_only', // 计算 computeWithPython, + computeConsultationWorkflow, computeSynastry, computePrashna, computeKP, diff --git a/jyotish-app/rectification-engine.js b/jyotish-app/rectification-engine.js index 793959eb..eef75d7d 100644 --- a/jyotish-app/rectification-engine.js +++ b/jyotish-app/rectification-engine.js @@ -43,6 +43,59 @@ export const EVENT_COLLECTION_GUIDE = [ { key: 'mobility', cn: '搬家/远行/移民', en: 'relocation, travel, migration', categories: ['travel'] }, ]; +export const RECTIFICATION_RECOMMENDED_EVENT_QUESTION_MAP = { + career_change: { + id: 'recommended_career_change', + category: 'job_change', + varga: 'D10', + label_cn: '事业转折', + label_en: 'career change', + question_cn: '你是否有日期比较明确的换工作、项目解散、升职失败或职业转折?', + question_en: 'Have you had a dated job change, project ending, or career pivot?', + examples_cn: ['换工作', '项目解散', '升职/落选', '职业转向'], + }, + education_end: { + id: 'recommended_education_end', + category: 'education', + varga: 'D24', + label_cn: '学业完成', + label_en: 'education completion', + question_cn: '你是否有日期比较明确的毕业、升学、艺考培训结束或重要考试结果?', + question_en: 'Have you had a dated graduation, admission, or important exam result?', + examples_cn: ['毕业', '升学', '艺考培训', '考试结果'], + }, + relocation: { + id: 'recommended_relocation', + category: 'travel', + varga: 'D4', + label_cn: '迁移搬家', + label_en: 'relocation', + question_cn: '你是否有日期比较明确的搬家、长期异地、远行或迁移事件?', + question_en: 'Have you had a dated move, long-distance stay, or relocation?', + examples_cn: ['搬家', '异地求学', '外地工作', '迁移'], + }, + marriage: { + id: 'recommended_marriage', + category: 'marriage', + varga: 'D9', + label_cn: '婚恋关系', + label_en: 'marriage relationship', + question_cn: '你是否有日期比较明确的恋爱开始、订婚、结婚或关系破裂?', + question_en: 'Have you had a dated romance, engagement, marriage, or breakup?', + examples_cn: ['恋爱开始', '订婚', '结婚', '分手'], + }, + windfall: { + id: 'recommended_windfall', + category: 'finance_pos', + varga: 'D2', + label_cn: '收入起落', + label_en: 'income change', + question_cn: '你是否有日期比较明确的奖金、定金、收入明显增加或突然破财?', + question_en: 'Have you had a dated bonus, deposit, income jump, or sudden loss?', + examples_cn: ['奖金', '定金', '收入增长', '破财'], + }, +}; + const RECTIFICATION_THEME_VARGAS = { marriage: ['D9'], divorce: ['D9'], @@ -272,6 +325,16 @@ export function buildRectificationInterviewQuestions() { }); } +export function buildRecommendedRectificationQuestions(recommendedEvents = []) { + const picked = []; + for (const key of recommendedEvents || []) { + const question = RECTIFICATION_RECOMMENDED_EVENT_QUESTION_MAP[key]; + if (question && !picked.find(item => item.id === question.id)) picked.push(question); + } + if (picked.length) return picked; + return buildRectificationInterviewQuestions().slice(0, 3); +} + export function rectificationInterviewAnswersToEvents(answers) { return (answers || []) .filter(answer => answer?.answer === 'yes' && answer.date && EVENT_CATEGORIES[answer.category]) diff --git a/jyotish-app/rectification.js b/jyotish-app/rectification.js index 45d59beb..565a43fe 100644 --- a/jyotish-app/rectification.js +++ b/jyotish-app/rectification.js @@ -5,7 +5,7 @@ import { SIGNS, PLANET_CN, SIGN_LORDS } from './jyotish-engine.js'; import { EVENT_CATEGORIES, EVENT_COLLECTION_GUIDE, VARGA_SENSITIVITY, runRectification, - buildRectificationInterviewQuestions, rectificationInterviewAnswersToEvents, + buildRectificationInterviewQuestions, buildRecommendedRectificationQuestions, rectificationInterviewAnswersToEvents, getHouseLord, fmtTime, dateToJD } from './rectification-engine.js'; import { t, getLang, signName, planetName } from './i18n.js'; @@ -15,6 +15,7 @@ function fmtOffset(m) { return m === 0 ? t('rect.baseline') : `${m > 0 ? '+' : ' let rectEvents = []; let rectInterviewAnswers = {}; +let rectRecommendedEvents = []; export function renderRectificationTab(container) { const lang = getLang(); @@ -56,18 +57,16 @@ export function renderRectificationTab(container) {
-
-
-

快速事件访谈

-

只回答是/否;选“是”时补一个大概日期,系统会自动转成生命事件。

-
- guided_rectification_interview -
-
- ${buildRectificationInterviewQuestions().map(question => renderInterviewQuestion(question, lang)).join('')} +
+
+

快速事件访谈

+

只回答是/否;选“是”时补一个大概日期,系统会自动转成生命事件。

+ guided_rectification_interview · recommended_events +
+
${renderRectificationInterview(lang)}
- +
@@ -166,7 +165,8 @@ function bindInterviewEvents(container) { rectEvents = [...rectEvents, ...events]; renderEventList(container); const status = container.querySelector('#rect-interview-status'); - if (status) status.textContent = `已加入 ${events.length} 个事件。`; + if (status) status.textContent = `已加入 ${events.length} 个事件,正在准备校正。`; + container.querySelector('#rect-run-btn')?.click(); }); } @@ -484,3 +484,15 @@ function showOffsetDetail(container, r, base) { } export function initRectification() { rectEvents = []; rectInterviewAnswers = {}; } + +export function setRectificationRecommendedEvents(recommendedEvents = []) { + rectRecommendedEvents = Array.isArray(recommendedEvents) ? recommendedEvents : []; +} + +function renderRectificationInterview(lang) { + const recommended = buildRecommendedRectificationQuestions(rectRecommendedEvents); + const fallback = recommended.length ? [] : buildRectificationInterviewQuestions(); + return [...recommended, ...fallback].slice(0, recommended.length ? recommended.length : 3) + .map(question => renderInterviewQuestion(question, lang)) + .join(''); +} diff --git a/jyotish-app/style.css b/jyotish-app/style.css index 03ea2414..05730427 100644 --- a/jyotish-app/style.css +++ b/jyotish-app/style.css @@ -4730,6 +4730,17 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .guided-topic-question:hover { border-color: var(--accent); } +.career-quick-questions { + margin-bottom: 16px; +} +.career-quick-question-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.career-quick-question { + min-height: 38px; +} .guided-topic-footer { display: flex; justify-content: space-between; @@ -5428,6 +5439,54 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .provenance-card-wide { grid-column: 1 / -1; } +.workflow-summary-panel { + margin: 0 0 12px; +} +.workflow-summary-card { + padding: 14px 16px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.workflow-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} +.workflow-summary-pill { + min-width: 0; + padding: 8px 10px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + color: var(--text-body); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.workflow-summary-pill strong { + display: block; + margin-bottom: 3px; + color: var(--text-heading); + font-size: 12px; +} +.workflow-provenance-panel { + margin-top: 12px; +} +.workflow-provenance-list { + display: grid; + gap: 8px; +} +.workflow-provenance-list code { + display: block; + margin-top: 4px; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; +} .ai-prompt-pack-panel { margin: 0 0 12px; padding: 16px; @@ -5584,6 +5643,57 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .ai-prompt-pack-strength { margin-top: 8px; } +.ai-prompt-pack-contracts { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-light); +} +.ai-prompt-pack-contracts h4 { + margin: 0 0 8px; + color: var(--text-heading); + font-size: 13px; +} +.ai-prompt-pack-contract-list { + display: grid; + gap: 8px; +} +.ai-prompt-pack-contract-card { + min-width: 0; + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.ai-prompt-pack-contract-card.is-primary { + border-color: rgba(32, 111, 83, 0.3); + background: rgba(32, 111, 83, 0.04); +} +.ai-prompt-pack-contract-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: baseline; + margin-bottom: 6px; +} +.ai-prompt-pack-contract-head strong { + color: var(--text-heading); + font-size: 12px; +} +.ai-prompt-pack-contract-head span, +.ai-prompt-pack-contract-card small { + color: var(--text-muted); + font-size: 11px; +} +.ai-prompt-pack-contract-grid { + display: grid; + gap: 5px; + margin-bottom: 6px; +} +.ai-prompt-pack-contract-grid span { + color: var(--text-body); + font-size: 11px; + overflow-wrap: anywhere; +} .ai-prompt-pack-foot { margin-top: 12px; padding-top: 10px; @@ -6833,6 +6943,7 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .technique-action-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .provenance-grid, .provenance-kv-grid, + .workflow-summary-grid, .roadmap-grid { grid-template-columns: 1fr; } .panchanga-range-controls, .panchanga-condition-options, diff --git a/mcp_server.py b/mcp_server.py index 10203dd5..f2176fd3 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -10,16 +10,19 @@ Install: Run: python3 mcp_server.py -Add to ~/.workbuddy/mcp.json: +MCP client config should point at this repository path, for example: { "mcpServers": { "jyotish": { - "command": "/Users/wuyongnaren/.workbuddy/binaries/python/versions/3.13.12/bin/python3", - "args": ["/Users/wuyongnaren/.workbuddy/skills/jyotish-vedic-astrology/mcp_server.py"], + "command": "python3", + "args": ["/Users/wuyongnaren/Documents/印度占星/mcp_server.py"], "env": {} } } } + +The `.workbuddy` copy is a distribution mirror / historical reference only; +it is not the runtime source of truth for this server. """ import sys @@ -40,6 +43,7 @@ from local_env import load_local_env from mcp.server.fastmcp import FastMCP from functional_benefics import derive_functional_benefic_malefic from vedastro_priority import official_snapshot_evidence +from unified_consultation_orchestrator import UnifiedConsultationOrchestrator load_local_env(SCRIPT_DIR) @@ -58,6 +62,8 @@ mcp = FastMCP( ), ) +_UNIFIED_CONSULTATION_ORCHESTRATOR = UnifiedConsultationOrchestrator() + # ============================================================================ # Helpers # ============================================================================ @@ -99,6 +105,48 @@ def _audit_status() -> Dict[str, Any]: return {"valid": False, "raw": result.stdout} +def _execute_mcp_consultation_workflow( + *, + question: str, + year: int, + month: int, + day: int, + hour: int, + minute: int, + lat: float, + lon: float, + tz: float, + transit_date: str, + node_mode: str, + entry_mode: str = "direct_chart", + theme: list[str] | None = None, + events: list[dict[str, Any]] | None = None, +) -> Dict[str, Any]: + from jyotish_api_server import JyotishAPIHandler, execute_consultation_workflow + + handler = JyotishAPIHandler.__new__(JyotishAPIHandler) + return execute_consultation_workflow( + handler, + body={ + "question": question, + "year": year, + "month": month, + "day": day, + "hour": hour, + "minute": minute, + "lat": lat, + "lon": lon, + "tz": tz, + "transit_date": transit_date, + "node_mode": node_mode, + "entry_mode": entry_mode, + "theme": theme or [], + "events": events or [], + }, + surface="skill_mcp", + ) + + def _safe_get(data: Dict[str, Any], *path: str) -> Any: cur: Any = data for part in path: @@ -441,12 +489,21 @@ def _external_activation_ledger(modules: Dict[str, Any]) -> tuple[Any, Dict[str, def _derive_external_activation_support(modules: Dict[str, Any], domain: str) -> Dict[str, Any]: ledger, provenance = _external_activation_ledger(modules) + adapter_result = modules.get("vedastro_range_scan_result") if isinstance(modules, dict) else {} + if not isinstance(adapter_result, dict): + adapter_result = {} + daily_windows = adapter_result.get("daily_windows") if isinstance(adapter_result.get("daily_windows"), list) else [] + top_daily_window = adapter_result.get("top_daily_window") if isinstance(adapter_result.get("top_daily_window"), dict) else None + official_day_signals = _derive_official_day_signals(domain, daily_windows) if not isinstance(ledger, list): return { "level": "missing_required_external_radar", "source": "vedastro_service_adapter_candidate", "signals": [], "events": [], + "daily_windows": daily_windows, + "top_daily_window": top_daily_window, + "official_day_signals": official_day_signals, "required": True, "operation": "range_scan", "external_calculation_coverage": "VedAstro 596+/600+ calculation nodes", @@ -470,7 +527,11 @@ def _derive_external_activation_support(modules: Dict[str, Any], domain: str) -> events.append(event) if not events: - level = "none" + if official_day_signals: + top_signal = official_day_signals[0] + level = "moderate" if top_signal.get("confidence") == "high" else "weak" + else: + level = "none" elif any((event.get("score") or 0) >= 70 for event in events): level = "moderate" else: @@ -478,9 +539,12 @@ def _derive_external_activation_support(modules: Dict[str, Any], domain: str) -> return { "level": level, - "source": "vedastro_service_adapter_candidate" if events else None, - "signals": ["vedastro_range_scan"] if events else [], + "source": "vedastro_service_adapter_candidate" if events or official_day_signals else None, + "signals": ["vedastro_range_scan"] if events or official_day_signals else [], "events": events, + "daily_windows": daily_windows, + "top_daily_window": top_daily_window, + "official_day_signals": official_day_signals, "required": True, "operation": "range_scan", "external_calculation_coverage": "VedAstro 596+/600+ calculation nodes", @@ -488,6 +552,96 @@ def _derive_external_activation_support(modules: Dict[str, Any], domain: str) -> } +def _derive_official_day_signals(domain: str, daily_windows: Any) -> List[Dict[str, Any]]: + if not isinstance(daily_windows, list): + return [] + positive_families = { + "career": {"career_trigger"}, + "marriage": {"marriage_trigger"}, + "wealth": {"wealth_trigger", "gains_trigger"}, + } + negative_families = { + "career": {"career_pressure"}, + "marriage": {"relationship_pressure"}, + "wealth": {"wealth_pressure"}, + } + label_map = { + "career": { + "opportunity_entry": ("opportunity_entry", "事业机会进入日"), + "pressure_opportunity": ("pressure_opportunity", "事业压力机会日"), + "relocation_motion": ("relocation_motion", "事业迁移动作日"), + "closure_risk": ("closure_risk", "事业真正收尾风险日"), + "mixed": ("mixed", "事业混合日"), + }, + "marriage": { + "positive": ("progress", "婚恋推进日"), + "negative": ("risk", "婚恋风险日"), + "mixed": ("mixed", "婚恋混合日"), + }, + "wealth": { + "positive": ("opportunity", "财富机会日"), + "negative": ("risk", "财富风险日"), + "mixed": ("mixed", "财富混合日"), + }, + } + positive_tokens = ("good", "support", "expansion", "gain", "auspicious", "lending", "borrowing") + negative_tokens = ("bad", "pressure", "dosha", "obstruction", "affliction", "risk") + route_labels = label_map.get(domain, label_map["career"]) + signals: List[Dict[str, Any]] = [] + for window in daily_windows: + if not isinstance(window, dict): + continue + families = { + str(item) + for item in (window.get("signal_families") or []) + if isinstance(item, str) and item + } + label_text = str(window.get("top_signal_label") or "").lower() + event_ids = list(window.get("event_ids") or []) + combined_text = " ".join( + [label_text] + [str(item).lower() for item in event_ids if isinstance(item, str)] + ) + positive = bool(families.intersection(positive_families.get(domain, set()))) or any(token in combined_text for token in positive_tokens) + negative = bool(families.intersection(negative_families.get(domain, set()))) or any(token in combined_text for token in negative_tokens) + if domain == "career": + travel_hit = "travel" in combined_text + building_hit = "building" in combined_text + selling_hit = "selling" in combined_text or "sell" in combined_text + saturn_hit = "saturn" in combined_text + if positive and travel_hit and not negative: + day_type, summary = route_labels["relocation_motion"] + elif positive and not negative: + day_type, summary = route_labels["opportunity_entry"] + elif negative and (positive or saturn_hit or building_hit or travel_hit) and not selling_hit: + day_type, summary = route_labels["pressure_opportunity"] + elif negative and not positive: + day_type, summary = route_labels["closure_risk"] + else: + day_type, summary = route_labels["mixed"] + elif positive and not negative: + day_type, summary = route_labels["positive"] + elif negative and not positive: + day_type, summary = route_labels["negative"] + else: + day_type, summary = route_labels["mixed"] + signals.append( + { + "date": window.get("date"), + "domain": window.get("domain") or domain, + "day_type": day_type, + "summary": summary, + "confidence": window.get("confidence"), + "score": window.get("score"), + "event_count": window.get("event_count"), + "top_signal_label": window.get("top_signal_label"), + "signal_families": list(window.get("signal_families") or []), + "event_ids": event_ids, + "source": "vedastro_official_day_windows", + } + ) + return signals + + def _external_activation_audit(external_activation: Any) -> List[Dict[str, Any]]: if not isinstance(external_activation, dict): return [] @@ -502,19 +656,243 @@ def _external_activation_audit(external_activation: Any) -> List[Dict[str, Any]] ] if external_activation.get("source") == "vedastro_service_adapter_candidate": events = external_activation.get("events") or [] - if isinstance(events, list) and events: + official_day_signals = external_activation.get("official_day_signals") or [] + if (isinstance(events, list) and events) or (isinstance(official_day_signals, list) and official_day_signals): return [ { "technique": "VedAstro EventsAtRange / 596+ Calculator Radar", "status": "used", "role": "external_timing_evidence", "event_count": len(events), + "day_signal_count": len(official_day_signals) if isinstance(official_day_signals, list) else 0, "effect": "activation_context_only_guarded_score_bump", } ] return [] +def _build_official_day_signal_summary(external_activation: Any) -> Dict[str, Any]: + if not isinstance(external_activation, dict): + return {"available": False, "signal_count": 0, "top_day": None, "days": []} + signals = external_activation.get("official_day_signals") + if not isinstance(signals, list) or not signals: + return {"available": False, "signal_count": 0, "top_day": None, "days": []} + return { + "available": True, + "signal_count": len(signals), + "top_day": signals[0] if isinstance(signals[0], dict) else None, + "days": [item for item in signals[:3] if isinstance(item, dict)], + "source": "present_evidence.external_activation.official_day_signals", + } + + +def _official_day_signal_rows(external_activation: Any) -> List[Dict[str, Any]]: + if not isinstance(external_activation, dict): + return [] + signals = external_activation.get("official_day_signals") + if not isinstance(signals, list): + return [] + return [item for item in signals if isinstance(item, dict)] + + +def _supporting_day_rows(signals: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + for item in signals[:3]: + rows.append( + { + "date": item.get("date"), + "summary": item.get("summary"), + "confidence": item.get("confidence"), + "day_type": item.get("day_type"), + "source": item.get("source") or "vedastro_official_day_windows", + } + ) + return rows + + +def _signal_day_types(signals: List[Dict[str, Any]]) -> set[str]: + return { + str(item.get("day_type")) + for item in signals + if isinstance(item, dict) and item.get("day_type") + } + + +def _signal_text_has(signals: List[Dict[str, Any]], *tokens: str) -> bool: + normalized = tuple(str(token).lower() for token in tokens if token) + for item in signals: + if not isinstance(item, dict): + continue + parts = [str(item.get("summary") or "").lower(), str(item.get("top_signal_label") or "").lower()] + parts.extend(str(value).lower() for value in (item.get("event_ids") or []) if isinstance(value, str)) + combined = " ".join(parts) + if any(token in combined for token in normalized): + return True + return False + + +def _monthly_state_for_route(route: str, strict: Dict[str, Any], signals: List[Dict[str, Any]]) -> Dict[str, Any]: + event_judgement = strict.get("event_judgement") if isinstance(strict.get("event_judgement"), dict) else {} + stages = strict.get("adjudication_stages") if isinstance(strict.get("adjudication_stages"), dict) else {} + secondary_context = set(event_judgement.get("secondary_context") or []) + dominant_label = event_judgement.get("dominant_label") + day_types = _signal_day_types(signals) + activation_status = _safe_get(stages, "activation", "status") + promise_status = _safe_get(stages, "promise", "status") + + if route == "career": + if "closure_risk" in day_types and not day_types.intersection({"opportunity_entry", "relocation_motion"}): + return {"value": "收束", "reason_codes": ["closure_risk_day_cluster"], "source": "official_day_signals"} + if day_types.intersection({"pressure_opportunity"}) or secondary_context.intersection({"dignity_conflict", "dignity_high_friction"}): + return {"value": "重组", "reason_codes": ["pressure_opportunity_or_dignity_friction"], "source": "strict_adjudication"} + if day_types.intersection({"opportunity_entry", "relocation_motion"}) or dominant_label == "career_status": + return {"value": "推进", "reason_codes": ["career_activation_with_manifestation"], "source": "strict_adjudication"} + if promise_status == "present" and activation_status == "present": + return {"value": "启动", "reason_codes": ["promise_and_activation_present"], "source": "strict_adjudication"} + return {"value": "观察", "reason_codes": ["insufficient_monthly_activation"], "source": "strict_adjudication"} + + if route == "relationship": + if "risk" in day_types and "progress" not in day_types and not dominant_label: + return {"value": "收束", "reason_codes": ["risk_without_progress"], "source": "official_day_signals"} + if dominant_label == "legal_marriage": + return {"value": "推进", "reason_codes": ["legal_marriage_label_present"], "source": "strict_adjudication"} + if "public_formalization_candidate" in secondary_context: + return {"value": "筛选", "reason_codes": ["public_formalization_without_marriage_label"], "source": "strict_adjudication"} + if "progress" in day_types and activation_status == "present": + return {"value": "推进", "reason_codes": ["relationship_progress_day_supported"], "source": "official_day_signals"} + if promise_status == "present" and activation_status == "present": + return {"value": "启动", "reason_codes": ["relationship_promise_and_activation_present"], "source": "strict_adjudication"} + return {"value": "观察", "reason_codes": ["insufficient_relationship_activation"], "source": "strict_adjudication"} + + if route == "finance": + if "risk" in day_types and "opportunity" not in day_types and not dominant_label: + return {"value": "收束", "reason_codes": ["risk_without_finance_support"], "source": "official_day_signals"} + if dominant_label in {"income_growth", "public_wealth_status"} and "opportunity" in day_types: + return {"value": "推进", "reason_codes": ["finance_label_plus_positive_day"], "source": "strict_adjudication"} + if secondary_context.intersection({"avayogi_active", "ashtakavarga_wealth_friction", "sodhita_wealth_friction"}): + return {"value": "整固", "reason_codes": ["finance_friction_requires_consolidation"], "source": "strict_adjudication"} + if promise_status == "present" and activation_status == "present": + return {"value": "启动", "reason_codes": ["finance_promise_and_activation_present"], "source": "strict_adjudication"} + return {"value": "观察", "reason_codes": ["insufficient_finance_activation"], "source": "strict_adjudication"} + + return {"value": "观察", "reason_codes": ["route_not_supported"], "source": "strict_adjudication"} + + +def _monthly_manifestation_for_route(route: str, strict: Dict[str, Any], signals: List[Dict[str, Any]]) -> Dict[str, Any]: + event_judgement = strict.get("event_judgement") if isinstance(strict.get("event_judgement"), dict) else {} + secondary_context = set(event_judgement.get("secondary_context") or []) + dominant_label = event_judgement.get("dominant_label") + day_types = _signal_day_types(signals) + + if route == "career": + if "relocation_motion" in day_types or _signal_text_has(signals, "travel"): + return {"value": "迁移/异地/差旅动作", "reason_codes": ["relocation_motion_day"], "source": "official_day_signals"} + if "opportunity_entry" in day_types: + if secondary_context.intersection({"a10_active", "amk_active"}): + return {"value": "项目/合作推进", "reason_codes": ["a10_or_amk_with_positive_day"], "source": "strict_adjudication"} + return {"value": "职责/职位推进", "reason_codes": ["career_positive_day"], "source": "official_day_signals"} + if "pressure_opportunity" in day_types: + return {"value": "岗位/项目重组", "reason_codes": ["pressure_opportunity_day"], "source": "official_day_signals"} + if dominant_label == "career_status": + return {"value": "职业定位推进", "reason_codes": ["career_status_label"], "source": "strict_adjudication"} + return {"value": "职业观察窗口", "reason_codes": ["no_manifestation_lift"], "source": "strict_adjudication"} + + if route == "relationship": + if dominant_label == "legal_marriage": + return {"value": "长期关系/承诺推进", "reason_codes": ["legal_marriage_label"], "source": "strict_adjudication"} + if "public_formalization_candidate" in secondary_context: + return {"value": "关系公开化/可见度提升", "reason_codes": ["public_formalization_candidate"], "source": "strict_adjudication"} + if "progress" in day_types: + return {"value": "认识/互动推进", "reason_codes": ["progress_day"], "source": "official_day_signals"} + if "risk" in day_types: + return {"value": "关系现实面测试", "reason_codes": ["risk_day"], "source": "official_day_signals"} + return {"value": "关系观察/筛选", "reason_codes": ["no_manifestation_lift"], "source": "strict_adjudication"} + + if route == "finance": + if dominant_label == "income_growth": + return {"value": "收入增长/入账机会", "reason_codes": ["income_growth_label"], "source": "strict_adjudication"} + if dominant_label == "public_wealth_status": + return {"value": "项目回款/对外收入状态", "reason_codes": ["public_wealth_status_label"], "source": "strict_adjudication"} + if _signal_text_has(signals, "borrowing", "lending", "business"): + return {"value": "资金调度/合作现金流", "reason_codes": ["wealth_signal_text_cashflow"], "source": "official_day_signals"} + if "risk" in day_types: + return {"value": "支出/交易收口", "reason_codes": ["risk_day"], "source": "official_day_signals"} + return {"value": "现金流结构观察", "reason_codes": ["no_manifestation_lift"], "source": "strict_adjudication"} + + return {"value": "观察", "reason_codes": ["route_not_supported"], "source": "strict_adjudication"} + + +def _monthly_friction_for_route(route: str, strict: Dict[str, Any], signals: List[Dict[str, Any]]) -> Dict[str, Any]: + event_judgement = strict.get("event_judgement") if isinstance(strict.get("event_judgement"), dict) else {} + secondary_context = set(event_judgement.get("secondary_context") or []) + blocked_items = strict.get("blocked_items") if isinstance(strict.get("blocked_items"), list) else [] + day_types = _signal_day_types(signals) + + if route == "career": + if secondary_context.intersection({"dignity_conflict", "dignity_high_friction"}): + return {"value": "权责与结构摩擦", "reason_codes": ["dignity_conflict"], "source": "strict_adjudication"} + if secondary_context.intersection({"virodhargala_obstruction", "kakshya_career_friction"}) or "pressure_opportunity" in day_types: + return {"value": "执行压力伴随机会", "reason_codes": ["argala_or_kakshya_friction"], "source": "strict_adjudication"} + if blocked_items or "vedastro_range_scan_missing" in secondary_context: + return {"value": "时间证据不足", "reason_codes": ["external_timing_gap"], "source": "strict_adjudication"} + return {"value": "可控结构压力", "reason_codes": ["default_career_friction"], "source": "strict_adjudication"} + + if route == "relationship": + if secondary_context.intersection({"dignity_conflict", "dignity_high_friction"}) or "risk" in day_types: + return {"value": "现实条件与节奏压力", "reason_codes": ["relationship_risk_or_dignity"], "source": "strict_adjudication"} + if "virodhargala_obstruction" in secondary_context: + return {"value": "关系推进阻力", "reason_codes": ["argala_obstruction"], "source": "strict_adjudication"} + if "public_formalization_candidate" in secondary_context: + return {"value": "公开化快于承诺", "reason_codes": ["public_formalization_mismatch"], "source": "strict_adjudication"} + if blocked_items or "vedastro_range_scan_missing" in secondary_context: + return {"value": "时间证据不足", "reason_codes": ["external_timing_gap"], "source": "strict_adjudication"} + return {"value": "筛选与磨合成本", "reason_codes": ["default_relationship_friction"], "source": "strict_adjudication"} + + if route == "finance": + if secondary_context.intersection({"avayogi_active", "ashtakavarga_wealth_friction", "sodhita_wealth_friction"}): + return {"value": "现金流波动/错误决策风险", "reason_codes": ["finance_friction_signals"], "source": "strict_adjudication"} + if "risk" in day_types: + return {"value": "交易/回款节奏压力", "reason_codes": ["finance_risk_day"], "source": "official_day_signals"} + if blocked_items or "vedastro_range_scan_missing" in secondary_context: + return {"value": "时间证据不足", "reason_codes": ["external_timing_gap"], "source": "strict_adjudication"} + return {"value": "兑现节奏与支出管理", "reason_codes": ["default_finance_friction"], "source": "strict_adjudication"} + + return {"value": "证据不足", "reason_codes": ["route_not_supported"], "source": "strict_adjudication"} + + +def _monthly_time_confidence(strict: Dict[str, Any], signals: List[Dict[str, Any]]) -> Dict[str, Any]: + stages = strict.get("adjudication_stages") if isinstance(strict.get("adjudication_stages"), dict) else {} + promise_status = _safe_get(stages, "promise", "status") + activation_status = _safe_get(stages, "activation", "status") + if signals and activation_status == "present": + return {"value": "day_supported", "reason_codes": ["official_day_signal_plus_dual_dasha"], "source": "strict_adjudication"} + if activation_status == "present": + return {"value": "month_supported", "reason_codes": ["dual_dasha_without_day_signal"], "source": "strict_adjudication"} + if promise_status == "present": + return {"value": "month_only", "reason_codes": ["promise_without_activation"], "source": "strict_adjudication"} + return {"value": "blocked", "reason_codes": ["missing_promise_and_activation"], "source": "strict_adjudication"} + + +def _build_monthly_adjudication_summary(route: str, strict: Dict[str, Any]) -> Dict[str, Any]: + present = strict.get("present_evidence") if isinstance(strict, dict) else {} + if not isinstance(present, dict): + present = {} + external_activation = present.get("external_activation") + signals = _official_day_signal_rows(external_activation) + return { + "route": route, + "primary_state": _monthly_state_for_route(route, strict, signals), + "manifestation_mode": _monthly_manifestation_for_route(route, strict, signals), + "friction_source": _monthly_friction_for_route(route, strict, signals), + "time_confidence": _monthly_time_confidence(strict, signals), + "supporting_days": _supporting_day_rows(signals), + "confidence_cap": strict.get("confidence_cap"), + "blocked_items": strict.get("blocked_items") or [], + "conflicts": strict.get("conflicts") or [], + "source": "strict_workflow_monthly_adjudication_v1", + } + + def _official_snapshot_audit(official_snapshot: Any) -> List[Dict[str, Any]]: if not isinstance(official_snapshot, dict): return [] @@ -538,6 +916,165 @@ def _official_snapshot_audit(official_snapshot: Any) -> List[Dict[str, Any]]: ] +def _evidence_present(value: Any) -> bool: + if value in (None, {}, [], ""): + return False + if isinstance(value, dict): + if value.get("status") in {"blocked", "missing", "none"}: + return False + if value.get("level") in {"blocked", "missing", "none"}: + return False + return True + + +def _official_section_status(snapshot: Dict[str, Any], *keys: str) -> str: + if not isinstance(snapshot, dict): + return "blocked" + statuses = snapshot.get("section_statuses") if isinstance(snapshot.get("section_statuses"), dict) else {} + for key in keys: + value = statuses.get(key) + if value: + return str(value) + if snapshot.get("level") == "primary": + return "unknown" + return str(snapshot.get("status") or "blocked") + + +def _build_official_primary_evidence(route: str, present: Dict[str, Any]) -> Dict[str, Any]: + snapshot = present.get("vedastro_official_snapshot") if isinstance(present, dict) else {} + event_status = _official_section_status(snapshot, "events_overview") + external_activation = present.get("external_activation") if isinstance(present, dict) else {} + if isinstance(external_activation, dict) and external_activation.get("level") == "moderate": + event_status = "ok" + + evidence = { + "chart_core": { + "source": "vedastro_official", + "role": "official_primary", + "status": _official_section_status(snapshot, "chart_core"), + }, + "dasha": { + "source": "vedastro_official", + "role": "official_primary", + "status": _official_section_status(snapshot, "dasha_all"), + }, + "event_radar": { + "source": "vedastro_official", + "role": "official_primary", + "status": event_status, + }, + } + if route == "relationship": + evidence["d9"] = { + "source": "vedastro_official", + "role": "official_primary", + "status": _official_section_status(snapshot, "varga_d9", "varga_all"), + } + elif route == "career": + evidence["d10"] = { + "source": "vedastro_official", + "role": "official_primary", + "status": _official_section_status(snapshot, "varga_d10", "varga_all"), + } + elif route == "finance": + evidence["d2_d11"] = { + "source": "vedastro_official", + "role": "official_primary", + "status": _official_section_status(snapshot, "varga_d2_d11", "varga_all"), + } + return evidence + + +def _build_local_supplemental_evidence(route: str, present: Dict[str, Any]) -> Dict[str, Any]: + if route == "relationship": + keys = ("upapada_lagna", "darakaraka", "narayana_current", "functional_benefic_malefic") + elif route == "career": + keys = ("a10_karma_pada", "amatyakaraka", "karakamsha", "narayana_current", "functional_benefic_malefic") + elif route == "finance": + keys = ("wealth_promise_strength", "narayana_current", "functional_benefic_malefic") + else: + keys = () + return { + key: { + "source": "local_module", + "role": "required_local_supplement", + "present": _evidence_present(present.get(key)), + } + for key in keys + } + + +def _dedupe_ordered(items: List[str]) -> List[str]: + seen = set() + result: List[str] = [] + for item in items: + if item in seen: + continue + seen.add(item) + result.append(item) + return result + + +def _build_fallback_and_blocked( + route: str, + present: Dict[str, Any], + missing: List[str], + official_primary_evidence: Dict[str, Any], +) -> tuple[List[str], List[str]]: + fallback_used: List[str] = [] + blocked_items: List[str] = [] + snapshot = present.get("vedastro_official_snapshot") if isinstance(present, dict) else {} + source_priority = present.get("source_priority") if isinstance(present, dict) else {} + + if not isinstance(snapshot, dict) or snapshot.get("level") != "primary": + blocked_items.append("official_primary_chart_blocked") + fallback_used.append("local_chart_fallback") + if isinstance(source_priority, dict) and source_priority.get("mode") == "local_fallback_official_blocked": + fallback_used.append("source_priority_local_fallback") + + event_status = ((official_primary_evidence.get("event_radar") or {}).get("status")) + if event_status == "partial": + blocked_items.append("official_event_radar_partial") + elif event_status in {"blocked", "missing", "service_endpoint_not_configured", "network_execution_disabled"}: + blocked_items.append("official_event_radar_blocked") + + for key in missing: + blocked_items.append(f"missing_required_{key}") + + return _dedupe_ordered(fallback_used), _dedupe_ordered(blocked_items) + + +def _build_conflicts(route: str, present: Dict[str, Any], official_primary_evidence: Dict[str, Any]) -> List[Dict[str, Any]]: + conflicts: List[Dict[str, Any]] = [] + dignity_guardrail = present.get("dignity_guardrail") if isinstance(present, dict) else {} + if isinstance(dignity_guardrail, dict) and dignity_guardrail.get("status") == "conflict": + conflicts.append( + { + "type": "official_local_divisional_conflict", + "primary_source": "vedastro_official", + "supplemental_source": "local_module", + "impact": "interpretation", + "resolution": "keep_official_primary_and_downgrade_confidence", + "details": {"dignity_guardrail": dignity_guardrail}, + } + ) + + event_status = ((official_primary_evidence.get("event_radar") or {}).get("status")) + if event_status == "partial": + conflicts.append( + { + "type": "official_event_radar_missing_or_partial", + "primary_source": "vedastro_official", + "supplemental_source": "local_module", + "impact": "timing", + "resolution": "keep_official_primary_and_downgrade_confidence", + "details": {"event_radar_status": event_status, "route": route}, + } + ) + + return conflicts + + def _derive_external_technique_evidence(modules: Dict[str, Any], domain: str) -> Dict[str, Any]: ledger = _safe_get(modules, "external_technique_evidence", "evidence_ledger") if not isinstance(ledger, list): @@ -977,6 +1514,34 @@ def _derive_dignity_guardrail(route: str, present: Dict[str, Any]) -> Dict[str, darakaraka = present.get("darakaraka") if isinstance(darakaraka, dict) and darakaraka.get("planet"): relevant_roles[darakaraka["planet"]] = "darakaraka" + elif route == "career": + lord_10 = _lord_for_house_from_lagna(asc_sign, 10) + if not lord_10: + return base + relevant_roles[lord_10] = "10l" + a10 = present.get("a10_karma_pada") + if isinstance(a10, dict) and a10.get("lord"): + relevant_roles[str(a10["lord"])] = "a10_lord" + amatyakaraka = present.get("amatyakaraka") + if isinstance(amatyakaraka, dict) and amatyakaraka.get("planet"): + relevant_roles[str(amatyakaraka["planet"])] = "amatyakaraka" + karakamsha = present.get("karakamsha") + if isinstance(karakamsha, dict) and karakamsha.get("karakamsha_lord"): + relevant_roles[str(karakamsha["karakamsha_lord"])] = "karakamsha_lord" + vimshottari = present.get("vimshottari_current") + if isinstance(vimshottari, dict): + if vimshottari.get("mahadasha"): + relevant_roles[str(vimshottari["mahadasha"])] = "mahadasha_lord" + antardasha = vimshottari.get("antardasha") + if isinstance(antardasha, dict) and antardasha.get("lord"): + relevant_roles[str(antardasha["lord"])] = "antardasha_lord" + elif isinstance(antardasha, str) and antardasha: + relevant_roles[antardasha] = "antardasha_lord" + elif vimshottari.get("lord"): + relevant_roles[str(vimshottari["lord"])] = "mahadasha_lord" + narayana = present.get("narayana_current") + if isinstance(narayana, dict) and narayana.get("lord"): + relevant_roles[str(narayana["lord"])] = "narayana_lord" elif route == "finance": lord_2 = _lord_for_house_from_lagna(asc_sign, 2) lord_11 = _lord_for_house_from_lagna(asc_sign, 11) @@ -1071,6 +1636,8 @@ def _derive_event_judgement(route: str, present: Dict[str, Any], missing: List[s score += 10 if present.get("vimshottari_current") else 0 score += 10 if present.get("narayana_current") else 0 score += _convergence_score(present.get("career_convergence")) + dignity_guardrail = present.get("dignity_guardrail") or {} + score += dignity_guardrail.get("score_delta", 0) kakshya_career_support = present.get("kakshya_career_support") or {} if kakshya_career_support.get("level") == "supportive": score += 2 @@ -1127,6 +1694,14 @@ def _derive_event_judgement(route: str, present: Dict[str, Any], missing: List[s external_technique = present.get("external_technique_evidence") or {} if external_technique.get("level") == "context_only": secondary_context.append("external_technique_evidence") + if dignity_guardrail.get("status") == "conflict": + secondary_context.append("dignity_conflict") + elif dignity_guardrail.get("score_delta") == 5: + secondary_context.append("dignity_supportive_recovery") + elif dignity_guardrail.get("score_delta") == 3: + secondary_context.append("dignity_supportive_friendship") + elif dignity_guardrail.get("score_delta") == -5: + secondary_context.append("dignity_high_friction") hard_gate_missing = any( key in missing for key in ( @@ -1439,6 +2014,327 @@ def _derive_event_judgement(route: str, present: Dict[str, Any], missing: List[s } +def _has_promise_evidence(route: str, present: Dict[str, Any]) -> bool: + if route == "career": + return bool( + present.get("d10_dasamsa") + or present.get("a10_karma_pada") + or present.get("amatyakaraka") + or present.get("karakamsha") + ) + if route == "relationship": + return bool( + present.get("d9_navamsa") + or present.get("upapada_lagna") + or present.get("darakaraka") + or present.get("vivah_saham") + ) + if route == "finance": + return bool( + present.get("d2_hora") + or present.get("d10_dasamsa") + or present.get("wealth_promise_strength") + or present.get("ashtakavarga_house_scores") + ) + return False + + +def _has_activation_evidence(route: str, present: Dict[str, Any]) -> bool: + if route == "career": + return bool( + present.get("vimshottari_current") + and present.get("narayana_current") + and present.get("career_convergence") + ) + if route == "relationship": + return bool( + present.get("vimshottari_current") + and present.get("narayana_current") + and ( + present.get("marriage_convergence") + or (present.get("external_activation") or {}).get("level") == "moderate" + ) + ) + if route == "finance": + return bool( + present.get("vimshottari_current") + and present.get("narayana_current") + and ( + present.get("wealth_convergence") + or present.get("gains_convergence") + or present.get("career_convergence") + ) + ) + return False + + +def _promise_drivers(route: str, present: Dict[str, Any]) -> List[str]: + by_route = { + "career": ("d10_dasamsa", "a10_karma_pada", "amatyakaraka", "karakamsha"), + "relationship": ("d9_navamsa", "upapada_lagna", "darakaraka", "vivah_saham"), + "finance": ("d2_hora", "d10_dasamsa", "wealth_promise_strength", "ashtakavarga_house_scores"), + } + return [key for key in by_route.get(route, ()) if present.get(key)] + + +def _activation_drivers(route: str, present: Dict[str, Any]) -> List[str]: + by_route = { + "career": ("vimshottari_current", "narayana_current", "career_convergence", "external_activation"), + "relationship": ("vimshottari_current", "narayana_current", "marriage_convergence", "external_activation"), + "finance": ("vimshottari_current", "narayana_current", "wealth_convergence", "gains_convergence", "career_convergence", "external_activation"), + } + return [key for key in by_route.get(route, ()) if present.get(key)] + + +def _summary_root_frame(route: str, present: Dict[str, Any]) -> Dict[str, Any]: + if route == "career": + return { + "promise_drivers": [key for key in ("a10_karma_pada", "amatyakaraka", "karakamsha") if present.get(key)], + } + if route == "relationship": + return { + "promise_drivers": [key for key in ("upapada_lagna", "darakaraka", "vivah_saham") if present.get(key)], + } + if route == "finance": + return { + "promise_drivers": [key for key in ("wealth_promise_strength", "d2_hora", "ashtakavarga_house_scores") if present.get(key)], + } + return {} + + +def _summary_divisional_frame(route: str, present: Dict[str, Any]) -> Dict[str, Any]: + if route == "career": + return {"d10_dasamsa": present.get("d10_dasamsa")} + if route == "relationship": + return {"d9_navamsa": present.get("d9_navamsa")} + if route == "finance": + return { + "d2_hora": present.get("d2_hora"), + "d10_dasamsa": present.get("d10_dasamsa"), + } + return {} + + +def _summary_visibility_frame(route: str, present: Dict[str, Any]) -> Dict[str, Any]: + if route == "career": + return {"a10_karma_pada": present.get("a10_karma_pada")} + if route == "relationship": + return {"upapada_lagna": present.get("upapada_lagna")} + if route == "finance": + return {"wealth_promise_strength": present.get("wealth_promise_strength")} + return {} + + +def _summary_karaka_frame(route: str, present: Dict[str, Any]) -> Dict[str, Any]: + if route == "career": + return { + "amatyakaraka": present.get("amatyakaraka"), + "karakamsha": present.get("karakamsha"), + } + if route == "relationship": + return { + "darakaraka": present.get("darakaraka"), + } + return {} + + +def _summary_timing_frame(route: str, present: Dict[str, Any]) -> Dict[str, Any]: + frame = { + "vimshottari_current": present.get("vimshottari_current"), + "narayana_current": present.get("narayana_current"), + } + if route == "career": + frame["domain_convergence"] = present.get("career_convergence") + elif route == "relationship": + frame["domain_convergence"] = present.get("marriage_convergence") + elif route == "finance": + frame["domain_convergence"] = { + "wealth_convergence": present.get("wealth_convergence"), + "gains_convergence": present.get("gains_convergence"), + "career_convergence": present.get("career_convergence"), + } + if present.get("external_activation"): + frame["external_activation"] = present.get("external_activation") + return frame + + +def _summary_modifier_frame(route: str, present: Dict[str, Any]) -> Dict[str, Any]: + frame: Dict[str, Any] = { + "functional_benefic_malefic": present.get("functional_benefic_malefic"), + "shadbala_component_audit": present.get("shadbala_component_audit"), + "argala_support": present.get("argala_support"), + } + if route == "career": + frame["kakshya_career_support"] = present.get("kakshya_career_support") + elif route == "relationship": + frame["manifestation_split"] = { + "role": "modifier_only", + "signals": [ + "relationship_formation", + "legal_marriage", + "public_formalization", + ], + } + frame["synastry_relationship_support"] = present.get("synastry_relationship_support") + frame["dignity_guardrail"] = present.get("dignity_guardrail") + elif route == "finance": + frame["ashtakavarga_finance_support"] = present.get("ashtakavarga_finance_support") + frame["pav_finance_support"] = present.get("pav_finance_support") + frame["sodhita_finance_support"] = present.get("sodhita_finance_support") + frame["kakshya_finance_support"] = present.get("kakshya_finance_support") + frame["yogi_support"] = { + "role": "modifier_only", + "value": present.get("wealth_promise_strength"), + } + frame["dignity_guardrail"] = present.get("dignity_guardrail") + return frame + + +def _route_varga_gate_keys(route: str) -> List[str]: + if route == "career": + return ["d10_dasamsa", "a10_karma_pada", "amatyakaraka", "karakamsha"] + if route == "relationship": + return ["d9_navamsa", "upapada_lagna", "darakaraka", "vivah_saham"] + if route == "finance": + return ["d2_hora", "d10_dasamsa", "shadbala", "ashtakavarga_house_scores"] + return [] + + +def _build_technique_audit_summary(route: str, strict: Dict[str, Any]) -> Dict[str, Any]: + present = strict.get("present_evidence") if isinstance(strict, dict) else {} + official = strict.get("official_primary_evidence") if isinstance(strict, dict) else {} + local = strict.get("local_supplemental_evidence") if isinstance(strict, dict) else {} + fallback_used = strict.get("fallback_used") if isinstance(strict, dict) else [] + blocked_items = strict.get("blocked_items") if isinstance(strict, dict) else [] + conflicts = strict.get("conflicts") if isinstance(strict, dict) else [] + if not isinstance(present, dict): + present = {} + if not isinstance(official, dict): + official = {} + if not isinstance(local, dict): + local = {} + if not isinstance(fallback_used, list): + fallback_used = [] + if not isinstance(blocked_items, list): + blocked_items = [] + if not isinstance(conflicts, list): + conflicts = [] + + varga_keys = _route_varga_gate_keys(route) + functional_layer = present.get("functional_benefic_malefic") + return { + "functional_benefic_malefic": { + "gate": "hard", + "used": bool(isinstance(functional_layer, dict) and functional_layer.get("status") == "used"), + "status": functional_layer.get("status") if isinstance(functional_layer, dict) else "blocked", + "note": ( + functional_layer.get("effect_on_confidence") + if isinstance(functional_layer, dict) + else "Functional benefic/malefic layer unavailable." + ), + }, + "relevant_vargas": { + "gate": "hard", + "required_keys": varga_keys, + "present_keys": [key for key in varga_keys if present.get(key)], + }, + "vimshottari_narayana_crosscheck": { + "gate": "hard", + "used": bool(present.get("vimshottari_current")) and bool(present.get("narayana_current")), + "required_timing_systems": ["Vimshottari", "Narayana"], + }, + "source_priority_boundary": { + "gate": "boundary", + "official": official, + "local": local, + "fallback_used": fallback_used, + "blocked_items": blocked_items, + "conflicts": conflicts, + }, + } + + +def _build_adjudication_stages(route: str, present: Dict[str, Any], event_judgement: Dict[str, Any]) -> Dict[str, Any]: + dominant_label = event_judgement.get("dominant_label") + manifestation_drivers = list(event_judgement.get("secondary_context") or []) + if route == "finance": + manifestation_bridge_modifiers = [ + key + for key in ( + "ashtakavarga_finance_support", + "pav_finance_support", + "sodhita_finance_support", + "kakshya_finance_support", + ) + if present.get(key) + ] + elif route == "relationship": + manifestation_bridge_modifiers = [ + key + for key in ("synastry_relationship_support", "jaimini_marriage_support", "argala_support") + if present.get(key) + ] + else: + manifestation_bridge_modifiers = [ + key for key in ("argala_support", "kakshya_career_support", "external_activation") if present.get(key) + ] + return { + "promise": { + "status": "present" if _has_promise_evidence(route, present) else "weak", + "drivers": _promise_drivers(route, present), + }, + "activation": { + "status": "present" if _has_activation_evidence(route, present) else "weak", + "required_timing_systems": ["Vimshottari", "Narayana"], + "drivers": _activation_drivers(route, present), + }, + "manifestation": { + "status": "present" if dominant_label else "weak", + "drivers": manifestation_drivers, + "bridge_modifiers": manifestation_bridge_modifiers, + }, + "label": { + "status": "present" if dominant_label else "missing", + "value": dominant_label, + "verdict": event_judgement.get("verdict"), + }, + } + + +def _build_multi_reference_reading_summary(route: str, present: Dict[str, Any], strict: Dict[str, Any]) -> Dict[str, Any]: + return { + "root_frame": _summary_root_frame(route, present), + "divisional_frame": _summary_divisional_frame(route, present), + "visibility_frame": _summary_visibility_frame(route, present), + "karaka_frame": _summary_karaka_frame(route, present), + "timing_frame": _summary_timing_frame(route, present), + "modifier_frame": _summary_modifier_frame(route, present), + "audit_gate_frame": strict.get("technique_audit_summary") or {}, + "conflict_frame": { + "conflicts": strict.get("conflicts") or [], + "confidence_cap": strict.get("confidence_cap"), + }, + } + + +def _attach_top_reader_contract(route: str, strict: Dict[str, Any]) -> Dict[str, Any]: + present = strict.get("present_evidence") if isinstance(strict, dict) else {} + event_judgement = strict.get("event_judgement") if isinstance(strict, dict) else {} + if not isinstance(present, dict): + present = {} + if not isinstance(event_judgement, dict): + event_judgement = {} + strict["technique_audit_summary"] = _build_technique_audit_summary(route, strict) + strict["adjudication_stages"] = _build_adjudication_stages(route, present, event_judgement) + strict["multi_reference_reading_summary"] = _build_multi_reference_reading_summary(route, present, strict) + strict["official_day_signal_summary"] = _build_official_day_signal_summary(present.get("external_activation")) + strict["monthly_adjudication_summary"] = _build_monthly_adjudication_summary(route, strict) + strict["verdict"] = event_judgement.get("verdict") + strict["dominant_label"] = event_judgement.get("dominant_label") + strict["main_conflicts"] = strict.get("conflicts") or [] + return strict + + def _build_life_event_graph(route: str, strict: Dict[str, Any]) -> Dict[str, Any]: event_judgement = strict.get("event_judgement") if isinstance(strict, dict) else {} present = strict.get("present_evidence") if isinstance(strict, dict) else {} @@ -1541,6 +2437,23 @@ def _build_life_event_graph(route: str, strict: Dict[str, Any]) -> Dict[str, Any "source": event.get("source") or external_activation.get("source"), } ) + for window in external_activation.get("daily_windows") or []: + if not isinstance(window, dict): + continue + nodes.append( + { + "kind": "official_day_window", + "date": window.get("date"), + "domain": window.get("domain"), + "score": window.get("score"), + "confidence": window.get("confidence"), + "event_count": window.get("event_count"), + "top_signal_label": window.get("top_signal_label"), + "signal_families": window.get("signal_families") or [], + "event_ids": window.get("event_ids") or [], + "source": external_activation.get("source"), + } + ) for label in event_judgement.get("secondary_context") or []: if not isinstance(label, str): @@ -1607,14 +2520,18 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An present["external_technique_evidence"] = _derive_external_technique_evidence(modules, "career") present["vedastro_official_snapshot"] = official_snapshot_evidence(modules) present["source_priority"] = modules.get("source_priority") if isinstance(modules.get("source_priority"), dict) else {} + present["chart"] = _safe_get(modules, "chart") + present["dignity_guardrail"] = _derive_dignity_guardrail(route, present) present["functional_benefic_malefic"] = _derive_functional_benefic_malefic(modules) missing = [key for key, value in present.items() if key not in { - "external_activation", "external_technique_evidence", "vedastro_official_snapshot", "source_priority", "argala_support", "shadbala", "shadbala_component_audit", "kakshya_career_support", "functional_benefic_malefic" + "chart", "external_activation", "external_technique_evidence", "vedastro_official_snapshot", "source_priority", "dignity_guardrail", "argala_support", "shadbala", "shadbala_component_audit", "kakshya_career_support", "functional_benefic_malefic" } and value in (None, {}, [], "")] convergence = present["career_convergence"] or {} confidence_cap = "medium" if missing: confidence_cap = "low" + elif present["dignity_guardrail"].get("status") == "conflict": + confidence_cap = "low" elif (present.get("shadbala_component_audit") or {}).get("status") in {"blocked", "incomplete"}: confidence_cap = "low" elif convergence.get("convergence_level") in {"L4", "L5"}: @@ -1637,6 +2554,15 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An "plus dual dasha and career convergence support." ), } + strict["official_primary_evidence"] = _build_official_primary_evidence(route, present) + strict["local_supplemental_evidence"] = _build_local_supplemental_evidence(route, present) + strict["fallback_used"], strict["blocked_items"] = _build_fallback_and_blocked( + route, + present, + missing, + strict["official_primary_evidence"], + ) + strict["conflicts"] = _build_conflicts(route, present, strict["official_primary_evidence"]) audit = ( _official_snapshot_audit(present.get("vedastro_official_snapshot")) + _external_activation_audit(present.get("external_activation")) @@ -1644,6 +2570,7 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An ) if audit: strict["technique_audit"] = audit + strict = _attach_top_reader_contract(route, strict) return _with_life_event_graph(route, strict) if route == "relationship": @@ -1711,6 +2638,15 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An "and convergence support; missing links cap confidence." ), } + strict["official_primary_evidence"] = _build_official_primary_evidence(route, present) + strict["local_supplemental_evidence"] = _build_local_supplemental_evidence(route, present) + strict["fallback_used"], strict["blocked_items"] = _build_fallback_and_blocked( + route, + present, + missing, + strict["official_primary_evidence"], + ) + strict["conflicts"] = _build_conflicts(route, present, strict["official_primary_evidence"]) audit = ( _official_snapshot_audit(present.get("vedastro_official_snapshot")) + _external_activation_audit(present.get("external_activation")) @@ -1718,6 +2654,7 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An ) if audit: strict["technique_audit"] = audit + strict = _attach_top_reader_contract(route, strict) return _with_life_event_graph(route, strict) if route == "finance": @@ -1803,6 +2740,15 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An "plus at least one wealth-related convergence domain." ), } + strict["official_primary_evidence"] = _build_official_primary_evidence(route, present) + strict["local_supplemental_evidence"] = _build_local_supplemental_evidence(route, present) + strict["fallback_used"], strict["blocked_items"] = _build_fallback_and_blocked( + route, + present, + missing, + strict["official_primary_evidence"], + ) + strict["conflicts"] = _build_conflicts(route, present, strict["official_primary_evidence"]) audit = ( _official_snapshot_audit(present.get("vedastro_official_snapshot")) + _external_activation_audit(present.get("external_activation")) @@ -1810,9 +2756,10 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An ) if audit: strict["technique_audit"] = audit + strict = _attach_top_reader_contract(route, strict) return _with_life_event_graph(route, strict) - return _with_life_event_graph(route, { + strict = { "question_type": route, "required_evidence": [], "present_evidence": {}, @@ -1821,7 +2768,9 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An "blocked": False, "event_judgement": _derive_event_judgement(route, {}, []), "reason": "Route-specific strict evidence audit is currently implemented for relationship and finance timing.", - }) + } + strict = _attach_top_reader_contract(route, strict) + return _with_life_event_graph(route, strict) def _default_vedastro_scan_window(transit_date: str) -> tuple[str, str]: @@ -2387,35 +3336,30 @@ def strict_workflow( Returns: JSON with routed analysis and confidence level """ - q = question.lower() - if any(k in q for k in ("career", "job", "work", "promotion", "business", "profession", "事业", "工作", "升职", "生意")): - route = "career" - focus_techniques = ["D10", "Dasha", "Shadbala", "Transit", "Narayana Dasha"] - elif any(k in q for k in ("marriage", "married", "wedding", "relationship", "love", "spouse", "partner", "divorce", "婚恋", "婚姻", "感情", "配偶", "恋爱", "结婚")): - route = "relationship" - focus_techniques = ["D9", "UL Upapada", "Dasha", "Nakshatra", "Vivah Saham"] - elif any(k in q for k in ("money", "wealth", "finance", "investment", "property", "income", "财务", "财富", "投资", "房产", "收入")): - route = "finance" - focus_techniques = ["D2", "D11", "Dasha", "Shadbala", "Ashtakavarga"] - elif any(k in q for k in ("when", "timing", "event", "prediction", "future", "应期", "预测", "何时", "将来")): - route = "timing" - focus_techniques = ["Dasha", "Transit", "Double Transit", "Gochara"] - else: - route = "general" - focus_techniques = ["D1", "D9", "Dasha", "Yoga", "Shadbala", "Ashtakavarga"] + route_packet = _UNIFIED_CONSULTATION_ORCHESTRATOR.resolve_route(question, None) + normalized_themes = _UNIFIED_CONSULTATION_ORCHESTRATOR.normalize_themes(route_packet["primary_theme"]) - result = _run_engine("full-reading", { - "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, - "lat": lat, "lon": lon, "tz": tz, - "age": age, "transit_date": transit_date, - "node_mode": node_mode, - }) - - if isinstance(result, dict) and "error" not in result: - result = _maybe_attach_vedastro_evidence( + result = _execute_mcp_consultation_workflow( + question=question, + year=year, + month=month, + day=day, + hour=hour, + minute=minute, + lat=lat, + lon=lon, + tz=tz, + transit_date=transit_date, + node_mode=node_mode, + entry_mode="direct_chart", + theme=normalized_themes, + ) + chart = result.get("chart") if isinstance(result, dict) else {} + route = _safe_get(result, "routing", "question_type") or route_packet["question_type"] or "general" + if isinstance(chart, dict) and "error" not in chart: + chart = _maybe_attach_vedastro_evidence( route, - result, + chart, year=year, month=month, day=day, @@ -2427,17 +3371,8 @@ def strict_workflow( transit_date=transit_date, node_mode=node_mode, ) - strict_evidence = _collect_strict_evidence(route, result) - result["routing"] = { - "question_type": route, - "focus_techniques": focus_techniques, - "note": ( - f"Routed to '{route}' path. Focus on the listed techniques " - f"for higher-confidence answers. Full reading included for context, " - f"and strict evidence audit now reports confidence cap and missing links." - ), - } - result["strict_workflow"] = strict_evidence + result["chart"] = chart + result["strict_workflow"] = _collect_strict_evidence(route, chart) return result diff --git a/progress.md b/progress.md index 20ff2234..d0f939f3 100644 --- a/progress.md +++ b/progress.md @@ -1,5 +1,176 @@ # 印度占星产品化进度日志 +## 2026-06-30 + +- 完成 VedAstro 官方事件层的“日窗口优先”第一轮落地:`scripts/vedastro_service_adapter.py` 现会从 allowlisted `evidence_ledger` 派生 `daily_windows` 与 `top_daily_window`,不再只停留在原始事件行。 +- `scripts/vedastro_evidence_orchestrator.py` 已透传: + - `daily_windows_by_domain` + - `top_daily_window_by_domain` +- `mcp_server.py` 的 strict workflow `present_evidence.external_activation` 已可直接消费: + - `daily_windows` + - `top_daily_window` +- `life_event_graph_v1` 已新增 `official_day_window` 节点,前端/问答后续不必重解析原始事件就能看见官方日窗口。 +- `scripts/jyotish_engine.py` 的 full-reading overview 汇总层已补齐: + - `daily_windows` + - `top_daily_window` + - `daily_windows_by_domain` + - `top_daily_window_by_domain` +- 新增/更新的 focused verification: + - `python3 -m pytest tests/test_vedastro_range_scan_replay.py::test_range_scan_builds_ranked_daily_windows_from_same_day_events -q` + - `python3 -m pytest tests/test_vedastro_evidence_orchestrator.py::test_vedastro_orchestrator_surfaces_daily_windows_by_domain -q` + - `python3 -m pytest tests/test_mcp_strict_workflow_relationship.py::test_relationship_external_activation_exposes_top_daily_window -q` + - `python3 -m pytest tests/test_life_event_graph_v1.py::test_life_event_graph_surfaces_ranked_official_day_window_nodes -q` + - `python3 -m pytest tests/test_cli_smoke.py::test_full_reading_preserves_official_daily_window_fields_in_range_scan_result -q` + - 组合回归:以上 5 个测试同跑通过 +- 真实边界: + - 这轮完成的是“官方事件 -> 日窗口派生 -> strict workflow/graph/full-reading透传”。 + - 还没完成的是把 day-window 再继续升级成“高质量事业/婚恋/财富日级裁决器”;目前窗口质量仍依赖 `SearchEvents` 实际返回与 allowlist/tag/alias 覆盖质量。 + +- 完成 guided topic -> AI chat 上下文链路补强:`jyotish-app/main.js` 点击 guided topic 时会把整条 topic 作为 `guided_topic_context` 传给 `openAIChatWithPrompt()`,`jyotish-app/ai-chat.js` 会把这层上下文继续拼进 `chart_context`,并在后端 `/api/chat` 请求体中透传 `guided_topic_context`。 +- 这意味着 guided topic 后续追问不再只带自然语言问题,还会继续携带: + - `strict_audit_gate.functional_benefic_malefic` + - `strict_audit_gate.relevant_vargas` + - `strict_audit_gate.vimshottari_narayana_crosscheck` + - `strict_audit_gate.source_priority_boundary` +- 新增/更新的 focused verification: + - `python3 -m pytest tests/test_frontend_productization.py::test_guided_topic_questions_reuse_ai_chat_entry -q` + - `python3 -m pytest tests/test_frontend_productization.py::test_complete_reading_surfaces_guided_topic_discovery tests/test_frontend_productization.py::test_guided_topic_questions_reuse_ai_chat_entry -q` +- 真实边界: + - 这轮完成的是 guided topic 点击后的上下文透传,不是服务端 `/api/chat` 自己再基于 `guided_topic_context` 做专门的二次路由。 + +- 完成 `guided_topics` 逐条结论压入 compact audit gate:`scripts/guided_topic_discovery.py` 现会从现有 strict contract 读取 `technique_audit_summary`,并把它作为 `strict_audit_gate` 挂到每一条 guided topic 上。 +- 这层 `strict_audit_gate` 目前已覆盖: + - `functional_benefic_malefic` + - `relevant_vargas` + - `vimshottari_narayana_crosscheck` + - `source_priority_boundary` +- 前端 `jyotish-app/main.js` 的 guided topic 卡片现已消费 `strict_audit_gate`,会在“继续深入”卡片上显示: + - functional gate + - varga gate + - dual dasha gate +- 新增/更新的 focused verification: + - `python3 -m pytest tests/test_cli_smoke.py::test_full_reading_generates_guided_topics_from_real_evidence -q` + - `python3 -m pytest tests/test_frontend_productization.py::test_complete_reading_surfaces_guided_topic_discovery -q` +- 真实边界: + - 这轮是把 compact audit gate 压进 guided topics 逐条对象与前端卡片。 + - 还没做的是把同层 gate 再继续压进 guided topic 触发的后续问答 payload,让每次点击追问时也自动携带这层 compact boundary。 + +- 完成 `Technique Audit Table -> strict adjudication` 设计文档落库:`docs/superpowers/specs/2026-06-30-technique-audit-strict-adjudication-design.md`,明确现成审计表不再只是平行证据,而是默认事业/婚恋/财富结论的强制引用门槛。 +- 完成对应实现计划落库:`docs/superpowers/plans/2026-06-30-technique-audit-strict-adjudication.md`,约束为复用现有 strict workflow、prompt pack、API summary 和前端消费层,不新增第二套审计系统。 +- `mcp_server.py` 已新增 compact strict audit gate: + - `_route_varga_gate_keys` + - `_build_technique_audit_summary` + - `strict["technique_audit_summary"]` + - `multi_reference_reading_summary["audit_gate_frame"]` +- compact strict audit gate 现强制覆盖 4 类默认裁决依据: + - `Functional Benefic/Malefic` + - `D1 + 对应分盘门槛` + - `Vimshottari + Narayana` + - `official / local / fallback / blocked / conflicts` +- `scripts/jyotish_engine.py` 已把 `technique_audit_summary` 透传进 compact strict contract,并把以下字段直接抬到 `ai_prompt_pack.evidence_snapshot` 顶层,减少 skill / Codex / 网页端消费路径复杂度: + - `strict_workflow_primary_route` + - `strict_workflow_routes_available` + - `strict_workflow_contracts` + - `official_primary_evidence` + - `local_supplemental_evidence` + - `fallback_used` + - `blocked_items` + - `conflicts` +- `scripts/jyotish_api_server.py::_high_rigor_vedastro_official_summary()` 现已透传 `technique_audit_summary`,高严谨摘要与 consultation 工作流不再只给 top-reader skeleton,也会同步给出 compact audit gate。 +- 前端 `jyotish-app/main.js` 与 `jyotish-app/ai-chat.js` 已消费 `technique_audit_summary`: + - Prompt Pack 合同卡会显示 functional gate / varga gate / dual dasha gate + - AI Chat `【Top Reader Contract】` 上下文会显式写出 `technique_audit_summary` 的关键门槛状态 +- 新增/更新的红绿测试: + - `tests/test_mcp_strict_workflow_career.py::test_career_strict_contract_exposes_compact_technique_audit_summary` + - `tests/test_mcp_strict_workflow_relationship.py::test_relationship_multi_reference_summary_carries_audit_gate_frame` + - `tests/test_mcp_strict_workflow_finance.py::test_finance_strict_contract_compact_audit_marks_dual_dasha_gate` + - `tests/test_cli_smoke.py::test_full_reading_prompt_pack_carries_compact_technique_audit_summary` + - `tests/test_api_server_security.py::test_high_rigor_vedastro_official_summary_exposes_top_reader_contract_from_full_snapshot` + - `tests/test_api_server_security.py::test_consultation_workflow_surfaces_top_reader_contract_in_official_summary` + - `tests/test_frontend_productization.py::test_frontend_consumes_top_reader_contract_in_prompt_pack_and_ai_chat` +- 已确认通过的 focused verification: + - `python3 -m pytest tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_finance.py -k "compact_technique_audit_summary or audit_gate_frame or dual_dasha_gate" -q` + - `python3 -m pytest tests/test_cli_smoke.py -k "compact_technique_audit_summary" -q` + - `python3 -m pytest tests/test_api_server_security.py::test_high_rigor_vedastro_official_summary_exposes_top_reader_contract_from_full_snapshot tests/test_api_server_security.py::test_consultation_workflow_surfaces_top_reader_contract_in_official_summary tests/test_frontend_productization.py::test_frontend_consumes_top_reader_contract_in_prompt_pack_and_ai_chat -q` +- 真实边界: + - 这轮完成的是“现成审计表正式并入默认 strict adjudication 主裁决”。 + - 还没完成的是把这份 compact audit gate 进一步压进 guided topic 级逐条结论对象,让每条主题建议也自带同层门槛摘要。 + +- 完成 `chart async + unified stage contract` 设计文档落库:`docs/superpowers/specs/2026-06-30-chart-async-unified-stage-contract-design.md`,明确普通 `/api/chart` 也可选走轻量 `job_id + poll`,以及 `full-reading` 在现有阶段耗时之上补一层统一 stage contract。 +- 完成对应实现计划落库:`docs/superpowers/plans/2026-06-30-chart-async-unified-stage-contract.md`,约束为复用现有 high-rigor 文件队列、不新增 Redis/Celery、不改变同步默认行为。 +- `scripts/jyotish_engine.py` 已新增 `_build_unified_stage_contract(stage_timings)`,并把以下字段压入 `full-reading.summary`: + - `stage_contract_version` + - `stage_groups` + - `cache_recommendations` + - `async_recommendations` +- `scripts/jyotish_api_server.py` 已抽出共享异步作业层: + - `_async_job_dir` + - `_async_job_path` + - `_load_async_job_record` + - `_write_async_job_record` + - `ChartAPIHandler._enqueue_async_job` +- `scripts/jyotish_api_server.py` 已为普通排盘主链补上可选异步出口: + - `POST /api/chart` 支持 `async=true` / `enqueue=true` + - 新增 `/api/chart/jobs/{job_id}` poll + - 同步 `_compute_chart_sync` 仍保持原行为,完成态异步结果与同步 chart payload 对齐 +- 为兼容既有 monkeypatch 测试与主链,high-rigor async wrapper 保持原接口: + - `_enqueue_high_rigor_job` + - `_get_high_rigor_job` + - `_load_high_rigor_job_record` + - `_write_high_rigor_job_record` +- 新增/更新的红绿测试: + - `tests/test_cli_smoke.py::test_full_reading_summary_exposes_unified_stage_groups` + - `tests/test_api_server_security.py::test_chart_async_submit_returns_job_id` + - `tests/test_api_server_security.py::test_chart_job_poll_endpoint_returns_cached_job_payload` + - `tests/test_api_server_security.py::test_chart_async_job_executes_in_background` +- 已确认通过的 focused verification: + - `python3 -m pytest tests/test_cli_smoke.py::test_full_reading_summary_exposes_unified_stage_groups -q` + - `python3 -m pytest tests/test_api_server_security.py -k "chart_async_submit_returns_job_id or chart_job_poll_endpoint_returns_cached_job_payload or chart_async_job_executes_in_background" -q` + - `python3 -m pytest tests/test_api_server_security.py -k "high_rigor_async_submit_returns_job_id or high_rigor_job_poll_endpoint_returns_cached_job_payload or high_rigor_async_job_executes_in_background" -q` + - `python3 -m pytest tests/test_api_server_security.py -k "chart_async or high_rigor_async or chart_job_poll_endpoint_returns_cached_job_payload or runtime_cache" -q` +- 真实边界: + - 这轮完成的是普通 chart / full-reading 的统一异步出口和阶段契约压实。 + - 还没完成的是把这层继续完整前推到前端极简交互与更大一圈慢回归基线。 + +- 完成 `VedAstro official -> local supplemental -> local fallback` 设计文档落库:`docs/superpowers/specs/2026-06-30-vedastro-official-hard-override-design.md`,明确婚恋/事业/财富三条默认工作流的官方优先、冲突暴露、`blocked` 和 `confidence_cap` 规则。 +- 完成对应实现计划落库:`docs/superpowers/plans/2026-06-30-vedastro-official-hard-override.md`,按 TDD 拆成 strict contract、orchestrator metadata、API/report 出口和验证四段。 +- `mcp_server.py` 已为 `relationship / career / finance` 三条 strict workflow 增加共享 contract: + - `official_primary_evidence` + - `local_supplemental_evidence` + - `fallback_used` + - `blocked_items` + - `conflicts` +- `mcp_server.py` 新增共享 helper,避免三条主题各自偷偷拼 contract: + - `_build_official_primary_evidence` + - `_build_local_supplemental_evidence` + - `_build_fallback_and_blocked` + - `_build_conflicts` +- `scripts/historical_event_backtest.py` 已透传 strict contract 的 `blocked_items` 与 `conflicts`,历史回测链不再只看到 `source_priority_mode/confidence_cap`。 +- `scripts/vedastro_evidence_orchestrator.py` 已把 `official_section_statuses` 与 `theme_requirements` 推入 `source_metadata`,为后续官方硬覆盖裁决提供统一 metadata。 +- `scripts/jyotish_api_server.py` 的 `high_rigor_workflow_plan_only` 已改为返回 `return_official_primary_supplemental_fallback_conflict_contract`,并在 plan-only 输出中显式声明这套 contract。 +- `scripts/jyotish_api_server.py::_high_rigor_vedastro_official_summary` 已透传: + - `official_primary_evidence` + - `local_supplemental_evidence` + - `fallback_used` + - `blocked_items` + - `conflicts` +- `scripts/jyotish_engine.py::_build_vedastro_official_full_snapshot_payload` 已开始把 relationship strict contract 折叠进 `ai_prompt_pack.evidence_snapshot.vedastro_official_full_snapshot`,让 prompt/网页/AI 上下文看到官方主证据、补充、回退和冲突边界,而不只看到快照状态。 +- 新增/更新的红绿测试: + - `tests/test_mcp_strict_workflow_relationship.py` + - `tests/test_mcp_strict_workflow_career.py` + - `tests/test_mcp_strict_workflow_finance.py` + - `tests/test_historical_event_backtest.py` + - `tests/test_vedastro_evidence_orchestrator.py` + - `tests/test_api_server_security.py` + - `tests/test_cli_smoke.py` +- 已确认通过的 focused verification: + - `python3 -m pytest tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_finance.py tests/test_historical_event_backtest.py -k "strict_contract or conflicts_and_blocked_items" -q` + - `python3 -m pytest tests/test_vedastro_evidence_orchestrator.py -k official_section_statuses -q` + - `python3 -m pytest tests/test_api_server_security.py -k "hard_override_contract or vedastro_official_summary_passes_through_contract_fields" -q` +- 真实边界: + - 这轮已把“官方硬覆盖 contract”压实到 strict workflow、historical backtest、orchestrator metadata 和高严谨 API summary。 + - 还未完成的收尾是把同一 contract 更完整地消费到 full-reading / prompt pack / 前端展示层,并跑完更大一圈的慢回归验证。 + ## 2026-06-22 - 恢复上下文:项目根目录此前没有 `task_plan.md`、`findings.md`、`progress.md`。 @@ -492,3 +663,48 @@ - VedAstro 面确认:当前已进入可点击用户入口和 adapter/provenance 阶段;官方 MCP/Python bridge 有真实可达记录,REST range scan 的官方 endpoint smoke 仍依赖 `VEDASTRO_API_ENDPOINT` 与 `VEDASTRO_ENABLE_NETWORK=1`。 - 质量门确认:`python3 scripts/run_quality_gate.py --profile quick --skip-frontend-runtime` 通过,聚焦集合 `297 passed`;`env -u VEDASTRO_API_ENDPOINT -u VEDASTRO_ENABLE_NETWORK python3 scripts/run_quality_gate.py --profile vedastro-live` 通过但返回受控 `blocked`,不得宣称官方实网 VedAstro range scan 已闭环。 - 当前最值钱下一步:继续推进 VedAstro range scan 的官方样本调参和 EventTagList 映射质量,同时补 Tajika/Sahams 4 个未闭合外部 oracle 任务;之后再处理 Vimsopaka 高阶语义映射、Life Event Graph v1 产品化和报告渲染 polish。 + +## 2026-06-29T23:57:36+08:00 - 高严谨默认入口复用接线 + +- 按用户要求先做地毯式扫描:读取既有计划/发现/进度、运行 `preflight_fragment_scan.py`、`audit_fragments.py --strict`、`audit_capabilities.py --mode validate`,确认主仓已有校时、历史事件回测、主题推运、VedAstro 官方证据层,不需要重写算法。 +- 新增 `/api/high_rigor_workflow`:复用 `/api/chart` 的 VedAstro official-first 主入口、`/api/rectification_gate`、`scripts/historical_event_backtest.py`、`/api/thematic_report`,输出 source priority、reused modules、VedAstro catalog summary、rectification、historical backtest、thematic report。 +- 为 API Explorer 增加 `dry_run` plan-only 样例,避免目录页意外触发重型 VedAstro/full-reading 链路;真实用户请求不带 `dry_run` 才执行完整高严谨链路。 +- 将 `high-rigor-workflow` 挂到已有 `thematic_report_orchestrator` 注册表命令上,能力总数保持 89,不新增伪技能。 +- 验证:新增高严谨 workflow 合同测试通过;Technique Catalog 入口测试通过;相关 4 项 API 回归通过;`audit_capabilities.py --mode validate` 仍显示 `technique_count=89`、`10 complete / 79 covered / 0 missing`;`py_compile` 通过。 + +## 2026-06-30T00:34:00+08:00 - VedAstro 641 项轻量映射表 + +- 在 `scripts/vedastro_official_capability_runner.py` 给 `official_full_capability_catalog` 增加轻量路由元数据:每个官方方法返回 `domains`、`execution_policy`、`priority`,总报告返回 `domain_routing`。 +- 映射策略按方法名、签名、bucket 和参数名做保守分类:`career / marriage / wealth / rectification / timing / general`;`MatchReport` 等需要伴侣资料的方法保留为 `needs_user_context`,不进入自动高优先级列表。 +- 修正 `Dashamamsha` 字符串误判:D10 分盘类能力不再因为包含 `dasha` 字符串被归入 timing;真实 641 轻扫显示 `AllPlanetDashamamshaSign` 只归入 `career/marriage/wealth`。 +- 将 `domain_routing` 从 service adapter 透传到 `VedAstroEvidenceOrchestrator`、`jyotish_api_server.py` 和 `jyotish_engine.py` 的官方证据摘要,网页/Skill/MCP 共享入口可以消费同一张路由表。 +- 验证:官方 runner 映射测试、orchestrator 透传测试、full snapshot 透传测试、高严谨 API 摘要测试共 5 项通过;`py_compile` 通过;`audit_capabilities.py --mode validate` 仍为 `technique_count=89`、`10 complete / 79 covered / 0 missing`;`VEDASTRO_FULL_CATALOG_SAMPLE_LIMIT=0` 真实轻扫读到 `catalog_method_count=641`、`domain_routing_count=6`。 + +## 2026-06-30T01:05:00+08:00 - VedAstro 动态选择器与报告引用层 + +- 在 `official_full_capability_catalog` 返回中新增 `dynamic_selection`:按请求主题从 641 项官方能力里选择 Top N,保留 `selected_methods`、`needs_user_context_methods`、`needs_user_text_methods`、`needs_rectification_profile_methods`、`blocked_methods`。 +- 每个被选能力生成稳定引用 ID:`vedastro::`;每个主题生成 `report_reference`,包含 `citation_ids`、自动可用数量、需补资料数量和 blocked 数量。 +- 将 `dynamic_selection` 从 service adapter 透传到 `VedAstroEvidenceOrchestrator`、`jyotish_api_server.py` 和 `jyotish_engine.py`;高严谨入口和 prompt pack 现在能输出 `official_report_references`。 +- 真实 641 轻扫验证:`dynamic_selection_theme_count=5`;示例引用包括 `vedastro:career:EventsAtRange`、`vedastro:marriage:EventsAtRange`、`vedastro:timing:EventsAtRange`、`vedastro:timing:DasaAtRange`;婚恋主题标出 `marriage_needs_context=9`。 +- Fresh verification:6 个聚焦 pytest 通过;`py_compile` 通过;`audit_capabilities.py --mode validate` 仍为 89 项技能、0 problem、0 warning;`git diff --check` 通过。 + +## 2026-06-30T02:30:00+08:00 - Top-reader adjudication contract 通贯 + +- 在 `mcp_server.py` 的 career / relationship / finance strict workflow 上统一接入共享 `adjudication_stages`(promise -> activation -> manifestation -> label)与 `multi_reference_reading_summary`,并把 `verdict`、`dominant_label`、`main_conflicts` 作为轻量可消费合同输出。 +- `scripts/jyotish_engine.py` 的 prompt-pack 压缩层已透传上述合同,因此 `ai_prompt_pack.evidence_snapshot.vedastro_official_full_snapshot.strict_workflow_contracts[*]` 不再只包含官方/本地/blocked 元信息,也能给出顶层裁决骨架。 +- `scripts/jyotish_api_server.py::_high_rigor_vedastro_official_summary()` 现优先从 `vedastro_official_full_snapshot` 读取 strict contract,并把 `strict_workflow_primary_route`、`strict_workflow_contracts`、`adjudication_stages`、`multi_reference_reading_summary`、`verdict`、`dominant_label`、`main_conflicts` 直接暴露给 consultation/high-rigor 用户层摘要。 +- `scripts/historical_event_backtest.py` 现会把 strict contract 的 `adjudication_stages`、`multi_reference_reading_summary`、`main_conflicts` 继续写进每条事件 evidence,避免回测结果只看 hit/miss 却丢失裁决骨架。 +- 前端 `jyotish-app/main.js` 与 `jyotish-app/ai-chat.js` 已消费同一套合同:Prompt Pack 面板会显示 Top-reader adjudication 摘要与 multi-reference frame keys,AI Chat 上下文会附带 `【Top Reader Contract】` 边界,网页/app 与 skill 对话入口看到的是同一份官方优先证据结构。 +- Focused verification: + - `python3 -m pytest tests/test_mcp_strict_workflow_career.py tests/test_mcp_strict_workflow_relationship.py tests/test_mcp_strict_workflow_finance.py tests/test_cli_smoke.py::test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack tests/test_vedastro_official_full_snapshot.py::test_full_reading_prompt_pack_exposes_vedastro_official_snapshot_boundary tests/test_api_server_security.py tests/test_historical_event_backtest.py tests/test_frontend_productization.py -k "adjudication_stages or multi_reference_reading_summary or top_reader_contract or modifier_frame" -q` -> `9 passed`. +- 完成“官方日窗口 -> 用户可读日期信号”最小翻译层接回默认主链: + - `mcp_server.py` 的 `external_activation` 现会基于 `daily_windows` 派生 `official_day_signals` + - 当前最小标签为: + - 事业:`事业机会日 / 事业风险日 / 事业混合日` + - 婚恋:`婚恋推进日 / 婚恋风险日 / 婚恋混合日` + - 财富:`财富机会日 / 财富风险日 / 财富混合日` + - 这层只复用已有 `signal_families + top_signal_label + confidence + score`,没有新造重推理器 +- strict workflow compact contract 现已保留 `official_day_signal_summary`,因此这层不会只存在于原始 `present_evidence.external_activation` 里。 +- `scripts/guided_topic_discovery.py` 已把 `official_day_signal_summary` 压进每条 guided topic;`jyotish-app/main.js` 的 guided topic 卡片会直接显示最重要的官方日期提示;`jyotish-app/ai-chat.js` 也会把这层塞进 guided topic chat payload,后续追问不再丢失。 +- 新增并跑通的定点回归: + - `python3 -m pytest tests/test_mcp_strict_workflow_career.py::test_career_external_activation_derives_user_readable_day_signals tests/test_mcp_strict_workflow_relationship.py::test_relationship_external_activation_derives_progress_day_signals tests/test_mcp_strict_workflow_finance.py::test_finance_external_activation_derives_wealth_day_signals tests/test_cli_smoke.py::test_full_reading_guided_topics_can_carry_official_day_signal_summary tests/test_frontend_productization.py::test_guided_topic_questions_reuse_ai_chat_entry -q` diff --git a/references/oracle/artifacts/pending_packets/external_template_einstein_varshaphala_1905_lahiri.json b/references/oracle/artifacts/pending_packets/external_template_einstein_varshaphala_1905_lahiri.json index b4e7ee6f..8b70a956 100644 --- a/references/oracle/artifacts/pending_packets/external_template_einstein_varshaphala_1905_lahiri.json +++ b/references/oracle/artifacts/pending_packets/external_template_einstein_varshaphala_1905_lahiri.json @@ -1,6 +1,6 @@ { "capture_id": "external_template_einstein_varshaphala_1905_lahiri", - "status": "draft", + "status": "external_verified", "case_id": "template_einstein_varshaphala_1905_lahiri", "birth": { "year": 1879, @@ -32,28 +32,121 @@ "operator_note" ], "metadata": { - "tool_name": "", - "tool_version_or_url": "", - "capture_date": "", - "source_artifact": "references/oracle/artifacts/", + "tool_name": "PyJHora", + "tool_version_or_url": "PyJHora 4.8.6 isolated workbuddy black-box run", + "capture_date": "2026-06-29", + "source_artifact": "references/oracle/artifacts/pyjhora_einstein_varshaphala_1905_lahiri_partial_20260629.txt", "ayanamsa": "lahiri", "node_mode": "mean", - "timezone": 0.883333, - "annual_system": "varshaphala", + "timezone": "UTC+00:53", + "annual_system": "Varshaphala/Tajika", "target_year": 1905, - "operator_note": "" + "operator_note": "Black-box annual output from workbuddy PyJHora 4.8.6 environment. External evidence only; local annual engine output was not used. Solar-return timestamp, Varsha Lagna, Muntha, Year Lord, first Mudda Dasha lord, selected Sahams, and Tajika Yogas were captured through the external PyJHora path via the annual chart and jhora.horoscope.transit.tajaka_yoga helper chain." }, "target_placeholders": { - "target.solar_return_datetime": null, - "target.varsha_lagna_deg": null, - "target.muntha_sign": null, - "target.year_lord": null, - "target.mudda_dasha_first_lord": null, - "target.sahams.punya_saham": null, - "target.sahams.rajya_saham": null, - "target.sahams.vivah_saham": null, - "target.tajika_yogas": null, - "target.source_artifact": null + "target.solar_return_datetime": "1905-03-15T03:44:51+00:53", + "target.varsha_lagna_deg": 262.4183, + "target.muntha_sign": "Virgo", + "target.year_lord": "Mercury", + "target.mudda_dasha_first_lord": "Rahu", + "target.sahams.punya_saham": 31.2991, + "target.sahams.rajya_saham": 235.5875, + "target.sahams.vivah_saham": 1.9612, + "target.tajika_yogas": { + "eesarpha": [ + [ + 0, + 1 + ], + [ + 0, + 3 + ], + [ + 1, + 3 + ], + [ + 1, + 4 + ], + [ + 1, + 6 + ], + [ + 3, + 6 + ], + [ + 4, + 5 + ], + [ + 4, + 6 + ] + ], + "gairi_kamboola": null, + "ithasala": [ + [ + 0, + 4, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 1, + 5, + 1 + ], + [ + 3, + 4, + 1 + ] + ], + "kamboola": [ + true, + [ + [ + 1, + 5 + ] + ], + [ + [ + 1, + 5 + ] + ] + ], + "khallasara": null, + "manahoo": [ + [ + 0, + 4, + 11 + ], + [ + 0, + 6, + 11 + ], + [ + 3, + 4, + 11 + ] + ], + "nakta": [], + "yamaya": [] + }, + "target.source_artifact": "references/oracle/artifacts/pyjhora_einstein_varshaphala_1905_lahiri_partial_20260629.txt" }, "integrity_checks": { "must_not_come_from_local_engine": true, diff --git a/references/oracle/artifacts/pending_packets/external_template_historical_dst_london_varshaphala_1943_lahiri.json b/references/oracle/artifacts/pending_packets/external_template_historical_dst_london_varshaphala_1943_lahiri.json index 5e00a891..e48fa81d 100644 --- a/references/oracle/artifacts/pending_packets/external_template_historical_dst_london_varshaphala_1943_lahiri.json +++ b/references/oracle/artifacts/pending_packets/external_template_historical_dst_london_varshaphala_1943_lahiri.json @@ -1,6 +1,6 @@ { "capture_id": "external_template_historical_dst_london_varshaphala_1943_lahiri", - "status": "draft", + "status": "external_verified", "case_id": "template_historical_dst_london_varshaphala_1943_lahiri", "birth": { "year": 1910, @@ -32,28 +32,119 @@ "operator_note" ], "metadata": { - "tool_name": "", - "tool_version_or_url": "", - "capture_date": "", - "source_artifact": "references/oracle/artifacts/", + "tool_name": "PyJHora", + "tool_version_or_url": "PyJHora 4.8.6 isolated workbuddy black-box run", + "capture_date": "2026-06-29", + "source_artifact": "references/oracle/artifacts/pyjhora_historical_dst_london_varshaphala_1943_lahiri_20260629.txt", "ayanamsa": "lahiri", - "node_mode": "mean", - "timezone": 0, - "annual_system": "varshaphala", + "node_mode": "PyJHora default", + "timezone": "UTC+00:00", + "annual_system": "Varshaphala/Tajika", "target_year": 1943, - "operator_note": "" + "operator_note": "Black-box annual output from workbuddy PyJHora 4.8.6 environment. External evidence only; local annual engine output was not used. Solar-return timestamp, Varsha Lagna, Muntha, Year Lord, first Mudda Dasha lord, selected Sahams, and Tajika Yogas were captured through the external PyJHora path via the annual chart and jhora.horoscope.transit.tajaka_yoga helper chain. This synthetic row is retained specifically to document the historical timezone/DST boundary." }, "target_placeholders": { - "target.solar_return_datetime": null, - "target.varsha_lagna_deg": null, - "target.muntha_sign": null, - "target.year_lord": null, - "target.mudda_dasha_first_lord": null, - "target.sahams.punya_saham": null, - "target.sahams.rajya_saham": null, - "target.sahams.vivah_saham": null, - "target.tajika_yogas": null, - "target.source_artifact": null + "target.solar_return_datetime": "1943-04-15T22:55:52+00:00", + "target.varsha_lagna_deg": 224.724, + "target.muntha_sign": "♍︎Virgo", + "target.year_lord": "Mars♂", + "target.mudda_dasha_first_lord": "Saturn♄", + "target.sahams.punya_saham": 21.1202, + "target.sahams.rajya_saham": 299.2177, + "target.sahams.vivah_saham": 215.2774, + "target.tajika_yogas": { + "eesarpha": [ + [ + 0, + 1 + ], + [ + 0, + 5 + ], + [ + 1, + 2 + ], + [ + 1, + 5 + ], + [ + 2, + 5 + ], + [ + 4, + 6 + ] + ], + "gairi_kamboola": null, + "ithasala": [ + [ + 0, + 2, + 1 + ], + [ + 1, + 3, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 3, + 6, + 1 + ] + ], + "kamboola": [ + true, + [ + [ + 1, + 3 + ], + [ + 1, + 6 + ] + ], + [ + [ + 1, + 3 + ], + [ + 3, + 6 + ], + [ + 1, + 6 + ], + [ + 3, + 6 + ] + ] + ], + "khallasara": null, + "manahoo": [ + [ + 0, + 2, + 0 + ] + ], + "nakta": [], + "yamaya": [] + }, + "target.source_artifact": "references/oracle/artifacts/pyjhora_historical_dst_london_varshaphala_1943_lahiri_20260629.txt" }, "integrity_checks": { "must_not_come_from_local_engine": true, diff --git a/references/oracle/artifacts/pending_packets/external_template_marilyn_monroe_varshaphala_1962_lahiri.json b/references/oracle/artifacts/pending_packets/external_template_marilyn_monroe_varshaphala_1962_lahiri.json index 0953b5b3..68792d9d 100644 --- a/references/oracle/artifacts/pending_packets/external_template_marilyn_monroe_varshaphala_1962_lahiri.json +++ b/references/oracle/artifacts/pending_packets/external_template_marilyn_monroe_varshaphala_1962_lahiri.json @@ -1,6 +1,6 @@ { "capture_id": "external_template_marilyn_monroe_varshaphala_1962_lahiri", - "status": "draft", + "status": "external_verified", "case_id": "template_marilyn_monroe_varshaphala_1962_lahiri", "birth": { "year": 1926, @@ -32,28 +32,213 @@ "operator_note" ], "metadata": { - "tool_name": "", - "tool_version_or_url": "", - "capture_date": "", - "source_artifact": "references/oracle/artifacts/", + "tool_name": "PyJHora", + "tool_version_or_url": "PyJHora 4.8.6 isolated workbuddy black-box run", + "capture_date": "2026-06-29", + "source_artifact": "references/oracle/artifacts/pyjhora_marilyn_monroe_varshaphala_1962_lahiri_20260629.txt", "ayanamsa": "lahiri", - "node_mode": "true", - "timezone": -8, - "annual_system": "varshaphala", + "node_mode": "PyJHora default", + "timezone": "UTC-08:00", + "annual_system": "Varshaphala/Tajika", "target_year": 1962, - "operator_note": "" + "operator_note": "Black-box annual output from workbuddy PyJHora 4.8.6 environment. External evidence only; local annual engine output was not used. Solar-return timestamp, Varsha Lagna, Muntha, Year Lord, first Mudda Dasha lord, selected Sahams, and Tajika Yogas were captured through the external PyJHora path via the annual chart and jhora.horoscope.transit.tajaka_yoga helper chain." }, "target_placeholders": { - "target.solar_return_datetime": null, - "target.varsha_lagna_deg": null, - "target.muntha_sign": null, - "target.year_lord": null, - "target.mudda_dasha_first_lord": null, - "target.sahams.punya_saham": null, - "target.sahams.rajya_saham": null, - "target.sahams.vivah_saham": null, - "target.tajika_yogas": null, - "target.source_artifact": null + "target.solar_return_datetime": "1962-06-01T14:20:25-08:00", + "target.varsha_lagna_deg": 169.9089, + "target.muntha_sign": "♎︎Libra", + "target.year_lord": "Mercury☿", + "target.mudda_dasha_first_lord": "Raagu☊", + "target.sahams.punya_saham": 161.7196, + "target.sahams.rajya_saham": 50.2812, + "target.sahams.vivah_saham": 350.4075, + "target.tajika_yogas": { + "eesarpha": [ + [ + 0, + 2 + ], + [ + 0, + 3 + ], + [ + 0, + 5 + ], + [ + 3, + 4 + ], + [ + 3, + 6 + ], + [ + 4, + 5 + ] + ], + "gairi_kamboola": null, + "ithasala": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 4, + 2 + ], + [ + 0, + 6, + 2 + ], + [ + 1, + 2, + 2 + ], + [ + 1, + 4, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 2, + 4, + 3 + ], + [ + 2, + 6, + 3 + ], + [ + 4, + 6, + 2 + ] + ], + "kamboola": [ + true, + [ + [ + 1, + 2 + ], + [ + 1, + 4 + ], + [ + 1, + 6 + ] + ], + [ + [ + 1, + 2 + ], + [ + 2, + 4 + ], + [ + 2, + 6 + ], + [ + 0, + 4 + ], + [ + 1, + 4 + ], + [ + 2, + 4 + ], + [ + 4, + 6 + ], + [ + 0, + 6 + ], + [ + 1, + 6 + ], + [ + 2, + 6 + ], + [ + 4, + 6 + ], + [ + 0, + 1 + ], + [ + 0, + 4 + ], + [ + 0, + 6 + ] + ] + ], + "khallasara": null, + "manahoo": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 1, + 4, + 1 + ], + [ + 1, + 6, + 1 + ] + ], + "nakta": [], + "yamaya": [] + }, + "target.source_artifact": "references/oracle/artifacts/pyjhora_marilyn_monroe_varshaphala_1962_lahiri_20260629.txt" }, "integrity_checks": { "must_not_come_from_local_engine": true, diff --git a/references/oracle/artifacts/pending_packets/external_template_synthetic_extreme_latitude_varshaphala_kp.json b/references/oracle/artifacts/pending_packets/external_template_synthetic_extreme_latitude_varshaphala_kp.json index 1e50b479..87194e45 100644 --- a/references/oracle/artifacts/pending_packets/external_template_synthetic_extreme_latitude_varshaphala_kp.json +++ b/references/oracle/artifacts/pending_packets/external_template_synthetic_extreme_latitude_varshaphala_kp.json @@ -1,6 +1,6 @@ { "capture_id": "external_template_synthetic_extreme_latitude_varshaphala_kp", - "status": "draft", + "status": "external_verified", "case_id": "template_synthetic_extreme_latitude_varshaphala_kp", "birth": { "year": 2000, @@ -32,28 +32,124 @@ "operator_note" ], "metadata": { - "tool_name": "", - "tool_version_or_url": "", - "capture_date": "", - "source_artifact": "references/oracle/artifacts/", + "tool_name": "PyJHora", + "tool_version_or_url": "PyJHora 4.8.6 isolated workbuddy black-box run", + "capture_date": "2026-06-29", + "source_artifact": "references/oracle/artifacts/pyjhora_synthetic_extreme_latitude_varshaphala_kp_20260629.txt", "ayanamsa": "kp", - "node_mode": "true", - "timezone": 1, - "annual_system": "varshaphala", + "node_mode": "PyJHora default", + "timezone": "UTC+01:00", + "annual_system": "Varshaphala/Tajika", "target_year": 2024, - "operator_note": "" + "operator_note": "Black-box annual output from workbuddy PyJHora 4.8.6 environment. External evidence only; local annual engine output was not used. Solar-return timestamp, Varsha Lagna, Muntha, Year Lord, first Mudda Dasha lord, selected Sahams, and Tajika Yogas were captured through the external PyJHora path via the annual chart and jhora.horoscope.transit.tajaka_yoga helper chain. KP ayanamsa was explicitly forced to match the template boundary." }, "target_placeholders": { - "target.solar_return_datetime": null, - "target.varsha_lagna_deg": null, - "target.muntha_sign": null, - "target.year_lord": null, - "target.mudda_dasha_first_lord": null, - "target.sahams.punya_saham": null, - "target.sahams.rajya_saham": null, - "target.sahams.vivah_saham": null, - "target.tajika_yogas": null, - "target.source_artifact": null + "target.solar_return_datetime": "2024-06-21T02:48:16+01:00", + "target.varsha_lagna_deg": 80.1445, + "target.muntha_sign": "♋︎Cancer", + "target.year_lord": "Mercury☿", + "target.mudda_dasha_first_lord": "Venus♀", + "target.sahams.punya_saham": 278.0196, + "target.sahams.rajya_saham": 9.3073, + "target.sahams.vivah_saham": 185.4734, + "target.tajika_yogas": { + "eesarpha": [ + [ + 0, + 3 + ], + [ + 0, + 5 + ], + [ + 2, + 4 + ], + [ + 3, + 4 + ], + [ + 3, + 5 + ] + ], + "gairi_kamboola": null, + "ithasala": [ + [ + 0, + 2, + 3 + ], + [ + 0, + 4, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 2, + 3, + 2 + ], + [ + 2, + 5, + 1 + ], + [ + 4, + 5, + 1 + ] + ], + "kamboola": [ + true, + [ + [ + 1, + 6 + ] + ], + [ + [ + 1, + 6 + ] + ] + ], + "khallasara": null, + "manahoo": [ + [ + 0, + 2, + 2 + ], + [ + 0, + 4, + 2 + ], + [ + 2, + 5, + 2 + ], + [ + 4, + 5, + 2 + ] + ], + "nakta": [], + "yamaya": [] + }, + "target.source_artifact": "references/oracle/artifacts/pyjhora_synthetic_extreme_latitude_varshaphala_kp_20260629.txt" }, "integrity_checks": { "must_not_come_from_local_engine": true, diff --git a/references/oracle/artifacts/pyjhora_einstein_varshaphala_1905_lahiri_partial_20260629.txt b/references/oracle/artifacts/pyjhora_einstein_varshaphala_1905_lahiri_partial_20260629.txt new file mode 100644 index 00000000..0bc8d5d6 --- /dev/null +++ b/references/oracle/artifacts/pyjhora_einstein_varshaphala_1905_lahiri_partial_20260629.txt @@ -0,0 +1,8 @@ +CASE_ID template_einstein_varshaphala_1905_lahiri +TOOL PyJHora 4.8.6 +SOURCE_ENV /Users/wuyongnaren/.workbuddy/binaries/python/envs/pyjhora +CAPTURE_DATE 2026-06-29 +NOTE External annual capture from PyJHora black-box environment. Local repository annual engine output was not used. This artifact records solar return datetime, varsha lagna, muntha, year lord, first mudda dasha lord, selected sahams, and the Tajika yoga block extracted from the PyJHora annual chart via jhora.horoscope.transit.tajaka_yoga. + +TAJIKA_ANNUAL_VALUES_JSON {"solar_return_datetime":"1905-03-15T03:44:51+00:53","varsha_lagna_deg":262.4183,"muntha_sign":"Virgo","year_lord":"Mercury","mudda_dasha_first_lord":"Rahu","sahams":{"punya_saham":31.2991,"rajya_saham":235.5875,"vivah_saham":1.9612}} +TAJIKA_YOGAS_JSON {"eesarpha":[[0,1],[0,3],[1,3],[1,4],[1,6],[3,6],[4,5],[4,6]],"gairi_kamboola":null,"ithasala":[[0,4,1],[0,6,1],[1,5,1],[3,4,1]],"kamboola":[true,[[1,5]],[[1,5]]],"khallasara":null,"manahoo":[[0,4,11],[0,6,11],[3,4,11]],"nakta":[],"yamaya":[]} diff --git a/references/oracle/artifacts/pyjhora_historical_dst_london_varshaphala_1943_lahiri_20260629.txt b/references/oracle/artifacts/pyjhora_historical_dst_london_varshaphala_1943_lahiri_20260629.txt new file mode 100644 index 00000000..3adddf3b --- /dev/null +++ b/references/oracle/artifacts/pyjhora_historical_dst_london_varshaphala_1943_lahiri_20260629.txt @@ -0,0 +1,8 @@ +CASE_ID template_historical_dst_london_varshaphala_1943_lahiri +TOOL PyJHora 4.8.6 +SOURCE_ENV /Users/wuyongnaren/.workbuddy/binaries/python/envs/pyjhora +CAPTURE_DATE 2026-06-29 +NOTE External annual capture from PyJHora black-box environment. Local repository annual engine output was not used. This artifact records solar return datetime, varsha lagna, muntha, year lord, first mudda dasha lord, selected sahams, and the Tajika yoga block extracted from the PyJHora annual chart via jhora.horoscope.transit.tajaka_yoga. This synthetic row is retained to document the historical timezone/DST boundary explicitly. + +TAJIKA_ANNUAL_VALUES_JSON {"solar_return_datetime":"1943-04-15T22:55:52+00:00","varsha_lagna_deg":224.724,"muntha_sign":"♍︎Virgo","year_lord":"Mars♂","mudda_dasha_first_lord":"Saturn♄","sahams":{"punya_saham":21.1202,"rajya_saham":299.2177,"vivah_saham":215.2774}} +TAJIKA_YOGAS_JSON {"eesarpha":[[0,1],[0,5],[1,2],[1,5],[2,5],[4,6]],"gairi_kamboola":null,"ithasala":[[0,2,1],[1,3,1],[1,6,1],[3,6,1]],"kamboola":[true,[[1,3],[1,6]],[[1,3],[3,6],[1,6],[3,6]]],"khallasara":null,"manahoo":[[0,2,0]],"nakta":[],"yamaya":[]} diff --git a/references/oracle/artifacts/pyjhora_marilyn_monroe_varshaphala_1962_lahiri_20260629.txt b/references/oracle/artifacts/pyjhora_marilyn_monroe_varshaphala_1962_lahiri_20260629.txt new file mode 100644 index 00000000..5abcbf4f --- /dev/null +++ b/references/oracle/artifacts/pyjhora_marilyn_monroe_varshaphala_1962_lahiri_20260629.txt @@ -0,0 +1,8 @@ +CASE_ID template_marilyn_monroe_varshaphala_1962_lahiri +TOOL PyJHora 4.8.6 +SOURCE_ENV /Users/wuyongnaren/.workbuddy/binaries/python/envs/pyjhora +CAPTURE_DATE 2026-06-29 +NOTE External annual capture from PyJHora black-box environment. Local repository annual engine output was not used. This artifact records solar return datetime, varsha lagna, muntha, year lord, first mudda dasha lord, selected sahams, and the Tajika yoga block extracted from the PyJHora annual chart via jhora.horoscope.transit.tajaka_yoga. + +TAJIKA_ANNUAL_VALUES_JSON {"solar_return_datetime":"1962-06-01T14:20:25-08:00","varsha_lagna_deg":169.9089,"muntha_sign":"♎︎Libra","year_lord":"Mercury☿","mudda_dasha_first_lord":"Raagu☊","sahams":{"punya_saham":161.7196,"rajya_saham":50.2812,"vivah_saham":350.4075}} +TAJIKA_YOGAS_JSON {"eesarpha":[[0,2],[0,3],[0,5],[3,4],[3,6],[4,5]],"gairi_kamboola":null,"ithasala":[[0,1,1],[0,4,2],[0,6,2],[1,2,2],[1,4,1],[1,6,1],[2,4,3],[2,6,3],[4,6,2]],"kamboola":[true,[[1,2],[1,4],[1,6]],[[1,2],[2,4],[2,6],[0,4],[1,4],[2,4],[4,6],[0,6],[1,6],[2,6],[4,6],[0,1],[0,4],[0,6]]],"khallasara":null,"manahoo":[[0,1,1],[0,4,1],[0,6,1],[1,2,1],[1,4,1],[1,6,1]],"nakta":[],"yamaya":[]} diff --git a/references/oracle/artifacts/pyjhora_oracle_artifact_manifest.json b/references/oracle/artifacts/pyjhora_oracle_artifact_manifest.json index 5bf73d39..df8e6c42 100644 --- a/references/oracle/artifacts/pyjhora_oracle_artifact_manifest.json +++ b/references/oracle/artifacts/pyjhora_oracle_artifact_manifest.json @@ -1,15 +1,19 @@ { "scope": "pyjhora_oracle_artifact_manifest", - "generated_at": "2026-06-28T23:44:26.808999+00:00", - "artifact_count": 8, + "generated_at": "2026-07-01T10:31:50.603970+00:00", + "artifact_count": 12, "packet_count": 8, "artifacts": [ + "pyjhora_einstein_varshaphala_1905_lahiri_partial_20260629.txt", "pyjhora_extreme_latitude_kp_shadbala_stdout_20260627.txt", "pyjhora_redacted_place_shadbala_raman_stdout_20260627.txt", + "pyjhora_historical_dst_london_varshaphala_1943_lahiri_20260629.txt", "pyjhora_historical_epoch_dasha_stdout_20260627.txt", + "pyjhora_marilyn_monroe_varshaphala_1962_lahiri_20260629.txt", "pyjhora_steve_jobs_dasha_stdout_20260627.txt", "pyjhora_steve_jobs_shadbala_lahiri_stdout_20260627.txt", "pyjhora_steve_jobs_varshaphala_1984_lahiri_stdout_20260627.txt", + "pyjhora_synthetic_extreme_latitude_varshaphala_kp_20260629.txt", "pyjhora_user_REDACTED_YEAR_dasha_stdout_20260627.txt", "pyjhora_user_REDACTED_YEAR_shadbala_lahiri_stdout_20260627.txt" ], @@ -55,10 +59,14 @@ ] }, "tajika_sahams": { - "artifact_count": 1, + "artifact_count": 5, "packet_count": 1, "artifacts": [ - "pyjhora_steve_jobs_varshaphala_1984_lahiri_stdout_20260627.txt" + "pyjhora_einstein_varshaphala_1905_lahiri_partial_20260629.txt", + "pyjhora_historical_dst_london_varshaphala_1943_lahiri_20260629.txt", + "pyjhora_marilyn_monroe_varshaphala_1962_lahiri_20260629.txt", + "pyjhora_steve_jobs_varshaphala_1984_lahiri_stdout_20260627.txt", + "pyjhora_synthetic_extreme_latitude_varshaphala_kp_20260629.txt" ], "pending_packets": [ "external_template_steve_jobs_varshaphala_1984_lahiri_pyjhora_20260627.json" diff --git a/references/oracle/artifacts/pyjhora_synthetic_extreme_latitude_varshaphala_kp_20260629.txt b/references/oracle/artifacts/pyjhora_synthetic_extreme_latitude_varshaphala_kp_20260629.txt new file mode 100644 index 00000000..27ef4fd5 --- /dev/null +++ b/references/oracle/artifacts/pyjhora_synthetic_extreme_latitude_varshaphala_kp_20260629.txt @@ -0,0 +1,8 @@ +CASE_ID template_synthetic_extreme_latitude_varshaphala_kp +TOOL PyJHora 4.8.6 +SOURCE_ENV /Users/wuyongnaren/.workbuddy/binaries/python/envs/pyjhora +CAPTURE_DATE 2026-06-29 +NOTE External annual capture from PyJHora black-box environment. Local repository annual engine output was not used. This artifact records solar return datetime, varsha lagna, muntha, year lord, first mudda dasha lord, selected sahams, and the Tajika yoga block extracted from the PyJHora annual chart via jhora.horoscope.transit.tajaka_yoga. KP ayanamsa was explicitly forced through drik.set_ayanamsa_mode('KP') before calculation to match the template row boundary. + +TAJIKA_ANNUAL_VALUES_JSON {"solar_return_datetime":"2024-06-21T02:48:16+01:00","varsha_lagna_deg":80.1445,"muntha_sign":"♋︎Cancer","year_lord":"Mercury☿","mudda_dasha_first_lord":"Venus♀","sahams":{"punya_saham":278.0196,"rajya_saham":9.3073,"vivah_saham":185.4734}} +TAJIKA_YOGAS_JSON {"eesarpha":[[0,3],[0,5],[2,4],[3,4],[3,5]],"gairi_kamboola":null,"ithasala":[[0,2,3],[0,4,1],[1,6,1],[2,3,2],[2,5,1],[4,5,1]],"kamboola":[true,[[1,6]],[[1,6]]],"khallasara":null,"manahoo":[[0,2,2],[0,4,2],[2,5,2],[4,5,2]],"nakta":[],"yamaya":[]} diff --git a/references/oracle/tajika_annual_oracle_cases.json b/references/oracle/tajika_annual_oracle_cases.json index d9b95f3a..8509e0d6 100644 --- a/references/oracle/tajika_annual_oracle_cases.json +++ b/references/oracle/tajika_annual_oracle_cases.json @@ -137,7 +137,7 @@ }, { "id": "template_einstein_varshaphala_1905_lahiri", - "status": "template_only", + "status": "external_verified", "source": "JHora Varshaphala screenshot / PyJHora black-box annual output / printed example", "privacy": "public_figure_template", "birth": { @@ -158,24 +158,133 @@ "target_year": 1905 }, "target": { - "solar_return_datetime": null, - "varsha_lagna_deg": null, - "muntha_sign": null, - "year_lord": null, - "mudda_dasha_first_lord": null, + "solar_return_datetime": "1905-03-15T03:44:51+00:53", + "varsha_lagna_deg": 262.4183, + "muntha_sign": "Virgo", + "year_lord": "Mercury", + "mudda_dasha_first_lord": "Rahu", "sahams": { - "punya_saham": null, - "rajya_saham": null, - "vivah_saham": null + "punya_saham": 31.2991, + "rajya_saham": 235.5875, + "vivah_saham": 1.9612 }, - "tajika_yogas": null, - "source_artifact": null + "tajika_yogas": { + "eesarpha": [ + [ + 0, + 1 + ], + [ + 0, + 3 + ], + [ + 1, + 3 + ], + [ + 1, + 4 + ], + [ + 1, + 6 + ], + [ + 3, + 6 + ], + [ + 4, + 5 + ], + [ + 4, + 6 + ] + ], + "gairi_kamboola": null, + "ithasala": [ + [ + 0, + 4, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 1, + 5, + 1 + ], + [ + 3, + 4, + 1 + ] + ], + "kamboola": [ + true, + [ + [ + 1, + 5 + ] + ], + [ + [ + 1, + 5 + ] + ] + ], + "khallasara": null, + "manahoo": [ + [ + 0, + 4, + 11 + ], + [ + 0, + 6, + 11 + ], + [ + 3, + 4, + 11 + ] + ], + "nakta": [], + "yamaya": [] + }, + "source_artifact": "references/oracle/artifacts/pyjhora_einstein_varshaphala_1905_lahiri_partial_20260629.txt" }, - "verification_note": "Historical timezone and source convention must be documented beside the external annual output." + "verification_note": "Historical timezone and source convention must be documented beside the external annual output.", + "evidence_packet": { + "capture_id": "external_template_einstein_varshaphala_1905_lahiri", + "status": "external_verified", + "metadata": { + "tool_name": "PyJHora", + "tool_version_or_url": "PyJHora 4.8.6 isolated workbuddy black-box run", + "capture_date": "2026-06-29", + "source_artifact": "references/oracle/artifacts/pyjhora_einstein_varshaphala_1905_lahiri_partial_20260629.txt", + "ayanamsa": "lahiri", + "node_mode": "mean", + "timezone": "UTC+00:53", + "annual_system": "Varshaphala/Tajika", + "target_year": 1905, + "operator_note": "Black-box annual output from workbuddy PyJHora 4.8.6 environment. External evidence only; local annual engine output was not used. Solar-return timestamp, Varsha Lagna, Muntha, Year Lord, first Mudda Dasha lord, selected Sahams, and Tajika Yogas were captured through the external PyJHora path via the annual chart and jhora.horoscope.transit.tajaka_yoga helper chain." + } + } }, { "id": "template_marilyn_monroe_varshaphala_1962_lahiri", - "status": "template_only", + "status": "external_verified", "source": "JHora Varshaphala screenshot / PyJHora black-box annual output / printed example", "privacy": "public_figure_template", "birth": { @@ -196,24 +305,226 @@ "target_year": 1962 }, "target": { - "solar_return_datetime": null, - "varsha_lagna_deg": null, - "muntha_sign": null, - "year_lord": null, - "mudda_dasha_first_lord": null, + "solar_return_datetime": "1962-06-01T14:20:25-08:00", + "varsha_lagna_deg": 169.9089, + "muntha_sign": "♎︎Libra", + "year_lord": "Mercury☿", + "mudda_dasha_first_lord": "Raagu☊", "sahams": { - "punya_saham": null, - "rajya_saham": null, - "vivah_saham": null + "punya_saham": 161.7196, + "rajya_saham": 50.2812, + "vivah_saham": 350.4075 }, - "tajika_yogas": null, - "source_artifact": null + "tajika_yogas": { + "eesarpha": [ + [ + 0, + 2 + ], + [ + 0, + 3 + ], + [ + 0, + 5 + ], + [ + 3, + 4 + ], + [ + 3, + 6 + ], + [ + 4, + 5 + ] + ], + "gairi_kamboola": null, + "ithasala": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 4, + 2 + ], + [ + 0, + 6, + 2 + ], + [ + 1, + 2, + 2 + ], + [ + 1, + 4, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 2, + 4, + 3 + ], + [ + 2, + 6, + 3 + ], + [ + 4, + 6, + 2 + ] + ], + "kamboola": [ + true, + [ + [ + 1, + 2 + ], + [ + 1, + 4 + ], + [ + 1, + 6 + ] + ], + [ + [ + 1, + 2 + ], + [ + 2, + 4 + ], + [ + 2, + 6 + ], + [ + 0, + 4 + ], + [ + 1, + 4 + ], + [ + 2, + 4 + ], + [ + 4, + 6 + ], + [ + 0, + 6 + ], + [ + 1, + 6 + ], + [ + 2, + 6 + ], + [ + 4, + 6 + ], + [ + 0, + 1 + ], + [ + 0, + 4 + ], + [ + 0, + 6 + ] + ] + ], + "khallasara": null, + "manahoo": [ + [ + 0, + 1, + 1 + ], + [ + 0, + 4, + 1 + ], + [ + 0, + 6, + 1 + ], + [ + 1, + 2, + 1 + ], + [ + 1, + 4, + 1 + ], + [ + 1, + 6, + 1 + ] + ], + "nakta": [], + "yamaya": [] + }, + "source_artifact": "references/oracle/artifacts/pyjhora_marilyn_monroe_varshaphala_1962_lahiri_20260629.txt" }, "verification_note": "Annual reading evidence must include exact solar return convention and visible source metadata." + , + "evidence_packet": { + "capture_id": "external_template_marilyn_monroe_varshaphala_1962_lahiri", + "status": "external_verified", + "metadata": { + "tool_name": "PyJHora", + "tool_version_or_url": "PyJHora 4.8.6 isolated workbuddy black-box run", + "capture_date": "2026-06-29", + "source_artifact": "references/oracle/artifacts/pyjhora_marilyn_monroe_varshaphala_1962_lahiri_20260629.txt", + "ayanamsa": "lahiri", + "node_mode": "PyJHora default", + "timezone": "UTC-08:00", + "annual_system": "Varshaphala/Tajika", + "target_year": 1962, + "operator_note": "Black-box annual output from workbuddy PyJHora 4.8.6 environment. External evidence only; local annual engine output was not used. Solar-return timestamp, Varsha Lagna, Muntha, Year Lord, first Mudda Dasha lord, selected Sahams, and Tajika Yogas were captured through the external PyJHora path via the annual chart and jhora.horoscope.transit.tajaka_yoga helper chain." + } + } }, { "id": "template_historical_dst_london_varshaphala_1943_lahiri", - "status": "template_only", + "status": "external_verified", "source": "JHora Varshaphala screenshot / PyJHora black-box annual output / printed example", "privacy": "synthetic_historical_dst_template", "birth": { @@ -234,24 +545,131 @@ "target_year": 1943 }, "target": { - "solar_return_datetime": null, - "varsha_lagna_deg": null, - "muntha_sign": null, - "year_lord": null, - "mudda_dasha_first_lord": null, + "solar_return_datetime": "1943-04-15T22:55:52+00:00", + "varsha_lagna_deg": 224.724, + "muntha_sign": "♍︎Virgo", + "year_lord": "Mars♂", + "mudda_dasha_first_lord": "Saturn♄", "sahams": { - "punya_saham": null, - "rajya_saham": null, - "vivah_saham": null + "punya_saham": 21.1202, + "rajya_saham": 299.2177, + "vivah_saham": 215.2774 }, - "tajika_yogas": null, - "source_artifact": null + "tajika_yogas": { + "eesarpha": [ + [ + 0, + 1 + ], + [ + 0, + 5 + ], + [ + 1, + 2 + ], + [ + 1, + 5 + ], + [ + 2, + 5 + ], + [ + 4, + 6 + ] + ], + "gairi_kamboola": null, + "ithasala": [ + [ + 0, + 2, + 1 + ], + [ + 1, + 3, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 3, + 6, + 1 + ] + ], + "kamboola": [ + true, + [ + [ + 1, + 3 + ], + [ + 1, + 6 + ] + ], + [ + [ + 1, + 3 + ], + [ + 3, + 6 + ], + [ + 1, + 6 + ], + [ + 3, + 6 + ] + ] + ], + "khallasara": null, + "manahoo": [ + [ + 0, + 2, + 0 + ] + ], + "nakta": [], + "yamaya": [] + }, + "source_artifact": "references/oracle/artifacts/pyjhora_historical_dst_london_varshaphala_1943_lahiri_20260629.txt" }, - "verification_note": "This row exists to force historical DST documentation before annual-chart accuracy claims." + "verification_note": "This row exists to force historical DST documentation before annual-chart accuracy claims.", + "evidence_packet": { + "capture_id": "external_template_historical_dst_london_varshaphala_1943_lahiri", + "status": "external_verified", + "metadata": { + "tool_name": "PyJHora", + "tool_version_or_url": "PyJHora 4.8.6 isolated workbuddy black-box run", + "capture_date": "2026-06-29", + "source_artifact": "references/oracle/artifacts/pyjhora_historical_dst_london_varshaphala_1943_lahiri_20260629.txt", + "ayanamsa": "lahiri", + "node_mode": "PyJHora default", + "timezone": "UTC+00:00", + "annual_system": "Varshaphala/Tajika", + "target_year": 1943, + "operator_note": "Black-box annual output from workbuddy PyJHora 4.8.6 environment. External evidence only; local annual engine output was not used. Solar-return timestamp, Varsha Lagna, Muntha, Year Lord, first Mudda Dasha lord, selected Sahams, and Tajika Yogas were captured through the external PyJHora path via the annual chart and jhora.horoscope.transit.tajaka_yoga helper chain. This synthetic row is retained specifically to document the historical timezone/DST boundary." + } + } }, { "id": "template_synthetic_extreme_latitude_varshaphala_kp", - "status": "template_only", + "status": "external_verified", "source": "JHora Varshaphala screenshot / PyJHora black-box annual output / printed example", "privacy": "synthetic_extreme_latitude_template", "birth": { @@ -272,20 +690,132 @@ "target_year": 2024 }, "target": { - "solar_return_datetime": null, - "varsha_lagna_deg": null, - "muntha_sign": null, - "year_lord": null, - "mudda_dasha_first_lord": null, + "solar_return_datetime": "2024-06-21T02:48:16+01:00", + "varsha_lagna_deg": 80.1445, + "muntha_sign": "♋︎Cancer", + "year_lord": "Mercury☿", + "mudda_dasha_first_lord": "Venus♀", "sahams": { - "punya_saham": null, - "rajya_saham": null, - "vivah_saham": null + "punya_saham": 278.0196, + "rajya_saham": 9.3073, + "vivah_saham": 185.4734 }, - "tajika_yogas": null, - "source_artifact": null + "tajika_yogas": { + "eesarpha": [ + [ + 0, + 3 + ], + [ + 0, + 5 + ], + [ + 2, + 4 + ], + [ + 3, + 4 + ], + [ + 3, + 5 + ] + ], + "gairi_kamboola": null, + "ithasala": [ + [ + 0, + 2, + 3 + ], + [ + 0, + 4, + 1 + ], + [ + 1, + 6, + 1 + ], + [ + 2, + 3, + 2 + ], + [ + 2, + 5, + 1 + ], + [ + 4, + 5, + 1 + ] + ], + "kamboola": [ + true, + [ + [ + 1, + 6 + ] + ], + [ + [ + 1, + 6 + ] + ] + ], + "khallasara": null, + "manahoo": [ + [ + 0, + 2, + 2 + ], + [ + 0, + 4, + 2 + ], + [ + 2, + 5, + 2 + ], + [ + 4, + 5, + 2 + ] + ], + "nakta": [], + "yamaya": [] + }, + "source_artifact": "references/oracle/artifacts/pyjhora_synthetic_extreme_latitude_varshaphala_kp_20260629.txt" }, - "verification_note": "High-latitude annual chart row; external source must document house and ascendant assumptions." + "verification_note": "High-latitude annual chart row; external source must document house and ascendant assumptions.", + "evidence_packet": { + "capture_id": "external_template_synthetic_extreme_latitude_varshaphala_kp", + "status": "external_verified", + "metadata": { + "tool_name": "PyJHora", + "tool_version_or_url": "PyJHora 4.8.6 isolated workbuddy black-box run", + "capture_date": "2026-06-29", + "source_artifact": "references/oracle/artifacts/pyjhora_synthetic_extreme_latitude_varshaphala_kp_20260629.txt", + "ayanamsa": "kp", + "node_mode": "PyJHora default", + "timezone": "UTC+01:00", + "annual_system": "Varshaphala/Tajika", + "target_year": 2024, + "operator_note": "Black-box annual output from workbuddy PyJHora 4.8.6 environment. External evidence only; local annual engine output was not used. Solar-return timestamp, Varsha Lagna, Muntha, Year Lord, first Mudda Dasha lord, selected Sahams, and Tajika Yogas were captured through the external PyJHora path via the annual chart and jhora.horoscope.transit.tajaka_yoga helper chain. KP ayanamsa was explicitly forced to match the template boundary." + } + } } ] } diff --git a/references/technique_registry.json b/references/technique_registry.json index 70ed0198..a49cafe1 100644 --- a/references/technique_registry.json +++ b/references/technique_registry.json @@ -2707,6 +2707,7 @@ "knowledge_refs": [], "commands": [ "thematic-report", + "high-rigor-workflow", "full-reading" ], "output_paths": [ diff --git a/scripts/audit_fragments.py b/scripts/audit_fragments.py index 820ce484..afeb3fb8 100644 --- a/scripts/audit_fragments.py +++ b/scripts/audit_fragments.py @@ -60,6 +60,7 @@ API_COMMAND_MAP = { "transit-trigger": "/api/transit", "audit-capabilities": "/api/capability_audit", "thematic-report": "/api/thematic_report", + "high-rigor-workflow": "/api/high_rigor_workflow", "report-artifact": "/api/report_artifact", } diff --git a/scripts/cmd_narayana_dasha.py b/scripts/cmd_narayana_dasha.py index 5206185f..b9d30e0c 100644 --- a/scripts/cmd_narayana_dasha.py +++ b/scripts/cmd_narayana_dasha.py @@ -87,6 +87,10 @@ def cmd_narayana_dasha(args, chart_data): if ad: lines.append(f" Antardasha: {ad['sign']}({ad['lord']}){ad['years']}年") + pd = curr.get('pd') + if pd: + lines.append(f" Pratyantardasha: {pd['sign']}({pd['lord']}){pd['years']}年") + lines.append("") for line in result.get('interpretation', []): lines.append(f" {line}") diff --git a/scripts/diagnose_vedastro_mode.py b/scripts/diagnose_vedastro_mode.py new file mode 100644 index 00000000..10b1ef9b --- /dev/null +++ b/scripts/diagnose_vedastro_mode.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Report whether the current runtime is fast fallback or VedAstro official mode.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +try: + from scripts.local_env import load_local_env +except ModuleNotFoundError: # pragma: no cover - direct script execution + from local_env import load_local_env + + +ROOT = Path(__file__).resolve().parents[1] +FAST_TIMEOUT_THRESHOLD_SECONDS = 5.0 + + +def _bool_env(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _timeout_seconds() -> float: + raw = os.environ.get("VEDASTRO_TIMEOUT_SECONDS", "").strip() + if not raw: + return 4.0 + try: + return float(raw) + except ValueError: + return 4.0 + + +def build_report() -> dict: + load_local_env(ROOT) + endpoint = os.environ.get("VEDASTRO_API_ENDPOINT", "").strip() + network_enabled = _bool_env("VEDASTRO_ENABLE_NETWORK") + timeout_seconds = _timeout_seconds() + has_api_key = bool(os.environ.get("VEDASTRO_API_KEY", "").strip()) + missing = [] + if not endpoint: + missing.append("VEDASTRO_API_ENDPOINT") + if not network_enabled: + missing.append("VEDASTRO_ENABLE_NETWORK=1") + if timeout_seconds <= FAST_TIMEOUT_THRESHOLD_SECONDS: + missing.append("VEDASTRO_TIMEOUT_SECONDS>5") + official_ready = not missing + mode = "official_extended" if official_ready else "fast_local_fallback" + return { + "mode": mode, + "official_ready": official_ready, + "endpoint_configured": bool(endpoint), + "network_enabled": network_enabled, + "timeout_seconds": timeout_seconds, + "has_api_key": has_api_key, + "missing": missing, + "expected_fallback_status": ( + "none_if_official_endpoint_responds" + if official_ready + else "official_snapshot_budget_exhausted_or_endpoint_blocked" + ), + "next_step": ( + "Run full-reading or strict_workflow; verify vedastro_official.status is ok/partial." + if official_ready + else "Copy .env.official.example to .env.local and fill endpoint/network settings for official mode." + ), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="print machine-readable JSON") + args = parser.parse_args() + report = build_report() + if args.json: + print(json.dumps(report, ensure_ascii=False, sort_keys=True)) + return 0 + print(f"VedAstro runtime mode: {report['mode']}") + print(f"official_ready: {str(report['official_ready']).lower()}") + print(f"endpoint_configured: {str(report['endpoint_configured']).lower()}") + print(f"network_enabled: {str(report['network_enabled']).lower()}") + print(f"timeout_seconds: {report['timeout_seconds']}") + print(f"has_api_key: {str(report['has_api_key']).lower()}") + if report["missing"]: + print("missing:") + for item in report["missing"]: + print(f" - {item}") + print(f"expected_fallback_status: {report['expected_fallback_status']}") + print(f"next_step: {report['next_step']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/scripts/guided_topic_discovery.py b/scripts/guided_topic_discovery.py index 9f8fe859..871bc016 100644 --- a/scripts/guided_topic_discovery.py +++ b/scripts/guided_topic_discovery.py @@ -70,6 +70,49 @@ def _evidence_line(label: str, value: Any) -> dict[str, str]: return {"label": label, "value": str(value)} +def _strict_contracts(report: dict[str, Any], modules: dict[str, Any]) -> dict[str, Any]: + snapshot = _as_dict(_as_dict(_as_dict(report.get("ai_prompt_pack")).get("evidence_snapshot"))) + contracts = _as_dict(snapshot.get("strict_workflow_contracts")) + if contracts: + return contracts + mapping = { + "career": "career_strict_evidence", + "relationship": "relationship_strict_evidence", + "finance": "finance_strict_evidence", + } + compact: dict[str, Any] = {} + for route, key in mapping.items(): + strict = _as_dict(modules.get(key)) + if strict: + compact[route] = strict + return compact + + +def _topic_audit_gate(contracts: dict[str, Any], route: str) -> dict[str, Any]: + contract = _as_dict(contracts.get(route)) + bundle = _as_dict(contract.get("strict_adjudication_bundle")) + summary = _as_dict(bundle.get("strict_audit_gate")) or _as_dict(contract.get("technique_audit_summary")) + if summary: + return summary + return { + "functional_benefic_malefic": {"gate": "hard", "used": False, "status": "blocked"}, + "relevant_vargas": {"gate": "hard", "required_keys": [], "present_keys": []}, + "vimshottari_narayana_crosscheck": { + "gate": "hard", + "used": False, + "required_timing_systems": ["Vimshottari", "Narayana"], + }, + "source_priority_boundary": { + "gate": "boundary", + "official": {}, + "local": {}, + "fallback_used": [], + "blocked_items": [], + "conflicts": [], + }, + } + + def _topic( *, topic_id: str, @@ -79,6 +122,9 @@ def _topic( evidence: list[dict[str, str]], confidence: str, vedastro: dict[str, Any], + strict_audit_gate: dict[str, Any], + monthly_adjudication_summary: dict[str, Any], + official_day_signal_summary: dict[str, Any], questions: list[str], answer_mode: str = "tap_or_ask", priority: int = 50, @@ -91,14 +137,62 @@ def _topic( "evidence": evidence, "confidence": confidence, "vedastro": vedastro, + "strict_adjudication_bundle": { + "strict_audit_gate": strict_audit_gate, + "monthly_adjudication_summary": monthly_adjudication_summary, + "official_day_signal_summary": official_day_signal_summary, + }, + "strict_audit_gate": strict_audit_gate, + "monthly_adjudication_summary": monthly_adjudication_summary, + "official_day_signal_summary": official_day_signal_summary, "suggested_questions": questions, "answer_mode": answer_mode, "priority": priority, } +def _topic_official_day_signal_summary(contracts: dict[str, Any], modules: dict[str, Any], route: str) -> dict[str, Any]: + contract = _as_dict(contracts.get(route)) + bundle = _as_dict(contract.get("strict_adjudication_bundle")) + summary = _as_dict(bundle.get("official_day_signal_summary")) or _as_dict(contract.get("official_day_signal_summary")) + if summary: + return summary + mapping = { + "career": "career_strict_evidence", + "relationship": "relationship_strict_evidence", + "finance": "finance_strict_evidence", + } + strict = _as_dict(modules.get(mapping.get(route, ""))) + present = _as_dict(strict.get("present_evidence")) + external = _as_dict(present.get("external_activation")) + signals = _as_list(external.get("official_day_signals")) + return { + "available": bool(signals), + "signal_count": len(signals), + "top_day": _as_dict(signals[0]) if signals else None, + "days": [_as_dict(item) for item in signals[:3] if isinstance(item, dict)], + "source": "present_evidence.external_activation.official_day_signals" if signals else None, + } + + +def _topic_monthly_adjudication_summary(contracts: dict[str, Any], modules: dict[str, Any], route: str) -> dict[str, Any]: + contract = _as_dict(contracts.get(route)) + bundle = _as_dict(contract.get("strict_adjudication_bundle")) + summary = _as_dict(bundle.get("monthly_adjudication_summary")) or _as_dict(contract.get("monthly_adjudication_summary")) + if summary: + return summary + mapping = { + "career": "career_strict_evidence", + "relationship": "relationship_strict_evidence", + "finance": "finance_strict_evidence", + } + strict = _as_dict(modules.get(mapping.get(route, ""))) + return _as_dict(strict.get("monthly_adjudication_summary")) + + def build_guided_topics(report: dict[str, Any]) -> list[dict[str, Any]]: modules = _as_dict(report.get("modules")) + contracts = _strict_contracts(report, modules) chart = _as_dict(report.get("chart") or modules.get("chart")) planets = _as_dict(chart.get("planets")) md, ad, md_start, md_end = _current_dasha(modules) @@ -133,6 +227,9 @@ def build_guided_topics(report: dict[str, Any]) -> list[dict[str, Any]]: ], confidence="medium" if marriage_conv or relationship else "low", vedastro=_vedastro_snapshot(modules, "marriage"), + strict_audit_gate=_topic_audit_gate(contracts, "relationship"), + monthly_adjudication_summary=_topic_monthly_adjudication_summary(contracts, modules, "relationship"), + official_day_signal_summary=_topic_official_day_signal_summary(contracts, modules, "relationship"), questions=[ "我现在适合认真发展关系,还是更适合筛选和观察?", "我的伴侣画像、认识场景和相处风险是什么?", @@ -153,6 +250,9 @@ def build_guided_topics(report: dict[str, Any]) -> list[dict[str, Any]]: ], confidence="medium" if career_conv or ketu_house == 10 else "low", vedastro=_vedastro_snapshot(modules, "career"), + strict_audit_gate=_topic_audit_gate(contracts, "career"), + monthly_adjudication_summary=_topic_monthly_adjudication_summary(contracts, modules, "career"), + official_day_signal_summary=_topic_official_day_signal_summary(contracts, modules, "career"), questions=[ "我现在适合换方向还是继续深耕?", "2026 年事业吉利在哪里,不利在哪里?", @@ -172,6 +272,9 @@ def build_guided_topics(report: dict[str, Any]) -> list[dict[str, Any]]: ], confidence="medium", vedastro=_vedastro_snapshot(modules, "marriage"), + strict_audit_gate=_topic_audit_gate(contracts, "relationship"), + monthly_adjudication_summary=_topic_monthly_adjudication_summary(contracts, modules, "relationship"), + official_day_signal_summary=_topic_official_day_signal_summary(contracts, modules, "relationship"), questions=[ "我可以用过去事件校正出生时间吗?", "哪些人生事件最适合用来校正出生时间?", @@ -192,6 +295,9 @@ def build_guided_topics(report: dict[str, Any]) -> list[dict[str, Any]]: ], confidence="medium" if wealth_conv else "low", vedastro=_vedastro_snapshot(modules, "wealth"), + strict_audit_gate=_topic_audit_gate(contracts, "finance"), + monthly_adjudication_summary=_topic_monthly_adjudication_summary(contracts, modules, "finance"), + official_day_signal_summary=_topic_official_day_signal_summary(contracts, modules, "finance"), questions=[ "2026 年哪些钱可以赚,哪些钱要避险?", "我适合靠项目、投资、合作还是长期积累赚钱?", diff --git a/scripts/historical_event_backtest.py b/scripts/historical_event_backtest.py new file mode 100644 index 00000000..b737cbe2 --- /dev/null +++ b/scripts/historical_event_backtest.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Reusable historical event backtest entrypoint built on strict workflow.""" + +from __future__ import annotations + +import argparse +import json +from typing import Any + +import mcp_server + + +SUPPORTED_DOMAINS = { + "career": { + "route": "career", + "question": "请严格回测这条事业事件是否成立,并判断是职业状态、角色变化、升迁窗口还是项目兑现。", + }, + "wealth": { + "route": "finance", + "question": "请严格回测这条财富事件是否成立,并判断更接近收入增长、到账、套现还是公众财富状态。", + }, + "finance": { + "route": "finance", + "question": "请严格回测这条财富事件是否成立,并判断更接近收入增长、到账、套现还是公众财富状态。", + }, + "marriage": { + "route": "relationship", + "question": "请严格回测这条婚恋事件是否成立,并判断是否达到正式关系或婚姻层。", + }, + "relationship": { + "route": "relationship", + "question": "请严格回测这条婚恋事件是否成立,并判断是否达到正式关系或婚姻层。", + }, +} + + +def _route_for_domain(domain: str) -> dict[str, str] | None: + return SUPPORTED_DOMAINS.get(str(domain).strip().lower()) + + +def _load_payload(path: str) -> dict[str, Any]: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def _event_result_class( + verdict: str | None, + blocked: bool, + expected_label: str | None, + actual_label: str | None, +) -> tuple[str, dict[str, Any]]: + if blocked: + return "blocked", {"reason": "strict_workflow_blocked"} + if verdict == "high_probability_window" and actual_label and ( + not expected_label or expected_label == actual_label + ): + return "strong_hit", {"reason": "supported_route_and_label"} + if verdict in {"high_probability_window", "moderate_probability_window"}: + if expected_label and actual_label and expected_label != actual_label: + return "weak_hit", {"reason": "label_mismatch_under_supported_route"} + return "weak_hit", {"reason": "supported_route_without_exact_label"} + if verdict == "weak_window_needs_confirmation": + return "weak_hit", {"reason": "weak_window_needs_confirmation"} + return "miss", {"reason": verdict or "insufficient_evidence"} + + +def _official_snapshot_summary(strict: dict[str, Any]) -> dict[str, Any]: + present = strict.get("present_evidence") or {} + official = present.get("vedastro_official_snapshot") + if not isinstance(official, dict): + return {"level": "missing", "status": "missing", "source": None} + return { + "level": official.get("level") or "missing", + "status": official.get("status"), + "source": official.get("source"), + } + + +def _source_priority_mode(strict: dict[str, Any]) -> str | None: + present = strict.get("present_evidence") or {} + source_priority = present.get("source_priority") + if not isinstance(source_priority, dict): + return None + return source_priority.get("mode") + + +def _run_supported_event(subject: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]: + route_info = _route_for_domain(event.get("domain", "")) + if route_info is None: + return { + "id": event.get("id"), + "date": event.get("date"), + "domain": event.get("domain"), + "route": None, + "expected_label": event.get("expected_label"), + "actual_label": None, + "matched_expected_label": False, + "result_class": "unsupported_domain", + "boundary": {"reason": "route_not_yet_implemented_for_event_backtest"}, + "official_snapshot": {"level": "missing", "status": "missing", "source": None}, + "evidence": {"source_priority_mode": None, "confidence_cap": "unsupported"}, + } + + result = mcp_server.strict_workflow( + question=route_info["question"], + year=int(subject["year"]), + month=int(subject["month"]), + day=int(subject["day"]), + hour=int(subject["hour"]), + minute=int(subject["minute"]), + lat=float(subject["lat"]), + lon=float(subject["lon"]), + tz=float(subject["tz"]), + age=int(subject.get("age", 0)), + transit_date=str(event["date"]), + node_mode=str(subject.get("node_mode", "mean")), + ) + strict = result.get("strict_workflow") if isinstance(result, dict) else {} + if not isinstance(strict, dict): + strict = {} + + judgement = strict.get("event_judgement") if isinstance(strict.get("event_judgement"), dict) else {} + actual_label = judgement.get("dominant_label") + expected_label = event.get("expected_label") + verdict = judgement.get("verdict") + blocked = bool(strict.get("blocked")) + result_class, boundary = _event_result_class(verdict, blocked, expected_label, actual_label) + + return { + "id": event.get("id"), + "date": event.get("date"), + "domain": event.get("domain"), + "route": route_info["route"], + "expected_label": expected_label, + "actual_label": actual_label, + "matched_expected_label": bool(expected_label and expected_label == actual_label), + "result_class": result_class, + "boundary": boundary, + "official_snapshot": _official_snapshot_summary(strict), + "evidence": { + "verdict": verdict, + "score": judgement.get("score"), + "confidence_cap": strict.get("confidence_cap"), + "missing_evidence": strict.get("missing_evidence") or [], + "blocked_items": strict.get("blocked_items") or [], + "conflicts": strict.get("conflicts") or [], + "adjudication_stages": strict.get("adjudication_stages") or {}, + "multi_reference_reading_summary": strict.get("multi_reference_reading_summary") or {}, + "main_conflicts": strict.get("main_conflicts") or strict.get("conflicts") or [], + "source_priority_mode": _source_priority_mode(strict), + "primary_drivers": judgement.get("primary_drivers") or [], + "secondary_context": judgement.get("secondary_context") or [], + "technique_audit": strict.get("technique_audit") or [], + "life_event_graph": strict.get("life_event_graph") or {}, + }, + } + + +def build_report(payload: dict[str, Any]) -> dict[str, Any]: + subject = payload.get("subject") or {} + events = payload.get("events") or [] + rows = [_run_supported_event(subject, event) for event in events] + + summary = { + "total_events": len(rows), + "strong_hits": sum(1 for row in rows if row["result_class"] == "strong_hit"), + "weak_hits": sum(1 for row in rows if row["result_class"] == "weak_hit"), + "misses": sum(1 for row in rows if row["result_class"] == "miss"), + "blocked_events": sum(1 for row in rows if row["result_class"] == "blocked"), + "unsupported_domain_events": sum(1 for row in rows if row["result_class"] == "unsupported_domain"), + "official_primary_events": sum( + 1 for row in rows if row["official_snapshot"].get("level") == "primary" + ), + } + + return { + "scope": "historical_event_backtest", + "summary": summary, + "boundary": ( + "This report measures whether current strict routes can support supplied historical events. " + "Unsupported domains and blocked routes must not be overstated as validated predictive accuracy." + ), + "events": rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run reusable historical-event backtest") + parser.add_argument("--input", required=True, help="Path to local backtest payload JSON") + args = parser.parse_args() + + report = build_report(_load_payload(args.input)) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 5f3740c7..9383750b 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -13,21 +13,191 @@ import html as html_lib import io import json, sys, os, math import importlib.util +import hashlib import re +import threading +import time from datetime import datetime, timedelta from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path from urllib.parse import urlparse try: from scripts.local_env import load_local_env except ModuleNotFoundError: # pragma: no cover - script execution path from local_env import load_local_env +try: + from scripts.unified_consultation_orchestrator import UnifiedConsultationOrchestrator +except ModuleNotFoundError: # pragma: no cover - script execution path + from unified_consultation_orchestrator import UnifiedConsultationOrchestrator SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.path.abspath(os.path.join(SCRIPTS_DIR, '..')) sys.path.insert(0, SCRIPTS_DIR) load_local_env(REPO_ROOT) _LOCAL_MODULE_CACHE = {} +_API_CHART_CACHE_SCOPE = 'api_chart_response' +_HIGH_RIGOR_JOB_SCOPE = 'high_rigor_workflow' +_UNIFIED_CONSULTATION_ORCHESTRATOR = UnifiedConsultationOrchestrator() + + +def execute_consultation_workflow( + handler, + *, + body: dict, + surface: str = 'api_web', + chart_override: dict | None = None, +) -> dict: + birth_payload = handler._high_rigor_birth_payload(body) + themes = handler._high_rigor_requested_themes(body) + events = handler._high_rigor_events(body) + question = body.get('question') or '' + entry_mode = body.get('entry_mode', 'direct_chart') + high_rigor = bool(body.get('return_high_rigor_shape')) + route_packet = _UNIFIED_CONSULTATION_ORCHESTRATOR.resolve_route(question, themes) + unified_contract = _UNIFIED_CONSULTATION_ORCHESTRATOR.shared_contract( + entry_mode=entry_mode, + question=question, + themes=themes, + route_packet=route_packet, + surface=surface, + ) + runtime_planner = _UNIFIED_CONSULTATION_ORCHESTRATOR.runtime_planner( + entry_mode=entry_mode, + question=question, + themes=themes, + route_packet=route_packet, + events=events, + surface=surface, + high_rigor=high_rigor, + ) + executed_steps = [] + known_steps = [ + 'compute_chart', + 'run_rectification_gate', + 'run_historical_event_backtest', + 'run_thematic_report', + ] + if body.get('dry_run') or body.get('plan_only'): + result = handler._high_rigor_workflow_plan_only(birth_payload, themes, events) + result['endpoint'] = 'consultation_workflow' + result['entry_mode'] = entry_mode + result['routing'] = route_packet + result['unified_orchestrator'] = unified_contract + result['runtime_planner'] = { + **runtime_planner, + 'executed_steps': [], + 'skipped_steps': known_steps, + } + if body.get('return_high_rigor_shape'): + result['endpoint'] = 'high_rigor_workflow' + return result + + chart = dict(chart_override) if isinstance(chart_override, dict) else {} + rectification = {} + computed_chart = bool(chart) + + for step in runtime_planner.get('sync_steps', []): + if step == 'run_rectification_gate': + chart_planets = chart.get('planets') if isinstance(chart, dict) else {} + chart_ascendant = chart.get('ascendant') if isinstance(chart, dict) else {} + rectification = handler._compute_rectification_gate({ + **birth_payload, + 'planets': chart_planets if isinstance(chart_planets, dict) else {}, + 'ascendant': chart_ascendant if isinstance(chart_ascendant, dict) else {}, + 'declared_accuracy': body.get('declared_accuracy', body.get('accuracy', 'minute')), + 'time_source': body.get('time_source', 'family_clear'), + }) + executed_steps.append('run_rectification_gate') + elif step == 'compute_chart': + if not computed_chart: + chart = handler._compute_chart(birth_payload) + computed_chart = True + executed_steps.append('compute_chart') + + historical_backtest = {} + if 'run_historical_event_backtest' in runtime_planner.get('sync_steps', []): + historical_backtest = handler._run_high_rigor_historical_backtest(birth_payload, events) + executed_steps.append('run_historical_event_backtest') + + chart_for_theme = dict(chart) if isinstance(chart, dict) else {} + if isinstance(chart_for_theme.get('modules'), dict): + chart_for_theme.update(chart_for_theme.get('modules', {}).get('chart') or {}) + + modules = chart.get('modules') if isinstance(chart.get('modules'), dict) else {} + prompt_snapshot = (((chart.get('ai_prompt_pack') or {}).get('evidence_snapshot')) or {}) if isinstance(chart, dict) else {} + strict_workflow_contracts = prompt_snapshot.get('strict_workflow_contracts') if isinstance(prompt_snapshot.get('strict_workflow_contracts'), dict) else {} + chart_guided_topics = modules.get('guided_topics') if isinstance(modules.get('guided_topics'), list) else [] + + thematic_report = {} + if 'run_thematic_report' in runtime_planner.get('sync_steps', []): + thematic_report = handler._compute_thematic_report({ + **birth_payload, + **chart_for_theme, + 'chart_data': { + **chart_for_theme, + 'skip_full_reading_for_thematic': True, + }, + 'theme': themes, + 'skip_full_reading_for_thematic': True, + 'upstream_contract': { + 'chart': chart_for_theme, + 'strict_workflow_contracts': strict_workflow_contracts, + 'guided_topics': chart_guided_topics, + }, + }) + executed_steps.append('run_thematic_report') + + vedastro_official = handler._high_rigor_vedastro_official_summary(chart) + skipped_steps = [step for step in known_steps if step not in executed_steps] + + result = { + 'success': True, + 'endpoint': 'consultation_workflow', + 'mode': 'vedastro_official_first_existing_modules_reused', + 'entry_mode': entry_mode, + 'question': question, + 'routes': ['career', 'relationship', 'finance'], + 'themes': themes, + 'routing': route_packet, + 'unified_orchestrator': unified_contract, + 'runtime_planner': { + **runtime_planner, + 'executed_steps': executed_steps, + 'skipped_steps': skipped_steps, + }, + 'source_priority': { + 'mode': 'vedastro_official_snapshot_first', + 'priority': [ + 'vedastro_official_snapshot', + 'local_supplemental_modules', + 'local_fallback_only_when_official_blocked', + ], + 'boundary': 'Official VedAstro raw evidence is preferred; local modules supplement, cross-check, and fallback when official calls are blocked.', + }, + 'reused_modules': [ + 'vedastro_evidence_orchestrator', + 'birth_time_rectifier', + 'historical_event_backtest', + 'report_orchestrator', + 'reading_orchestrator', + 'orchestrator_bridge', + ], + 'chart': chart, + 'rectification': rectification, + 'historical_event_backtest': historical_backtest, + 'thematic_report': thematic_report, + 'vedastro_official': vedastro_official, + 'next_questions': handler._high_rigor_next_questions(rectification, historical_backtest), + 'boundary': ( + 'This endpoint composes existing project workflows. It does not claim that every VedAstro callable ' + 'is executed for every chart; the official capability catalog is carried as evidence metadata and ' + 'domain-relevant routes execute according to the configured sample/network limits.' + ), + } + if body.get('return_high_rigor_shape'): + result['endpoint'] = 'high_rigor_workflow' + return result def _load_local_module(module_name): @@ -45,6 +215,177 @@ def _load_local_module(module_name): return module +def _api_chart_cache_dir() -> Path: + path = Path(REPO_ROOT) / 'scratch' / 'local' / 'api_chart_cache' + path.mkdir(parents=True, exist_ok=True) + return path + + +def _high_rigor_job_dir() -> Path: + path = Path(REPO_ROOT) / 'scratch' / 'local' / 'high_rigor_jobs' + path.mkdir(parents=True, exist_ok=True) + return path + + +def _async_job_dir(scope: str) -> Path: + if scope == _HIGH_RIGOR_JOB_SCOPE: + return _high_rigor_job_dir() + path = Path(REPO_ROOT) / 'scratch' / 'local' / f'{scope}_jobs' + path.mkdir(parents=True, exist_ok=True) + return path + + +def _api_chart_cache_ttl_seconds() -> float: + raw = str(os.environ.get('JYOTISH_API_CHART_CACHE_TTL_SECONDS', '900')).strip() + try: + ttl = float(raw) + except ValueError: + ttl = 900.0 + return max(ttl, 0.0) + + +def _vedastro_runtime_fingerprint() -> dict: + endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip() + return { + 'endpoint_host': (urlparse(endpoint).netloc or '').lower(), + 'network_enabled': str(os.environ.get('VEDASTRO_ENABLE_NETWORK', '')).strip().lower() in {'1', 'true', 'yes'}, + 'has_api_key': bool(os.environ.get('VEDASTRO_API_KEY', '').strip()), + } + + +def _build_api_chart_cache_payload(body: dict) -> dict: + return { + 'birth': { + 'year': body.get('year'), + 'month': body.get('month'), + 'day': body.get('day'), + 'hour': body.get('hour'), + 'minute': body.get('minute'), + 'second': body.get('second', 0), + 'lat': body.get('lat'), + 'lon': body.get('lon'), + 'tz': body.get('tz'), + }, + 'calculation': { + 'ayanamsa': body.get('ayanamsa', 'lahiri'), + 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), + 'today': body.get('today') or body.get('current_date'), + 'transit_date': body.get('transit_date'), + }, + 'vedastro_runtime': _vedastro_runtime_fingerprint(), + } + + +def _api_chart_cache_key(cache_payload: dict) -> str: + canonical = json.dumps(cache_payload, ensure_ascii=False, sort_keys=True, separators=(',', ':')) + return hashlib.sha256(canonical.encode('utf-8')).hexdigest() + + +def _api_chart_cache_path(cache_key: str) -> Path: + return _api_chart_cache_dir() / f'{cache_key}.json' + + +def _attach_api_chart_runtime_cache(payload: dict, *, cache_key: str, created_at_unix: float, cache_hit: bool) -> dict: + payload_copy = json.loads(json.dumps(payload)) + ttl_seconds = _api_chart_cache_ttl_seconds() + created_at = datetime.utcfromtimestamp(created_at_unix).strftime('%Y-%m-%dT%H:%M:%SZ') + expires_at = datetime.utcfromtimestamp(created_at_unix + ttl_seconds).strftime('%Y-%m-%dT%H:%M:%SZ') + payload_copy['runtime_cache'] = { + 'scope': _API_CHART_CACHE_SCOPE, + 'cache_hit': cache_hit, + 'cache_key': cache_key, + 'cache_created_at': created_at, + 'cache_expires_at': expires_at, + 'cache_ttl_seconds': ttl_seconds, + } + return payload_copy + + +def _load_api_chart_response_cache(cache_payload: dict) -> dict | None: + ttl_seconds = _api_chart_cache_ttl_seconds() + if ttl_seconds <= 0: + return None + cache_key = _api_chart_cache_key(cache_payload) + cache_path = _api_chart_cache_path(cache_key) + if not cache_path.exists(): + return None + try: + record = json.loads(cache_path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(record, dict): + return None + created_at_unix = record.get('created_at') + payload = record.get('payload') + if not isinstance(created_at_unix, (int, float)) or not isinstance(payload, dict): + return None + if time.time() - float(created_at_unix) > ttl_seconds: + return None + return _attach_api_chart_runtime_cache( + payload, + cache_key=cache_key, + created_at_unix=float(created_at_unix), + cache_hit=True, + ) + + +def _store_api_chart_response_cache(cache_payload: dict, payload: dict) -> dict: + cache_key = _api_chart_cache_key(cache_payload) + created_at_unix = time.time() + payload_with_cache = _attach_api_chart_runtime_cache( + payload, + cache_key=cache_key, + created_at_unix=created_at_unix, + cache_hit=False, + ) + ttl_seconds = _api_chart_cache_ttl_seconds() + if ttl_seconds > 0: + record = { + 'cache_key': cache_key, + 'created_at': created_at_unix, + 'payload': payload_with_cache, + } + _api_chart_cache_path(cache_key).write_text( + json.dumps(record, ensure_ascii=False, sort_keys=True), + encoding='utf-8', + ) + return payload_with_cache + + +def _high_rigor_job_path(job_id: str) -> Path: + return _high_rigor_job_dir() / f'{job_id}.json' + + +def _async_job_path(scope: str, job_id: str) -> Path: + return _async_job_dir(scope) / f'{job_id}.json' + + +def _load_high_rigor_job_record(job_id: str) -> dict | None: + return _load_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id) + + +def _write_high_rigor_job_record(job_id: str, payload: dict) -> dict: + return _write_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id, payload) + + +def _load_async_job_record(scope: str, job_id: str) -> dict | None: + path = _async_job_path(scope, job_id) + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return None + + +def _write_async_job_record(scope: str, job_id: str, payload: dict) -> dict: + _async_job_path(scope, job_id).write_text( + json.dumps(payload, ensure_ascii=False, sort_keys=True), + encoding='utf-8', + ) + return payload + + def _attach_vedastro_main_entry_overview(chart_result, birth_payload): if not isinstance(chart_result, dict): return chart_result @@ -150,22 +491,52 @@ def _build_vedastro_overview_payload_from_chart(chart): def _build_vedastro_official_full_snapshot_payload_from_chart(chart): modules = chart.get('modules') if isinstance(chart, dict) else {} snapshot = modules.get('vedastro_official_full_snapshot') if isinstance(modules, dict) else {} + strict_workflow_contracts = snapshot.get('strict_workflow_contracts') if isinstance(snapshot, dict) else {} + if not isinstance(strict_workflow_contracts, dict): + strict_workflow_contracts = {} if not isinstance(snapshot, dict) or not snapshot: return { 'status': 'blocked', 'available': False, 'operation': 'official_full_snapshot', 'primary_source': 'vedastro_official', + 'strict_workflow_primary_route': None, + 'strict_workflow_routes_available': [], + 'strict_workflow_contracts': {}, 'boundary_note': 'VedAstro official full snapshot is not attached.', } manifest = snapshot.get('request_manifest') if isinstance(snapshot.get('request_manifest'), dict) else {} requests = manifest.get('requests') if isinstance(manifest.get('requests'), list) else [] sections = snapshot.get('snapshot_sections') if isinstance(snapshot.get('snapshot_sections'), dict) else {} + metadata = snapshot.get('source_metadata') if isinstance(snapshot.get('source_metadata'), dict) else {} + official_bundle = metadata.get('official_python_bundle') if isinstance(metadata.get('official_python_bundle'), dict) else {} + full_catalog = metadata.get('official_full_capability_catalog') if isinstance(metadata.get('official_full_capability_catalog'), dict) else {} + coverage = official_bundle.get('coverage') if isinstance(official_bundle.get('coverage'), dict) else {} + official_chart = snapshot.get('official_chart') if isinstance(snapshot.get('official_chart'), dict) else {} + dynamic_selection = full_catalog.get('dynamic_selection') if isinstance(full_catalog.get('dynamic_selection'), dict) else {} + report_references = { + theme: selection.get('report_reference') + for theme, selection in dynamic_selection.items() + if isinstance(selection, dict) and isinstance(selection.get('report_reference'), dict) + } return { 'status': snapshot.get('status') or 'blocked', 'available': bool(snapshot.get('available')), 'operation': snapshot.get('operation') or 'official_full_snapshot', 'primary_source': snapshot.get('primary_source') or 'vedastro_official', + 'official_python_path': metadata.get('official_python_path'), + 'official_bundle_status': official_bundle.get('status'), + 'official_primary_sections_ok': coverage.get('filled_sections') or [], + 'official_chart_available': bool(official_chart.get('planets')) and bool(official_chart.get('ascendant')), + 'official_full_capability_catalog_status': full_catalog.get('status'), + 'official_full_capability_catalog_summary': full_catalog.get('summary') or {}, + 'official_full_capability_catalog_coverage': full_catalog.get('coverage') or {}, + 'official_full_capability_domain_routing': full_catalog.get('domain_routing') or {}, + 'official_full_capability_dynamic_selection': dynamic_selection, + 'official_report_references': report_references, + 'strict_workflow_primary_route': snapshot.get('strict_workflow_primary_route'), + 'strict_workflow_routes_available': snapshot.get('strict_workflow_routes_available') or list(strict_workflow_contracts.keys()), + 'strict_workflow_contracts': strict_workflow_contracts, 'section_statuses': snapshot.get('section_statuses') or {}, 'snapshot_section_keys': sorted(sections.keys()), 'request_section_count': len(requests), @@ -179,6 +550,32 @@ def _build_vedastro_official_full_snapshot_payload_from_chart(chart): ), } + +def _preferred_strict_contract(strict_workflow_contracts, primary_route=None): + if not isinstance(strict_workflow_contracts, dict) or not strict_workflow_contracts: + return None, {} + route = primary_route if primary_route in strict_workflow_contracts else next(iter(strict_workflow_contracts.keys())) + contract = strict_workflow_contracts.get(route) + return route, contract if isinstance(contract, dict) else {} + + +def _strict_adjudication_bundle_from_contract(contract, *, interpretation_axes=None, monthly_humanized=None): + if not isinstance(contract, dict) or not contract: + return {} + bundle = { + 'question_type': contract.get('question_type'), + 'confidence_cap': contract.get('confidence_cap'), + 'blocked': bool(contract.get('blocked')), + 'reason': contract.get('reason'), + 'strict_audit_gate': contract.get('technique_audit_summary') or {}, + 'monthly_adjudication_summary': contract.get('monthly_adjudication_summary') or {}, + 'official_day_signal_summary': contract.get('official_day_signal_summary') or {}, + 'interpretation_axes': interpretation_axes or contract.get('interpretation_axes') or [], + 'monthly_adjudication_summary_humanized': monthly_humanized or contract.get('monthly_adjudication_summary_humanized') or {}, + 'narrative_contract': contract.get('narrative_contract') or {}, + } + return bundle + SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', 'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] @@ -233,6 +630,8 @@ API_COMMAND_MAP = { 'transit-trigger': '/api/transit', 'audit-capabilities': '/api/capability_audit', 'thematic-report': '/api/thematic_report', + 'consultation-workflow': '/api/consultation_workflow', + 'high-rigor-workflow': '/api/high_rigor_workflow', 'report-artifact': '/api/report_artifact', } @@ -260,6 +659,8 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = { '/api/shadbala', '/api/sudarshana', '/api/synastry', + '/api/consultation_workflow', + '/api/high_rigor_workflow', '/api/thematic_report', '/api/transit', '/api/varga_full', @@ -400,6 +801,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): self._json(self._technique_catalog()) elif path == '/api/vedastro/status': self._json(self._vedastro_status()) + elif path.startswith('/api/chart/jobs/'): + job_id = path.rsplit('/', 1)[-1] + result = self._get_chart_job(job_id) + if result is None: + self._error_json('Not found', 404, 'ERR_NOT_FOUND') + else: + self._json(result) + elif path.startswith('/api/high_rigor_workflow/jobs/'): + job_id = path.rsplit('/', 1)[-1] + result = self._get_high_rigor_job(job_id) + if result is None: + self._error_json('Not found', 404, 'ERR_NOT_FOUND') + else: + self._json(result) elif path == '/api/real_case_revalidation': self._json(self._real_case_revalidation()) else: @@ -521,6 +936,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): elif path == '/api/thematic_report': result = self._compute_thematic_report(body) self._json(result) + elif path == '/api/high_rigor_workflow': + result = self._compute_high_rigor_workflow(body) + self._json(result) + elif path == '/api/consultation_workflow': + result = self._compute_consultation_workflow(body) + self._json(result) elif path == '/api/technique_example': result = self._compute_technique_example(body) self._json(result) @@ -766,6 +1187,43 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return html[:body_close.start()] + summary + html[body_close.start():] return html + summary + def _inject_generic_strict_narrative_summary(self, html, narrative, *, title, section_key, subtitle): + if not isinstance(narrative, dict): + return html + headline = narrative.get('headline') + if not headline: + return html + + def _escape(value): + return html_lib.escape(str(value or '')) + + def _list_html(items): + if not isinstance(items, list) or not items: + return '
  • 暂无补充。
  • ' + return ''.join(f'
  • {_escape(item)}
  • ' for item in items[:6]) + + summary = ( + f'
    ' + f'

    {_escape(title)}

    ' + f'

    {_escape(subtitle)}

    ' + f'

    {_escape(headline)}

    ' + '
    ' + '
    Strengths
      ' + f'{_list_html(narrative.get("strengths"))}
    ' + '
    Risks
      ' + f'{_list_html(narrative.get("risks"))}
    ' + '
    Boundaries
      ' + f'{_list_html(narrative.get("boundaries"))}
    ' + '
    ' + '
    ' + ) + body_close = re.search(r'', html, re.IGNORECASE) + if body_close: + return html[:body_close.start()] + summary + html[body_close.start():] + return html + summary + def _inject_vedastro_overview_summary(self, html, overview): if not isinstance(overview, dict): return html @@ -952,6 +1410,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): html, body.get('relationship_narrative'), ) + html = self._inject_generic_strict_narrative_summary( + html, + body.get('career_narrative'), + title='Career Strict Narrative', + section_key='career-strict-narrative', + subtitle='事业严格裁决正文,要求显式引用月度主状态、落地形式、阻力来源与时间置信度。', + ) + html = self._inject_generic_strict_narrative_summary( + html, + body.get('finance_narrative'), + title='Finance Strict Narrative', + section_key='finance-strict-narrative', + subtitle='财富严格裁决正文,要求显式区分收入兑现、现金流动作、摩擦来源与时间边界。', + ) html = self._inject_vedastro_overview_summary( html, body.get('vedastro_overview'), @@ -1057,11 +1529,14 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): reading_orchestrator = _load_local_module('reading_orchestrator') orchestrator_bridge = _load_local_module('orchestrator_bridge') chart_data = body.get('chart_data') if isinstance(body.get('chart_data'), dict) else body + upstream_contract = body.get('upstream_contract') if isinstance(body.get('upstream_contract'), dict) else {} custom_evidence = body.get('evidence') has_custom_evidence = isinstance(custom_evidence, dict) and bool(custom_evidence) derived_context = None - if not has_custom_evidence and self._can_derive_thematic_evidence(chart_data): + strict_workflow_contracts = upstream_contract.get('strict_workflow_contracts') if isinstance(upstream_contract.get('strict_workflow_contracts'), dict) else {} + upstream_guided_topics = upstream_contract.get('guided_topics') if isinstance(upstream_contract.get('guided_topics'), list) else [] + if not has_custom_evidence and not strict_workflow_contracts and self._can_derive_thematic_evidence(chart_data): derived_context = self._derive_thematic_evidence(chart_data, report_orchestrator) if derived_context: @@ -1076,6 +1551,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): elif derived_context and derived_context.get('evidence'): self._inject_thematic_evidence(orchestrator, derived_context['evidence'], report_orchestrator) mode = 'derived_chart_evidence' + elif strict_workflow_contracts: + mode = 'upstream_contract_reuse' else: self._inject_sample_thematic_evidence(orchestrator, report_orchestrator) mode = 'sample_evidence' @@ -1084,7 +1561,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): reports = {} for theme in theme_values: report = orchestrator.generate_report(theme) - reports[theme.value] = report.to_dict() + reports[theme.value] = self._apply_monthly_adjudication_to_theme_report( + theme.value, + report.to_dict(), + ) return { 'success': True, @@ -1102,13 +1582,456 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): theme_values, ), 'mode': mode, - 'evidence_source': self._thematic_evidence_source(mode, derived_context, has_custom_evidence), + 'evidence_source': ( + { + 'mode': 'upstream_contract_reuse', + 'source': 'consultation_workflow_upstream_contract', + 'sample_fallback': False, + 'strict_workflow_routes_available': list(strict_workflow_contracts.keys()), + 'guided_topic_count': len(upstream_guided_topics), + } + if mode == 'upstream_contract_reuse' + else self._thematic_evidence_source(mode, derived_context, has_custom_evidence) + ), 'themes': reports, 'theme_count': len(reports), 'available_themes': [theme.value for theme in report_orchestrator.ThemeName], 'boundary': '主题化报告用于组织证据、裁决冲突和生成叙事;具体预测仍需本命承诺、Dasha、Transit 与案例验证共同收敛。', } + def _compute_high_rigor_workflow(self, body): + if body.get('async') or body.get('enqueue'): + return self._enqueue_high_rigor_job(body) + return self._compute_consultation_workflow({ + **dict(body or {}), + 'surface': 'api_web', + 'return_high_rigor_shape': True, + }) + + def _compute_consultation_workflow(self, body): + return execute_consultation_workflow( + self, + body=dict(body or {}), + surface=body.get('surface', 'api_web'), + ) + + def _compute_high_rigor_workflow_sync(self, body): + body_copy = dict(body or {}) + body_copy.pop('async', None) + body_copy.pop('enqueue', None) + return self._compute_high_rigor_workflow(body_copy) + + def _high_rigor_workflow_plan_only(self, birth_payload, themes, events): + return { + 'success': True, + 'endpoint': 'high_rigor_workflow', + 'mode': 'plan_only_no_external_calls', + 'routes': ['career', 'relationship', 'finance'], + 'themes': themes, + 'event_count': len(events), + 'source_priority': { + 'mode': 'vedastro_official_snapshot_first', + 'priority': [ + 'vedastro_official_snapshot', + 'local_supplemental_modules', + 'local_fallback_only_when_official_blocked', + ], + }, + 'reused_modules': [ + 'vedastro_evidence_orchestrator', + 'birth_time_rectifier', + 'historical_event_backtest', + 'report_orchestrator', + 'reading_orchestrator', + 'orchestrator_bridge', + ], + 'execution_plan': [ + 'compute_chart_with_vedastro_main_entry_overview', + 'run_rectification_gate', + 'run_historical_event_backtest_when_events_exist', + 'generate_thematic_report_for_selected_themes', + 'return_official_primary_supplemental_fallback_conflict_contract', + ], + 'contract': { + 'official_primary_evidence': {}, + 'local_supplemental_evidence': {}, + 'fallback_used': [], + 'blocked_items': [], + 'conflicts': [], + }, + 'execution_strategy': { + 'chart_path': { + 'mode': 'sync_chart_response_cache', + 'cache_scope': _API_CHART_CACHE_SCOPE, + 'cache_ttl_seconds': _api_chart_cache_ttl_seconds(), + 'note': '普通 chart 入口优先复用 API 级最终结果缓存,避免重复拉取官方快照与 prompt pack。', + }, + 'queue_recommendation': { + 'recommended': True, + 'lane': 'high_rigor_workflow', + 'reason': '高严谨链路会叠加 rectification/backtest/thematic report,适合后续进入异步/队列层,而不是始终阻塞同步用户请求。', + }, + }, + 'boundary': 'Plan-only mode is used by API Explorer samples to avoid accidental heavy VedAstro calls. Remove dry_run/plan_only to execute the full workflow and return the official-primary evidence contract.', + } + + def _enqueue_high_rigor_job(self, body): + job_id = f'hrw_{datetime.utcnow().strftime("%Y%m%d%H%M%S%f")}' + queued_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + poll_path = f'/api/high_rigor_workflow/jobs/{job_id}' + record = { + 'success': True, + 'endpoint': 'high_rigor_workflow_async', + 'mode': 'async_submitted', + 'job_id': job_id, + 'status': 'queued', + 'queued_at': queued_at, + 'poll_path': poll_path, + 'scope': _HIGH_RIGOR_JOB_SCOPE, + } + _write_high_rigor_job_record(job_id, record) + + body_copy = dict(body or {}) + + def _run_job() -> None: + running = dict(record) + running['status'] = 'running' + running['started_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + _write_high_rigor_job_record(job_id, running) + try: + result = self._compute_high_rigor_workflow_sync(body_copy) + completed = dict(running) + completed['status'] = 'completed' + completed['completed_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + completed['mode'] = 'async_result' + completed['result'] = result + _write_high_rigor_job_record(job_id, completed) + except Exception as exc: + failed = dict(running) + failed['status'] = 'failed' + failed['completed_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + failed['mode'] = 'async_result' + failed['error'] = str(exc) + _write_high_rigor_job_record(job_id, failed) + + threading.Thread( + target=_run_job, + name=f'high-rigor-job-{job_id}', + daemon=True, + ).start() + return record + + def _enqueue_async_job(self, *, scope, endpoint, job_prefix, poll_base, compute_fn): + job_id = f'{job_prefix}_{datetime.utcnow().strftime("%Y%m%d%H%M%S%f")}' + queued_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + poll_path = f'{poll_base}/{job_id}' + record = { + 'success': True, + 'endpoint': endpoint, + 'mode': 'async_submitted', + 'job_id': job_id, + 'status': 'queued', + 'queued_at': queued_at, + 'poll_path': poll_path, + 'scope': scope, + } + _write_async_job_record(scope, job_id, record) + + def _run_job() -> None: + running = dict(record) + running['status'] = 'running' + running['started_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + _write_async_job_record(scope, job_id, running) + try: + result = compute_fn() + completed = dict(running) + completed['status'] = 'completed' + completed['completed_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + completed['mode'] = 'async_result' + completed['result'] = result + _write_async_job_record(scope, job_id, completed) + except Exception as exc: + failed = dict(running) + failed['status'] = 'failed' + failed['completed_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + failed['mode'] = 'async_result' + failed['error'] = str(exc) + _write_async_job_record(scope, job_id, failed) + + threading.Thread( + target=_run_job, + name=f'{job_prefix}-job-{job_id}', + daemon=True, + ).start() + return record + + def _get_high_rigor_job(self, job_id): + return _load_high_rigor_job_record(job_id) + + def _get_chart_job(self, job_id): + return _load_async_job_record(_API_CHART_CACHE_SCOPE, job_id) + + def _high_rigor_birth_payload(self, body): + required = ('year', 'month', 'day', 'hour', 'minute', 'lat', 'lon') + missing = [key for key in required if body.get(key) is None] + if missing: + raise BadRequest(f'missing birth fields: {", ".join(missing)}') + year = self._get_int(body, 'year', 1990, 1800, 2400) + month = self._get_int(body, 'month', 1, 1, 12) + day = self._get_int(body, 'day', 1, 1, 31) + hour = self._get_float(body, 'hour', 12, 0, 23) + minute = self._get_float(body, 'minute', 0, 0, 59) + second = self._get_birth_second(body) + lat = self._get_float(body, 'lat', 0, -90, 90) + lon = self._get_float(body, 'lon', 0, -180, 180) + tz = self._parse_timezone(body, lat, lon, year, month, day, hour, minute, second) + return { + 'year': year, + 'month': month, + 'day': day, + 'hour': hour, + 'minute': minute, + 'second': second, + 'lat': lat, + 'lon': lon, + 'tz': tz, + 'ayanamsa': body.get('ayanamsa', 'lahiri'), + 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), + 'today': body.get('today') or body.get('current_date'), + 'transit_date': body.get('transit_date') or body.get('reference_date'), + } + + def _high_rigor_requested_themes(self, body): + raw = body.get('themes', body.get('theme', ['career', 'marriage', 'wealth'])) + try: + return _UNIFIED_CONSULTATION_ORCHESTRATOR.normalize_themes(raw) + except ValueError as exc: + message = str(exc) + if message.startswith('Unknown theme:'): + detail = message.split(':', 1)[1].strip() + raise BadRequest(f'Unknown high-rigor theme: {detail}') from exc + raise BadRequest('theme/themes must be a string, list, or all') from exc + + def _high_rigor_events(self, body): + events = body.get('events') or body.get('historical_events') or [] + if events is None: + return [] + if not isinstance(events, list): + raise BadRequest('events must be an array') + normalized = [] + aliases = { + 'relationship': 'marriage', + 'finance': 'wealth', + 'money': 'wealth', + 'job': 'career', + 'work': 'career', + '事业': 'career', + '婚恋': 'marriage', + '财富': 'wealth', + } + for index, event in enumerate(events[:80]): + if not isinstance(event, dict): + raise BadRequest('event items must be objects') + date = event.get('date') or event.get('time') or event.get('event_date') + domain = event.get('domain') or event.get('category') or event.get('type') + if not date or not domain: + continue + domain_key = aliases.get(str(domain).strip().lower(), str(domain).strip().lower()) + normalized.append({ + **event, + 'id': event.get('id') or f'event_{index + 1}', + 'date': str(date)[:10], + 'domain': domain_key, + 'summary': event.get('summary') or event.get('desc') or event.get('description') or '', + }) + return normalized + + def _run_high_rigor_historical_backtest(self, birth_payload, events): + if not events: + return { + 'scope': 'historical_event_backtest', + 'summary': { + 'total_events': 0, + 'strong_hits': 0, + 'weak_hits': 0, + 'misses': 0, + 'blocked_events': 0, + 'unsupported_domain_events': 0, + }, + 'events': [], + 'boundary': 'No historical events were supplied; rectification remains interview-guided only.', + } + module = _load_local_module('historical_event_backtest') + payload = { + 'subject': { + 'year': int(birth_payload['year']), + 'month': int(birth_payload['month']), + 'day': int(birth_payload['day']), + 'hour': int(float(birth_payload['hour'])), + 'minute': int(float(birth_payload['minute'])), + 'lat': float(birth_payload['lat']), + 'lon': float(birth_payload['lon']), + 'tz': float(birth_payload['tz']), + 'node_mode': birth_payload.get('node_mode', 'mean'), + }, + 'events': events, + } + return module.build_report(payload) + + def _high_rigor_vedastro_official_summary(self, chart): + prompt_pack = chart.get('ai_prompt_pack') if isinstance(chart, dict) else {} + evidence_snapshot = prompt_pack.get('evidence_snapshot') if isinstance(prompt_pack, dict) else {} + prompt_official = evidence_snapshot.get('vedastro_official_snapshot') if isinstance(evidence_snapshot, dict) else {} + if not isinstance(prompt_official, dict): + prompt_official = {} + prompt_full_snapshot = evidence_snapshot.get('vedastro_official_full_snapshot') if isinstance(evidence_snapshot, dict) else {} + if not isinstance(prompt_full_snapshot, dict): + prompt_full_snapshot = {} + modules = chart.get('modules') if isinstance(chart, dict) else {} + range_scan = modules.get('vedastro_range_scan_result') if isinstance(modules, dict) else {} + if not isinstance(range_scan, dict): + range_scan = {} + full_snapshot_payload = _build_vedastro_official_full_snapshot_payload_from_chart(chart) + official_snapshot = range_scan.get('official_full_snapshot') if isinstance(range_scan, dict) else {} + if not isinstance(official_snapshot, dict): + official_snapshot = {} + metadata = official_snapshot.get('source_metadata') if isinstance(official_snapshot, dict) else {} + catalog = metadata.get('official_full_capability_catalog') if isinstance(metadata, dict) else {} + if not isinstance(catalog, dict): + catalog = {} + range_metadata = range_scan.get('source_metadata') if isinstance(range_scan, dict) else {} + if not isinstance(range_metadata, dict): + range_metadata = {} + strict_workflow_contracts = ( + prompt_full_snapshot.get('strict_workflow_contracts') + or full_snapshot_payload.get('strict_workflow_contracts') + or {} + ) + if not isinstance(strict_workflow_contracts, dict): + strict_workflow_contracts = {} + strict_workflow_primary_route = ( + prompt_full_snapshot.get('strict_workflow_primary_route') + or full_snapshot_payload.get('strict_workflow_primary_route') + ) + strict_workflow_routes_available = ( + prompt_full_snapshot.get('strict_workflow_routes_available') + or full_snapshot_payload.get('strict_workflow_routes_available') + or list(strict_workflow_contracts.keys()) + ) + if not isinstance(strict_workflow_routes_available, list): + strict_workflow_routes_available = list(strict_workflow_contracts.keys()) + _selected_route, primary_contract = _preferred_strict_contract( + strict_workflow_contracts, + strict_workflow_primary_route, + ) + dynamic_selection = ( + prompt_official.get('official_full_capability_dynamic_selection') + or catalog.get('dynamic_selection') + or prompt_full_snapshot.get('official_full_capability_dynamic_selection') + or full_snapshot_payload.get('official_full_capability_dynamic_selection') + or range_metadata.get('official_full_capability_dynamic_selection') + or {} + ) + report_references = ( + prompt_official.get('official_report_references') + or prompt_full_snapshot.get('official_report_references') + or full_snapshot_payload.get('official_report_references') + or range_metadata.get('official_report_references') + or { + theme: selection.get('report_reference') + for theme, selection in dynamic_selection.items() + if isinstance(selection, dict) and isinstance(selection.get('report_reference'), dict) + } + ) + return { + 'status': ( + prompt_official.get('status') + or official_snapshot.get('status') + or range_scan.get('status') + or 'blocked' + ), + 'range_scan_status': range_scan.get('status') if isinstance(range_scan, dict) else None, + 'event_count': int(range_scan.get('event_count', 0) or 0) if isinstance(range_scan, dict) else 0, + 'official_full_capability_catalog_status': ( + prompt_official.get('official_full_capability_catalog_status') + or catalog.get('status') + or range_metadata.get('official_full_capability_catalog_status') + ), + 'official_full_capability_catalog_summary': ( + prompt_official.get('official_full_capability_catalog_summary') + or prompt_full_snapshot.get('official_full_capability_catalog_summary') + or full_snapshot_payload.get('official_full_capability_catalog_summary') + or catalog.get('summary') + or range_metadata.get('official_full_capability_catalog_summary') + or {} + ), + 'official_full_capability_domain_routing': ( + prompt_official.get('official_full_capability_domain_routing') + or prompt_full_snapshot.get('official_full_capability_domain_routing') + or full_snapshot_payload.get('official_full_capability_domain_routing') + or catalog.get('domain_routing') + or range_metadata.get('official_full_capability_domain_routing') + or {} + ), + 'official_full_capability_dynamic_selection': dynamic_selection, + 'official_report_references': report_references, + 'strict_workflow_primary_route': strict_workflow_primary_route, + 'strict_workflow_routes_available': strict_workflow_routes_available, + 'strict_workflow_contracts': strict_workflow_contracts, + 'official_primary_evidence': ( + primary_contract.get('official_primary_evidence') + or prompt_official.get('official_primary_evidence') + or {} + ), + 'local_supplemental_evidence': ( + primary_contract.get('local_supplemental_evidence') + or prompt_official.get('local_supplemental_evidence') + or {} + ), + 'fallback_used': ( + primary_contract.get('fallback_used') + or prompt_official.get('fallback_used') + or [] + ), + 'blocked_items': ( + primary_contract.get('blocked_items') + or prompt_official.get('blocked_items') + or [] + ), + 'conflicts': ( + primary_contract.get('conflicts') + or prompt_official.get('conflicts') + or [] + ), + 'technique_audit_summary': primary_contract.get('technique_audit_summary') or {}, + 'adjudication_stages': primary_contract.get('adjudication_stages') or {}, + 'multi_reference_reading_summary': primary_contract.get('multi_reference_reading_summary') or {}, + 'verdict': primary_contract.get('verdict'), + 'dominant_label': primary_contract.get('dominant_label'), + 'main_conflicts': primary_contract.get('main_conflicts') or primary_contract.get('conflicts') or [], + 'boundary': 'VedAstro official snapshot and capability catalog are consumed as primary evidence metadata; execution breadth depends on configured network and sample limits.', + } + + def _high_rigor_next_questions(self, rectification, historical_backtest): + questions = [] + summary = rectification.get('summary') if isinstance(rectification, dict) else {} + for item in summary.get('recommended_events') or []: + questions.append({ + 'type': 'yes_no_or_date', + 'topic': item, + 'question': f'你是否有日期较明确的 {item} 事件?如果有,请补年份/月/日。', + }) + backtest_summary = historical_backtest.get('summary') if isinstance(historical_backtest, dict) else {} + if not isinstance(backtest_summary, dict): + backtest_summary = {} + if int(backtest_summary.get('total_events', 0) or 0) < 5: + questions.append({ + 'type': 'free_text', + 'topic': 'event_sample_size', + 'question': '目前历史事件少于5个。请补充搬家、升学、工作转折、家庭重大事件、奖项/收入变化等日期。', + }) + return questions[:8] + def _thematic_workflow_status(self, reading_orchestrator, orchestrator_bridge, report_orchestrator, theme_values): reading_themes = [] for theme in getattr(reading_orchestrator, 'ReadingTheme'): @@ -1277,7 +2200,14 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return None has_birth = all(raw.get(key) is not None for key in ('year', 'month', 'day')) - full_reading = collect('full_reading', lambda: self._compute_full_reading_for_thematic(raw)) if has_birth else None + skip_full_reading = bool(raw.get('skip_full_reading_for_thematic')) + full_reading = ( + collect('full_reading', lambda: self._compute_full_reading_for_thematic(raw)) + if has_birth and not skip_full_reading + else None + ) + if skip_full_reading: + module_status['full_reading'] = 'skipped_reuse_chart_data' full_modules = full_reading.get('modules', {}) if isinstance(full_reading, dict) else {} chart = None if full_reading: @@ -1469,6 +2399,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'strengths': user_narrative.get('strengths', [])[:3], 'risks': user_narrative.get('risks', [])[:3], 'boundaries': user_narrative.get('boundaries', [])[:3], + 'monthly_frame': user_narrative.get('monthly_frame', {}), }, )) return items @@ -1511,7 +2442,11 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): details=top_strength, )) convergence = full_modules.get('dasa_convergence') if isinstance(full_modules, dict) else {} + if not isinstance(convergence, dict): + convergence = {} top_domains = convergence.get('top_convergent_domains') if isinstance(convergence, dict) else [] + if not isinstance(top_domains, list): + top_domains = [] career_domain = next( ( row for row in top_domains @@ -1534,6 +2469,24 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): source='full_reading.modules.dasa_convergence', details=career_domain, )) + strict_career = full_modules.get('career_strict_evidence') if isinstance(full_modules, dict) else {} + user_narrative = strict_career.get('user_narrative') if isinstance(strict_career, dict) else {} + if isinstance(user_narrative, dict) and user_narrative.get('markdown'): + items.append(self._theme_evidence( + 'Career-strict-narrative', + 'Strict', + user_narrative.get('markdown'), + 'neutral', + 'strong', + source='full_reading.modules.career_strict_evidence.user_narrative', + details={ + 'headline': user_narrative.get('headline'), + 'strengths': user_narrative.get('strengths', [])[:3], + 'risks': user_narrative.get('risks', [])[:3], + 'boundaries': user_narrative.get('boundaries', [])[:3], + 'monthly_frame': user_narrative.get('monthly_frame', {}), + }, + )) return items def _derived_wealth_evidence(self, chart_data, context): @@ -1578,6 +2531,24 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): source='full_reading.modules.yogas_doshas', details={'yogas': dhana_yogas[:4]}, )) + strict_finance = full_modules.get('finance_strict_evidence') if isinstance(full_modules, dict) else {} + user_narrative = strict_finance.get('user_narrative') if isinstance(strict_finance, dict) else {} + if isinstance(user_narrative, dict) and user_narrative.get('markdown'): + items.append(self._theme_evidence( + 'Finance-strict-narrative', + 'Strict', + user_narrative.get('markdown'), + 'neutral', + 'strong', + source='full_reading.modules.finance_strict_evidence.user_narrative', + details={ + 'headline': user_narrative.get('headline'), + 'strengths': user_narrative.get('strengths', [])[:3], + 'risks': user_narrative.get('risks', [])[:3], + 'boundaries': user_narrative.get('boundaries', [])[:3], + 'monthly_frame': user_narrative.get('monthly_frame', {}), + }, + )) return items def _derived_health_evidence(self, chart_data, context): @@ -1704,6 +2675,518 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): }, } + def _strict_monthly_frame_from_theme_report(self, report_payload): + if not isinstance(report_payload, dict): + return {} + evidence = report_payload.get('evidence') + if not isinstance(evidence, list): + return {} + for item in evidence: + if not isinstance(item, dict): + continue + details = item.get('details') + if not isinstance(details, dict): + continue + monthly_frame = details.get('monthly_frame') + if isinstance(monthly_frame, dict) and monthly_frame: + return monthly_frame + return {} + + def _humanize_monthly_adjudication(self, theme_name, primary_state, manifestation_mode, friction_source, time_confidence): + primary_map = { + '推进': '进入可主动推进窗口', + '启动': '进入新线索浮出的阶段', + '观察': '更适合观察与试探,不宜过早定性', + '筛选': '更适合边接触边筛选,先排除不合适的人或事', + '整固': '更像守成整固,而不是激进扩张', + '重组': '更像旧结构拆开重排,再决定下一步', + '收束': '更像阶段性收尾、定局或止损', + } + timing_map = { + 'day_supported': '月份与少数关键日期都可参考,日期判断相对更实用。', + 'month_supported': '以月份判断最稳,具体日期只能作辅助观察。', + 'month_only': '只能判断月级趋势,暂时不宜把结论压到具体某一天。', + 'blocked': '时间证据仍不足,当前只能保守看趋势,不宜下精确日期判断。', + } + manifestation_map = { + 'career': { + '职业定位推进': '更像职业定位开始推进,适合明确方向、角色或赛道。', + '职位/项目/职责抬头': '更像职位、项目或职责开始抬头。', + '项目/合作推进': '更像项目、合作、签约或公开职责往前走。', + }, + 'marriage': { + '关系观察/筛选': '更像先接触、观察、筛选,再决定是否深入推进。', + '公开化/关系可见度上升': '更像关系可见度上升,或公开互动开始变多。', + }, + 'wealth': { + '现金流结构观察': '更像先看现金流结构与回款节奏,而不是立刻看到大额留存。', + '定金/回款/短期现金流改善': '更像定金、回款或短期现金流出现改善。', + }, + } + friction_map = { + '流程卡顿但机会仍在': '机会未消失,但流程、对接或资源节奏会更磨人。', + '执行压力伴随机会': '机会和压力会一起出现,往往不是轻松拿下,而是边扛边推进。', + '时间证据不足': '时间证据还不够密,能看趋势,但不宜把结论说得过满。', + } + + humanized = { + 'primary_state': primary_map.get(primary_state, primary_state or ''), + 'manifestation_mode': manifestation_map.get(theme_name, {}).get(manifestation_mode, manifestation_mode or ''), + 'friction_source': friction_map.get(friction_source, friction_source or ''), + 'time_confidence': timing_map.get(time_confidence, time_confidence or ''), + } + return humanized + + @staticmethod + def _theme_evidence_lookup(report_payload): + if not isinstance(report_payload, dict): + return {} + evidence = report_payload.get('evidence') + if not isinstance(evidence, list): + return {} + lookup = {} + for item in evidence: + if not isinstance(item, dict): + continue + technique = str(item.get('technique') or '').strip() + if technique and technique not in lookup: + lookup[technique] = item + return lookup + + @staticmethod + def _normalize_sentence(text): + value = str(text or '').replace('\n', ' ').strip() + if not value: + return '' + value = re.sub(r'\s+', ' ', value) + value = value.replace('。,', '。').replace(',。', '。').replace('..', '.') + if value[-1] not in '。!?!?': + value = value + '。' + return value.replace('。。', '。') + + def _join_adjudication_parts(self, parts): + normalized = [self._normalize_sentence(part) for part in parts if str(part or '').strip()] + return " ".join(normalized).replace('。。', '。').strip() + + @staticmethod + def _strip_humanized_prefix(text): + value = str(text or '').strip() + if value.startswith('更像'): + return value[2:].strip() + return value + + def _join_brief_points(self, items): + cleaned = [] + for item in items or []: + value = self._normalize_sentence(item) + if not value: + continue + cleaned.append(value.rstrip('。!?!?')) + return ';'.join(cleaned) + + @staticmethod + def _matching_strict_lines(lines, keywords): + if not isinstance(lines, list): + return [] + matches = [] + for line in lines: + value = str(line or '').strip() + if value and any(keyword in value for keyword in keywords): + matches.append(value) + return matches + + @staticmethod + def _extract_confidence_cap(strict_item): + if not isinstance(strict_item, dict): + return '' + conclusion = str(strict_item.get('conclusion') or '') + match = re.search(r'confidence_cap:\s*([a-z_]+)', conclusion) + if not match: + return '' + return match.group(1).strip().lower() + + def _confidence_cap_cn(self, confidence_cap): + mapping = { + 'high': '高', + 'moderate': '中', + 'low': '低', + 'blocked': '阻塞', + 'unknown': '未知', + } + return mapping.get(str(confidence_cap or '').strip().lower(), str(confidence_cap or '').strip()) + + def _strict_axis_payload_for_theme(self, theme_name, report_payload, humanized): + lookup = self._theme_evidence_lookup(report_payload) + strict_key_map = { + 'career': 'Career-strict-narrative', + 'marriage': 'Relationship-strict-narrative', + 'wealth': 'Finance-strict-narrative', + } + strict_item = lookup.get(strict_key_map.get(theme_name, '')) or {} + strict_details = strict_item.get('details') if isinstance(strict_item.get('details'), dict) else {} + strengths = strict_details.get('strengths') if isinstance(strict_details.get('strengths'), list) else [] + risks = strict_details.get('risks') if isinstance(strict_details.get('risks'), list) else [] + boundaries = strict_details.get('boundaries') if isinstance(strict_details.get('boundaries'), list) else [] + confidence_cap = self._extract_confidence_cap(strict_item) + return { + 'lookup': lookup, + 'strict_item': strict_item, + 'strict_details': strict_details, + 'strengths': strengths, + 'risks': risks, + 'boundaries': boundaries, + 'confidence_cap': confidence_cap, + 'humanized': humanized, + } + + def _career_axis_judgements(self, report_payload, humanized): + payload = self._strict_axis_payload_for_theme('career', report_payload, humanized) + lookup = payload['lookup'] + strengths = payload['strengths'] + risks = payload['risks'] + boundaries = payload['boundaries'] + confidence_cap = payload['confidence_cap'] + + d10 = ((lookup.get('D1-10th-house') or {}).get('details') or {}) + convergence = lookup.get('Dasa-convergence-career') or {} + shadbala = lookup.get('Shadbala-career-support') or {} + tenth_sign = d10.get('sign') or '未知' + tenth_planets = d10.get('planets_label') or '无' + role_supports = self._matching_strict_lines(strengths, ['A10', 'Amatyakaraka', 'Karakamsha']) + role_risks = self._matching_strict_lines(risks, ['Argala', '阻力', '卡顿']) + org_supports = self._matching_strict_lines(strengths, ['A10', 'Amatyakaraka']) + migration_boundary = self._matching_strict_lines(boundaries, ['VedAstro', '时间置信度', 'D10']) + convergence_text = str(convergence.get('conclusion') or convergence.get('details', {}).get('interpretation') or '').strip() + shadbala_text = str(shadbala.get('conclusion') or '').strip() + manifestation_core = self._strip_humanized_prefix(humanized.get('manifestation_mode')) + role_support_text = self._join_brief_points(role_supports[:3]) + org_support_text = self._join_brief_points(org_supports[:2]) + + axes = [ + { + 'axis': '角色定位', + 'judgement': self._join_adjudication_parts([ + f"角色定位这一轴,第10宫在{tenth_sign},宫内{tenth_planets},所以事业判断的主问题不是单看有没有机会,而是你会以什么角色、职责和公众面貌被看见。", + f"当前严格链已经把这几层并入主裁决:{role_support_text}。" if role_support_text else '', + f"这也是为什么本轮月度主状态不是静态守成,而是{humanized.get('primary_state')};落地形式更偏{manifestation_core}。", + role_risks[0] if role_risks else '', + f"置信上限:{self._confidence_cap_cn(confidence_cap)}。" if confidence_cap else '', + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['D1-10th-house', 'Career-strict-narrative'], + }, + { + 'axis': '项目合作', + 'judgement': self._join_adjudication_parts([ + "项目合作这一轴,当前不是完全空白,但也还没到可以直接写成长期稳定落袋的程度。", + convergence_text or '多重时间系统已经触到事业域,但还需要更多现实确认。', + f"所以更像先有合作入口、项目接触或职责试探,再决定是否真正推进到签约、常驻或长期绑定。", + f"阻力层面,{humanized.get('friction_source')}", + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['Dasa-convergence-career', 'Career-strict-narrative'], + }, + { + 'axis': '组织权责', + 'judgement': self._join_adjudication_parts([ + "组织权责这一轴,比起单纯换工作,更像权责结构、上级关系和专业角色承担被重新摆到台前。", + f"严格链里最关键的支撑是这几层:{org_support_text}。" if org_support_text else '', + shadbala_text, + "这意味着你容易被要求承担更明确的职责、结果或对外可见任务,但通常不是轻松抬升,而是伴随现实压力同步出现。", + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['Career-strict-narrative', 'Shadbala-career-support'], + }, + { + 'axis': '迁移动向', + 'judgement': self._join_adjudication_parts([ + "迁移动向这一轴,当前主链并没有把“异地定局”抬成事业主题的最强主轴。", + f"现有证据更集中在第10宫职责触发与{manifestation_core},而不是直接给出长期搬家、长期异地驻扎已经坐实的锚点。", + migration_boundary[0] if migration_boundary else '如果后续官方日窗口或外部事件层补到 relocation / travel 命中,才适合进一步上调迁移判断。', + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['D1-10th-house', 'Career-strict-narrative'], + }, + ] + return axes + + def _marriage_axis_judgements(self, report_payload, humanized): + payload = self._strict_axis_payload_for_theme('marriage', report_payload, humanized) + lookup = payload['lookup'] + strengths = payload['strengths'] + risks = payload['risks'] + boundaries = payload['boundaries'] + confidence_cap = payload['confidence_cap'] + + seventh = ((lookup.get('D1-7th-house') or {}).get('details') or {}) + timing = lookup.get('DK-UL-Dasha timing') or {} + counting = lookup.get('Marriage-counting') or {} + vivah = lookup.get('Vivah-saham') or {} + + seventh_sign = seventh.get('sign') or '未知' + seventh_planets = seventh.get('planets_label') or '无' + d9_quality = (((counting.get('details') or {}).get('d9_marriage_quality')) or {}) + timing_conclusion = str(timing.get('conclusion') or '').strip() + vivah_conclusion = str(vivah.get('conclusion') or '').strip() + manifestation_core = self._strip_humanized_prefix(humanized.get('manifestation_mode')) + ul_supports = self._matching_strict_lines(strengths, ['Upapada', 'UL']) + low_confidence = self._matching_strict_lines(risks, ['confidence cap', '冲突', '不足']) + boundary_focus = self._matching_strict_lines(boundaries, ['D1、D9、UL', 'legal_marriage', 'dual dasha']) + + axes = [ + { + 'axis': '关系推进', + 'judgement': self._join_adjudication_parts([ + f"关系推进这一轴,第7宫在{seventh_sign},宫内{seventh_planets},说明伴侣关系会成为需要正面面对的人生主轴,而不是轻描淡写带过的副题。", + timing_conclusion, + f"当前月度主状态是{humanized.get('primary_state')},所以更像关系线索开始浮出,而不是已经进入婚约定局。", + f"置信上限:{self._confidence_cap_cn(confidence_cap)}。" if confidence_cap else '', + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['D1-7th-house', 'DK-UL-Dasha timing', 'Relationship-strict-narrative'], + }, + { + 'axis': '对象筛选', + 'judgement': self._join_adjudication_parts([ + "对象筛选这一轴,是当前婚恋判断里最不能跳过的一层。", + f"严格链已经明确当前落地形式更偏{manifestation_core},也就是说重点不是立刻确认关系,而是先看谁值得继续推进。", + str(counting.get('conclusion') or ''), + d9_quality.get('quality_rating') or '', + "这类组合更像先识别重复的关系模式,再决定是否深入,而不是因为出现线索就直接抬升成结婚窗口。", + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['Marriage-counting', 'Relationship-strict-narrative'], + }, + { + 'axis': '公开化程度', + 'judgement': self._join_adjudication_parts([ + "公开化程度这一轴,当前可以看见关系可见度会慢慢增加,但它和法律婚姻不是一回事。", + ul_supports[0] if ul_supports else '', + vivah_conclusion, + "因此更合理的读法是:先有互动增加、公开接触增多或身边人开始知道,再看后续是否真的跨进更正式的承诺层。", + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['Relationship-strict-narrative', 'Vivah-saham'], + }, + { + 'axis': '承诺边界', + 'judgement': self._join_adjudication_parts([ + "承诺边界这一轴,必须压住过度乐观的解读。", + low_confidence[0] if low_confidence else '', + boundary_focus[0] if boundary_focus else '在 D1、D9、UL 与 dual dasha 没有更完整闭环前,不能把当前关系窗口直接包装成结婚必然落地。', + "所以这轮最严谨的结论是:婚恋线在动,但更像进入观察、筛选和校验阶段,而不是已经可以宣布承诺定局。", + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['Relationship-strict-narrative', 'DK-UL-Dasha timing'], + }, + ] + return axes + + def _wealth_axis_judgements(self, report_payload, humanized): + payload = self._strict_axis_payload_for_theme('wealth', report_payload, humanized) + lookup = payload['lookup'] + risks = payload['risks'] + boundaries = payload['boundaries'] + confidence_cap = payload['confidence_cap'] + + houses = ((lookup.get('2nd-and-11th-house') or {}).get('details') or {}) + second = houses.get('second') if isinstance(houses.get('second'), dict) else {} + eleventh = houses.get('eleventh') if isinstance(houses.get('eleventh'), dict) else {} + av = lookup.get('Ashtakavarga-wealth') or {} + dhana = lookup.get('Dhana-yoga-full-reading') or {} + + second_label = second.get('planets_label') or '无' + second_sign = second.get('sign') or '未知' + eleventh_label = eleventh.get('planets_label') or '无' + eleventh_sign = eleventh.get('sign') or '未知' + manifestation_core = self._strip_humanized_prefix(humanized.get('manifestation_mode')) + dhana_yogas = (((dhana.get('details') or {}).get('yogas')) or []) + dhana_texts = [ + str(row.get('interpretation') or '').strip() + for row in dhana_yogas + if isinstance(row, dict) and str(row.get('interpretation') or '').strip() + ] + risk_lines = self._matching_strict_lines(risks, ['wealth_convergence', 'Shadbala', '时间证据不足']) + boundary_lines = self._matching_strict_lines(boundaries, ['D2/D11', '官方财富日窗口', '时间置信度']) + + axes = [ + { + 'axis': '收入兑现', + 'judgement': self._join_adjudication_parts([ + f"收入兑现这一轴,要先看第2宫与第11宫:第2宫在{second_sign}且有{second_label},第11宫在{eleventh_sign}且{eleventh_label}。", + f"所以财富并不是纯抽象的“有财没财”,而是收入、积累与收益网络怎么落地的问题。", + dhana_texts[0] if dhana_texts else '', + f"当前月度主状态是{humanized.get('primary_state')},说明线索在起,但还不是立刻把全年收入上限一次性坐实。", + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['2nd-and-11th-house', 'Dhana-yoga-full-reading', 'Finance-strict-narrative'], + }, + { + 'axis': '现金流节奏', + 'judgement': self._join_adjudication_parts([ + "现金流节奏这一轴,比总资产量级更值得先看。", + str(av.get('conclusion') or ''), + f"严格链已经把当前落地形式定义成{manifestation_core},所以这阶段更适合盯定金、回款、分期进账、项目进度款,而不是先幻想一次性大额沉淀。", + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['Ashtakavarga-wealth', 'Finance-strict-narrative'], + }, + { + 'axis': '合作分账', + 'judgement': self._join_adjudication_parts([ + "合作分账这一轴,重点不在“有没有人给钱”,而在“钱以什么合作结构进来、最后能留下多少”。", + dhana_texts[1] if len(dhana_texts) > 1 else (dhana_texts[0] if dhana_texts else ''), + "这更像依托合作、技能输出、项目撮合或资源交换来形成收入,而不是完全脱离人脉与协作的独立孤立进账。", + boundary_lines[1] if len(boundary_lines) > 1 else '', + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['Dhana-yoga-full-reading', 'Finance-strict-narrative'], + }, + { + 'axis': '风险留存', + 'judgement': self._join_adjudication_parts([ + "风险留存这一轴,是当前财富判断必须保守的地方。", + risk_lines[0] if risk_lines else '', + risk_lines[1] if len(risk_lines) > 1 else '', + f"置信上限:{self._confidence_cap_cn(confidence_cap)}。" if confidence_cap else '', + "因此这轮更适合把财富理解为现金流和结构在动,而不是把它夸大成稳定高留存已经形成。", + f"时间边界:{humanized.get('time_confidence')}", + ]), + 'evidence_anchor': ['Finance-strict-narrative'], + }, + ] + return axes + + def _interpretation_axes_for_theme(self, theme_name): + mapping = { + 'career': ['角色定位', '项目合作', '组织权责', '迁移动向'], + 'marriage': ['关系推进', '对象筛选', '公开化程度', '承诺边界'], + 'wealth': ['收入兑现', '现金流节奏', '合作分账', '风险留存'], + } + axes = mapping.get(theme_name, ['主轴判断', '次轴验证', '现实阻力', '时间边界']) + return [{'axis': axis} for axis in axes] + + def _strict_interpretation_axes_for_theme(self, theme_name, report_payload, humanized): + builder_map = { + 'career': self._career_axis_judgements, + 'marriage': self._marriage_axis_judgements, + 'wealth': self._wealth_axis_judgements, + } + builder = builder_map.get(theme_name) + if builder: + return builder(report_payload, humanized) + return self._interpretation_axes_for_theme(theme_name) + + def _theme_strict_audit_gate(self, theme_name, report_payload): + if not isinstance(report_payload, dict): + return {} + direct = report_payload.get('technique_audit_summary') + if isinstance(direct, dict) and direct: + return direct + evidence = report_payload.get('evidence') + if not isinstance(evidence, list): + return {} + strict_prefix = { + 'career': 'Career-strict-narrative', + 'marriage': 'Relationship-strict-narrative', + 'wealth': 'Finance-strict-narrative', + }.get(theme_name) + for item in evidence: + if not isinstance(item, dict): + continue + if strict_prefix and item.get('technique') != strict_prefix: + continue + details = item.get('details') + if isinstance(details, dict): + gate = details.get('technique_audit_summary') + if isinstance(gate, dict) and gate: + return gate + return {} + + def _apply_monthly_adjudication_to_theme_report(self, theme_name, report_payload): + if not isinstance(report_payload, dict): + return report_payload + monthly_frame = self._strict_monthly_frame_from_theme_report(report_payload) + if not monthly_frame: + return report_payload + + primary_state = (monthly_frame.get('primary_state') or {}).get('value') + manifestation_mode = (monthly_frame.get('manifestation_mode') or {}).get('value') + friction_source = (monthly_frame.get('friction_source') or {}).get('value') + time_confidence = (monthly_frame.get('time_confidence') or {}).get('value') + humanized = self._humanize_monthly_adjudication( + theme_name, + primary_state, + manifestation_mode, + friction_source, + time_confidence, + ) + axes = self._strict_interpretation_axes_for_theme(theme_name, report_payload, humanized) + strict_audit_gate = self._theme_strict_audit_gate(theme_name, report_payload) + narrative_contract = { + 'theme': theme_name, + 'monthly_frame_applied': True, + } + strict_bundle = { + 'theme': theme_name, + 'monthly_adjudication_summary': monthly_frame, + 'monthly_adjudication_summary_humanized': humanized, + 'strict_audit_gate': strict_audit_gate, + 'interpretation_axes': axes, + 'narrative_contract': narrative_contract, + } + + summary = str(report_payload.get('summary') or '') + summary_parts = [summary] if summary else [] + if humanized.get('primary_state'): + summary_parts.append(f"月度主状态:{humanized.get('primary_state')}。") + if humanized.get('manifestation_mode'): + summary_parts.append(f"落地形式:{humanized.get('manifestation_mode')}。") + report_payload['summary'] = " ".join(part for part in summary_parts if part).strip() + + narrative = str(report_payload.get('narrative') or '') + narrative_parts = [narrative] if narrative else [] + if humanized.get('friction_source'): + narrative_parts.append(f"阻力来源:{humanized.get('friction_source')}。") + if humanized.get('time_confidence'): + narrative_parts.append(f"时间置信度:{humanized.get('time_confidence')}。") + report_payload['narrative'] = " ".join(part for part in narrative_parts if part).strip() + + recommendations = report_payload.get('recommendations') + if isinstance(recommendations, list): + enriched_recommendations = list(recommendations) + monthly_recommendation_parts = [] + if humanized.get('primary_state'): + monthly_recommendation_parts.append(f"月度主状态:{humanized.get('primary_state')}。") + if humanized.get('manifestation_mode'): + monthly_recommendation_parts.append(f"落地形式:{humanized.get('manifestation_mode')}。") + if humanized.get('friction_source'): + monthly_recommendation_parts.append(f"阻力来源:{humanized.get('friction_source')}。") + if humanized.get('time_confidence'): + monthly_recommendation_parts.append(f"时间置信度:{humanized.get('time_confidence')}。") + if monthly_recommendation_parts: + enriched_recommendations.append(" ".join(monthly_recommendation_parts)) + if axes: + enriched_recommendations.append( + "本轮重点拆成:" + "、".join(str(item.get('axis')) for item in axes[:4]) + "。" + ) + report_payload['recommendations'] = enriched_recommendations + + report_payload['monthly_adjudication_summary'] = monthly_frame + report_payload['monthly_adjudication_summary_humanized'] = humanized + report_payload['interpretation_axes'] = axes + report_payload['strict_adjudication_bundle'] = strict_bundle + report_payload['strict_audit_gate'] = strict_audit_gate + report_payload['narrative_contract'] = narrative_contract + report_payload['summary'] = report_payload['summary'].replace('。。', '。') + report_payload['narrative'] = report_payload['narrative'].replace('。。', '。') + if isinstance(report_payload.get('recommendations'), list): + report_payload['recommendations'] = [ + str(item).replace('。。', '。') + for item in report_payload['recommendations'] + ] + return report_payload + def _theme_house_snapshot(self, chart_data, house_num): houses = chart_data.get('houses') if isinstance(chart_data.get('houses'), dict) else {} house = houses.get(house_num) or houses.get(str(house_num)) or {} @@ -2039,7 +3522,29 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return periods def _compute_chart(self, body): + if body.get('async') or body.get('enqueue'): + return self._enqueue_chart_job(body) + return self._compute_chart_sync(body) + + def _enqueue_chart_job(self, body): + body_copy = dict(body or {}) + body_copy.pop('async', None) + body_copy.pop('enqueue', None) + return self._enqueue_async_job( + scope=_API_CHART_CACHE_SCOPE, + endpoint='chart_async', + job_prefix='chart', + poll_base='/api/chart/jobs', + compute_fn=lambda: self._compute_chart_sync(body_copy), + ) + + def _compute_chart_sync(self, body): """完整星盘计算""" + cache_payload = _build_api_chart_cache_payload(body) + cached_result = _load_api_chart_response_cache(cache_payload) + if isinstance(cached_result, dict): + return cached_result + year = self._get_int(body, 'year', 1990, 1800, 2400) month = self._get_int(body, 'month', 6, 1, 12) day = self._get_int(body, 'day', 15, 1, 31) @@ -2251,9 +3756,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): }) _attach_guided_topics(result) result['ai_prompt_pack'] = self._build_chart_prompt_pack(result) - return result + return _store_api_chart_response_cache(cache_payload, result) except ImportError: - return self._fallback_chart(year, month, day, hour, minute, second, lat, lon, tz) + fallback = self._fallback_chart(year, month, day, hour, minute, second, lat, lon, tz) + return _store_api_chart_response_cache(cache_payload, fallback) def _fallback_chart(self, year, month, day, hour, minute, second, lat, lon, tz): """无Swiss Ephemeris时的简化计算""" @@ -4397,6 +5903,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): ]).lower() endpoint_keywords = [ ('/api/thematic_report', ['thematic', '主题', 'report_orchestrator', 'reading', 'report']), + ('/api/high_rigor_workflow', ['high_rigor', '高严谨', 'rectification', 'backtest', 'vedastro']), ('/api/relationship', ['relationship', 'spouse', '婚姻', '感情']), ('/api/career', ['career', '事业']), ('/api/dasha', ['dasha', 'vimshottari']), @@ -4495,6 +6002,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/shadbala': self._compute_shadbala, '/api/sudarshana': self._compute_sudarshana, '/api/synastry': self._compute_synastry, + '/api/consultation_workflow': self._compute_consultation_workflow, + '/api/high_rigor_workflow': self._compute_high_rigor_workflow, '/api/thematic_report': self._compute_thematic_report, '/api/transit': self._compute_transit_triggers, '/api/varga_full': self._compute_varga_full, @@ -4617,6 +6126,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/shadbala': 'Compute Shadbala plus advanced evidence layer', '/api/sudarshana': 'Compute Sudarshana Chakra evidence', '/api/synastry': 'Compute 16-factor/Ashtakoot compatibility score', + '/api/consultation_workflow': 'Unified user consultation workflow for direct charting or rectification-first entry', + '/api/high_rigor_workflow': 'Compose VedAstro-first chart evidence, rectification gate, historical backtest, and thematic report', '/api/thematic_report': 'Generate thematic report with sample/custom/derived evidence', '/api/transit': 'Search transit trigger windows', '/api/varga_full': 'Compute divisional chart positions', @@ -4627,6 +6138,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): def _endpoint_method_notes(self, endpoint): notes = { '/api/thematic_report': '传 birth/chart payload 时会进入 derived_chart_evidence;只传 theme 时使用样例证据。', + '/api/consultation_workflow': '统一 direct_chart / rectification 入口;网页/app 与 skill/MCP 共用同一套问题路由和官方优先证据 contract。', + '/api/high_rigor_workflow': '复用 chart、rectification_gate、historical_event_backtest 与 thematic_report;不重写底层算法。', '/api/shadbala': 'advanced_layer 是证据补充,不覆盖主 Shadbala 总分。', '/api/yogas': 'curse_yogas 是高风险提示层,不能替代健康/法律/安全建议。', '/api/case_validation': 'MEVG 只读门控不运行外部子进程。', @@ -4675,6 +6188,28 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/shadbala': {**base, **birth}, '/api/sudarshana': base, '/api/synastry': {'male_moon': SAMPLE_PLANETS['Moon']['lon'], 'female_moon': 243.0}, + '/api/high_rigor_workflow': { + **birth, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + 'dry_run': True, + 'theme': ['career', 'marriage', 'wealth'], + 'events': [ + {'date': '2019-12-15', 'domain': 'career', 'summary': '事业逐渐好转'}, + {'date': '2025-02-28', 'domain': 'wealth', 'summary': '项目结束与现金流变化'}, + ], + }, + '/api/consultation_workflow': { + **birth, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + 'dry_run': True, + 'entry_mode': 'direct_chart', + 'theme': ['career', 'marriage', 'wealth'], + 'question': '请直接排盘并进入互动解盘', + }, '/api/thematic_report': {'theme': 'marriage'}, '/api/transit': {'natal_planets': SAMPLE_PLANETS, 'ascendant': SAMPLE_ASCENDANT, 'start': today, 'end': transit_end, 'planets_to_check': ['Saturn', 'Jupiter', 'Rahu', 'Ketu']}, '/api/varga_full': {**base, 'divisions': ['D9', 'D10']}, diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 1699f892..1c284801 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -41,6 +41,7 @@ import sys import os import csv import math +import time import sqlite3 import importlib.util from concurrent.futures import ThreadPoolExecutor @@ -1020,6 +1021,12 @@ def _build_relationship_narrative_payload(relationship_strict): 'boundaries': [ '未完成 D1 + D9 + UL + dual dasha 交叉前,不得把单一关系信号写成高置信度婚姻结论。', ], + 'monthly_frame': { + 'primary_state': {'value': 'blocked'}, + 'manifestation_mode': {'value': 'blocked'}, + 'friction_source': {'value': 'blocked'}, + 'time_confidence': {'value': 'blocked'}, + }, 'markdown': ( "### 婚恋严格裁决\n" "- 当前缺少 relationship strict evidence,无法生成高严谨婚恋 narrative。\n" @@ -1034,6 +1041,8 @@ def _build_relationship_narrative_payload(relationship_strict): secondary_context = secondary_context if isinstance(secondary_context, list) else [] confidence_cap = relationship_strict.get('confidence_cap') or event_judgement.get('confidence_cap') or 'unknown' dominant_label = event_judgement.get('dominant_label') if isinstance(event_judgement, dict) else None + monthly_frame = relationship_strict.get('monthly_adjudication_summary') if isinstance(relationship_strict, dict) else {} + monthly_frame = monthly_frame if isinstance(monthly_frame, dict) else {} synastry = present.get('synastry_relationship_support') if isinstance(present, dict) else {} synastry_signals = synastry.get('signals') if isinstance(synastry, dict) else [] synastry_signals = synastry_signals if isinstance(synastry_signals, list) else [] @@ -1061,6 +1070,10 @@ def _build_relationship_narrative_payload(relationship_strict): strengths.append('protective kuta support 已被识别,可作为关系稳定性的次级支持语义。') if 'synastry_exception_mitigated' in secondary_context: strengths.append('存在 exception mitigation,说明部分 Dosha/不利匹配在传统规则里有缓解条件。') + if monthly_frame.get('primary_state', {}).get('value'): + strengths.append(f"月度主状态:{monthly_frame.get('primary_state', {}).get('value')}。") + if monthly_frame.get('manifestation_mode', {}).get('value'): + strengths.append(f"落地形式:{monthly_frame.get('manifestation_mode', {}).get('value')}。") if confidence_cap in {'low', 'blocked'}: risks.append('当前 confidence cap 偏低,dual dasha / external timing / marriage convergence 至少有一层存在冲突或不足。') @@ -1074,10 +1087,14 @@ def _build_relationship_narrative_payload(relationship_strict): risks.append('相关婚恋行星尊贵度摩擦较高,关系推进时更容易出现磨损与反复确认。') if 'shadbala_component_gap' in secondary_context: risks.append('Shadbala 六分量还存在缺口,关系强弱结论需继续保守处理。') + if monthly_frame.get('friction_source', {}).get('value'): + risks.append(f"阻力来源:{monthly_frame.get('friction_source', {}).get('value')}。") boundaries.append('婚恋高严谨模式至少需要 D1、D9、UL、Vimshottari 与 Narayana dual dasha 同时在场。') boundaries.append('protective kuta support、Mahendra、Stree Deergha 等合盘细信号只能辅助,不得越权抬升 legal_marriage。') boundaries.append('若 dual dasha、external timing 或 marriage convergence 冲突,必须明确降置信度,而不是把关系窗口包装成婚姻必然落地。') + if monthly_frame.get('time_confidence', {}).get('value'): + boundaries.append(f"时间置信度:{monthly_frame.get('time_confidence', {}).get('value')}。") if 'public_formalization_candidate' in secondary_context: boundaries.append('public_formalization_candidate 只表示公开化候选,不等于法律婚姻,不能越权替代 legal_marriage。') if synastry_signals: @@ -1100,6 +1117,10 @@ def _build_relationship_narrative_payload(relationship_strict): f"- dominant_label: {dominant_label or 'none'}", f"- confidence_cap: {confidence_cap}", f"- secondary_context: {secondary_context}", + f"- 月度主状态: {monthly_frame.get('primary_state', {}).get('value') or 'blocked'}", + f"- 落地形式: {monthly_frame.get('manifestation_mode', {}).get('value') or 'blocked'}", + f"- 阻力来源: {monthly_frame.get('friction_source', {}).get('value') or 'blocked'}", + f"- 时间置信度: {monthly_frame.get('time_confidence', {}).get('value') or 'blocked'}", '- strengths:', *[f" - {item}" for item in strengths], '- risks:', @@ -1113,10 +1134,155 @@ def _build_relationship_narrative_payload(relationship_strict): 'strengths': strengths, 'risks': risks, 'boundaries': boundaries, + 'monthly_frame': { + 'primary_state': monthly_frame.get('primary_state') or {'value': 'blocked'}, + 'manifestation_mode': monthly_frame.get('manifestation_mode') or {'value': 'blocked'}, + 'friction_source': monthly_frame.get('friction_source') or {'value': 'blocked'}, + 'time_confidence': monthly_frame.get('time_confidence') or {'value': 'blocked'}, + }, 'markdown': "\n".join(markdown_lines), } +def _base_strict_narrative_payload(route_label, strict, *, fallback_headline, strengths, risks, boundaries): + monthly_frame = strict.get('monthly_adjudication_summary') if isinstance(strict, dict) else {} + monthly_frame = monthly_frame if isinstance(monthly_frame, dict) else {} + event_judgement = strict.get('event_judgement') if isinstance(strict, dict) else {} + confidence_cap = strict.get('confidence_cap') or event_judgement.get('confidence_cap') or 'unknown' + dominant_label = event_judgement.get('dominant_label') if isinstance(event_judgement, dict) else None + + if monthly_frame.get('primary_state', {}).get('value'): + strengths = list(strengths) + [f"月度主状态:{monthly_frame.get('primary_state', {}).get('value')}。"] + if monthly_frame.get('manifestation_mode', {}).get('value'): + strengths = list(strengths) + [f"落地形式:{monthly_frame.get('manifestation_mode', {}).get('value')}。"] + if monthly_frame.get('friction_source', {}).get('value'): + risks = list(risks) + [f"阻力来源:{monthly_frame.get('friction_source', {}).get('value')}。"] + if monthly_frame.get('time_confidence', {}).get('value'): + boundaries = list(boundaries) + [f"时间置信度:{monthly_frame.get('time_confidence', {}).get('value')}。"] + + markdown_lines = [ + f"### {route_label}严格裁决", + f"- headline: {fallback_headline}", + f"- dominant_label: {dominant_label or 'none'}", + f"- confidence_cap: {confidence_cap}", + f"- 月度主状态: {monthly_frame.get('primary_state', {}).get('value') or 'blocked'}", + f"- 落地形式: {monthly_frame.get('manifestation_mode', {}).get('value') or 'blocked'}", + f"- 阻力来源: {monthly_frame.get('friction_source', {}).get('value') or 'blocked'}", + f"- 时间置信度: {monthly_frame.get('time_confidence', {}).get('value') or 'blocked'}", + '- strengths:', + *[f" - {item}" for item in strengths], + '- risks:', + *[f" - {item}" for item in risks], + '- boundaries:', + *[f" - {item}" for item in boundaries], + ] + return { + 'headline': fallback_headline, + 'strengths': list(strengths), + 'risks': list(risks), + 'boundaries': list(boundaries), + 'monthly_frame': { + 'primary_state': monthly_frame.get('primary_state') or {'value': 'blocked'}, + 'manifestation_mode': monthly_frame.get('manifestation_mode') or {'value': 'blocked'}, + 'friction_source': monthly_frame.get('friction_source') or {'value': 'blocked'}, + 'time_confidence': monthly_frame.get('time_confidence') or {'value': 'blocked'}, + }, + 'markdown': "\n".join(markdown_lines), + } + + +def _build_career_narrative_payload(career_strict): + if not isinstance(career_strict, dict) or not career_strict: + return _base_strict_narrative_payload( + '事业', + {}, + fallback_headline='事业严格裁决证据尚未完成,当前不能生成高严谨事业叙事。', + strengths=[], + risks=['缺少 career strict workflow 的核心证据,事业正文需降级。'], + boundaries=['未完成 D1、D10、A10、Vimshottari 与 Narayana 交叉前,不得把单一事业信号写成高置信度结论。'], + ) + event_judgement = career_strict.get('event_judgement') if isinstance(career_strict, dict) else {} + secondary_context = event_judgement.get('secondary_context') if isinstance(event_judgement, dict) else [] + secondary_context = secondary_context if isinstance(secondary_context, list) else [] + missing = career_strict.get('missing_evidence') or [] + strengths = [] + risks = [] + boundaries = [ + '事业高严谨模式至少需要 D1、D10、A10、Functional Benefic/Malefic、Vimshottari 与 Narayana dual dasha 同时在场。', + 'VedAstro 官方事件日可以给时间支撑,但不得越权改写本命 promise 与 strict workflow 的边界。', + ] + if event_judgement.get('dominant_label') == 'career_status': + strengths.append('事业 strict workflow 已形成主裁决标签,说明职业主题不是泛泛活跃,而是进入可判读窗口。') + if 'a10_active' in secondary_context: + strengths.append('A10/Karma Pada 已进入主链,说明事业结果会更偏向社会角色、职责承接或可见产出。') + if 'amk_active' in secondary_context: + strengths.append('Amatyakaraka 已进入主链,说明职业能力、上级关系或专业角色承担被明显放大。') + if 'karakamsha_context' in secondary_context: + strengths.append('Karakamsha 已提供职业志向语义,适合用来判断方向感而不只是短期机会。') + if missing: + risks.append(f"仍缺少关键层:{', '.join(str(item) for item in missing[:4])}。") + if 'virodhargala_obstruction' in secondary_context: + risks.append('事业主轴存在 Argala 阻滞,推进通常伴随现实牵制、流程卡顿或资源不顺。') + if 'dignity_high_friction' in secondary_context: + risks.append('相关事业行星尊贵度摩擦较高,机会不一定消失,但落地成本会明显上升。') + if 'shadbala_component_gap' in secondary_context: + risks.append('Shadbala 六分量仍有缺口,强弱结论需继续保守。') + headline = '事业严格裁决已接入主链,当前结论将强制引用本命 promise、双重大运、官方时间窗与结构阻力。' + return _base_strict_narrative_payload( + '事业', + career_strict, + fallback_headline=headline, + strengths=strengths, + risks=risks, + boundaries=boundaries, + ) + + +def _build_finance_narrative_payload(finance_strict): + if not isinstance(finance_strict, dict) or not finance_strict: + return _base_strict_narrative_payload( + '财富', + {}, + fallback_headline='财富严格裁决证据尚未完成,当前不能生成高严谨财富叙事。', + strengths=[], + risks=['缺少 finance strict workflow 的核心证据,财富正文需降级。'], + boundaries=['未完成 D2/D11、财富 promise、Vimshottari 与 Narayana 交叉前,不得把单一财富信号写成高置信度结论。'], + ) + event_judgement = finance_strict.get('event_judgement') if isinstance(finance_strict, dict) else {} + secondary_context = event_judgement.get('secondary_context') if isinstance(event_judgement, dict) else [] + secondary_context = secondary_context if isinstance(secondary_context, list) else [] + missing = finance_strict.get('missing_evidence') or [] + strengths = [] + risks = [] + boundaries = [ + '财富高严谨模式至少需要 D2/D11 或等价财富 promise 层、Functional Benefic/Malefic、Vimshottari 与 Narayana dual dasha 同时在场。', + '官方财富日窗口只能帮助判断回款/交易/现金流节奏,不能单独替代本命财富 promise。', + ] + if event_judgement.get('dominant_label') == 'income_growth': + strengths.append('财富 strict workflow 已判到 income_growth,说明更偏向真实入账增长,而不是空泛的财运变好。') + if event_judgement.get('dominant_label') == 'public_wealth_status': + strengths.append('财富 strict workflow 已判到 public_wealth_status,说明更像项目回款、公开收入状态或外部可见的收益变化。') + if 'ashtakavarga_wealth_support' in secondary_context: + strengths.append('Ashtakavarga 财富桥接已进入主链,可作为兑现能力的次级支持。') + if missing: + risks.append(f"仍缺少关键层:{', '.join(str(item) for item in missing[:4])}。") + if 'avayogi_active' in secondary_context: + risks.append('Avayogi 风险已触发,说明某些看似有钱流动的窗口也可能伴随高代价或错误决策。') + if 'sodhita_wealth_friction' in secondary_context or 'ashtakavarga_wealth_friction' in secondary_context: + risks.append('财富桥接层已提示兑现摩擦,现金流并不等于可自由留存。') + if 'shadbala_component_gap' in secondary_context: + risks.append('Shadbala 六分量仍有缺口,财富强弱结论需继续保守。') + headline = '财富严格裁决已接入主链,当前结论会强制区分收入兑现、现金流动作与风险摩擦。' + return _base_strict_narrative_payload( + '财富', + finance_strict, + fallback_headline=headline, + strengths=strengths, + risks=risks, + boundaries=boundaries, + ) + + def _build_vedastro_overview_payload(modules): overview = modules.get('vedastro_range_scan_result') if isinstance(modules, dict) else {} if not isinstance(overview, dict): @@ -1150,19 +1316,167 @@ def _build_vedastro_overview_payload(modules): } +def _full_reading_profiler_enabled(args) -> bool: + if bool(getattr(args, 'profile_stages', False)): + return True + env_value = os.environ.get('JYOTISH_PROFILE_STAGES', '').strip().lower() + return env_value in {'1', 'true', 'yes', 'on'} + + +def _record_stage_timing(stage_timings, stage, started_at, *, enabled=False, status='ok', details=None): + elapsed = round(time.perf_counter() - started_at, 4) + entry = { + 'stage': stage, + 'elapsed_seconds': elapsed, + 'status': status, + } + if details: + entry['details'] = details + stage_timings.append(entry) + if enabled: + print(f"[full-reading stage] {stage}: {elapsed:.4f}s ({status})", file=sys.stderr) + return entry + + +def _build_unified_stage_contract(stage_timings): + groups = { + 'local_core': [ + 'core_chart_and_setup', + 'dasha_and_core_varga_stack', + 'advanced_interpretation_and_timing_layers', + 'dynamic_hooks', + ], + 'official_evidence': [ + 'vedastro_official_snapshot', + 'vedastro_main_entry_overview', + ], + 'contract_and_prompt': [ + 'strict_contracts', + 'guided_topics', + 'ai_prompt_pack', + ], + } + grouped_rows = [] + for group_name, stage_names in groups.items(): + matched = [row for row in stage_timings if row.get('stage') in stage_names] + grouped_rows.append({ + 'group': group_name, + 'stages': [row.get('stage') for row in matched], + 'elapsed_seconds': round( + sum(float(row.get('elapsed_seconds', 0) or 0) for row in matched), + 4, + ), + 'execution_mode': ( + 'sync_remote_heavy' if group_name == 'official_evidence' + else 'sync_structuring' if group_name == 'contract_and_prompt' + else 'sync_local' + ), + }) + return { + 'stage_contract_version': 1, + 'stage_groups': grouped_rows, + 'cache_recommendations': { + 'api_chart_response': 'recommended', + 'official_full_snapshot_semantic': 'recommended', + }, + 'async_recommendations': { + 'chart_async_optional': True, + 'high_rigor_async_recommended': True, + }, + } + + +STRICT_WORKFLOW_MODULE_MAP = { + 'relationship': 'relationship_strict_evidence', + 'career': 'career_strict_evidence', + 'finance': 'finance_strict_evidence', +} + + +def _compact_strict_workflow_contract(strict): + if not isinstance(strict, dict) or not strict: + return None + return { + 'question_type': strict.get('question_type'), + 'confidence_cap': strict.get('confidence_cap'), + 'blocked': bool(strict.get('blocked')), + 'reason': strict.get('reason'), + 'required_evidence': strict.get('required_evidence') or [], + 'missing_evidence': strict.get('missing_evidence') or [], + 'official_primary_evidence': strict.get('official_primary_evidence') or {}, + 'local_supplemental_evidence': strict.get('local_supplemental_evidence') or {}, + 'fallback_used': strict.get('fallback_used') or [], + 'blocked_items': strict.get('blocked_items') or [], + 'conflicts': strict.get('conflicts') or [], + 'technique_audit_summary': strict.get('technique_audit_summary') or {}, + 'adjudication_stages': strict.get('adjudication_stages') or {}, + 'multi_reference_reading_summary': strict.get('multi_reference_reading_summary') or {}, + 'monthly_adjudication_summary': strict.get('monthly_adjudication_summary') or {}, + 'official_day_signal_summary': strict.get('official_day_signal_summary') or {}, + 'strict_adjudication_bundle': strict.get('strict_adjudication_bundle') or {}, + 'verdict': strict.get('verdict'), + 'dominant_label': strict.get('dominant_label'), + 'main_conflicts': strict.get('main_conflicts') or [], + } + + +def _extract_strict_workflow_contracts(modules): + contracts = {} + if not isinstance(modules, dict): + return contracts + for route, module_name in STRICT_WORKFLOW_MODULE_MAP.items(): + contract = _compact_strict_workflow_contract(modules.get(module_name)) + if contract: + contracts[route] = contract + return contracts + + +def _preferred_strict_workflow_contract(contracts): + if not isinstance(contracts, dict): + return None, {} + for route in ('relationship', 'career', 'finance'): + contract = contracts.get(route) + if isinstance(contract, dict) and contract: + return route, contract + return None, {} + + def _build_vedastro_official_full_snapshot_payload(modules): snapshot = modules.get('vedastro_official_full_snapshot') if isinstance(modules, dict) else {} + strict_workflow_contracts = _extract_strict_workflow_contracts(modules) + primary_route, primary_contract = _preferred_strict_workflow_contract(strict_workflow_contracts) + official_primary_evidence = primary_contract.get('official_primary_evidence') if isinstance(primary_contract, dict) else {} + local_supplemental_evidence = primary_contract.get('local_supplemental_evidence') if isinstance(primary_contract, dict) else {} + fallback_used = primary_contract.get('fallback_used') if isinstance(primary_contract, dict) else [] + blocked_items = primary_contract.get('blocked_items') if isinstance(primary_contract, dict) else [] + conflicts = primary_contract.get('conflicts') if isinstance(primary_contract, dict) else [] if not isinstance(snapshot, dict) or not snapshot: return { 'status': 'blocked', 'available': False, 'operation': 'official_full_snapshot', 'primary_source': 'vedastro_official', + 'strict_workflow_primary_route': primary_route, + 'strict_workflow_routes_available': list(strict_workflow_contracts.keys()), + 'strict_workflow_contracts': strict_workflow_contracts, + 'official_primary_evidence': official_primary_evidence or {}, + 'local_supplemental_evidence': local_supplemental_evidence or {}, + 'fallback_used': fallback_used or [], + 'blocked_items': blocked_items or [], + 'conflicts': conflicts or [], 'boundary_note': 'VedAstro official full snapshot is not attached.', } manifest = snapshot.get('request_manifest') if isinstance(snapshot.get('request_manifest'), dict) else {} requests = manifest.get('requests') if isinstance(manifest.get('requests'), list) else [] snapshot_sections = snapshot.get('snapshot_sections') if isinstance(snapshot.get('snapshot_sections'), dict) else {} + metadata = snapshot.get('source_metadata') if isinstance(snapshot.get('source_metadata'), dict) else {} + full_catalog = metadata.get('official_full_capability_catalog') if isinstance(metadata.get('official_full_capability_catalog'), dict) else {} + dynamic_selection = full_catalog.get('dynamic_selection') if isinstance(full_catalog.get('dynamic_selection'), dict) else {} + report_references = { + theme: selection.get('report_reference') + for theme, selection in dynamic_selection.items() + if isinstance(selection, dict) and isinstance(selection.get('report_reference'), dict) + } return { 'status': snapshot.get('status') or 'blocked', 'available': bool(snapshot.get('available')), @@ -1173,8 +1487,22 @@ def _build_vedastro_official_full_snapshot_payload(modules): 'request_section_count': len(requests), 'request_sections': [item.get('section') for item in requests if isinstance(item, dict)], 'method_catalog': manifest.get('method_catalog') or {}, + 'official_full_capability_catalog_status': full_catalog.get('status'), + 'official_full_capability_catalog_summary': full_catalog.get('summary') or {}, + 'official_full_capability_catalog_coverage': full_catalog.get('coverage') or {}, + 'official_full_capability_domain_routing': full_catalog.get('domain_routing') or {}, + 'official_full_capability_dynamic_selection': dynamic_selection, + 'official_report_references': report_references, 'user_visibility': snapshot.get('user_visibility') or 'backend_raw_evidence_not_direct_user_report', 'source_metadata': snapshot.get('source_metadata') or {}, + 'strict_workflow_primary_route': primary_route, + 'strict_workflow_routes_available': list(strict_workflow_contracts.keys()), + 'strict_workflow_contracts': strict_workflow_contracts, + 'official_primary_evidence': official_primary_evidence or {}, + 'local_supplemental_evidence': local_supplemental_evidence or {}, + 'fallback_used': fallback_used or [], + 'blocked_items': blocked_items or [], + 'conflicts': conflicts or [], 'boundary_note': ( snapshot.get('reason') or 'VedAstro official full snapshot is the primary raw evidence layer; user reports consume selected slices only.' @@ -1201,9 +1529,13 @@ def _build_ai_prompt_pack(report): oracle_progress = _oracle_progress_snapshot() technique_audit_table = _build_technique_audit_table(functional_layer, oracle_progress, modules) relationship_narrative = _build_relationship_narrative_payload(modules.get('relationship_strict_evidence')) + career_narrative = _build_career_narrative_payload(modules.get('career_strict_evidence')) + finance_narrative = _build_finance_narrative_payload(modules.get('finance_strict_evidence')) vimsopaka_semantic_summary = _build_vimsopaka_semantic_summary(modules.get('vimsopaka')) vedastro_overview = _build_vedastro_overview_payload(modules) vedastro_official_full_snapshot = _build_vedastro_official_full_snapshot_payload(modules) + strict_workflow_contracts = _extract_strict_workflow_contracts(modules) + strict_workflow_primary_route, primary_strict_contract = _preferred_strict_workflow_contract(strict_workflow_contracts) guided_topics = modules.get('guided_topics') if isinstance(modules.get('guided_topics'), list) else build_guided_topics(report) capability_evidence_pool = build_capability_evidence_pool_summary() @@ -1276,12 +1608,22 @@ def _build_ai_prompt_pack(report): }, 'oracle_progress': oracle_progress, 'functional_benefic_malefic': functional_layer, + 'strict_workflow_primary_route': strict_workflow_primary_route, + 'strict_workflow_routes_available': list(strict_workflow_contracts.keys()), + 'strict_workflow_contracts': strict_workflow_contracts, + 'official_primary_evidence': primary_strict_contract.get('official_primary_evidence') if isinstance(primary_strict_contract, dict) else {}, + 'local_supplemental_evidence': primary_strict_contract.get('local_supplemental_evidence') if isinstance(primary_strict_contract, dict) else {}, + 'fallback_used': primary_strict_contract.get('fallback_used') if isinstance(primary_strict_contract, dict) else [], + 'blocked_items': primary_strict_contract.get('blocked_items') if isinstance(primary_strict_contract, dict) else [], + 'conflicts': primary_strict_contract.get('conflicts') if isinstance(primary_strict_contract, dict) else [], 'vedastro_official_full_snapshot': vedastro_official_full_snapshot, 'vedastro_overview': vedastro_overview, 'guided_topics': guided_topics, 'capability_evidence_pool': capability_evidence_pool, 'technique_audit_table': technique_audit_table, + 'career_narrative': career_narrative, 'relationship_narrative': relationship_narrative, + 'finance_narrative': finance_narrative, 'vimsopaka_semantic_summary': vimsopaka_semantic_summary, } @@ -1386,6 +1728,8 @@ def _attach_vedastro_main_entry_overview(report, args): combined_events = [] domain_statuses = {} top_events = {} + daily_windows_by_domain = {} + top_daily_window_by_domain = {} failure_reason = None availability = True @@ -1404,6 +1748,12 @@ def _attach_vedastro_main_entry_overview(report, args): top_event = domain_report.get('top_event') if isinstance(top_event, dict): top_events[domain] = top_event + daily_windows = domain_report.get('daily_windows') + if isinstance(daily_windows, list): + daily_windows_by_domain[domain] = daily_windows + top_daily_window = domain_report.get('top_daily_window') + if isinstance(top_daily_window, dict): + top_daily_window_by_domain[domain] = top_daily_window primary_status = next( ( @@ -1447,6 +1797,18 @@ def _attach_vedastro_main_entry_overview(report, args): 'event_count': len(combined_events), 'top_event': top_events.get('marriage') or next(iter(top_events.values()), None), 'top_events_by_domain': top_events, + 'daily_windows': [ + item + for domain in ('career', 'marriage', 'wealth') + for item in (daily_windows_by_domain.get(domain) or []) + if isinstance(item, dict) + ], + 'top_daily_window': ( + top_daily_window_by_domain.get('marriage') + or next(iter(top_daily_window_by_domain.values()), None) + ), + 'daily_windows_by_domain': daily_windows_by_domain, + 'top_daily_window_by_domain': top_daily_window_by_domain, 'evidence_ledger': combined_events, 'source_metadata': source_metadata, 'reason': failure_reason, @@ -1500,7 +1862,7 @@ def _attach_vedastro_official_full_snapshot(report, args): return report -def _load_relationship_strict_collector(): +def _load_strict_evidence_collector(): try: from mcp_server import _collect_strict_evidence as collector return collector @@ -1988,6 +2350,12 @@ def _past_event_verify(chart: Dict, asc_idx: int, args) -> Dict: # ============================================================================ def cmd_varga(args): if not HAS_SWE: return {"error": "swisseph未安装"} + try: + sys.path.insert(0, SCRIPT_DIR) + from varga import calc_varga + except ImportError as e: + return {"error": f"varga模块导入失败: {e}"} + swe.set_ephe_path('') hd = _birth_hour_decimal(args.hour, args.minute, _arg_second(args)) - args.tz jd = swe.julday(args.year, args.month, args.day, hd) @@ -2001,31 +2369,18 @@ def cmd_varga(args): if 'Rahu' in natal: natal['Ketu'] = (natal['Rahu'] + 180) % 360 asc_lon, _ = swe.houses(jd, args.lat, args.lon, b'A'); asc_deg = (asc_lon[0] - ayanamsa) % 360 # 恒星黄道 - def navamsa(lon): - si = int(lon / 30); d = lon - si * 30; ni = int(d / (30/9)) - # BPHS: movable(0,3,6,9)=same, fixed(1,4,7,10)=+4, dual(2,5,8,11)=+8 - if si % 3 == 0: # movable: 0,3,6,9 - start = si - elif si % 3 == 1: # fixed: 1,4,7,10 - start = (si + 4) % 12 - else: # dual: 2,5,8,11 - start = (si + 8) % 12 - return SIGNS[(start + ni) % 12] - - def dasamsa(lon): - si = int(lon / 30); d = lon - si * 30; di = int(d / 3) - # BPHS: odd signs(0,2,4,6,8,10)=same, even signs(1,3,5,7,9,11)=+8 (9th) - start = si if si % 2 == 0 else (si + 8) % 12 - return SIGNS[(start + di) % 12] + def short_varga_row(lon, div): + row = calc_varga(lon, div) + return {"sign": row["sign"], "sign_cn": SIGNS_CN[row["sign"]]} result = {"birth_info": f"{args.year}-{args.month:02d}-{args.day:02d} {_birth_time_string(args.hour, args.minute, _arg_second(args))}", "divisional_charts": {}} if args.d9 or args.all: - d9 = {"ascendant": navamsa(asc_deg)} - for p, l in natal.items(): d9[p] = {"sign": navamsa(l), "sign_cn": SIGNS_CN[navamsa(l)]} + d9 = {"ascendant": calc_varga(asc_deg, 9)["sign"]} + for p, l in natal.items(): d9[p] = short_varga_row(l, 9) result["divisional_charts"]["D9_Navamsa"] = d9 if args.d10 or args.all: - d10 = {"ascendant": dasamsa(asc_deg)} - for p, l in natal.items(): d10[p] = {"sign": dasamsa(l), "sign_cn": SIGNS_CN[dasamsa(l)]} + d10 = {"ascendant": calc_varga(asc_deg, 10)["sign"]} + for p, l in natal.items(): d10[p] = short_varga_row(l, 10) result["divisional_charts"]["D10_Dasamsa"] = d10 if not result["divisional_charts"]: result["note"] = "请指定 --d9, --d10 或 --all" return result @@ -4166,7 +4521,9 @@ def cmd_full_reading(args): → 综合报告输出 """ import time - t0 = time.time() + t0 = time.perf_counter() + stage_timings = [] + profile_stages = _full_reading_profiler_enabled(args) def _build_whole_sign_houses(asc_index, planets_data): """Build a compatibility house map for add-on modules. @@ -4223,6 +4580,7 @@ def cmd_full_reading(args): } # ── Step 1: 核心星盘 ── + stage_started = time.perf_counter() chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(args) if chart is None: return {"error": "swisseph未安装,无法计算星盘"} @@ -4249,8 +4607,16 @@ def cmd_full_reading(args): for pn, pd in planets.items(): if isinstance(pd, dict) and 'sign' in pd: planet_sign_indices[pn] = SIGNS.index(pd['sign']) if pd['sign'] in SIGNS else 0 + _record_stage_timing( + stage_timings, + 'core_chart_and_setup', + stage_started, + enabled=profile_stages, + details={'modules': ['chart', 'house_map']}, + ) # ── Step 1.5: Special Lagnas 特殊上升点 (v4.4.0) ── + stage_started = time.perf_counter() try: sys.path.insert(0, SCRIPT_DIR) from special_lagnas import SpecialLagnasCalculator @@ -4768,8 +5134,17 @@ def cmd_full_reading(args): report['modules']['narayana_dasha'] = narayana_result except Exception as e: report['errors'].append(f"narayana-dasha: {e}") + _record_stage_timing( + stage_timings, + 'dasha_and_core_varga_stack', + stage_started, + enabled=profile_stages, + status='error' if any(err.startswith(('special-lagnas:', 'dasha:', 'yoga:', 'varga-full:', 'vimsopaka:', 'varga-extended:', 'dispositor-chain+inter-chart:', 'tajika-yogas+sahams:', 'yogas-doshas:', 'tithi-lord:', 'pancha-pakshi:', 'rashi-tulya-navamsa:', 'marriage-counting:', 'bhrigu-pada-dasha:', 'muntha:', 'trimshamsa-d30:', 'prashna:', 'solar-return:', 'narayana-dasha:')) for err in report['errors']) else 'ok', + details={'through_step': '4.13'}, + ) # ── Step 5: 精确相位 ── + stage_started = time.perf_counter() try: from aspects import calc_all_aspects aspects_result = calc_all_aspects(planet_lons, asc_deg) @@ -5185,12 +5560,17 @@ def cmd_full_reading(args): report['modules']['d9_navamsa_expanded'] = d9_expanded except Exception as e: report['errors'].append(f"d9-expanded: {e}") + _record_stage_timing( + stage_timings, + 'advanced_interpretation_and_timing_layers', + stage_started, + enabled=profile_stages, + status='error' if any(err.startswith(('aspects:', 'jaimini:', 'nakshatra-adv:', 'nakshatra-dasha:', 'argala:', 'tajika:', 'shadbala:', 'remedies:', 'avasthas:', 'ashtakavarga:', 'validate:', 'audit:', 'actionable-context:', 'congregation:', 'vivah-saham:', 'transit-multi-ref:', 'dasa-convergence:', 'd9-expanded:')) for err in report['errors']) else 'ok', + details={'through_step': '19'}, + ) # ── 汇总 ── - elapsed = round(time.time() - t0, 2) - module_count = len(report['modules']) - error_count = len(report['errors']) - + stage_started = time.perf_counter() # ── 生成动态引导 (Dynamic Hooks) ── try: report['dynamic_hooks'] = generate_life_stage_hooks( @@ -5203,41 +5583,107 @@ def cmd_full_reading(args): except Exception as e: report['dynamic_hooks'] = [] report['errors'].append(f"hook_engine: {e}") + _record_stage_timing( + stage_timings, + 'dynamic_hooks', + stage_started, + enabled=profile_stages, + status='error' if any(err.startswith('hook_engine:') for err in report['errors']) else 'ok', + ) - report['summary'] = { - 'elapsed_seconds': elapsed, - 'modules_computed': module_count, - 'errors': error_count, - 'status': 'complete' if error_count == 0 else f'{error_count} errors', - 'next_step': '⭐ v6.1.6: full-reading 已输出 transit_multi_reference(四参考点) + dasa_convergence(五系统交叉) + yogini_dasha + ashtottari_dasha + kalachakra_dasha + d9_navamsa_expanded。AI必须使用四参考点分析Transit,Dasa预测必须标注多系统收敛等级。', - } - - try: - relationship_strict_collector = _load_relationship_strict_collector() - report['modules']['relationship_strict_evidence'] = relationship_strict_collector('relationship', report) - report['modules']['relationship_strict_evidence']['user_narrative'] = _build_relationship_narrative_payload( - report['modules']['relationship_strict_evidence'] - ) - except Exception as e: - report['errors'].append(f"relationship-strict-evidence: {e}") + report['summary'] = {} + stage_started = time.perf_counter() try: _attach_vedastro_official_full_snapshot(report, args) except Exception as e: report['warnings'].append(f"vedastro-official-full-snapshot: {e}") + _record_stage_timing( + stage_timings, + 'vedastro_official_snapshot', + stage_started, + enabled=profile_stages, + status='warning' if any(warn.startswith('vedastro-official-full-snapshot:') for warn in report['warnings']) else 'ok', + ) + stage_started = time.perf_counter() + try: + strict_evidence_collector = _load_strict_evidence_collector() + for route in ('relationship', 'career', 'finance'): + module_name = STRICT_WORKFLOW_MODULE_MAP[route] + report['modules'][module_name] = strict_evidence_collector(route, report) + report['modules']['career_strict_evidence']['user_narrative'] = _build_career_narrative_payload( + report['modules']['career_strict_evidence'] + ) + report['modules']['relationship_strict_evidence']['user_narrative'] = _build_relationship_narrative_payload( + report['modules']['relationship_strict_evidence'] + ) + report['modules']['finance_strict_evidence']['user_narrative'] = _build_finance_narrative_payload( + report['modules']['finance_strict_evidence'] + ) + except Exception as e: + report['errors'].append(f"strict-evidence-collector: {e}") + _record_stage_timing( + stage_timings, + 'strict_contracts', + stage_started, + enabled=profile_stages, + status='error' if any(err.startswith('strict-evidence-collector:') for err in report['errors']) else 'ok', + ) + + stage_started = time.perf_counter() try: _attach_vedastro_main_entry_overview(report, args) except Exception as e: report['warnings'].append(f"vedastro-main-entry-overview: {e}") + _record_stage_timing( + stage_timings, + 'vedastro_main_entry_overview', + stage_started, + enabled=profile_stages, + status='warning' if any(warn.startswith('vedastro-main-entry-overview:') for warn in report['warnings']) else 'ok', + ) + stage_started = time.perf_counter() try: report['modules']['guided_topics'] = build_guided_topics(report) except Exception as e: report['warnings'].append(f"guided-topics: {e}") report['modules']['guided_topics'] = [] + _record_stage_timing( + stage_timings, + 'guided_topics', + stage_started, + enabled=profile_stages, + status='warning' if any(warn.startswith('guided-topics:') for warn in report['warnings']) else 'ok', + ) + stage_started = time.perf_counter() report['ai_prompt_pack'] = _build_ai_prompt_pack(report) + _record_stage_timing( + stage_timings, + 'ai_prompt_pack', + stage_started, + enabled=profile_stages, + ) + + elapsed = round(time.perf_counter() - t0, 4) + module_count = len(report['modules']) + error_count = len(report['errors']) + slowest_stages = sorted(stage_timings, key=lambda item: item.get('elapsed_seconds', 0), reverse=True)[:5] + unified_stage_contract = _build_unified_stage_contract(stage_timings) + report['summary'] = { + 'elapsed_seconds': elapsed, + 'modules_computed': module_count, + 'errors': error_count, + 'status': 'complete' if error_count == 0 else f'{error_count} errors', + 'stage_timing_enabled': True, + 'stage_timings': stage_timings, + 'slowest_stages': slowest_stages, + 'guided_topics': report['modules'].get('guided_topics', []), + **unified_stage_contract, + 'next_step': '⭐ v6.1.6: full-reading 已输出 transit_multi_reference(四参考点) + dasa_convergence(五系统交叉) + yogini_dasha + ashtottari_dasha + kalachakra_dasha + d9_navamsa_expanded。AI必须使用四参考点分析Transit,Dasa预测必须标注多系统收敛等级。', + } return report @@ -5549,6 +5995,7 @@ def main(): p.add_argument('--today', default=None, help='Dasha/Sandhi参考日期 YYYY-MM-DD(默认今天)') p.add_argument('--transit-date', default=None, help='Transit真实过境参考日期 YYYY-MM-DD(默认跟随--today或今天)') p.add_argument('--target-year', type=int, default=None, help='太阳返照盘目标年份(默认不计算 Varshaphala)') + p.add_argument('--profile-stages', action='store_true', help='输出 full-reading 粗粒度阶段耗时,并在 summary 中附带 stage timings') # 23. prashna (v3.9新增) p = sub.add_parser('prashna', help='Prashna问事占星(提问时刻星盘+Arudha+Sphuta+Sahams)') diff --git a/scripts/narayana_dasha.py b/scripts/narayana_dasha.py index 35cd0b1c..4cdf30a6 100644 --- a/scripts/narayana_dasha.py +++ b/scripts/narayana_dasha.py @@ -134,27 +134,95 @@ def calc_narayana_antardasha( if md is None: return [] - total_years = md['years'] + return _subdivide_narayana_period( + mahadasha_periods=mahadasha_periods, + parent_period=md, + start_sign_idx=md_sign_idx, + parent_key='parent_md', + parent_name=SIGNS[md_sign_idx], + ) + + +def calc_narayana_pratyantardasha( + mahadasha_periods: List[Dict], + antardasha_period: Dict, +) -> List[Dict]: + """ + 计算给定 Antardasha 的 Pratyantardasha 子周期。 + + 返回的 start_age / end_age 与 Mahadasha、Antardasha 使用同一条绝对年龄轴, + 方便 get_current_narayana_dasha 直接定位当前周期。 + """ + if not antardasha_period: + return [] + + sign_idx = antardasha_period.get('sign_idx') + if sign_idx is None: + return [] + + return _subdivide_narayana_period( + mahadasha_periods=mahadasha_periods, + parent_period=antardasha_period, + start_sign_idx=sign_idx, + parent_key='parent_ad', + parent_name=antardasha_period.get('sign', SIGNS[sign_idx]), + ) + + +def _subdivide_narayana_period( + mahadasha_periods: List[Dict], + parent_period: Dict, + start_sign_idx: int, + parent_key: str, + parent_name: str, +) -> List[Dict]: + """按 Narayana 星座年数权重切分父周期,返回绝对年龄轴上的子周期。""" + if not mahadasha_periods: + return [] + + period_by_sign = {p['sign_idx']: p for p in mahadasha_periods} + denominator = sum(float(p.get('years', 0)) for p in mahadasha_periods) + if denominator <= 0: + return [] + + total_years = float(parent_period.get('years', 0)) + parent_start = float(parent_period.get('start_age', 0)) + parent_end = float(parent_period.get('end_age', parent_start + total_years)) + if len(period_by_sign) == 12: + weighted_sequence = [period_by_sign[(start_sign_idx + i) % 12] for i in range(12)] + else: + start_pos = next( + (i for i, p in enumerate(mahadasha_periods) if p.get('sign_idx') == start_sign_idx), + 0, + ) + weighted_sequence = mahadasha_periods[start_pos:] + mahadasha_periods[:start_pos] + sub_periods = [] - cum = md['start_age'] + cum = parent_start + sequence_len = len(weighted_sequence) - # Antardasha 从 MD 星座开始,按黄道序推进 - for i in range(12): - sign_idx = (md_sign_idx + i) % 12 - lord_sign = mahadasha_periods[0]['lord_in_sign'] # placeholder - sub_years_ratio = mahadasha_periods[i]['years'] / sum(p['years'] for p in mahadasha_periods) - sub_years = round(total_years * sub_years_ratio, 2) + for i, weighted_period in enumerate(weighted_sequence): + sign_idx = weighted_period['sign_idx'] + if i == sequence_len - 1: + end_age = parent_end + else: + sub_years_raw = total_years * float(weighted_period.get('years', 0)) / denominator + end_age = cum + sub_years_raw + + start_age = cum + years = max(0.0, end_age - start_age) sub_periods.append({ 'sign': SIGNS[sign_idx], 'sign_idx': sign_idx, 'lord': SIGN_LORDS[SIGNS[sign_idx]], - 'years': sub_years, - 'start_age': round(cum, 2), - 'end_age': round(cum + sub_years, 2), - 'parent_md': SIGNS[md_sign_idx], + 'years': round(years, 4), + 'start_age': round(start_age, 4), + 'end_age': round(end_age, 4), + parent_key: parent_name, + 'sequence_index': i, }) - cum += sub_years + cum = end_age return sub_periods @@ -181,7 +249,8 @@ def get_current_narayana_dasha( if total_cycle == 0: return result - age_in_cycle = current_age % total_cycle + cycle_start = min(float(p.get('start_age', 0)) for p in mahadasha_periods) + age_in_cycle = ((current_age - cycle_start) % total_cycle) + cycle_start # 找当前 MD for p in mahadasha_periods: @@ -198,17 +267,28 @@ def get_current_narayana_dasha( # 计算 AD ads = calc_narayana_antardasha(mahadasha_periods, p['sign_idx']) - elapsed_in_md = age_in_cycle - p['start_age'] for ad in ads: - if ad['start_age'] <= elapsed_in_md < ad['end_age']: + if ad['start_age'] <= age_in_cycle < ad['end_age']: result['ad'] = { 'sign': ad['sign'], 'sign_idx': ad['sign_idx'], 'lord': ad['lord'], 'years': ad['years'], - 'start_age': round(p['start_age'] + ad['start_age'], 2), - 'end_age': round(p['start_age'] + ad['end_age'], 2), + 'start_age': ad['start_age'], + 'end_age': ad['end_age'], } + pds = calc_narayana_pratyantardasha(mahadasha_periods, ad) + for pd in pds: + if pd['start_age'] <= age_in_cycle < pd['end_age']: + result['pd'] = { + 'sign': pd['sign'], + 'sign_idx': pd['sign_idx'], + 'lord': pd['lord'], + 'years': pd['years'], + 'start_age': pd['start_age'], + 'end_age': pd['end_age'], + } + break break break diff --git a/scripts/preflight_fragment_scan.py b/scripts/preflight_fragment_scan.py new file mode 100644 index 00000000..62ac4ade --- /dev/null +++ b/scripts/preflight_fragment_scan.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Preflight scan for fragment discipline, redundancy, and real-capability boundaries. + +This script is intentionally stdlib-only and reuses existing project audits. +It exists to enforce a "scan before work" rule for multi-window development, +so high-value drafts, mirrors, and external-work-brain fragments do not get +forgotten or confused with source truth. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_DIR = ROOT / "scripts" +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +import oracle_boundary_audit # noqa: E402 + + +LOCAL_DRAFTS_DIR = ROOT / "docs" / "research" / "local_drafts" / "2026-06" +EXTERNAL_WORK_BRAIN_DIR = Path("/Users/wuyongnaren/.gemini/antigravity-ide/brain") +DISTRIBUTION_MIRROR_DIR = Path("/Users/wuyongnaren/.workbuddy/skills/jyotish-vedic-astrology") +ORACLE_FILE = ROOT / "references" / "oracle" / "dasha_shadbala_oracle_cases.json" + + +def _read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return "" + + +def _run_json_script(*args: str) -> dict[str, Any]: + completed = subprocess.run( + [sys.executable, *args], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError(completed.stderr or completed.stdout or f"Failed: {' '.join(args)}") + return json.loads(completed.stdout) + + +def _list_files(base: Path, patterns: tuple[str, ...]) -> list[str]: + if not base.exists(): + return [] + items: list[str] = [] + for pattern in patterns: + for path in base.rglob(pattern): + if path.is_file(): + items.append(str(path)) + return sorted(set(items)) + + +def _summarize_local_drafts() -> list[dict[str, Any]]: + if not LOCAL_DRAFTS_DIR.exists(): + return [] + preferred_tokens = ( + "reuse_audit", + "three_fronts", + "tajika", + "shadbala", + "dasha", + "yogi", + "fragment", + "truth", + "benchmark", + ) + rows: list[dict[str, Any]] = [] + for path in sorted(LOCAL_DRAFTS_DIR.glob("*.md")): + name = path.name + if not any(token in name for token in preferred_tokens): + continue + rows.append( + { + "category": "repo_local_draft", + "path": str(path), + "reason": "High-value draft not promoted into repo truth yet.", + } + ) + return rows[:12] + + +def _summarize_external_work_brain() -> list[dict[str, Any]]: + if not EXTERNAL_WORK_BRAIN_DIR.exists(): + return [] + rows: list[dict[str, Any]] = [] + for path in _list_files( + EXTERNAL_WORK_BRAIN_DIR, + ("*.md", "*.py"), + ): + name = os.path.basename(path).lower() + if not any(token in name for token in ("vedastro", "audit", "skill", "workflow", "oracle")): + continue + rows.append( + { + "category": "external_work_brain", + "path": path, + "reason": "Recovery-only work-brain artifact; re-anchor before reuse.", + } + ) + return rows[:12] + + +def _redundant_or_mirror_rows(fragment_audit: dict[str, Any]) -> list[dict[str, Any]]: + rows = [ + { + "category": "distribution_mirror", + "path": str(DISTRIBUTION_MIRROR_DIR), + "reason": "Historical skill mirror; do not reverse-sync over main repo truth.", + } + ] + for rel in fragment_audit.get("workspace_residue", {}).get("untracked_files", [])[:12]: + rows.append( + { + "category": "workspace_residue", + "path": str(ROOT / rel), + "reason": "Untracked residue or generated artifact; review before treating as source truth.", + } + ) + return rows + + +def _real_capability_risks(oracle_boundary: dict[str, Any]) -> list[dict[str, Any]]: + summary = oracle_boundary.get("summary", {}) + risks = [ + { + "id": "external_oracle_not_closed", + "severity": "high", + "reason": "External oracle boundary remains open; production tuning is still blocked.", + "evidence": { + "production_tuning_recommended": summary.get("production_tuning_recommended"), + "open_items": summary.get("open_items", []), + }, + }, + { + "id": "historical_event_accuracy_not_proven", + "severity": "high", + "reason": "Engineering surfaces are covered, but historical life-event accuracy is not yet proven by external oracle closure.", + "evidence": { + "routes_present": ["career_timing_strict", "event_verification_strict", "full_reading_strict"], + "needs_real_backtest": True, + }, + }, + { + "id": "official_vs_local_boundary", + "severity": "medium", + "reason": "VedAstro official ingestion exists, but some real-capability claims still depend on local fallback and incomplete external comparison packs.", + "evidence": { + "longitude_cases": summary.get("longitude_cases", 0), + "dasha_cases": summary.get("dasha_cases", 0), + "shadbala_cases": summary.get("shadbala_cases", 0), + }, + }, + ] + return risks + + +def _cleanup_priorities(fragment_audit: dict[str, Any], high_value_unpromoted: list[dict[str, Any]]) -> list[dict[str, Any]]: + workspace_residue = fragment_audit.get("workspace_residue", {}).get("untracked_files", []) + priorities = [ + { + "id": "triage_workspace_residue", + "severity": "high" if workspace_residue else "medium", + "why": "Untracked residue can hide generated truth, stale artifacts, or partial experiments across windows.", + "evidence": { + "untracked_count": len(workspace_residue), + "sample_paths": workspace_residue[:5], + }, + }, + { + "id": "promote_or_archive_high_value_drafts", + "severity": "medium", + "why": "High-value draft audits should either be promoted into repo truth or explicitly archived to reduce rediscovery cost.", + "evidence": { + "draft_count": len(high_value_unpromoted), + "sample_paths": [item["path"] for item in high_value_unpromoted[:5]], + }, + }, + ] + return priorities + + +def build_report() -> dict[str, Any]: + fragment_audit = _run_json_script("scripts/audit_fragments.py", "--strict") + capability_audit = _run_json_script( + "scripts/jyotish_engine.py", + "audit-capabilities", + "--mode", + "validate", + ) + oracle = json.loads(_read_text(ORACLE_FILE)) + oracle_boundary = oracle_boundary_audit.build_report(oracle) + + high_value_unpromoted = _summarize_local_drafts() + _summarize_external_work_brain() + redundant_or_mirror = _redundant_or_mirror_rows(fragment_audit) + real_capability_risks = _real_capability_risks(oracle_boundary) + cleanup_priorities = _cleanup_priorities(fragment_audit, high_value_unpromoted) + + return { + "scope": "preflight_fragment_scan", + "summary": { + "authority_layers_scanned": 4, + "production_truth_layer": "main_repo_truth", + "high_value_unpromoted_count": len(high_value_unpromoted), + "redundant_or_mirror_count": len(redundant_or_mirror), + "workspace_residue_count": len(fragment_audit.get("workspace_residue", {}).get("untracked_files", [])), + "real_capability_risk_count": len(real_capability_risks), + "real_capability_status": "engineering_surfaces_covered_but_external_accuracy_not_closed", + }, + "layers": { + "main_repo_truth": { + "status": "authoritative", + "paths": [ + str(ROOT / "SKILL.md"), + str(ROOT / "AGENTS.md"), + str(ROOT / "scripts"), + str(ROOT / "tests"), + str(ROOT / "references"), + str(ROOT / "docs" / "research"), + ], + }, + "repo_local_drafts": { + "status": "draft_reference_only", + "path": str(LOCAL_DRAFTS_DIR), + }, + "external_work_brain": { + "status": "recovery_reference_only", + "path": str(EXTERNAL_WORK_BRAIN_DIR), + }, + "distribution_mirror": { + "status": "mirror_do_not_reverse_sync", + "path": str(DISTRIBUTION_MIRROR_DIR), + }, + }, + "upstream_audits": { + "fragment_audit": { + "valid": fragment_audit.get("valid"), + "candidate_count": fragment_audit.get("fragments", {}).get("candidate_count"), + "workspace_residue_count": fragment_audit.get("workspace_residue", {}).get("untracked_count"), + }, + "capability_audit": { + "valid": capability_audit.get("valid"), + "technique_count": capability_audit.get("technique_count"), + "problem_count": capability_audit.get("problem_count"), + }, + }, + "findings": { + "high_value_unpromoted_count": len(high_value_unpromoted), + "redundant_or_mirror_count": len(redundant_or_mirror), + "workspace_residue_count": len(fragment_audit.get("workspace_residue", {}).get("untracked_files", [])), + "real_capability_risk_count": len(real_capability_risks), + }, + "high_value_unpromoted": high_value_unpromoted, + "redundant_or_mirror": redundant_or_mirror, + "real_capability_boundary": { + "status": "not_fully_closed", + "oracle_boundary": { + "scope": oracle_boundary.get("scope"), + "production_tuning_recommended": oracle_boundary.get("summary", {}).get("production_tuning_recommended"), + "dasha_cases": oracle_boundary.get("summary", {}).get("dasha_cases"), + "shadbala_cases": oracle_boundary.get("summary", {}).get("shadbala_cases"), + "longitude_cases": oracle_boundary.get("summary", {}).get("longitude_cases"), + "open_items": oracle_boundary.get("summary", {}).get("open_items", []), + }, + }, + "real_capability_risks": real_capability_risks, + "cleanup_priorities": cleanup_priorities, + "boundary": ( + "Run this preflight scan before major work so drafts, mirrors, and external-work-brain " + "fragments are reviewed deliberately, and so engineering-surface success is not mistaken " + "for externally validated historical accuracy." + ), + } + + +def main() -> int: + print(json.dumps(build_report(), ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/shadbala.py b/scripts/shadbala.py index c215c800..a433fae5 100644 --- a/scripts/shadbala.py +++ b/scripts/shadbala.py @@ -330,9 +330,9 @@ def calc_sthana_bala(pname: str, lon: float, sign: str, house: int) -> Dict: d3_sign_idx = varga_map(sign_idx, d3_part, 3) d3_sign = VARGA_SIGNS[d3_sign_idx] if pname == VARGA_SIGN_LORDS.get(d3_sign, ''): - d3_score = 45.0 + d3_score = 30.0 elif d3_sign == exalt_sign: - d3_score = 50.0 + d3_score = 45.0 elif d3_sign == debilit_sign: d3_score = 5.0 else: diff --git a/scripts/shadbala_dig_source_of_truth_audit.py b/scripts/shadbala_dig_source_of_truth_audit.py new file mode 100644 index 00000000..e8a24976 --- /dev/null +++ b/scripts/shadbala_dig_source_of_truth_audit.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Compare candidate Dig Bala models against external oracle rows.""" + +from __future__ import annotations + +import json +import math +import os +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_DIR = ROOT / "scripts" +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +import jyotish_engine as engine # type: ignore +import oracle_boundary_audit # type: ignore +from shadbala import DIG_BALA_HOUSE, calc_dig_bala # type: ignore + + +MODEL_NAMES = [ + "current_linear_house_model", + "house_midpoint_angular_model", + "bhava_madhya_angular_model", +] + + +def _load_oracle(path: str) -> dict[str, Any]: + resolved = Path(path) + if not resolved.is_absolute(): + resolved = ROOT / resolved + return oracle_boundary_audit._load_oracle(str(resolved)) + + +def _iter_external_verified_template_cases(oracle: dict[str, Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for case in oracle.get("template_cases", []): + if case.get("status") == "external_verified" and isinstance(case.get("target", {}).get("shadbala_components"), dict): + out.append(case) + return out + + +def _namespace_from_template(case: dict[str, Any]) -> Any: + birth = case["birth"] + settings = case.get("settings", {}) + return type( + "Args", + (), + { + "year": birth["year"], + "month": birth["month"], + "day": birth["day"], + "hour": birth["hour"], + "minute": birth.get("minute", 0), + "second": birth.get("second", 0), + "lat": birth["lat"], + "lon": birth["lon"], + "tz": birth["tz"], + "ayanamsa": settings.get("ayanamsa", "lahiri"), + "node_mode": settings.get("node_mode", "mean"), + }, + )() + + +def _planet_lon(chart: dict[str, Any], planet: str) -> float: + return float(chart["planets"][planet]["degree_raw"]) + + +def _asc_lon(chart: dict[str, Any]) -> float: + return float(chart["ascendant"]["degree_raw"]) + + +def _whole_sign_house_midpoint(asc_lon: float, house: int) -> float: + base = (asc_lon + (house - 1) * 30) % 360 + return (base + 15) % 360 + + +def _angular_distance(a: float, b: float) -> float: + diff = abs(a - b) % 360 + return min(diff, 360 - diff) + + +def _dig_from_strong_point(planet_lon: float, strong_point_lon: float) -> float: + shorter_arc = _angular_distance(planet_lon, strong_point_lon) + return max(0.0, (180.0 - shorter_arc) / 3.0) + + +def _best_house_midpoint_lon(asc_lon: float, planet: str) -> float: + best_house = DIG_BALA_HOUSE.get(planet, 1) + return _whole_sign_house_midpoint(asc_lon, best_house) + + +def _best_bhava_madhya_lon(chart: dict[str, Any], planet: str) -> float: + best_house = DIG_BALA_HOUSE.get(planet, 1) + house_row = chart["houses"].get(f"house_{best_house}", {}) + return float(house_row.get("cusp_degree", 0.0)) + + +def build_report(oracle_file: str) -> dict[str, Any]: + oracle = _load_oracle(oracle_file) + cases = _iter_external_verified_template_cases(oracle) + rows: list[dict[str, Any]] = [] + model_diffs: dict[str, list[float]] = {name: [] for name in MODEL_NAMES} + + for case in cases: + chart = engine.cmd_chart(_namespace_from_template(case)) + asc_lon = _asc_lon(chart) + target_components = case["target"]["shadbala_components"] + for planet, external_row in target_components.items(): + if planet not in chart["planets"] or not isinstance(external_row, dict): + continue + external_dig = external_row.get("dig") + if not isinstance(external_dig, (int, float)): + continue + planet_lon = _planet_lon(chart, planet) + house = int(chart["planets"][planet]["house"]) + current_linear = calc_dig_bala(planet, house) / 60.0 + house_midpoint = _dig_from_strong_point(planet_lon, _best_house_midpoint_lon(asc_lon, planet)) / 60.0 + bhava_madhya = _dig_from_strong_point(planet_lon, _best_bhava_madhya_lon(chart, planet)) / 60.0 + candidates = { + "current_linear_house_model": current_linear, + "house_midpoint_angular_model": house_midpoint, + "bhava_madhya_angular_model": bhava_madhya, + } + diffs = {name: round(abs(value - float(external_dig)), 4) for name, value in candidates.items()} + for name, diff in diffs.items(): + model_diffs[name].append(diff) + rows.append( + { + "case_id": case.get("id") or case.get("case_id"), + "planet": planet, + "external_dig_rupa": float(external_dig), + "house": house, + "planet_lon": round(planet_lon, 4), + "asc_lon": round(asc_lon, 4), + "current_linear_house_model": round(current_linear, 4), + "house_midpoint_angular_model": round(house_midpoint, 4), + "bhava_madhya_angular_model": round(bhava_madhya, 4), + "abs_diffs": diffs, + } + ) + + avg_diffs = { + name: round(sum(values) / len(values), 4) if values else math.inf + for name, values in model_diffs.items() + } + best_model = min(avg_diffs, key=avg_diffs.get) if rows else None + + return { + "scope": "shadbala_dig_source_of_truth_audit", + "schema_version": 1, + "candidate_models": MODEL_NAMES, + "summary": { + "case_count": len(cases), + "row_count": len(rows), + "best_model_by_avg_abs_diff": best_model, + "avg_abs_diff_by_model": avg_diffs, + }, + "rows": rows, + "boundary": ( + "This audit compares three local Dig Bala candidate models against external oracle rows. " + "It is diagnostic only and does not modify production scoring." + ), + } + + +def main() -> None: + report = build_report("references/oracle/dasha_shadbala_oracle_cases.json") + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/unified_consultation_orchestrator.py b/scripts/unified_consultation_orchestrator.py new file mode 100644 index 00000000..6a3a48eb --- /dev/null +++ b/scripts/unified_consultation_orchestrator.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Shared orchestration contract for skill/MCP and web/API surfaces.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class RouteDefinition: + question_type: str + primary_theme: str + focus_techniques: list[str] + display_label: str + + +class UnifiedConsultationOrchestrator: + """Normalizes user intent and exposes a surface-agnostic workflow contract.""" + + NAME = "UnifiedConsultationOrchestrator" + SOURCE_PRIORITY = { + "mode": "vedastro_official_snapshot_first", + "priority": [ + "vedastro_official_snapshot", + "local_supplemental_modules", + "local_fallback_only_when_official_blocked", + ], + "boundary": ( + "Official VedAstro raw evidence is preferred; local modules supplement, " + "cross-check, and fallback when official calls are blocked." + ), + } + _THEME_ALIASES = { + "relationship": "marriage", + "marriage": "marriage", + "finance": "wealth", + "money": "wealth", + "wealth": "wealth", + "career": "career", + "health": "health", + "spirituality": "spirituality", + "事业": "career", + "婚恋": "marriage", + "婚姻": "marriage", + "感情": "marriage", + "财富": "wealth", + "财运": "wealth", + "健康": "health", + "灵性": "spirituality", + } + _DEFAULT_THEMES = ["career", "marriage", "wealth"] + _ALLOWED_THEMES = {"career", "marriage", "wealth", "health", "spirituality"} + _ROUTE_DEFINITIONS = { + "career": RouteDefinition( + question_type="career", + primary_theme="career", + focus_techniques=["D10", "Dasha", "Shadbala", "Transit", "Narayana Dasha"], + display_label="career", + ), + "relationship": RouteDefinition( + question_type="relationship", + primary_theme="marriage", + focus_techniques=["D9", "UL Upapada", "Dasha", "Nakshatra", "Vivah Saham"], + display_label="relationship", + ), + "finance": RouteDefinition( + question_type="finance", + primary_theme="wealth", + focus_techniques=["D2", "D11", "Dasha", "Shadbala", "Ashtakavarga"], + display_label="finance", + ), + "timing": RouteDefinition( + question_type="timing", + primary_theme="career", + focus_techniques=["Dasha", "Transit", "Double Transit", "Gochara"], + display_label="timing", + ), + "general": RouteDefinition( + question_type="general", + primary_theme="career", + focus_techniques=["D1", "D9", "Dasha", "Yoga", "Shadbala", "Ashtakavarga"], + display_label="general", + ), + } + _SYNC_STEPS_BY_ROUTE = { + "career": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "relationship": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "finance": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "timing": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "general": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + } + _ASYNC_CANDIDATES = [ + "historical_event_backtest", + "official_event_radar_expansion", + "extended_prompt_pack_refresh", + ] + + def normalize_themes(self, raw: Any) -> list[str]: + if raw in (None, "", "all"): + values = list(self._DEFAULT_THEMES) + elif isinstance(raw, str): + values = [raw] + elif isinstance(raw, list): + values = raw + else: + raise ValueError("theme/themes must be a string, list, or all") + + normalized: list[str] = [] + for value in values: + key = self._THEME_ALIASES.get(str(value).strip().lower(), str(value).strip().lower()) + if key not in self._ALLOWED_THEMES: + raise ValueError(f"Unknown theme: {value}") + if key not in normalized: + normalized.append(key) + return normalized or list(self._DEFAULT_THEMES) + + def resolve_route(self, question: str, themes: list[str] | None = None) -> dict[str, Any]: + text = (question or "").lower() + normalized_themes = themes or list(self._DEFAULT_THEMES) + + domain_tokens = { + "career": ("career", "job", "work", "promotion", "business", "profession", "事业", "工作", "升职", "生意"), + "relationship": ("marriage", "married", "wedding", "relationship", "love", "spouse", "partner", "divorce", "婚恋", "婚姻", "感情", "配偶", "恋爱", "结婚", "marry"), + "finance": ("money", "wealth", "finance", "investment", "property", "income", "财务", "财富", "投资", "房产", "收入"), + } + first_hits: list[tuple[int, str]] = [] + for route_name, tokens in domain_tokens.items(): + indexes = [text.find(token) for token in tokens if token in text] + indexes = [idx for idx in indexes if idx >= 0] + if indexes: + first_hits.append((min(indexes), route_name)) + + if first_hits: + route_name = sorted(first_hits, key=lambda item: item[0])[0][1] + route = self._ROUTE_DEFINITIONS[route_name] + elif not text.strip(): + route = self._ROUTE_DEFINITIONS["general"] + elif any(token in text for token in ("when", "timing", "event", "prediction", "future", "应期", "预测", "何时", "将来")): + route = self._ROUTE_DEFINITIONS["timing"] + elif "career" in normalized_themes: + route = self._ROUTE_DEFINITIONS["career"] + elif "marriage" in normalized_themes: + route = self._ROUTE_DEFINITIONS["relationship"] + elif "wealth" in normalized_themes: + route = self._ROUTE_DEFINITIONS["finance"] + else: + route = self._ROUTE_DEFINITIONS["general"] + + return { + "question_type": route.question_type, + "primary_theme": route.primary_theme, + "focus_techniques": list(route.focus_techniques), + "display_label": route.display_label, + } + + def shared_contract( + self, + *, + entry_mode: str, + question: str, + themes: list[str], + route_packet: dict[str, Any], + surface: str, + ) -> dict[str, Any]: + return { + "name": self.NAME, + "surface": surface, + "entry_mode": entry_mode, + "question": question or "", + "themes": list(themes), + "route": dict(route_packet), + "source_priority": { + "mode": self.SOURCE_PRIORITY["mode"], + "priority": list(self.SOURCE_PRIORITY["priority"]), + "boundary": self.SOURCE_PRIORITY["boundary"], + }, + "shared_capabilities": [ + "theme_normalization", + "question_routing", + "vedastro_official_priority", + "rectification_gate_reuse", + "thematic_report_reuse", + ], + } + + def runtime_planner( + self, + *, + entry_mode: str, + question: str, + themes: list[str], + route_packet: dict[str, Any], + events: list[dict[str, Any]] | None, + surface: str, + high_rigor: bool, + ) -> dict[str, Any]: + route_name = route_packet.get("question_type") or "general" + sync_steps = list(self._SYNC_STEPS_BY_ROUTE.get(route_name, self._SYNC_STEPS_BY_ROUTE["general"])) + if entry_mode == "rectification": + sync_steps = [step for step in sync_steps if step != "run_rectification_gate"] + sync_steps.insert(0, "run_rectification_gate") + if high_rigor and "run_historical_event_backtest" not in sync_steps and events: + sync_steps.append("run_historical_event_backtest") + + async_candidates = list(self._ASYNC_CANDIDATES) + if not events: + async_candidates = [step for step in async_candidates if step != "historical_event_backtest"] + + return { + "planner_name": "UnifiedConsultationRuntimePlanner", + "surface": surface, + "entry_mode": entry_mode, + "high_rigor": bool(high_rigor), + "route": dict(route_packet), + "question_context": { + "question": question or "", + "themes": list(themes), + "event_count": len(events or []), + }, + "sync_steps": sync_steps, + "async_candidates": async_candidates, + "source_priority": { + "mode": self.SOURCE_PRIORITY["mode"], + "priority": list(self.SOURCE_PRIORITY["priority"]), + "boundary": self.SOURCE_PRIORITY["boundary"], + }, + "reuse_contract": { + "chart": "compute_chart", + "rectification": "rectification_gate", + "thematic_report": "thematic_report", + "historical_backtest": "historical_event_backtest", + }, + "boundary": ( + "This runtime planner unifies entry routing and module reuse. It does not imply that every VedAstro " + "catalog method executes on every request; route-relevant official evidence is still subject to live " + "availability, cache policy, and async limits." + ), + } diff --git a/scripts/validate_bphs_invariants.py b/scripts/validate_bphs_invariants.py index 6c52a831..76e5cf9f 100644 --- a/scripts/validate_bphs_invariants.py +++ b/scripts/validate_bphs_invariants.py @@ -14,6 +14,11 @@ BPHS 分盘与 Ashtakavarga 独立验证脚本 import sys import json +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', 'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] @@ -24,52 +29,12 @@ def sign_idx(name): return SIGNS.index(name) if name in SIGNS else -1 # ============================================================================= # 1. Navamsa (D9) 验证 - BPHS Chapter 6 # ============================================================================= -# BPHS 标准: +# 本仓采用的 JHora/BPHS Navamsa 口径: # - Movable signs (白羊0, 巨蟹3, 天秤6, 摩羯9): 从本星座开始 -# - Fixed signs (金牛1, 狮子4, 天蝎7, 水瓶10): 从第5星座开始 (+4) -# - Dual signs (双子2, 处女5, 射手8, 双鱼11): 从第9星座开始 (+8) +# - Fixed signs (金牛1, 狮子4, 天蝎7, 水瓶10): 从第9星座开始 (+8) +# - Dual signs (双子2, 处女5, 射手8, 双鱼11): 从第5星座开始 (+4) # 每份 = 30/9 = 3.333... 度 -NAVAMSA_TEST_CASES = [ - # (description, longitude, expected_sign) - # Movable signs - ("Aries 0° (movable, part 0)", 0.0, "Aries"), - ("Aries 3.33° (movable, part 1)", 3.3333, "Taurus"), - ("Aries 10° (movable, part 3)", 10.0, "Cancer"), - ("Aries 16.67° (movable, part 5)", 16.6667, "Virgo"), - ("Aries 23.33° (movable, part 7)", 23.3333, "Scorpio"), - ("Aries 28° (movable, part 8)", 28.0, "Sagittarius"), - ("Cancer 5° (movable, part 1)", 90 + 5, "Leo"), - ("Libra 15° (movable, part 4)", 180 + 15, "Capricorn"), - ("Capricorn 20° (movable, part 6)", 270 + 20, "Pisces"), - - # Fixed signs - ("Taurus 0° (fixed, part 0)", 30 + 0, "Virgo"), # start=1+4=5=Virgo - ("Taurus 5° (fixed, part 1)", 30 + 5, "Libra"), # start=5, part=1 → 6=Libra - ("Taurus 10° (fixed, part 2)", 30 + 10, "Scorpio"), # start=5, part=2 → 7=Scorpio - ("Leo 0° (fixed, part 0)", 120 + 0, "Sagittarius"), # start=4+4=8=Sagittarius - ("Leo 15° (fixed, part 4)", 120 + 15, "Aries"), # start=8, part=4 → 0=Aries - ("Scorpio 10° (fixed, part 2)", 210 + 10, "Capricorn"), # start=7+4=11, part=2 → 1=Aquarius? wait - # Let me recalculate: Scorpio=7, start=7+4=11=Aquarius, part=int(10/3.333)=3, result=(11+3)%12=2=Gemini - # Hmm wait, 10/3.333 = 3.0, so part=3. (11+3)%12=2=Gemini. But my test case says part 2. Let me fix. - ("Scorpio 6° (fixed, part 1)", 210 + 6, "Pisces"), # start=11, part=1 → 0=Aries... wait - # 6/3.333 = 1.8, int=1. (11+1)%12=0=Aries. Let me recalculate. - # Actually let me be more careful. - ("Aquarius 5° (fixed, part 1)", 300 + 5, "Gemini"), # start=10+4=14%12=2=Gemini, part=1 → 3=Cancer - # 5/3.333=1.5, int=1. (2+1)%12=3=Cancer - - # Dual signs - ("Gemini 0° (dual, part 0)", 60 + 0, "Aquarius"), # start=2+8=10=Aquarius - ("Gemini 5° (dual, part 1)", 60 + 5, "Pisces"), # start=10, part=1 → 11=Pisces - ("Virgo 10° (dual, part 2)", 150 + 10, "Cancer"), # start=5+8=13%12=1=Taurus, part=3 → 4=Leo - # 10/3.333=3.0, int=3. (1+3)%12=4=Leo. Hmm test case says part 2. Let me fix. - ("Sagittarius 15° (dual, part 4)", 240 + 15, "Libra"), # start=8+8=16%12=4=Leo, part=4 → 8=Sagittarius - # 15/3.333=4.5, int=4. (4+4)%12=8=Sagittarius - ("Pisces 20° (dual, part 6)", 330 + 20, "Leo"), # start=11+8=19%12=7=Libra, part=6 → 1=Taurus - # 20/3.333=6.0, int=6. (7+6)%12=1=Taurus -] - -# Let me rewrite the test cases more carefully def calc_navamsa_ref(lon): """BPHS标准navamsa - 参考实现""" si = int(lon / 30) @@ -78,9 +43,9 @@ def calc_navamsa_ref(lon): if si % 3 == 0: # movable start = si elif si % 3 == 1: # fixed - start = (si + 4) % 12 - else: # dual start = (si + 8) % 12 + else: # dual + start = (si + 4) % 12 return (start + ni) % 12 NAVAMSA_TEST_CASES = [ @@ -94,24 +59,17 @@ NAVAMSA_TEST_CASES = [ ("Cancer 5° → Leo (movable, part 1)", 90 + 5, 4), ("Libra 15° → Capricorn (movable, part 4)", 180 + 15, 9), ("Capricorn 20° → Pisces (movable, part 6)", 270 + 20, 11), - - # Fixed signs (si % 3 == 1): start = (si + 4) % 12 - ("Taurus 0° → Virgo (fixed, part 0, start=5)", 30 + 0, 5), - ("Taurus 5° → Libra (fixed, part 1, start=5)", 30 + 5, 6), - ("Taurus 10° → Scorpio (fixed, part 2, start=5)", 30 + 10, 7), - ("Taurus 20° → Capricorn (fixed, part 6, start=5)", 30 + 20, 11), # 20/3.333=6 - ("Leo 0° → Sagittarius (fixed, part 0, start=8)", 120 + 0, 8), - ("Leo 15° → Aries (fixed, part 4, start=8)", 120 + 15, 0), # 15/3.333=4.5→4, (8+4)%12=0 - ("Scorpio 6° → Aquarius (fixed, part 1, start=11)", 210 + 6, 0), # 6/3.333=1.8→1, (11+1)%12=0=Aries... wait - # Let me recalculate: Scorpio=7, start=(7+4)%12=11=Aquarius. 6°/(30/9)=6/3.333=1.8, int=1. (11+1)%12=0=Aries - # Hmm my expected was Aquarius. Let me trace more carefully. - # Actually I think I made an error. Let me recalculate: - # Scorpio = sign 7 (210-240°). 6° into Scorpio = 216° total. - # part = int(6 / 3.333) = int(1.8) = 1 - # start = (7 + 4) % 12 = 11 = Aquarius - # result = (11 + 1) % 12 = 0 = Aries - - # So my expected value was wrong. Let me fix all the test cases by computing them properly. + + # Fixed signs (si % 3 == 1): start = 9th from sign (+8) + ("Taurus 0° → Capricorn (fixed, part 0, start=9)", 30 + 0, 9), + ("Taurus 5° → Aquarius (fixed, part 1, start=9)", 30 + 5, 10), + ("Taurus 10° → Pisces (fixed, part 3, start=9)", 30 + 10, 0), + ("Leo 0° → Aries (fixed, part 0, start=0)", 120 + 0, 0), + + # Dual signs (si % 3 == 2): start = 5th from sign (+4) + ("Gemini 0° → Libra (dual, part 0, start=6)", 60 + 0, 6), + ("Gemini 5° → Scorpio (dual, part 1, start=6)", 60 + 5, 7), + ("Virgo 10° → Leo (dual, part 3, start=1)", 150 + 10, 4), ] # I'll generate test cases programmatically to avoid manual errors @@ -147,12 +105,11 @@ def navamsa_ref(lon): d = lon - si * 30 ni = int(d / (30 / 9)) if si % 3 == 0: start = si - elif si % 3 == 1: start = (si + 4) % 12 - else: start = (si + 8) % 12 + elif si % 3 == 1: start = (si + 8) % 12 + else: start = (si + 4) % 12 return (start + ni) % 12 -# 从 varga.py 导入 -sys.path.insert(0, '/Users/wuyongnaren/.workbuddy/skills/jyotish-vedic-astrology/scripts') +# 从本仓 scripts/varga.py 导入 from varga import calc_varga # 全面测试:每个星座的 0°, 5°, 10°, 15°, 20°, 25° @@ -237,8 +194,8 @@ SIGNS_JE = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', def navamsa_je(lon): si = int(lon / 30); d = lon - si * 30; ni = int(d / (30/9)) if si % 3 == 0: start = si - elif si % 3 == 1: start = (si + 4) % 12 - else: start = (si + 8) % 12 + elif si % 3 == 1: start = (si + 8) % 12 + else: start = (si + 4) % 12 return SIGNS_JE[(start + ni) % 12] def dasamsa_je(lon): diff --git a/scripts/varga.py b/scripts/varga.py index 9f03ff47..83a7df40 100644 --- a/scripts/varga.py +++ b/scripts/varga.py @@ -33,6 +33,15 @@ VARGA_META = { def _si(lon): return int(lon/30)%12 def _sn(i): return SIGNS[i%12] def _odd(si): return si%2==0 # Aries(0)=odd +def _modality(si): + if si % 3 == 0: + return 'movable' + if si % 3 == 1: + return 'fixed' + return 'dual' + +def _element(si): + return si % 4 def _d30_map(si, pi): if _odd(si): @@ -73,9 +82,68 @@ def varga_map(si, pi, div): if div==60: return (si+pi)%12 if o else (si+1+pi)%12 raise ValueError(f"不支持的D{div}") -def calc_varga(lon, div): + +def _d30_map_vedastro(si, pi): + if _odd(si): + if pi < 5: return 7 + if pi < 10: return 10 + if pi < 18: return 8 + if pi < 25: return 2 + return 6 + else: + if pi < 5: return 1 + if pi < 12: return 2 + if pi < 20: return 8 + if pi < 25: return 9 + return 7 + + +def varga_map_vedastro(si, pi, div): + """VedAstro-compatible varga sign mapping. + + This mode is calibrated against VedAstro official AllPlanetData / + AllHouseData outputs. It intentionally lives beside the historical local + mapping so older research workflows can still audit classical variants. + """ + if div == 2: + return (si + pi * 4) % 12 + if div == 4: + return (si + pi * 3) % 12 + if div == 7: + return (si + pi) % 12 + if div == 16: + start = {'movable': 0, 'fixed': 4, 'dual': 8}[_modality(si)] + return (start + pi) % 12 + if div == 20: + start = {'movable': 0, 'fixed': 8, 'dual': 4}[_modality(si)] + return (start + pi) % 12 + if div == 27: + start = {0: 0, 1: 3, 2: 6, 3: 9}[_element(si)] + return (start + pi) % 12 + if div == 30: + return _d30_map_vedastro(si, pi) + if div == 45: + start = {'movable': 0, 'fixed': 4, 'dual': 8}[_modality(si)] + return (start + pi) % 12 + if div == 60: + return (si + pi) % 12 + return varga_map(si, pi, div) + +def calc_varga(lon, div, mode='classical_local'): """计算行星在指定分盘的位置(星座+精确度数+尊贵状态)""" - si=_si(lon); d=lon-si*30; ps=30.0/div; pi=int(d/ps) + si=_si(lon); d=lon-si*30 + if mode == 'vedastro' and div == 2: + ps = 10.0 + pi = int(d / ps) + dp = (d - pi * ps) * 3 + dp_display = round(dp, 4) + if dp_display >= 30: + dp_display = 0.0 + vsi = varga_map_vedastro(si, pi, div) + r={'sign':_sn(vsi),'sign_idx':vsi,'degree_in_sign':dp_display, + 'part_index':pi,'lord':SIGN_LORDS.get(_sn(vsi),'')} + return r + ps=30.0/div; pi=int(d/ps) # For all vargas, degree within divisional sign is scaled to 0-30 degrees. dp=(d-pi*ps)*div # Keep the displayed divisional degree inside [0, 30). Values such as @@ -84,7 +152,7 @@ def calc_varga(lon, div): dp_display = round(dp, 4) if dp_display >= 30: dp_display = 0.0 - vsi=varga_map(si,pi,div) + vsi=varga_map_vedastro(si,pi,div) if mode == 'vedastro' else varga_map(si,pi,div) r={'sign':_sn(vsi),'sign_idx':vsi,'degree_in_sign':dp_display, 'part_index':pi,'lord':SIGN_LORDS.get(_sn(vsi),'')} if div==9: r['pada']=pi+1 @@ -173,7 +241,7 @@ def dignity(planet, sign_idx): if planet in OWN_SIGNS and sign_idx in OWN_SIGNS[planet]: return 'Own Sign' return 'Neutral' -def calc_all_vargas(planet_lons, asc_lon, divisions=None): +def calc_all_vargas(planet_lons, asc_lon, divisions=None, mode='classical_local'): """批量计算所有指定分盘""" if divisions is None: divisions=[2,3,4,7,9,10,12,16,20,24,27,30,40,45,60] @@ -183,9 +251,9 @@ def calc_all_vargas(planet_lons, asc_lon, divisions=None): key=f"D{div}_{m.get('name',f'D{div}')}" vd={'_meta':{'div':div,'name':m.get('name',''),'cn':m.get('cn',''), 'area':m.get('area',''),'part_size':30.0/div}} - vd['Ascendant']=calc_varga(asc_lon,div) + vd['Ascendant']=calc_varga(asc_lon,div,mode=mode) for pn,lon in planet_lons.items(): - vd[pn]=calc_varga(lon,div) + vd[pn]=calc_varga(lon,div,mode=mode) # 尊贵状态 vd['_dignity']={pn:dignity(pn,pd['sign_idx']) for pn,pd in vd.items() if pn not in ('Ascendant','_meta') and isinstance(pd,dict) and 'sign_idx' in pd} diff --git a/scripts/vedastro_evidence_orchestrator.py b/scripts/vedastro_evidence_orchestrator.py index 60e944e0..a1aa804b 100644 --- a/scripts/vedastro_evidence_orchestrator.py +++ b/scripts/vedastro_evidence_orchestrator.py @@ -38,6 +38,45 @@ ROUTE_DOMAIN_MAP = { } +ROUTE_THEME_REQUIREMENTS = { + "relationship": { + "route": "relationship", + "requires_dual_dasha": True, + "required_local_supplements": ["upapada_lagna", "darakaraka", "narayana_dasha", "functional_benefic_malefic"], + }, + "marriage": { + "route": "relationship", + "requires_dual_dasha": True, + "required_local_supplements": ["upapada_lagna", "darakaraka", "narayana_dasha", "functional_benefic_malefic"], + }, + "career": { + "route": "career", + "requires_dual_dasha": True, + "required_local_supplements": ["a10_karma_pada", "narayana_dasha", "functional_benefic_malefic"], + }, + "finance": { + "route": "finance", + "requires_dual_dasha": True, + "required_local_supplements": ["wealth_structure_explainer", "narayana_dasha", "functional_benefic_malefic"], + }, + "wealth": { + "route": "finance", + "requires_dual_dasha": True, + "required_local_supplements": ["wealth_structure_explainer", "narayana_dasha", "functional_benefic_malefic"], + }, + "overview": { + "route": "overview", + "requires_dual_dasha": True, + "required_local_supplements": [], + }, + "general": { + "route": "general", + "requires_dual_dasha": True, + "required_local_supplements": [], + }, +} + + def _default_window(reference_date: str | None, days: int = 180) -> tuple[str, str]: raw = str(reference_date or datetime.utcnow().strftime("%Y-%m-%d"))[:10] try: @@ -84,6 +123,8 @@ def orchestrate_vedastro_evidence( domain_reports: dict[str, Any] = {} evidence_ledger: list[dict[str, Any]] = [] top_events_by_domain: dict[str, Any] = {} + daily_windows_by_domain: dict[str, list[dict[str, Any]]] = {} + top_daily_window_by_domain: dict[str, dict[str, Any]] = {} domain_statuses: dict[str, Any] = {} domain_event_counts: dict[str, int] = {} available = False @@ -92,6 +133,24 @@ def orchestrate_vedastro_evidence( case, case_id=f"{case_id}_official_full_snapshot", ) + official_metadata = official_full_snapshot.get("source_metadata") if isinstance(official_full_snapshot, dict) else {} + if not isinstance(official_metadata, dict): + official_metadata = {} + full_catalog = official_metadata.get("official_full_capability_catalog") + if not isinstance(full_catalog, dict): + full_catalog = {} + full_catalog_domain_routing = full_catalog.get("domain_routing") if isinstance(full_catalog.get("domain_routing"), dict) else {} + full_catalog_dynamic_selection = full_catalog.get("dynamic_selection") if isinstance(full_catalog.get("dynamic_selection"), dict) else {} + official_section_statuses = official_full_snapshot.get("section_statuses") if isinstance(official_full_snapshot, dict) else {} + if not isinstance(official_section_statuses, dict): + official_section_statuses = {} + official_report_references = { + theme: selection.get("report_reference") + for theme, selection in full_catalog_dynamic_selection.items() + if isinstance(selection, dict) and isinstance(selection.get("report_reference"), dict) + } + theme_requirements = ROUTE_THEME_REQUIREMENTS.get(route, ROUTE_THEME_REQUIREMENTS["general"]).copy() + theme_requirements["domains"] = list(domains) for domain in domains: report = run_range_scan_for_case( @@ -108,6 +167,12 @@ def orchestrate_vedastro_evidence( first_reason = first_reason or report.get("reason") if isinstance(report.get("top_event"), dict): top_events_by_domain[domain] = report["top_event"] + daily_windows = report.get("daily_windows") + if isinstance(daily_windows, list): + daily_windows_by_domain[domain] = daily_windows + top_daily_window = report.get("top_daily_window") + if isinstance(top_daily_window, dict): + top_daily_window_by_domain[domain] = top_daily_window for event in report.get("evidence_ledger") or []: if isinstance(event, dict): evidence_ledger.append(event) @@ -123,6 +188,8 @@ def orchestrate_vedastro_evidence( "event_count": len(evidence_ledger), "top_event": next(iter(top_events_by_domain.values()), None), "top_events_by_domain": top_events_by_domain, + "daily_windows_by_domain": daily_windows_by_domain, + "top_daily_window_by_domain": top_daily_window_by_domain, "evidence_ledger": evidence_ledger, "reason": None if status == "ok" else first_reason, "official_full_snapshot": official_full_snapshot, @@ -130,8 +197,26 @@ def orchestrate_vedastro_evidence( "source_metadata": { "auto_ingested_by": "VedAstroEvidenceOrchestrator", "strategy": "official_full_snapshot_first_then_route_scoped_range_scan", + "official_python_path": ( + (official_full_snapshot.get("source_metadata") or {}).get("official_python_path") + if isinstance(official_full_snapshot, dict) + else None + ), + "official_python_bundle_status": ( + (official_metadata.get("official_python_bundle") or {}).get("status") + ), + "official_full_capability_catalog_status": full_catalog.get("status"), + "official_full_capability_catalog_summary": full_catalog.get("summary") or {}, + "official_full_capability_domain_routing": full_catalog_domain_routing, + "official_full_capability_dynamic_selection": full_catalog_dynamic_selection, + "official_report_references": official_report_references, + "official_section_statuses": official_section_statuses, + "theme_requirements": theme_requirements, "node_coverage": { "official_full_snapshot_first": True, + "official_full_capability_catalog_default": bool(full_catalog), + "official_full_capability_theme_routing": bool(full_catalog_domain_routing), + "official_full_capability_dynamic_selection": bool(full_catalog_dynamic_selection), "strategy": "domain_scoped_range_scan", "official_calculation_coverage": VEDASTRO_CALCULATION_COVERAGE, "selected_domains": domains, diff --git a/scripts/vedastro_method_catalog_sync.py b/scripts/vedastro_method_catalog_sync.py index d248ff9a..7f1f1542 100644 --- a/scripts/vedastro_method_catalog_sync.py +++ b/scripts/vedastro_method_catalog_sync.py @@ -11,6 +11,8 @@ from __future__ import annotations import argparse import json import os +import subprocess +import sys from pathlib import Path from typing import Any from urllib import request @@ -20,17 +22,19 @@ ROOT = Path(__file__).resolve().parents[1] DEFAULT_OUTPUT = ROOT / "scratch" / "local" / "vedastro_adapter" / "method_catalog_snapshot.json" OFFICIAL_TAG_CATALOG_URL = "https://api.vedastro.org/api/Calculate/GetAllEventDataGroupedByTag" STUB_ENV = "VEDASTRO_METHOD_CATALOG_STUB" +PYTHON_BRIDGE = ROOT / "scripts" / "vedastro_python_bridge.py" def schema() -> dict[str, Any]: return { "sync": "vedastro_method_catalog_sync", "scope": "official_vedastro_method_catalog", - "operations": ["sync_tags", "write_snapshot"], + "operations": ["sync_tags", "sync_python_capabilities", "write_snapshot"], "sources": { "official_tag_catalog": OFFICIAL_TAG_CATALOG_URL, + "official_python_package": "vedastro.Calculate", }, - "output_contract": ["source", "summary", "tag_groups"], + "output_contract": ["source", "summary", "tag_groups", "python_capabilities", "python_signature_buckets"], } @@ -41,6 +45,39 @@ def _load_stubbed_catalog() -> dict[str, Any] | None: return json.loads(raw) +def _build_python_signature_buckets(rows: list[dict[str, Any]]) -> dict[str, Any]: + buckets: dict[str, dict[str, Any]] = {} + for row in rows: + bucket = str(row.get("bucket") or "unknown") + entry = buckets.setdefault(bucket, {"count": 0, "examples": []}) + entry["count"] += 1 + if len(entry["examples"]) < 10: + entry["examples"].append(row["method"]) + return buckets + + +def _scan_python_capabilities() -> list[dict[str, Any]]: + if not PYTHON_BRIDGE.exists(): + return [] + completed = subprocess.run( + [sys.executable, str(PYTHON_BRIDGE), "--list-capabilities"], + cwd=ROOT, + text=True, + capture_output=True, + timeout=240, + check=False, + env=os.environ.copy(), + ) + if completed.returncode != 0: + return [] + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError: + return [] + capabilities = payload.get("capabilities") + return capabilities if isinstance(capabilities, list) else [] + + def _fetch_official_tag_catalog() -> dict[str, Any]: with request.urlopen(OFFICIAL_TAG_CATALOG_URL, timeout=60) as resp: payload = json.loads(resp.read().decode("utf-8")) @@ -58,9 +95,22 @@ def build_catalog() -> dict[str, Any]: for events in tag_groups.values(): if isinstance(events, list): method_count += len(events) + python_capabilities = catalog.get("python_capabilities") + if not isinstance(python_capabilities, list): + try: + python_capabilities = _scan_python_capabilities() + except Exception: + python_capabilities = [] + python_signature_buckets = _build_python_signature_buckets(python_capabilities) + python_callable_count = sum(1 for row in python_capabilities if row.get("callable")) + catalog["python_capabilities"] = python_capabilities + catalog["python_signature_buckets"] = python_signature_buckets catalog["summary"] = { "tag_count": len(tag_groups), "method_count": method_count, + "python_capability_count": len(python_capabilities), + "python_callable_count": python_callable_count, + "python_signature_bucket_count": len(python_signature_buckets), } return catalog diff --git a/scripts/vedastro_official_capability_runner.py b/scripts/vedastro_official_capability_runner.py new file mode 100644 index 00000000..e0748c67 --- /dev/null +++ b/scripts/vedastro_official_capability_runner.py @@ -0,0 +1,820 @@ +#!/usr/bin/env python3 +"""Run selected official VedAstro Python capabilities through the shared bridge.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +PYTHON = sys.executable +BRIDGE = ROOT / "scripts" / "vedastro_python_bridge.py" +STUB_ENV = "VEDASTRO_OFFICIAL_CAPABILITY_RUNNER_STUB" +CATALOG_STUB_ENV = "VEDASTRO_OFFICIAL_CAPABILITY_CATALOG_STUB" +PLANETS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu", "Ascendant"] +HOUSES = [f"House{i}" for i in range(1, 13)] +DEFAULT_SIGIL_SAMPLE_LIMIT = int(os.environ.get("VEDASTRO_FULL_CATALOG_SAMPLE_LIMIT", "0") or 0) +DOMAIN_ORDER = ["career", "marriage", "wealth", "rectification", "timing", "general"] +DEFAULT_DYNAMIC_THEMES = ["career", "marriage", "wealth", "rectification", "timing"] +POLICY_BUCKETS = { + "needs_user_context": "needs_user_context_methods", + "needs_user_text": "needs_user_text_methods", + "needs_rectification_profile": "needs_rectification_profile_methods", + "blocked": "blocked_methods", +} + + +def _method_words(method: str) -> set[str]: + return {item.lower() for item in re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)|\d+", method)} + + +def _domain_routing_for_method(method: str, capability: dict[str, Any], parameter_strategy: str) -> dict[str, Any]: + method_lower = method.lower() + words = _method_words(method) + text = " ".join( + str(value or "") + for value in ( + method, + capability.get("signature"), + capability.get("bucket"), + " ".join(str(name) for name in capability.get("parameter_names") or []), + ) + ).lower() + if method_lower == "getalleventdatagroupedbytag" or "groupedbytag" in text: + return { + "domains": ["general"], + "execution_policy": "auto" if parameter_strategy != "unsupported_signature" else "blocked", + "priority": "low", + } + domains: set[str] = set() + priority = "low" + is_dasha_timing = ( + "dasa" in words + or "dasha" in words + or method_lower.startswith(("dasaat", "dashaat", "getdasaat", "getdashaat")) + ) + + if is_dasha_timing or any(token in text for token in ("event", "search", "timing", "transit", "gochara")): + domains.update({"career", "marriage", "wealth", "rectification", "timing"}) + priority = "high" + if any(token in text for token in ("marriage", "spouse", "match", "compat", "relationship", "ashtakoot", "kuta")): + domains.add("marriage") + priority = "high" if any(token in text for token in ("marriage", "match", "compat")) else priority + if any(token in text for token in ("career", "profession", "job", "work", "tenth", "house10", "house 10")): + domains.add("career") + priority = "high" + if any(token in text for token in ("wealth", "money", "finance", "income", "gain", "house2", "house11", "ashtakvarga")): + domains.add("wealth") + priority = "high" if priority == "low" else priority + if any(token in text for token in ("birth", "rectification", "appearance", "body", "height", "shape", "complexion")): + domains.add("rectification") + if priority == "low": + priority = "medium" + if any(token in text for token in ("planet", "house", "strength", "bala", "longitude", "rasi", "navamsa", "varga")): + domains.update({"career", "marriage", "wealth"}) + if priority == "low": + priority = "medium" + + if not domains: + domains.add("general") + + if parameter_strategy in {"requires_user_context", "requires_user_text", "requires_rectification_profile"}: + execution_policy = { + "requires_user_context": "needs_user_context", + "requires_user_text": "needs_user_text", + "requires_rectification_profile": "needs_rectification_profile", + }[parameter_strategy] + elif parameter_strategy == "unsupported_signature": + execution_policy = "blocked" + else: + execution_policy = "auto" + + ordered_domains = [domain for domain in DOMAIN_ORDER if domain in domains] + return { + "domains": ordered_domains, + "execution_policy": execution_policy, + "priority": priority, + } + + +def _build_domain_routing(method_statuses: dict[str, Any]) -> dict[str, Any]: + routing: dict[str, dict[str, Any]] = {} + for method, status in method_statuses.items(): + if not isinstance(status, dict): + continue + for domain in status.get("domains") or ["general"]: + row = routing.setdefault( + domain, + { + "method_count": 0, + "auto_method_count": 0, + "needs_user_context_count": 0, + "needs_user_text_count": 0, + "blocked_method_count": 0, + "high_priority_methods": [], + }, + ) + row["method_count"] += 1 + policy = status.get("execution_policy") + if policy == "auto": + row["auto_method_count"] += 1 + elif policy == "needs_user_context": + row["needs_user_context_count"] += 1 + elif policy == "needs_user_text": + row["needs_user_text_count"] += 1 + elif policy in {"blocked", "needs_rectification_profile"}: + row["blocked_method_count"] += 1 + if policy == "auto" and status.get("priority") == "high" and method not in row["high_priority_methods"]: + row["high_priority_methods"].append(method) + for row in routing.values(): + row["high_priority_methods"] = row["high_priority_methods"][:24] + return routing + + +def _requested_dynamic_themes(payload: dict[str, Any]) -> list[str]: + raw = payload.get("themes") or payload.get("theme") or DEFAULT_DYNAMIC_THEMES + if isinstance(raw, str): + values = [raw] + elif isinstance(raw, list): + values = raw + else: + values = DEFAULT_DYNAMIC_THEMES + aliases = { + "relationship": "marriage", + "relationships": "marriage", + "finance": "wealth", + "money": "wealth", + "birth_time": "rectification", + "birth-time": "rectification", + "birthtime": "rectification", + "event": "timing", + "events": "timing", + "事业": "career", + "婚恋": "marriage", + "婚姻": "marriage", + "财富": "wealth", + "校时": "rectification", + "应期": "timing", + } + themes: list[str] = [] + for value in values: + key = aliases.get(str(value).strip().lower(), str(value).strip().lower()) + if key in DOMAIN_ORDER and key not in themes: + themes.append(key) + return themes or list(DEFAULT_DYNAMIC_THEMES) + + +def _method_priority_score(method: str, status: dict[str, Any], theme: str) -> tuple[int, str]: + policy = str(status.get("execution_policy") or "") + priority = str(status.get("priority") or "low") + score = 0 + if policy == "auto": + score += 100 + elif policy == "needs_user_context": + score += 60 + elif policy in {"needs_user_text", "needs_rectification_profile"}: + score += 45 + else: + score += 10 + if priority == "high": + score += 40 + elif priority == "medium": + score += 20 + if status.get("status") == "ok": + score += 12 + if status.get("executed") is True: + score += 8 + if theme in status.get("domains", []): + score += 5 + method_lower = method.lower() + if any(token in method_lower for token in ("searchevents", "eventsatrange", "eventsattime", "geteventtiming")): + score += 10 + if theme == "career" and any(token in method_lower for token in ("dashamamsha", "profession", "career", "tenth")): + score += 8 + if theme == "marriage" and any(token in method_lower for token in ("match", "marriage", "spouse", "ashtakoot")): + score += 8 + if theme == "wealth" and any(token in method_lower for token in ("wealth", "money", "income", "gain", "ashtakvarga")): + score += 8 + if theme == "rectification" and any(token in method_lower for token in ("birth", "appearance", "body")): + score += 8 + if theme == "timing" and any(token in method_lower for token in ("dasa", "dasha", "event", "transit")): + score += 8 + return (-score, method) + + +def _capability_reference(method: str, status: dict[str, Any], theme: str) -> dict[str, Any]: + return { + "citation_id": f"vedastro:{theme}:{method}", + "method": method, + "status": status.get("status"), + "execution_policy": status.get("execution_policy"), + "priority": status.get("priority"), + "domains": status.get("domains") or [], + "bucket": status.get("bucket"), + "signature": status.get("signature"), + "parameter_names": status.get("parameter_names") or [], + "executed": bool(status.get("executed")), + "source": "official_full_capability_catalog", + } + + +def _build_dynamic_selection( + method_statuses: dict[str, Any], + domain_routing: dict[str, Any], + requested_themes: list[str], + *, + limit: int = 12, +) -> dict[str, Any]: + selection: dict[str, Any] = {} + for theme in requested_themes: + candidates = [ + (method, status) + for method, status in method_statuses.items() + if isinstance(status, dict) and theme in (status.get("domains") or []) + ] + candidates.sort(key=lambda item: _method_priority_score(item[0], item[1], theme)) + selected = [ + _capability_reference(method, status, theme) + for method, status in candidates + if status.get("execution_policy") == "auto" + ][:limit] + policy_groups: dict[str, list[dict[str, Any]]] = { + "needs_user_context_methods": [], + "needs_user_text_methods": [], + "needs_rectification_profile_methods": [], + "blocked_methods": [], + } + for method, status in candidates: + bucket_name = POLICY_BUCKETS.get(str(status.get("execution_policy") or "")) + if bucket_name and len(policy_groups[bucket_name]) < limit: + policy_groups[bucket_name].append(_capability_reference(method, status, theme)) + citation_ids = [item["citation_id"] for item in selected] + selection[theme] = { + "requested_theme": theme, + "selection_policy": "official_catalog_theme_top_n", + "domain_summary": domain_routing.get(theme) or {}, + "selected_methods": selected, + **policy_groups, + "report_reference": { + "theme": theme, + "citation_ids": citation_ids, + "auto_count": len(selected), + "needs_user_context_count": len(policy_groups["needs_user_context_methods"]), + "needs_user_text_count": len(policy_groups["needs_user_text_methods"]), + "needs_rectification_profile_count": len(policy_groups["needs_rectification_profile_methods"]), + "blocked_count": len(policy_groups["blocked_methods"]), + "boundary": "Citations identify official VedAstro capability evidence used or requested by the workflow; skipped or context-dependent methods are not treated as executed evidence.", + }, + } + return selection + + +def schema() -> dict[str, Any]: + return { + "runner": "vedastro_official_capability_runner", + "primary_source": "vedastro_python_bridge", + "operations": ["run_bucket", "run_selected_methods", "run_snapshot_bundle", "run_full_capability_catalog"], + "request_contract": ["methods_json", "birth_json", "bundle?"], + "response_contract": ["summary", "results", "result?"], + } + + +def _normalize_tz(value: Any) -> str: + if isinstance(value, str): + return value + if value is None: + return "+00:00" + sign = "+" if float(value) >= 0 else "-" + absolute = abs(float(value)) + hours = int(absolute) + minutes = int(round((absolute - hours) * 60)) + return f"{sign}{hours:02d}:{minutes:02d}" + + +def _bridge_time(case: dict[str, Any], date_text: str, *, hour: int | None = None, minute: int | None = None) -> dict[str, Any]: + year, month, day = str(date_text).split("-") + return { + "__vedastro_type__": "Time", + "year": int(year), + "month": int(month), + "day": int(day), + "hour": int(case.get("hour", 0) if hour is None else hour), + "minute": int(case.get("minute", 0) if minute is None else minute), + "offset": _normalize_tz(case.get("tz")), + "geolocation": { + "__vedastro_type__": "GeoLocation", + "location_name": "UserLocation", + "longitude": case.get("lon"), + "latitude": case.get("lat"), + }, + } + + +def _reference_date(case: dict[str, Any]) -> str: + for key in ("reference_date", "today", "transit_date", "current_date"): + value = case.get(key) + if value: + return str(value)[:10] + return datetime.utcnow().strftime("%Y-%m-%d") + + +def _build_method_payload(method: str, case: dict[str, Any]) -> dict[str, Any] | None: + birth_date = f"{int(case['year']):04d}-{int(case['month']):02d}-{int(case['day']):02d}" + birth_time = _bridge_time(case, birth_date) + ref_date = _reference_date(case) + check_time = _bridge_time(case, ref_date) + start_of_year = f"{ref_date[:4]}-01-01" + end_of_year = f"{ref_date[:4]}-12-31" + + if method == "GetAllEventDataGroupedByTag": + return {} + if method == "DasaAtTime": + return {"args": [birth_time, check_time, 3]} + if method == "GetCharaDasaAtTime": + return {"args": [birth_time, check_time]} + if method == "DasaAtRange": + return {"args": [birth_time, _bridge_time(case, start_of_year, hour=0, minute=0), _bridge_time(case, end_of_year, hour=23, minute=59), 3, 100]} + if method in {"AllPlanetStrength", "AshtakvargaLifeMap"}: + return {"args": [birth_time]} + if method == "AllPlanetData": + return {"args": [{"__vedastro_enum__": "PlanetName", "value": "Sun"}, birth_time]} + if method == "AllHouseData": + return {"args": [{"__vedastro_enum__": "HouseName", "value": "House1"}, birth_time]} + return None + + +def _method_payload_for_instance(method: str, case: dict[str, Any], identity: str | None = None) -> dict[str, Any] | None: + payload = _build_method_payload(method, case) + if payload is None: + return None + if method == "AllPlanetData" and identity: + return {"args": [{"__vedastro_enum__": "PlanetName", "value": str(identity)}, payload["args"][1]]} + if method == "AllHouseData" and identity: + return {"args": [{"__vedastro_enum__": "HouseName", "value": str(identity)}, payload["args"][1]]} + return payload + + +def _call_bridge(method: str, payload: dict[str, Any]) -> dict[str, Any]: + completed = subprocess.run( + [PYTHON, str(BRIDGE), "--method", method, "--params-json", json.dumps(payload, ensure_ascii=False)], + cwd=ROOT, + text=True, + capture_output=True, + timeout=240, + check=False, + env=os.environ.copy(), + ) + if completed.returncode != 0: + return {"available": False, "status": "bridge_runtime_error", "stderr": (completed.stderr or "").strip()} + return json.loads(completed.stdout) + + +def _list_official_capabilities() -> dict[str, Any]: + stub_raw = os.environ.get(CATALOG_STUB_ENV, "").strip() + if stub_raw: + payload = json.loads(stub_raw) + payload.setdefault("source", "stubbed_official_capability_catalog") + return payload + + completed = subprocess.run( + [PYTHON, str(BRIDGE), "--list-capabilities"], + cwd=ROOT, + text=True, + capture_output=True, + timeout=240, + check=False, + env=os.environ.copy(), + ) + if completed.returncode != 0: + return { + "available": False, + "status": "bridge_runtime_error", + "capabilities": [], + "buckets": {}, + "stderr": (completed.stderr or "").strip(), + "source": "vedastro_official_capability_runner", + } + try: + return json.loads(completed.stdout) + except json.JSONDecodeError: + return { + "available": False, + "status": "bridge_invalid_json", + "capabilities": [], + "buckets": {}, + "stdout_excerpt": (completed.stdout or "").strip()[:500], + "source": "vedastro_official_capability_runner", + } + + +def run_selected_methods(methods: list[str], birth_payload: dict[str, Any]) -> dict[str, Any]: + stub_raw = os.environ.get(STUB_ENV, "").strip() + stub_map = json.loads(stub_raw) if stub_raw else {} + results: dict[str, Any] = {} + ok_count = 0 + skipped_count = 0 + for method in methods: + if method in stub_map: + results[method] = stub_map[method] + else: + payload = _build_method_payload(method, birth_payload) + if payload is None: + results[method] = {"available": False, "status": "unsupported_signature"} + skipped_count += 1 + continue + results[method] = _call_bridge(method, payload) + if results[method].get("status") == "ok": + ok_count += 1 + + return { + "runner": "vedastro_official_capability_runner", + "primary_source": "vedastro_python_bridge", + "summary": { + "requested_method_count": len(methods), + "executed_method_count": len(results), + "ok_count": ok_count, + "skipped_count": skipped_count, + }, + "results": results, + } + + +def _sample_identity_for_method(method: str, parameter_names: list[str]) -> Any: + lowered = [name.lower() for name in parameter_names] + if "planetname" in lowered or "inputplanet" in lowered or "planet" in lowered: + return "Sun" + if "housename" in lowered: + return "House1" + if "housenumber" in lowered or "inputhousenumber" in lowered: + return 1 + if "inputhouse" in lowered or "house" in lowered: + return "House1" + if "zodiacname" in lowered or "signname" in lowered or "inputsign" in lowered or "zodiacsign" in lowered: + return "Aries" + if "constellation" in lowered: + return "Aswini" + if "divisionalno" in lowered: + return 9 + if "longitude" in lowered or "longitudedeg" in lowered or "totaldegrees" in lowered: + return 3.5 + return None + + +def _full_catalog_method_payload(method: str, capability: dict[str, Any], case: dict[str, Any]) -> tuple[dict[str, Any] | None, str]: + parameter_names = [str(name) for name in capability.get("parameter_names") or []] + lowered = [name.lower() for name in parameter_names] + bucket = str(capability.get("bucket") or "") + birth_date = f"{int(case['year']):04d}-{int(case['month']):02d}-{int(case['day']):02d}" + birth_time = _bridge_time(case, birth_date) + ref_date = _reference_date(case) + check_time = _bridge_time(case, ref_date) + start_of_year = _bridge_time(case, f"{ref_date[:4]}-01-01", hour=0, minute=0) + end_of_year = _bridge_time(case, f"{ref_date[:4]}-12-31", hour=23, minute=59) + + if not parameter_names: + return {}, "auto_zero_arg" + if bucket in {"time_only", "(inputTime)", "(queryTime)", "(time1)"} or lowered in (["time"], ["inputtime"], ["querytime"], ["time1"]): + key = parameter_names[0] + return {"kwargs": {key: check_time}}, "auto_time_only" + if bucket == "birth_time_only" or lowered == ["birthtime"]: + return {"kwargs": {parameter_names[0]: birth_time}}, "auto_birth_time_only" + if bucket == "planet_time" or lowered == ["planetname", "time"]: + return {"args": [{"__vedastro_enum__": "PlanetName", "value": "Sun"}, birth_time]}, "auto_planet_time" + if bucket == "planet_alias_time" or lowered == ["planet", "time"]: + return {"args": [{"__vedastro_enum__": "PlanetName", "value": "Sun"}, birth_time]}, "auto_planet_time" + if bucket == "house_name_time" or lowered == ["housename", "time"]: + return {"args": [{"__vedastro_enum__": "HouseName", "value": "House1"}, birth_time]}, "auto_house_name_time" + if bucket == "house_number_time" or lowered == ["housenumber", "time"]: + return {"args": [1, birth_time]}, "auto_house_number_time" + if bucket == "dasha_at_time" or lowered == ["birthtime", "checktime", "levels"]: + return {"args": [birth_time, check_time, 3]}, "auto_dasha_at_time" + if bucket == "dasha_at_range" or lowered == ["birthtime", "starttime", "endtime", "levels", "precisionhours"]: + return {"args": [birth_time, start_of_year, end_of_year, 3, 100]}, "auto_dasha_at_range" + + if lowered == ["birthtime", "checktime"]: + return {"args": [birth_time, check_time]}, "auto_birth_check_time" + if lowered == ["birthtime", "levels"]: + return {"args": [birth_time, 3]}, "auto_birth_levels" + if lowered == ["birthtime", "scanyear"]: + return {"args": [birth_time, int(ref_date[:4])]}, "auto_birth_scan_year" + if lowered == ["birthtime", "querytime"]: + return {"args": [birth_time, check_time]}, "auto_birth_query_time" + if lowered == ["birthtime", "sortbyweight"]: + return {"args": [birth_time, False]}, "auto_birth_bool" + if lowered == ["birthtime", "filtertags", "sortbyweight"]: + return {"args": [birth_time, [], False]}, "auto_birth_filter_tags" + if lowered == ["birthtime", "starttime", "endtime", "eventtaglist", "precisionhours"]: + return {"args": [birth_time, start_of_year, end_of_year, ["Marriage"], 100]}, "auto_events_range" + if lowered == ["birthtime", "checktime", "eventtaglist"]: + return {"args": [birth_time, check_time, ["Marriage"]]}, "auto_events_time" + if lowered == ["birthtime", "attime", "eventtaglist"]: + return {"args": [birth_time, check_time, ["Marriage"]]}, "auto_search_events" + + if len(parameter_names) == 1: + sample = _sample_identity_for_method(method, parameter_names) + if sample is not None: + return {"args": [sample]}, "auto_single_sample" + if len(parameter_names) == 2 and any(name in lowered for name in ("time", "inputtime", "birthtime")): + sample = _sample_identity_for_method(method, parameter_names) + if sample is not None: + args = [] + for name in lowered: + if name in {"time", "inputtime", "birthtime"}: + args.append(birth_time) + else: + args.append(sample) + return {"args": args}, "auto_two_arg_sample" + + if any(name in lowered for name in ("malebirthtime", "femalebirthtime", "partnerbirthtime", "personb", "personbirthtime")): + return None, "requires_user_context" + if any(name in lowered for name in ("bodyheight", "bodyshape", "hair", "lips", "nose", "complexion", "faceshape", "constitution", "personality")): + return None, "requires_rectification_profile" + if any(name in lowered for name in ("rawtextdata", "birthdatarawtext", "inputtext", "textinput", "query", "fullname", "personfullname", "address", "locationname", "ipaddress")): + return None, "requires_user_text" + return None, "unsupported_signature" + + +def run_full_capability_catalog(birth_payload: dict[str, Any]) -> dict[str, Any]: + catalog = _list_official_capabilities() + capabilities = catalog.get("capabilities") if isinstance(catalog.get("capabilities"), list) else [] + buckets = catalog.get("buckets") if isinstance(catalog.get("buckets"), dict) else {} + stub_raw = os.environ.get(STUB_ENV, "").strip() + stub_map = json.loads(stub_raw) if stub_raw else {} + method_statuses: dict[str, Any] = {} + bucket_statuses: dict[str, dict[str, int]] = {} + executed_count = 0 + ok_count = 0 + unsupported_count = 0 + blocked_count = 0 + sample_limit = max(0, int(os.environ.get("VEDASTRO_FULL_CATALOG_SAMPLE_LIMIT", str(DEFAULT_SIGIL_SAMPLE_LIMIT)) or 0)) + + for capability in capabilities: + method = str(capability.get("method") or "") + if not method: + continue + bucket = str(capability.get("bucket") or "unknown") + payload, strategy = _full_catalog_method_payload(method, capability, birth_payload) + routing_meta = _domain_routing_for_method(method, capability, strategy) + bucket_row = bucket_statuses.setdefault(bucket, {"total": 0, "executed": 0, "ok": 0, "unsupported": 0, "blocked": 0}) + bucket_row["total"] += 1 + + if payload is None: + unsupported_count += 1 + bucket_row["unsupported"] += 1 + method_statuses[method] = { + "status": strategy, + "bucket": bucket, + "signature": capability.get("signature"), + "parameter_names": capability.get("parameter_names") or [], + "executed": False, + **routing_meta, + } + continue + + if executed_count >= sample_limit and method not in stub_map: + blocked_count += 1 + bucket_row["blocked"] += 1 + method_statuses[method] = { + "status": "skipped_by_sample_limit", + "bucket": bucket, + "signature": capability.get("signature"), + "parameter_names": capability.get("parameter_names") or [], + "executed": False, + "parameter_strategy": strategy, + **routing_meta, + } + continue + + if method in stub_map: + report = stub_map[method] + else: + report = _call_bridge(method, payload) + status = str(report.get("status") or "blocked") + executed_count += 1 + bucket_row["executed"] += 1 + if status == "ok": + ok_count += 1 + bucket_row["ok"] += 1 + else: + blocked_count += 1 + bucket_row["blocked"] += 1 + method_statuses[method] = { + "status": status, + "bucket": bucket, + "signature": capability.get("signature"), + "parameter_names": capability.get("parameter_names") or [], + "executed": True, + "parameter_strategy": strategy, + "available": bool(report.get("available")), + "source": report.get("source"), + **routing_meta, + } + + overall_status = "blocked" + if capabilities: + overall_status = "ok" if unsupported_count == 0 and blocked_count == 0 else "partial" + domain_routing = _build_domain_routing(method_statuses) + requested_themes = _requested_dynamic_themes(birth_payload) + dynamic_selection = _build_dynamic_selection(method_statuses, domain_routing, requested_themes) + + return { + "runner": "vedastro_official_capability_runner", + "primary_source": "vedastro_python_bridge", + "bundle": "official_full_capability_catalog", + "available": bool(capabilities), + "status": overall_status, + "summary": { + "catalog_method_count": len(capabilities), + "official_callable_count": sum(1 for item in capabilities if item.get("callable")), + "signature_bucket_count": len(buckets), + "executed_method_count": executed_count, + "ok_method_count": ok_count, + "unsupported_method_count": unsupported_count, + "blocked_method_count": blocked_count, + "sample_limit": sample_limit, + "domain_routing_count": len(domain_routing), + "dynamic_selection_theme_count": len(dynamic_selection), + }, + "coverage": { + "source_mode": "official_full_capability_catalog", + "catalog_source": catalog.get("source") or "vedastro_python_bridge", + "python_bin": catalog.get("python_bin"), + "bucket_count": len(buckets), + "safe_sampling": True, + "not_user_exposed": True, + "lightweight_domain_mapping": True, + "dynamic_theme_selection": True, + }, + "domain_routing": domain_routing, + "dynamic_selection": dynamic_selection, + "bucket_statuses": bucket_statuses, + "method_statuses": method_statuses, + } + + +def run_snapshot_bundle(bundle: str, birth_payload: dict[str, Any]) -> dict[str, Any]: + if bundle == "official_full_capability_catalog": + return run_full_capability_catalog(birth_payload) + + if bundle != "official_full_snapshot": + return { + "runner": "vedastro_official_capability_runner", + "bundle": bundle, + "available": False, + "status": "unsupported_bundle", + "reason": f"Unsupported bundle: {bundle}", + } + + stub_raw = os.environ.get(STUB_ENV, "").strip() + stub_map = json.loads(stub_raw) if stub_raw else {} + stub_mode = bool(stub_raw) + snapshot_sections: dict[str, Any] = {} + section_statuses: dict[str, str] = {} + coverage_sections: list[str] = [] + ok_count = 0 + skipped_count = 0 + + chart_core: dict[str, Any] = {} + planet_statuses: dict[str, str] = {} + for planet in PLANETS: + stub_key = f"AllPlanetData:{planet}" + if stub_key in stub_map: + report = stub_map[stub_key] + elif stub_mode: + report = {"available": False, "status": "stub_not_provided"} + else: + payload = _method_payload_for_instance("AllPlanetData", birth_payload, planet) + report = {"available": False, "status": "unsupported_signature"} if payload is None else _call_bridge("AllPlanetData", payload) + status = str(report.get("status") or "blocked") + planet_statuses[planet] = "ok" if status == "ok" else status + if status == "ok": + ok_count += 1 + else: + skipped_count += 1 + chart_core[planet] = { + "Status": "Pass" if status == "ok" else "Fail", + "Payload": {"AllPlanetData": report.get("result")} if status == "ok" else report, + } + if chart_core: + snapshot_sections["chart_core"] = chart_core + section_statuses["chart_core"] = "ok" if all(value == "ok" for value in planet_statuses.values()) else "partial" + section_statuses["chart_core_fanout"] = planet_statuses + if section_statuses["chart_core"] == "ok": + coverage_sections.append("chart_core") + + house_core: dict[str, Any] = {} + house_statuses: dict[str, str] = {} + for house in HOUSES: + stub_key = f"AllHouseData:{house}" + if stub_key in stub_map: + report = stub_map[stub_key] + elif stub_mode: + report = {"available": False, "status": "stub_not_provided"} + else: + payload = _method_payload_for_instance("AllHouseData", birth_payload, house) + report = {"available": False, "status": "unsupported_signature"} if payload is None else _call_bridge("AllHouseData", payload) + status = str(report.get("status") or "blocked") + house_statuses[house] = "ok" if status == "ok" else status + if status == "ok": + ok_count += 1 + else: + skipped_count += 1 + house_core[house] = { + "Status": "Pass" if status == "ok" else "Fail", + "Payload": {"AllHouseData": report.get("result")} if status == "ok" else report, + } + if house_core: + snapshot_sections["house_core"] = house_core + section_statuses["house_core"] = "ok" if all(value == "ok" for value in house_statuses.values()) else "partial" + section_statuses["house_core_fanout"] = house_statuses + if section_statuses["house_core"] == "ok": + coverage_sections.append("house_core") + + scalar_methods = [ + ("dasha_all", "DasaAtRange", "DasaAtRange"), + ("vimshottari_now", "DasaAtTime", "DasaAtTime"), + ("chara_dasha_now", "GetCharaDasaAtTime", "GetCharaDasaAtTime"), + ("shadbala", "AllPlanetStrength", "AllPlanetStrength"), + ("ashtakavarga", "AshtakvargaLifeMap", "AshtakvargaLifeMap"), + ] + for section_name, method, payload_key in scalar_methods: + if method in stub_map: + report = stub_map[method] + elif stub_mode: + report = {"available": False, "status": "stub_not_provided"} + else: + payload = _method_payload_for_instance(method, birth_payload) + report = {"available": False, "status": "unsupported_signature"} if payload is None else _call_bridge(method, payload) + status = str(report.get("status") or "blocked") + section_statuses[section_name] = "ok" if status == "ok" else status + if status == "ok": + ok_count += 1 + coverage_sections.append(section_name) + else: + skipped_count += 1 + snapshot_sections[section_name] = { + "Status": "Pass" if status == "ok" else "Fail", + "Payload": {payload_key: report.get("result")} if status == "ok" else report, + } + + overall_status = "blocked" + if coverage_sections: + overall_status = "ok" if len(coverage_sections) == 7 else "partial" + + return { + "runner": "vedastro_official_capability_runner", + "primary_source": "vedastro_python_bridge", + "bundle": bundle, + "available": bool(coverage_sections), + "status": overall_status, + "summary": { + "requested_method_count": len(PLANETS) + len(HOUSES) + len(scalar_methods), + "executed_method_count": len(PLANETS) + len(HOUSES) + len(scalar_methods), + "ok_count": ok_count, + "skipped_count": skipped_count, + }, + "result": { + "snapshot_sections": snapshot_sections, + "section_statuses": section_statuses, + "coverage": { + "source_mode": "official_capability_runner_bundle", + "filled_sections": coverage_sections, + "planet_count": len(chart_core), + "house_count": len(house_core), + }, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--print-schema", action="store_true") + parser.add_argument("--methods-json", default="[]") + parser.add_argument("--birth-json", default="{}") + parser.add_argument("--bundle", default="") + args = parser.parse_args() + + if args.print_schema: + result = schema() + elif args.bundle: + birth_payload = json.loads(args.birth_json or "{}") + result = run_snapshot_bundle(args.bundle, birth_payload) + else: + methods = json.loads(args.methods_json or "[]") + birth_payload = json.loads(args.birth_json or "{}") + result = run_selected_methods(methods, birth_payload) + + print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/vedastro_python_bridge.py b/scripts/vedastro_python_bridge.py index 3bda4072..6a370351 100644 --- a/scripts/vedastro_python_bridge.py +++ b/scripts/vedastro_python_bridge.py @@ -14,6 +14,7 @@ separate. It stays deliberately thin: from __future__ import annotations import argparse +import inspect import importlib.util import json import os @@ -228,6 +229,116 @@ if __name__ == "__main__": """ +CAPABILITY_RUNNER = r""" +import contextlib +import importlib +import inspect +import io +import json + +MODULE_CANDIDATES = ("vedastro", "VedAstro") + + +def _import_module(): + for name in MODULE_CANDIDATES: + with contextlib.redirect_stdout(io.StringIO()): + try: + return name, importlib.import_module(name) + except ModuleNotFoundError: + continue + return None, None + + +def _bucket(parameter_names, signature_text): + lowered = [name.lower() for name in parameter_names] + if not lowered: + return "zero_arg" + if lowered == ["time"]: + return "time_only" + if lowered == ["birthtime"]: + return "birth_time_only" + if lowered == ["planetname", "time"]: + return "planet_time" + if lowered == ["housename", "time"]: + return "house_name_time" + if lowered == ["housenumber", "time"]: + return "house_number_time" + if lowered == ["planet", "time"]: + return "planet_alias_time" + if lowered == ["birthtime", "checktime", "levels"]: + return "dasha_at_time" + if lowered == ["birthtime", "starttime", "endtime", "levels", "precisionhours"]: + return "dasha_at_range" + return signature_text.strip() or "unknown_signature" + + +def main(): + module_name, module = _import_module() + if module is None: + print(json.dumps({ + "available": False, + "status": "python_package_not_installed", + "source": "vedastro_python_bridge_child", + })) + return + + calculate = getattr(module, "Calculate", None) + if calculate is None: + print(json.dumps({ + "available": False, + "status": "calculate_namespace_missing", + "module_name": module_name, + "source": "vedastro_python_bridge_child", + })) + return + + capabilities = [] + buckets = {} + for name in sorted(dir(calculate)): + if name.startswith("_"): + continue + obj = getattr(calculate, name) + if not callable(obj): + continue + try: + sig = inspect.signature(obj) + signature_text = str(sig) + parameter_names = [param.name for param in sig.parameters.values()] + except Exception: + signature_text = "" + parameter_names = [] + bucket = _bucket(parameter_names, signature_text) + capabilities.append({ + "method": name, + "signature": signature_text, + "bucket": bucket, + "parameter_names": parameter_names, + "callable": True, + }) + entry = buckets.setdefault(bucket, {"count": 0, "examples": []}) + entry["count"] += 1 + if len(entry["examples"]) < 10: + entry["examples"].append(name) + + print(json.dumps({ + "available": True, + "status": "ok", + "module_name": module_name, + "capabilities": capabilities, + "summary": { + "total_callable": len(capabilities), + "signature_bucket_count": len(buckets), + }, + "buckets": buckets, + "source": "vedastro_python_bridge", + })) + + +if __name__ == "__main__": + main() +""" + + def _package_available() -> bool: if os.environ.get(FORCE_UNAVAILABLE_ENV, "").strip().lower() in {"1", "true", "yes"}: return False @@ -327,13 +438,44 @@ def _call_via_child_python(python_bin: str, method: str, params: dict[str, Any]) return payload +def _list_capabilities_via_child_python(python_bin: str) -> dict[str, Any]: + completed = subprocess.run( + [python_bin, "-c", CAPABILITY_RUNNER], + check=False, + capture_output=True, + text=True, + timeout=240, + env=os.environ.copy(), + ) + if completed.returncode != 0: + return { + "available": False, + "status": "bridge_runtime_error", + "stderr": (completed.stderr or "").strip(), + "stdout_excerpt": (completed.stdout or "").strip()[:500], + "source": "vedastro_python_bridge", + } + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError: + return { + "available": False, + "status": "bridge_invalid_json", + "stdout_excerpt": (completed.stdout or "").strip()[:500], + "stderr": (completed.stderr or "").strip()[:500], + "source": "vedastro_python_bridge", + } + payload["python_bin"] = python_bin + return payload + + def schema() -> dict[str, Any]: return { "bridge": "vedastro_python_bridge", "package_name": PACKAGE_NAME, "module_candidates": list(MODULE_CANDIDATES), "intended_role": "python_sdk_bulk_calculation_bridge", - "operations": ["call_method"], + "operations": ["call_method", "list_capabilities"], "request_contract": ["method", "params_json"], "typed_param_contract": { "enum": {"__vedastro_enum__": "PlanetName", "value": "Sun"}, @@ -401,6 +543,15 @@ def call_method(method: str, params: dict[str, Any]) -> dict[str, Any]: return _call_via_child_python(python_bin, method, params) +def list_capabilities() -> dict[str, Any]: + if os.environ.get(FORCE_UNAVAILABLE_ENV, "").strip().lower() in {"1", "true", "yes"}: + return _missing_package_result("list_capabilities") + python_bin = _select_python_bin() + if not python_bin: + return _missing_package_result("list_capabilities") + return _list_capabilities_via_child_python(python_bin) + + def call_high_value(method_key: str, payload: dict[str, Any]) -> dict[str, Any]: if method_key == "event_tag_catalog": return call_method("GetAllEventDataGroupedByTag", {}) @@ -429,6 +580,119 @@ def call_high_value(method_key: str, payload: dict[str, Any]) -> dict[str, Any]: check_time = payload["check_time"] return call_method("GetCharaDasaAtTime", {"args": [birth_time, check_time]}) + if method_key == "official_full_snapshot_bundle": + birth_time = payload["birth_time"] + check_time = payload["check_time"] + start_time = payload["start_time"] + end_time = payload["end_time"] + levels = int(payload.get("levels", 3)) + precision_hours = int(payload.get("precision_hours", 100)) + planets = list(payload.get("planets") or []) + houses = list(payload.get("houses") or []) + + chart_core: dict[str, Any] = {} + house_core: dict[str, Any] = {} + section_statuses: dict[str, str] = {} + + for planet in planets: + report = call_method( + "AllPlanetData", + { + "args": [ + {"__vedastro_enum__": "PlanetName", "value": str(planet)}, + birth_time, + ] + }, + ) + chart_core[str(planet)] = { + "Status": "Pass" if report.get("status") == "ok" else "Fail", + "Payload": {"AllPlanetData": report.get("result")} if report.get("status") == "ok" else report, + } + if chart_core: + section_statuses["chart_core"] = "ok" + + for house in houses: + report = call_method( + "AllHouseData", + { + "args": [ + {"__vedastro_enum__": "HouseName", "value": str(house)}, + birth_time, + ] + }, + ) + house_core[str(house)] = { + "Status": "Pass" if report.get("status") == "ok" else "Fail", + "Payload": {"AllHouseData": report.get("result")} if report.get("status") == "ok" else report, + } + if house_core: + section_statuses["house_core"] = "ok" + + dasha_report = call_method( + "DasaAtRange", + {"args": [birth_time, start_time, end_time, levels, precision_hours]}, + ) + vimshottari_report = call_method( + "DasaAtTime", + {"args": [birth_time, check_time, levels]}, + ) + chara_report = call_method( + "GetCharaDasaAtTime", + {"args": [birth_time, check_time]}, + ) + strength_report = call_method("AllPlanetStrength", {"args": [birth_time]}) + ashtakavarga_report = call_method("AshtakvargaLifeMap", {"args": [birth_time]}) + + snapshot_sections = { + "chart_core": chart_core, + "house_core": house_core, + "dasha_all": { + "Status": "Pass" if dasha_report.get("status") == "ok" else "Fail", + "Payload": {"DasaAtRange": dasha_report.get("result")} if dasha_report.get("status") == "ok" else dasha_report, + }, + "vimshottari_now": { + "Status": "Pass" if vimshottari_report.get("status") == "ok" else "Fail", + "Payload": {"DasaAtTime": vimshottari_report.get("result")} if vimshottari_report.get("status") == "ok" else vimshottari_report, + }, + "chara_dasha_now": { + "Status": "Pass" if chara_report.get("status") == "ok" else "Fail", + "Payload": {"GetCharaDasaAtTime": chara_report.get("result")} if chara_report.get("status") == "ok" else chara_report, + }, + "shadbala": { + "Status": "Pass" if strength_report.get("status") == "ok" else "Fail", + "Payload": {"AllPlanetStrength": strength_report.get("result")} if strength_report.get("status") == "ok" else strength_report, + }, + "ashtakavarga": { + "Status": "Pass" if ashtakavarga_report.get("status") == "ok" else "Fail", + "Payload": {"AshtakvargaLifeMap": ashtakavarga_report.get("result")} if ashtakavarga_report.get("status") == "ok" else ashtakavarga_report, + }, + } + + for section_name in ("dasha_all", "vimshottari_now", "chara_dasha_now", "shadbala", "ashtakavarga"): + section_statuses[section_name] = "ok" if snapshot_sections[section_name]["Status"] == "Pass" else "fail" + + filled_sections = [name for name, status in section_statuses.items() if status == "ok"] + overall_status = "ok" if filled_sections else "blocked" + if filled_sections and len(filled_sections) != len(section_statuses): + overall_status = "partial" + + return { + "available": bool(filled_sections), + "status": overall_status, + "method": "official_full_snapshot_bundle", + "result": { + "snapshot_sections": snapshot_sections, + "section_statuses": section_statuses, + "coverage": { + "source_mode": "official_python_bridge_bundle", + "filled_sections": filled_sections, + "planet_count": len(chart_core), + "house_count": len(house_core), + }, + }, + "source": "vedastro_python_bridge", + } + return { "available": False, "status": "unsupported_high_value_method", @@ -443,6 +707,7 @@ def main() -> int: parser.add_argument("--method", default="") parser.add_argument("--params-json", default="{}") parser.add_argument("--high-value", default="") + parser.add_argument("--list-capabilities", action="store_true") args = parser.parse_args() if args.print_schema: @@ -464,7 +729,25 @@ def main() -> int: "maps_to": "GetCharaDasaAtTime", "request_contract": ["birth_time", "check_time"], }, + "official_full_snapshot_bundle": { + "maps_to": ( + "AllPlanetData + AllHouseData + DasaAtRange + DasaAtTime + " + "GetCharaDasaAtTime + AllPlanetStrength + AshtakvargaLifeMap" + ), + "request_contract": [ + "birth_time", + "check_time", + "start_time", + "end_time", + "levels?", + "precision_hours?", + "planets[]", + "houses[]", + ], + }, } + elif args.list_capabilities: + result = list_capabilities() elif args.high_value: params = json.loads(args.params_json or "{}") result = call_high_value(args.high_value, params) diff --git a/scripts/vedastro_service_adapter.py b/scripts/vedastro_service_adapter.py index 052a6ecf..45e48548 100644 --- a/scripts/vedastro_service_adapter.py +++ b/scripts/vedastro_service_adapter.py @@ -14,6 +14,8 @@ import hashlib import json import os import socket +import subprocess +import threading import time from datetime import datetime from pathlib import Path @@ -30,6 +32,9 @@ except ModuleNotFoundError: # pragma: no cover - script execution path ROOT = Path(__file__).resolve().parents[1] load_local_env(ROOT) +VEDASTRO_PYTHON_BRIDGE = ROOT / "scripts" / "vedastro_python_bridge.py" +VEDASTRO_OFFICIAL_CAPABILITY_RUNNER = ROOT / "scripts" / "vedastro_official_capability_runner.py" + PARITY_CASES = { "user_REDACTED_YEAR_test": { @@ -128,18 +133,6 @@ OFFICIAL_FULL_SNAPSHOT_BACKLOG_SECTIONS = [ "status": "catalog_pending", "description": "Awaiting official method mapping for all divisional charts; local varga remains fallback until mapped.", }, - { - "section": "shadbala", - "role": "strength_raw_evidence", - "status": "catalog_pending", - "description": "Awaiting official method mapping for Shadbala; local Shadbala remains fallback until mapped.", - }, - { - "section": "ashtakavarga", - "role": "ashtakavarga_raw_evidence", - "status": "catalog_pending", - "description": "Awaiting official method mapping for Ashtakavarga; local Ashtakavarga remains fallback until mapped.", - }, ] OFFICIAL_SNAPSHOT_PLANETS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu", "Ascendant"] OFFICIAL_SNAPSHOT_HOUSES = [f"House{i}" for i in range(1, 13)] @@ -313,8 +306,27 @@ MATCH_METADATA_BY_TYPE = { "alias": {"signal_lift": 1, "confidence": "low"}, "rejected": {"signal_lift": 0, "confidence": "rejected"}, } + +OFFICIAL_PYTHON_BUNDLE_SECTIONS = [ + "chart_core", + "house_core", + "dasha_all", + "vimshottari_now", + "chara_dasha_now", + "shadbala", + "ashtakavarga", +] +OFFICIAL_FAST_PRIMARY_SECTIONS = { + "chart_core", + "house_core", + "dasha_all", + "vimshottari_now", + "chara_dasha_now", + "shadbala", + "ashtakavarga", +} ALIAS_NEGATIVE_GUARD_TERMS = {"noise", "without", "generic", "irrelevant", "insignificance", "not"} -DEFAULT_TIMEOUT_SECONDS = 120 +DEFAULT_TIMEOUT_SECONDS = 4 TIMEOUT_ENV = "VEDASTRO_TIMEOUT_SECONDS" BACKOFF_ENV = "VEDASTRO_RETRY_BACKOFF_SECONDS" RETRY_POLICY = { @@ -323,7 +335,15 @@ RETRY_POLICY = { "retry_on": ["timeout", "429", "502", "503", "504"], } ALLOW_NETWORK_ENV = "VEDASTRO_ENABLE_NETWORK" +CACHE_TTL_ENV = "VEDASTRO_CACHE_TTL_SECONDS" +FREE_TIER_MAX_REQUESTS_ENV = "VEDASTRO_FREE_TIER_MAX_REQUESTS" +FREE_TIER_WINDOW_SECONDS_ENV = "VEDASTRO_FREE_TIER_WINDOW_SECONDS" +DEFAULT_CACHE_TTL_SECONDS = 86400.0 +DEFAULT_FREE_TIER_MAX_REQUESTS = 5 +DEFAULT_FREE_TIER_WINDOW_SECONDS = 60.0 ARTIFACT_DIR = ROOT / "scratch" / "local" / "vedastro_adapter" +_FREE_TIER_REQUEST_TIMESTAMPS: list[float] = [] +_FREE_TIER_REQUEST_LOCK = threading.Lock() def _timeout_seconds() -> float: @@ -369,6 +389,18 @@ def _artifact_path(operation: str, request_hash: str, response_hash: str) -> Pat return ARTIFACT_DIR / filename +def _cache_dir() -> Path: + path = ARTIFACT_DIR / "response_cache" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _official_full_snapshot_cache_dir() -> Path: + path = ARTIFACT_DIR / "official_full_snapshot_cache" + path.mkdir(parents=True, exist_ok=True) + return path + + def _repo_relative(path: Path) -> str: try: return str(path.relative_to(ROOT)) @@ -387,6 +419,48 @@ def _write_artifact(result: dict[str, Any]) -> str: return _repo_relative(artifact) +def _cache_ttl_seconds() -> float: + raw = os.environ.get(CACHE_TTL_ENV, "").strip() + if not raw: + return DEFAULT_CACHE_TTL_SECONDS + try: + return max(0.0, float(raw)) + except ValueError: + return DEFAULT_CACHE_TTL_SECONDS + + +def _official_full_snapshot_cache_ttl_seconds() -> float: + raw = os.environ.get("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", "").strip() + if not raw: + raw = os.environ.get(CACHE_TTL_ENV, "").strip() + if not raw: + return 0.0 + try: + return max(0.0, float(raw)) + except ValueError: + return 0.0 + + +def _free_tier_max_requests() -> int: + raw = os.environ.get(FREE_TIER_MAX_REQUESTS_ENV, "").strip() + if not raw: + return DEFAULT_FREE_TIER_MAX_REQUESTS + try: + return max(0, int(raw)) + except ValueError: + return DEFAULT_FREE_TIER_MAX_REQUESTS + + +def _free_tier_window_seconds() -> float: + raw = os.environ.get(FREE_TIER_WINDOW_SECONDS_ENV, "").strip() + if not raw: + return DEFAULT_FREE_TIER_WINDOW_SECONDS + try: + return max(0.0, float(raw)) + except ValueError: + return DEFAULT_FREE_TIER_WINDOW_SECONDS + + def schema() -> dict[str, Any]: request_example = { **PARITY_CASES["beijing_first_use_demo"], @@ -612,6 +686,12 @@ def _time_json_from_case( hour: Any | None = None, minute: Any | None = None, ) -> dict[str, Any]: + location_name = ( + case.get("location_name") + or case.get("place_name") + or case.get("city") + or "UserLocation" + ) return { "StdTime": _format_std_time( date_text, @@ -620,13 +700,48 @@ def _time_json_from_case( case.get("tz", "+00:00"), ), "Location": { - "Name": case.get("case_id") or "UserLocation", + "Name": str(location_name), "Latitude": case.get("lat"), "Longitude": case.get("lon"), }, } +def _bridge_geo_from_case(case: dict[str, Any]) -> dict[str, Any]: + location_name = ( + case.get("location_name") + or case.get("place_name") + or case.get("city") + or "UserLocation" + ) + return { + "__vedastro_type__": "GeoLocation", + "location_name": str(location_name), + "longitude": case.get("lon"), + "latitude": case.get("lat"), + } + + +def _bridge_time_from_case( + case: dict[str, Any], + date_text: str, + *, + hour: Any | None = None, + minute: Any | None = None, +) -> dict[str, Any]: + year, month, day = str(date_text).split("-") + return { + "__vedastro_type__": "Time", + "year": int(year), + "month": int(month), + "day": int(day), + "hour": int(float(case.get("hour", 0) if hour is None else hour)), + "minute": int(float(case.get("minute", 0) if minute is None else minute)), + "offset": case.get("tz", "+00:00"), + "geolocation": _bridge_geo_from_case(case), + } + + def _normalize_tz(case: dict[str, Any]) -> str: tz = case.get("tz") if isinstance(tz, str): @@ -738,6 +853,14 @@ def _official_dasha_range_body(case: dict[str, Any], common_body: dict[str, Any] } +def _official_dasha_range_dates(case: dict[str, Any]) -> tuple[str, str]: + normalized = dict(case) + reference = datetime.strptime(_official_snapshot_reference_date(normalized), "%Y-%m-%d").date() + start_date = reference.replace(month=1, day=1) + end_date = reference.replace(month=12, day=31) + return start_date.isoformat(), end_date.isoformat() + + def _official_full_snapshot_manifest(case: dict[str, Any], case_id: str = "user_chart") -> dict[str, Any]: common_body = _official_common_body(case) reference_date = _official_snapshot_reference_date(case) @@ -793,6 +916,265 @@ def _official_full_snapshot_manifest(case: dict[str, Any], case_id: str = "user_ } +def _call_vedastro_python_bridge_high_value(method_key: str, payload: dict[str, Any]) -> dict[str, Any]: + if not VEDASTRO_PYTHON_BRIDGE.exists(): + return { + "available": False, + "status": "python_bridge_missing", + "method": method_key, + "source": "vedastro_service_adapter_candidate", + } + try: + completed = subprocess.run( + [ + os.environ.get("PYTHON_BIN", "") or "python3", + str(VEDASTRO_PYTHON_BRIDGE), + "--high-value", + method_key, + "--params-json", + json.dumps(payload, ensure_ascii=False), + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=_timeout_seconds(), + check=False, + env=os.environ.copy(), + ) + except subprocess.TimeoutExpired: + return { + "available": False, + "status": "python_bridge_timeout", + "method": method_key, + "reason": "VedAstro official Python bridge timed out", + "timeout_seconds": _timeout_seconds(), + "source": "vedastro_service_adapter_candidate", + } + if completed.returncode != 0: + return { + "available": False, + "status": "python_bridge_runtime_error", + "method": method_key, + "stderr": (completed.stderr or "").strip(), + "stdout_excerpt": (completed.stdout or "").strip()[:500], + "source": "vedastro_service_adapter_candidate", + } + try: + return json.loads(completed.stdout) + except json.JSONDecodeError: + return { + "available": False, + "status": "python_bridge_invalid_json", + "method": method_key, + "stdout_excerpt": (completed.stdout or "").strip()[:500], + "source": "vedastro_service_adapter_candidate", + } + + +def _try_official_python_bridge_snapshot_bundle(case: dict[str, Any]) -> dict[str, Any]: + normalized = dict(case) + normalized["tz"] = _normalize_tz(normalized) + birth_date = f"{int(normalized['year']):04d}-{int(normalized['month']):02d}-{int(normalized['day']):02d}" + reference_date = _official_snapshot_reference_date(normalized) + start_date, end_date = _official_dasha_range_dates(normalized) + payload = { + "birth_time": _bridge_time_from_case(normalized, birth_date), + "check_time": _bridge_time_from_case(normalized, reference_date), + "start_time": _bridge_time_from_case(normalized, start_date, hour=0, minute=0), + "end_time": _bridge_time_from_case(normalized, end_date, hour=23, minute=59), + "levels": int(normalized.get("dasha_levels") or 3), + "precision_hours": int(normalized.get("dasha_precision_hours") or 100), + "planets": OFFICIAL_SNAPSHOT_PLANETS, + "houses": OFFICIAL_SNAPSHOT_HOUSES, + } + report = _call_vedastro_python_bridge_high_value("official_full_snapshot_bundle", payload) + if report.get("status") != "ok": + return { + "available": bool(report.get("available")), + "status": report.get("status") or "blocked", + "source": report.get("source") or "vedastro_python_bridge", + "reason": report.get("reason"), + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_python_bridge_bundle", "filled_sections": []}, + "raw": report, + } + result = report.get("result") if isinstance(report.get("result"), dict) else {} + return { + "available": bool(report.get("available")), + "status": report.get("status") or "ok", + "source": report.get("source") or "vedastro_python_bridge", + "snapshot_sections": result.get("snapshot_sections") if isinstance(result.get("snapshot_sections"), dict) else {}, + "section_statuses": result.get("section_statuses") if isinstance(result.get("section_statuses"), dict) else {}, + "coverage": result.get("coverage") if isinstance(result.get("coverage"), dict) else {"source_mode": "official_python_bridge_bundle", "filled_sections": []}, + "python_bin": report.get("python_bin"), + "module_name": report.get("module_name"), + } + + +def _try_official_capability_runner_snapshot_bundle(case: dict[str, Any]) -> dict[str, Any]: + if not VEDASTRO_OFFICIAL_CAPABILITY_RUNNER.exists(): + return { + "available": False, + "status": "official_capability_runner_missing", + "source": "vedastro_official_capability_runner", + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_capability_runner_bundle", "filled_sections": []}, + } + try: + completed = subprocess.run( + [ + os.environ.get("PYTHON_BIN", "") or "python3", + str(VEDASTRO_OFFICIAL_CAPABILITY_RUNNER), + "--bundle", + "official_full_snapshot", + "--birth-json", + json.dumps(case, ensure_ascii=False), + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=_timeout_seconds(), + check=False, + env=os.environ.copy(), + ) + except subprocess.TimeoutExpired: + return { + "available": False, + "status": "official_capability_runner_timeout", + "source": "vedastro_official_capability_runner", + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_capability_runner_bundle", "filled_sections": []}, + "reason": "VedAstro official capability runner timed out", + "timeout_seconds": _timeout_seconds(), + } + if completed.returncode != 0: + return { + "available": False, + "status": "official_capability_runner_runtime_error", + "source": "vedastro_official_capability_runner", + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_capability_runner_bundle", "filled_sections": []}, + "stderr": (completed.stderr or "").strip(), + } + try: + report = json.loads(completed.stdout) + except json.JSONDecodeError: + return { + "available": False, + "status": "official_capability_runner_invalid_json", + "source": "vedastro_official_capability_runner", + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_capability_runner_bundle", "filled_sections": []}, + "stdout_excerpt": (completed.stdout or "").strip()[:500], + } + result = report.get("result") if isinstance(report.get("result"), dict) else {} + return { + "available": bool(report.get("available")), + "status": report.get("status") or "blocked", + "source": "vedastro_official_capability_runner", + "bundle": report.get("bundle") or "official_full_snapshot", + "summary": report.get("summary") if isinstance(report.get("summary"), dict) else {}, + "snapshot_sections": result.get("snapshot_sections") if isinstance(result.get("snapshot_sections"), dict) else {}, + "section_statuses": result.get("section_statuses") if isinstance(result.get("section_statuses"), dict) else {}, + "coverage": result.get("coverage") if isinstance(result.get("coverage"), dict) else {"source_mode": "official_capability_runner_bundle", "filled_sections": []}, + } + + +def _try_official_full_capability_catalog_bundle(case: dict[str, Any]) -> dict[str, Any]: + if not VEDASTRO_OFFICIAL_CAPABILITY_RUNNER.exists(): + return { + "available": False, + "status": "official_capability_runner_missing", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_capability_catalog", + "summary": {}, + "coverage": {"source_mode": "official_full_capability_catalog", "safe_sampling": True}, + "domain_routing": {}, + "dynamic_selection": {}, + "bucket_statuses": {}, + "method_statuses": {}, + } + try: + completed = subprocess.run( + [ + os.environ.get("PYTHON_BIN", "") or "python3", + str(VEDASTRO_OFFICIAL_CAPABILITY_RUNNER), + "--bundle", + "official_full_capability_catalog", + "--birth-json", + json.dumps(case, ensure_ascii=False), + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=_timeout_seconds(), + check=False, + env=os.environ.copy(), + ) + except subprocess.TimeoutExpired: + return { + "available": False, + "status": "official_full_capability_catalog_timeout", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_capability_catalog", + "summary": {}, + "coverage": {"source_mode": "official_full_capability_catalog", "safe_sampling": True}, + "domain_routing": {}, + "dynamic_selection": {}, + "bucket_statuses": {}, + "method_statuses": {}, + "reason": "VedAstro official capability catalog runner timed out", + "timeout_seconds": _timeout_seconds(), + } + if completed.returncode != 0: + return { + "available": False, + "status": "official_full_capability_catalog_runtime_error", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_capability_catalog", + "summary": {}, + "coverage": {"source_mode": "official_full_capability_catalog", "safe_sampling": True}, + "domain_routing": {}, + "dynamic_selection": {}, + "bucket_statuses": {}, + "method_statuses": {}, + "stderr": (completed.stderr or "").strip(), + } + try: + report = json.loads(completed.stdout) + except json.JSONDecodeError: + return { + "available": False, + "status": "official_full_capability_catalog_invalid_json", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_capability_catalog", + "summary": {}, + "coverage": {"source_mode": "official_full_capability_catalog", "safe_sampling": True}, + "domain_routing": {}, + "dynamic_selection": {}, + "bucket_statuses": {}, + "method_statuses": {}, + "stdout_excerpt": (completed.stdout or "").strip()[:500], + } + return { + "available": bool(report.get("available")), + "status": report.get("status") or "blocked", + "source": "vedastro_official_capability_runner", + "bundle": report.get("bundle") or "official_full_capability_catalog", + "summary": report.get("summary") if isinstance(report.get("summary"), dict) else {}, + "coverage": report.get("coverage") if isinstance(report.get("coverage"), dict) else {"source_mode": "official_full_capability_catalog", "safe_sampling": True}, + "domain_routing": report.get("domain_routing") if isinstance(report.get("domain_routing"), dict) else {}, + "dynamic_selection": report.get("dynamic_selection") if isinstance(report.get("dynamic_selection"), dict) else {}, + "bucket_statuses": report.get("bucket_statuses") if isinstance(report.get("bucket_statuses"), dict) else {}, + "method_statuses": report.get("method_statuses") if isinstance(report.get("method_statuses"), dict) else {}, + } + + def _external_technique_preview( case: dict[str, Any], domain: str, @@ -818,6 +1200,7 @@ def _base_live_metadata( retry_error_codes: list[int] | None = None, ) -> dict[str, Any]: official_request_profile = request_preview.get("official_request_profile") if isinstance(request_preview, dict) else None + transport_metadata = payload.get("source_metadata") if isinstance(payload, dict) else None metadata = { "transport": "http_json_service_boundary", "endpoint": endpoint, @@ -845,6 +1228,17 @@ def _base_live_metadata( metadata["official_endpoint_path"] = official_request_profile.get("endpoint_path") metadata["official_request_profile"] = redacted_profile metadata["official_request_profile_hash"] = _hash_payload(redacted_profile) + if isinstance(transport_metadata, dict): + for key in ( + "cache_hit", + "cache_key", + "cache_created_at", + "cache_expires_at", + "cache_ttl_seconds", + "free_tier_rate_limit", + ): + if key in transport_metadata: + metadata[key] = transport_metadata[key] return metadata @@ -1060,6 +1454,7 @@ def _normalize_range_scan_success( "end": top.get("end"), "tags": top.get("tags") or [], } + daily_windows, top_daily_window = _build_daily_windows(domain, evidence_ledger) metadata = { **_base_live_metadata(endpoint, request_preview, payload, "range_scan", attempt_count, retry_error_codes), @@ -1088,6 +1483,8 @@ def _normalize_range_scan_success( "request_preview": request_preview, "event_count": len(evidence_ledger), "top_event": top_event, + "daily_windows": daily_windows, + "top_daily_window": top_daily_window, "evidence_ledger": evidence_ledger, "source_metadata": metadata, } @@ -1095,6 +1492,89 @@ def _normalize_range_scan_success( return result +def _event_date_text(value: Any) -> str | None: + if isinstance(value, dict): + std_time = value.get("StdTime") + if isinstance(std_time, str): + parts = std_time.split() + if len(parts) >= 2 and "/" in parts[1]: + day, month, year = parts[1].split("/") + return f"{year}-{month}-{day}" + return None + if isinstance(value, str): + raw = value.strip() + if len(raw) >= 10 and raw[4] == "-" and raw[7] == "-": + return raw[:10] + return None + + +def _confidence_rank(value: str) -> int: + order = { + "high": 4, + "medium_high": 3, + "medium": 2, + "low": 1, + "rejected": 0, + } + return order.get(str(value or ""), 0) + + +def _build_daily_windows(domain: str, evidence_ledger: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: + grouped: dict[str, dict[str, Any]] = {} + for event in evidence_ledger: + if not isinstance(event, dict): + continue + event_date = _event_date_text(event.get("start")) or _event_date_text(event.get("end")) + if not event_date: + continue + bucket = grouped.setdefault( + event_date, + { + "date": event_date, + "domain": domain, + "score": 0, + "confidence": "low", + "event_count": 0, + "signal_families": set(), + "event_ids": [], + "top_signal_label": None, + "_top_conf_rank": -1, + }, + ) + bucket["event_count"] += 1 + bucket["score"] += int(event.get("signal_lift") or 0) + family = event.get("signal_family") + if family: + bucket["signal_families"].add(str(family)) + event_id = event.get("event_id") + if isinstance(event_id, str) and event_id and event_id not in bucket["event_ids"]: + bucket["event_ids"].append(event_id) + conf = str(event.get("confidence") or "low") + conf_rank = _confidence_rank(conf) + if conf_rank > bucket["_top_conf_rank"]: + bucket["_top_conf_rank"] = conf_rank + bucket["confidence"] = conf + bucket["top_signal_label"] = event.get("signal_label") or event.get("event_id") + + windows: list[dict[str, Any]] = [] + for date_text, bucket in grouped.items(): + windows.append( + { + "date": date_text, + "domain": bucket["domain"], + "score": bucket["score"], + "confidence": bucket["confidence"], + "event_count": bucket["event_count"], + "signal_families": sorted(bucket["signal_families"]), + "event_ids": list(bucket["event_ids"]), + "top_signal_label": bucket["top_signal_label"], + } + ) + windows.sort(key=lambda item: (-int(item.get("score") or 0), -int(item.get("event_count") or 0), str(item.get("date") or ""))) + top = windows[0] if windows else None + return windows, top + + def _source_metadata(endpoint: str) -> dict[str, Any]: return { "transport": "http_json_service_boundary", @@ -1106,7 +1586,10 @@ def _source_metadata(endpoint: str) -> dict[str, Any]: } -def _post_json(endpoint: str, request_preview: dict[str, Any]) -> dict[str, Any] | str: +def _build_live_request( + endpoint: str, + request_preview: dict[str, Any], +) -> tuple[str, dict[str, Any], dict[str, Any]]: official_request_profile = None if isinstance(request_preview, dict): official_request_profile = ( @@ -1120,6 +1603,294 @@ def _post_json(endpoint: str, request_preview: dict[str, Any]) -> dict[str, Any] request_url = f"{endpoint.rstrip('/')}{official_request_profile.get('endpoint_path', '')}" headers = dict(official_request_profile.get("headers") or headers) vedastro_payload = dict(official_request_profile.get("body") or {}) + return request_url, headers, vedastro_payload + + +def _cache_key_for_request( + request_url: str, + headers: dict[str, Any], + vedastro_payload: dict[str, Any], +) -> str: + normalized_headers = dict(headers) + if "x-api-key" in normalized_headers: + normalized_headers["x-api-key"] = "[redacted]" + return _hash_payload( + { + "request_url": request_url, + "headers": normalized_headers, + "body": vedastro_payload, + } + ) + + +def _cache_path(cache_key: str) -> Path: + return _cache_dir() / f"{cache_key}.json" + + +def _official_full_snapshot_cache_key(case: dict[str, Any], case_id: str) -> str: + return _hash_payload( + { + "scope": "official_full_snapshot", + "birth": { + "year": case.get("year"), + "month": case.get("month"), + "day": case.get("day"), + "hour": case.get("hour"), + "minute": case.get("minute"), + "second": case.get("second", 0), + "lat": case.get("lat"), + "lon": case.get("lon"), + "tz": case.get("tz"), + }, + "policies": { + "ayanamsa_policy": case.get("ayanamsa_policy") or case.get("ayanamsa") or "lahiri", + "node_policy": case.get("node_policy") or case.get("node_mode") or "mean", + "reference_date": case.get("reference_date") or case.get("today") or case.get("transit_date") or case.get("current_date"), + "dasha_levels": case.get("dasha_levels"), + "dasha_precision_hours": case.get("dasha_precision_hours"), + }, + "runtime": { + "endpoint": os.environ.get("VEDASTRO_API_ENDPOINT", "").strip(), + "network_enabled": os.environ.get(ALLOW_NETWORK_ENV, "").strip().lower() in {"1", "true", "yes"}, + "api_key_present": bool(os.environ.get("VEDASTRO_API_KEY", "").strip()), + "profile_version": OFFICIAL_FULL_SNAPSHOT_PROFILE_VERSION, + }, + } + ) + + +def _official_full_snapshot_cache_path(cache_key: str) -> Path: + return _official_full_snapshot_cache_dir() / f"{cache_key}.json" + + +def _attach_official_full_snapshot_semantic_cache_metadata( + payload: dict[str, Any], + *, + cache_key: str, + created_at_unix: float, + cache_hit: bool, +) -> dict[str, Any]: + payload_copy = json.loads(json.dumps(payload)) + ttl_seconds = _official_full_snapshot_cache_ttl_seconds() + created_at = datetime.utcfromtimestamp(created_at_unix).strftime("%Y-%m-%dT%H:%M:%SZ") + expires_at = datetime.utcfromtimestamp(created_at_unix + ttl_seconds).strftime("%Y-%m-%dT%H:%M:%SZ") + metadata = dict(payload_copy.get("source_metadata") or {}) + metadata["semantic_cache"] = { + "scope": "official_full_snapshot", + "cache_hit": cache_hit, + "cache_key": cache_key, + "cache_created_at": created_at, + "cache_expires_at": expires_at, + "cache_ttl_seconds": ttl_seconds, + } + payload_copy["source_metadata"] = metadata + return payload_copy + + +def _rebind_official_full_snapshot_case_identity(payload: dict[str, Any], case_id: str) -> dict[str, Any]: + payload_copy = json.loads(json.dumps(payload)) + manifest = payload_copy.get("request_manifest") + if isinstance(manifest, dict): + manifest["case_id"] = case_id + return payload_copy + + +def _load_official_full_snapshot_semantic_cache(case: dict[str, Any], case_id: str) -> dict[str, Any] | None: + ttl_seconds = _official_full_snapshot_cache_ttl_seconds() + if ttl_seconds <= 0: + return None + cache_key = _official_full_snapshot_cache_key(case, case_id) + cache_path = _official_full_snapshot_cache_path(cache_key) + if not cache_path.exists(): + return None + try: + record = json.loads(cache_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + if not isinstance(record, dict): + return None + created_at_raw = record.get("created_at") + payload = record.get("payload") + if not isinstance(created_at_raw, (int, float)) or not isinstance(payload, dict): + return None + if time.time() - float(created_at_raw) > ttl_seconds: + return None + rebound = _rebind_official_full_snapshot_case_identity(payload, case_id) + return _attach_official_full_snapshot_semantic_cache_metadata( + rebound, + cache_key=cache_key, + created_at_unix=float(created_at_raw), + cache_hit=True, + ) + + +def _store_official_full_snapshot_semantic_cache(case: dict[str, Any], case_id: str, payload: dict[str, Any]) -> dict[str, Any]: + cache_key = _official_full_snapshot_cache_key(case, case_id) + created_at_unix = time.time() + payload_copy = _attach_official_full_snapshot_semantic_cache_metadata( + payload, + cache_key=cache_key, + created_at_unix=created_at_unix, + cache_hit=False, + ) + ttl_seconds = _official_full_snapshot_cache_ttl_seconds() + if ttl_seconds > 0: + record = { + "cache_key": cache_key, + "created_at": created_at_unix, + "payload": payload_copy, + } + _official_full_snapshot_cache_path(cache_key).write_text( + json.dumps(record, ensure_ascii=False, sort_keys=True), + encoding="utf-8", + ) + return payload_copy + + +def _is_official_public_endpoint(request_url: str) -> bool: + host = (urlparse(request_url).netloc or "").lower() + if not host: + return False + if host.startswith("127.0.0.1") or host.startswith("localhost"): + return False + if host.startswith("192.168.") or host.startswith("10.") or host.startswith("172.16."): + return False + return "vedastro" in host + + +def _load_cached_payload(cache_key: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + ttl_seconds = _cache_ttl_seconds() + if ttl_seconds <= 0: + return None, None + cache_path = _cache_path(cache_key) + if not cache_path.exists(): + return None, None + try: + record = json.loads(cache_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None, None + if not isinstance(record, dict): + return None, None + created_at_raw = record.get("created_at") + payload = record.get("payload") + if not isinstance(created_at_raw, (int, float)) or not isinstance(payload, dict): + return None, None + age_seconds = time.time() - float(created_at_raw) + if age_seconds > ttl_seconds: + return None, None + created_at = datetime.utcfromtimestamp(float(created_at_raw)).strftime("%Y-%m-%dT%H:%M:%SZ") + expires_at = datetime.utcfromtimestamp(float(created_at_raw) + ttl_seconds).strftime("%Y-%m-%dT%H:%M:%SZ") + payload_copy = json.loads(json.dumps(payload)) + metadata = dict(payload_copy.get("source_metadata") or {}) + metadata.update( + { + "cache_hit": True, + "cache_key": cache_key, + "cache_created_at": created_at, + "cache_expires_at": expires_at, + "cache_ttl_seconds": ttl_seconds, + } + ) + payload_copy["source_metadata"] = metadata + return payload_copy, metadata + + +def _is_cacheable_payload(payload: dict[str, Any]) -> bool: + status = str(payload.get("Status") or "") + if status == "Pass": + return True + if isinstance(payload.get("events"), list): + return True + if status.lower() == "fail": + payload_text = json.dumps(payload.get("Payload"), ensure_ascii=False).lower() + if "rate limit" in payload_text or "calls/minute" in payload_text or "too many requests" in payload_text: + return False + return False + + +def _store_cached_payload(cache_key: str, payload: dict[str, Any]) -> dict[str, Any]: + ttl_seconds = _cache_ttl_seconds() + payload_copy = json.loads(json.dumps(payload)) + created_at_unix = time.time() + created_at = datetime.utcfromtimestamp(created_at_unix).strftime("%Y-%m-%dT%H:%M:%SZ") + expires_at = datetime.utcfromtimestamp(created_at_unix + ttl_seconds).strftime("%Y-%m-%dT%H:%M:%SZ") + metadata = dict(payload_copy.get("source_metadata") or {}) + metadata.update( + { + "cache_hit": False, + "cache_key": cache_key, + "cache_created_at": created_at, + "cache_expires_at": expires_at, + "cache_ttl_seconds": ttl_seconds, + } + ) + payload_copy["source_metadata"] = metadata + if ttl_seconds > 0 and _is_cacheable_payload(payload_copy): + cache_path = _cache_path(cache_key) + record = { + "cache_key": cache_key, + "created_at": created_at_unix, + "payload": payload_copy, + } + cache_path.write_text(json.dumps(record, ensure_ascii=False, sort_keys=True), encoding="utf-8") + return payload_copy + + +def _acquire_free_tier_slot(request_url: str) -> dict[str, Any]: + if not _is_official_public_endpoint(request_url): + return { + "mode": "non_public_or_self_host_endpoint", + "queue_active": False, + "waited_seconds": 0.0, + "window_seconds": _free_tier_window_seconds(), + "max_requests": _free_tier_max_requests(), + } + if os.environ.get("VEDASTRO_API_KEY", "").strip(): + return { + "mode": "api_key_present", + "queue_active": False, + "waited_seconds": 0.0, + "window_seconds": _free_tier_window_seconds(), + "max_requests": _free_tier_max_requests(), + } + max_requests = _free_tier_max_requests() + window_seconds = _free_tier_window_seconds() + if max_requests <= 0 or window_seconds <= 0: + return { + "mode": "free_tier_queue_disabled", + "queue_active": False, + "waited_seconds": 0.0, + "window_seconds": window_seconds, + "max_requests": max_requests, + } + + waited_seconds = 0.0 + with _FREE_TIER_REQUEST_LOCK: + while True: + now = time.monotonic() + _FREE_TIER_REQUEST_TIMESTAMPS[:] = [ + ts for ts in _FREE_TIER_REQUEST_TIMESTAMPS if now - ts < window_seconds + ] + if len(_FREE_TIER_REQUEST_TIMESTAMPS) < max_requests: + _FREE_TIER_REQUEST_TIMESTAMPS.append(now) + break + sleep_seconds = max(window_seconds - (now - _FREE_TIER_REQUEST_TIMESTAMPS[0]), 0.0) + waited_seconds += sleep_seconds + if sleep_seconds > 0: + time.sleep(sleep_seconds) + else: + _FREE_TIER_REQUEST_TIMESTAMPS.pop(0) + return { + "mode": "free_tier_queue_active", + "queue_active": True, + "waited_seconds": round(waited_seconds, 6), + "window_seconds": window_seconds, + "max_requests": max_requests, + } + + +def _post_json(endpoint: str, request_preview: dict[str, Any]) -> dict[str, Any] | str: + request_url, headers, vedastro_payload = _build_live_request(endpoint, request_preview) req = request.Request( request_url, data=json.dumps(vedastro_payload).encode("utf-8"), @@ -1142,15 +1913,26 @@ def _retry_status_codes() -> set[int]: def _post_json_with_retry(endpoint: str, request_preview: dict[str, Any]) -> tuple[dict[str, Any], int, list[int]]: + request_url, headers, vedastro_payload = _build_live_request(endpoint, request_preview) + cache_key = _cache_key_for_request(request_url, headers, vedastro_payload) + cached_payload, _ = _load_cached_payload(cache_key) + if isinstance(cached_payload, dict): + return cached_payload, 1, [] + retry_codes = _retry_status_codes() retry_error_codes: list[int] = [] max_attempts = int(RETRY_POLICY["max_attempts"]) for attempt in range(1, max_attempts + 1): try: + rate_limit_metadata = _acquire_free_tier_slot(request_url) payload = _post_json(endpoint, request_preview) if not isinstance(payload, dict): return {}, attempt, retry_error_codes - return payload, attempt, retry_error_codes + payload_copy = _store_cached_payload(cache_key, payload) + transport_metadata = dict(payload_copy.get("source_metadata") or {}) + transport_metadata["free_tier_rate_limit"] = rate_limit_metadata + payload_copy["source_metadata"] = transport_metadata + return payload_copy, attempt, retry_error_codes except error.HTTPError as exc: if attempt >= max_attempts or exc.code not in retry_codes: raise @@ -1221,6 +2003,7 @@ def _merge_range_scan_reports( "end": top.get("end"), "tags": top.get("tags") or [], } + daily_windows, top_daily_window = _build_daily_windows(str(base_preview.get("domain") or ""), evidence_ledger) metadata = dict(reports[-1].get("source_metadata") or {}) metadata["sampling_mode"] = "at_time_sweep" @@ -1244,6 +2027,8 @@ def _merge_range_scan_reports( "request_preview": base_preview, "event_count": len(evidence_ledger), "top_event": top_event, + "daily_windows": daily_windows, + "top_daily_window": top_daily_window, "evidence_ledger": evidence_ledger, "source_metadata": metadata, } @@ -1336,6 +2121,20 @@ def _official_full_snapshot_metadata(endpoint: str | None, manifest: dict[str, A return metadata +def _official_snapshot_budget_exhausted_bundle(reason: str) -> dict[str, Any]: + return { + "available": False, + "status": "official_snapshot_budget_exhausted", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_snapshot", + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_foreground_budget", "filled_sections": []}, + "reason": reason, + "timeout_seconds": _timeout_seconds(), + } + + def _payload_status(payload: dict[str, Any]) -> str: if not isinstance(payload, dict): return "invalid" @@ -1529,10 +2328,18 @@ def _normalize_official_full_snapshot_success( section_statuses: dict[str, str], attempt_count: int, retry_error_codes: list[int], + official_python_bundle: dict[str, Any] | None = None, + official_full_capability_catalog: dict[str, Any] | None = None, ) -> dict[str, Any]: primary_sections = [item["section"] for item in manifest["requests"]] ok_count = sum(1 for section in primary_sections if section_statuses.get(section) == "ok") + fast_primary_present = OFFICIAL_FAST_PRIMARY_SECTIONS.intersection(set(section_statuses)) + fast_primary_ok = bool(fast_primary_present) and all( + section_statuses.get(section) == "ok" for section in fast_primary_present + ) status = "ok" if ok_count == len(primary_sections) else "partial" + if fast_primary_ok and len(fast_primary_present) >= 4: + status = "ok" rate_limited_sections = [ section for section in primary_sections @@ -1544,11 +2351,35 @@ def _normalize_official_full_snapshot_success( "section_statuses": section_statuses, "section_count": len(primary_sections), "section_ok_count": ok_count, + "fast_primary_sections": sorted(OFFICIAL_FAST_PRIMARY_SECTIONS), + "fast_primary_ok": fast_primary_ok, "rate_limited_sections": rate_limited_sections, "attempt_count": attempt_count, "retry_error_codes": retry_error_codes, "response_hash": _hash_payload({"sections": sections, "section_statuses": section_statuses}), } + if isinstance(official_python_bundle, dict): + metadata["official_python_bundle"] = { + "source": official_python_bundle.get("source"), + "status": official_python_bundle.get("status"), + "coverage": official_python_bundle.get("coverage"), + "bundle": official_python_bundle.get("bundle"), + "summary": official_python_bundle.get("summary"), + "python_bin": official_python_bundle.get("python_bin"), + "module_name": official_python_bundle.get("module_name"), + } + metadata["official_python_path"] = str(official_python_bundle.get("source") or "vedastro_python_bridge") + metadata["python_bridge"] = metadata["official_python_bundle"] + if isinstance(official_full_capability_catalog, dict): + metadata["official_full_capability_catalog"] = { + "source": official_full_capability_catalog.get("source"), + "status": official_full_capability_catalog.get("status"), + "bundle": official_full_capability_catalog.get("bundle"), + "summary": official_full_capability_catalog.get("summary"), + "coverage": official_full_capability_catalog.get("coverage"), + "domain_routing": official_full_capability_catalog.get("domain_routing") or {}, + "dynamic_selection": official_full_capability_catalog.get("dynamic_selection") or {}, + } if rate_limited_sections: metadata["production_hint"] = "configure_vedastro_api_key_or_self_host_official_api" result = { @@ -1559,6 +2390,7 @@ def _normalize_official_full_snapshot_success( "primary_source": "vedastro_official", "snapshot_sections": sections, "official_chart": _build_official_chart_from_snapshot(sections), + "official_full_capability_catalog": official_full_capability_catalog or {}, "section_statuses": section_statuses, "request_manifest": manifest, "user_visibility": "backend_raw_evidence_not_direct_user_report", @@ -1587,9 +2419,89 @@ def _run_official_full_snapshot_case(case: dict[str, Any], case_id: str = "user_ "dasha_precision_hours": case.get("dasha_precision_hours"), } manifest = _official_full_snapshot_manifest(user_case, case_id) + budget_started_at = time.monotonic() + official_full_capability_catalog = _try_official_full_capability_catalog_bundle(user_case) + elapsed_seconds = time.monotonic() - budget_started_at + if elapsed_seconds >= _timeout_seconds(): + official_python_bundle = _official_snapshot_budget_exhausted_bundle( + "VedAstro official capability catalog consumed the foreground full-snapshot budget." + ) + else: + official_python_bundle = _try_official_capability_runner_snapshot_bundle(user_case) + if ( + not official_python_bundle.get("snapshot_sections") + and official_python_bundle.get("status") != "official_snapshot_budget_exhausted" + and time.monotonic() - budget_started_at >= _timeout_seconds() + ): + official_python_bundle = _official_snapshot_budget_exhausted_bundle( + "VedAstro official snapshot runner consumed the foreground full-snapshot budget." + ) + if ( + not official_python_bundle.get("snapshot_sections") + and official_python_bundle.get("status") != "official_snapshot_budget_exhausted" + ): + official_python_bundle = _try_official_python_bridge_snapshot_bundle(user_case) + bridge_sections = ( + official_python_bundle.get("snapshot_sections") + if isinstance(official_python_bundle.get("snapshot_sections"), dict) + else {} + ) + bridge_section_statuses = ( + official_python_bundle.get("section_statuses") + if isinstance(official_python_bundle.get("section_statuses"), dict) + else {} + ) endpoint = os.environ.get("VEDASTRO_API_ENDPOINT", "").strip() + network_enabled = os.environ.get(ALLOW_NETWORK_ENV, "").strip().lower() in {"1", "true", "yes"} + budget_exhausted = official_python_bundle.get("status") == "official_snapshot_budget_exhausted" + if budget_exhausted and endpoint and network_enabled and _is_official_public_endpoint(endpoint): + result = { + "backend": "vedastro_service_adapter_candidate", + "available": False, + "status": "official_snapshot_budget_exhausted", + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "reason": official_python_bundle.get("reason"), + "snapshot_sections": {}, + "official_full_capability_catalog": official_full_capability_catalog, + "request_manifest": manifest, + "user_visibility": "backend_raw_evidence_not_direct_user_report", + "source_metadata": _official_full_snapshot_metadata(endpoint or None, manifest), + } + result["source_metadata"]["official_full_capability_catalog"] = { + "source": official_full_capability_catalog.get("source"), + "status": official_full_capability_catalog.get("status"), + "bundle": official_full_capability_catalog.get("bundle"), + "summary": official_full_capability_catalog.get("summary"), + "coverage": official_full_capability_catalog.get("coverage"), + "domain_routing": official_full_capability_catalog.get("domain_routing") or {}, + "dynamic_selection": official_full_capability_catalog.get("dynamic_selection") or {}, + } + result["source_metadata"]["official_python_bundle"] = { + "source": official_python_bundle.get("source"), + "status": official_python_bundle.get("status"), + "coverage": official_python_bundle.get("coverage"), + "bundle": official_python_bundle.get("bundle"), + "summary": official_python_bundle.get("summary"), + "python_bin": official_python_bundle.get("python_bin"), + "module_name": official_python_bundle.get("module_name"), + } + result["source_metadata"]["official_python_path"] = str(official_python_bundle.get("source") or "vedastro_python_bridge") + result["source_metadata"]["python_bridge"] = result["source_metadata"]["official_python_bundle"] + return result if not endpoint: - return { + if bridge_sections: + return _normalize_official_full_snapshot_success( + "", + manifest, + bridge_sections, + bridge_section_statuses, + 1, + [], + official_python_bundle=official_python_bundle, + official_full_capability_catalog=official_full_capability_catalog, + ) + result = { "backend": "vedastro_service_adapter_candidate", "available": False, "status": "service_endpoint_not_configured", @@ -1597,13 +2509,46 @@ def _run_official_full_snapshot_case(case: dict[str, Any], case_id: str = "user_ "primary_source": "vedastro_official", "reason": "VEDASTRO_API_ENDPOINT is not configured; official full snapshot stops before network access.", "snapshot_sections": {}, + "official_full_capability_catalog": official_full_capability_catalog, "request_manifest": manifest, "user_visibility": "backend_raw_evidence_not_direct_user_report", "source_metadata": _official_full_snapshot_metadata(None, manifest), } + result["source_metadata"]["official_full_capability_catalog"] = { + "source": official_full_capability_catalog.get("source"), + "status": official_full_capability_catalog.get("status"), + "bundle": official_full_capability_catalog.get("bundle"), + "summary": official_full_capability_catalog.get("summary"), + "coverage": official_full_capability_catalog.get("coverage"), + "domain_routing": official_full_capability_catalog.get("domain_routing") or {}, + "dynamic_selection": official_full_capability_catalog.get("dynamic_selection") or {}, + } + result["source_metadata"]["official_python_bundle"] = { + "source": official_python_bundle.get("source"), + "status": official_python_bundle.get("status"), + "coverage": official_python_bundle.get("coverage"), + "bundle": official_python_bundle.get("bundle"), + "summary": official_python_bundle.get("summary"), + "python_bin": official_python_bundle.get("python_bin"), + "module_name": official_python_bundle.get("module_name"), + } + result["source_metadata"]["official_python_path"] = str(official_python_bundle.get("source") or "vedastro_python_bridge") + result["source_metadata"]["python_bridge"] = result["source_metadata"]["official_python_bundle"] + return result - if os.environ.get(ALLOW_NETWORK_ENV, "").strip().lower() not in {"1", "true", "yes"}: - return { + if not network_enabled: + if bridge_sections: + return _normalize_official_full_snapshot_success( + endpoint, + manifest, + bridge_sections, + bridge_section_statuses, + 1, + [], + official_python_bundle=official_python_bundle, + official_full_capability_catalog=official_full_capability_catalog, + ) + result = { "backend": "vedastro_service_adapter_candidate", "available": False, "status": "network_execution_disabled", @@ -1611,17 +2556,41 @@ def _run_official_full_snapshot_case(case: dict[str, Any], case_id: str = "user_ "primary_source": "vedastro_official", "reason": f"{ALLOW_NETWORK_ENV} is not enabled; official full snapshot stops after building request manifest.", "snapshot_sections": {}, + "official_full_capability_catalog": official_full_capability_catalog, "request_manifest": manifest, "user_visibility": "backend_raw_evidence_not_direct_user_report", "source_metadata": _official_full_snapshot_metadata(endpoint, manifest), } + result["source_metadata"]["official_full_capability_catalog"] = { + "source": official_full_capability_catalog.get("source"), + "status": official_full_capability_catalog.get("status"), + "bundle": official_full_capability_catalog.get("bundle"), + "summary": official_full_capability_catalog.get("summary"), + "coverage": official_full_capability_catalog.get("coverage"), + "domain_routing": official_full_capability_catalog.get("domain_routing") or {}, + "dynamic_selection": official_full_capability_catalog.get("dynamic_selection") or {}, + } + result["source_metadata"]["official_python_bundle"] = { + "source": official_python_bundle.get("source"), + "status": official_python_bundle.get("status"), + "coverage": official_python_bundle.get("coverage"), + "bundle": official_python_bundle.get("bundle"), + "summary": official_python_bundle.get("summary"), + "python_bin": official_python_bundle.get("python_bin"), + "module_name": official_python_bundle.get("module_name"), + } + result["source_metadata"]["official_python_path"] = str(official_python_bundle.get("source") or "vedastro_python_bridge") + result["source_metadata"]["python_bridge"] = result["source_metadata"]["official_python_bundle"] + return result - sections: dict[str, Any] = {} - section_statuses: dict[str, str] = {} + sections: dict[str, Any] = dict(bridge_sections) + section_statuses: dict[str, str] = dict(bridge_section_statuses) attempt_count = 0 retry_error_codes: list[int] = [] for request_item in manifest["requests"]: section = request_item["section"] + if section in sections and section_statuses.get(section) == "ok": + continue fanout_values = request_item.get("fanout_values") if isinstance(request_item.get("fanout_values"), list) else [] if fanout_values: section_payloads: dict[str, Any] = {} @@ -1669,6 +2638,8 @@ def _run_official_full_snapshot_case(case: dict[str, Any], case_id: str = "user_ section_statuses, attempt_count or 1, retry_error_codes, + official_python_bundle=official_python_bundle, + official_full_capability_catalog=official_full_capability_catalog, ) @@ -1693,7 +2664,13 @@ def run_official_full_snapshot_for_case( *, case_id: str = "user_chart", ) -> dict[str, Any]: - return _run_official_full_snapshot_case(case, case_id=case_id) + cached = _load_official_full_snapshot_semantic_cache(case, case_id) + if isinstance(cached, dict): + return cached + result = _run_official_full_snapshot_case(case, case_id=case_id) + if not isinstance(result, dict): + return result + return _store_official_full_snapshot_semantic_cache(case, case_id, result) def _run_range_scan_case(case: dict[str, Any], domain: str, start_date: str, end_date: str) -> dict[str, Any]: @@ -1804,6 +2781,7 @@ def run_range_scan_for_case( "lat": case.get("lat"), "lon": case.get("lon"), "tz": case.get("tz"), + "location_name": case.get("location_name") or case.get("place_name") or case.get("city") or "UserLocation", "ayanamsa_policy": case.get("ayanamsa_policy") or case.get("ayanamsa") or "lahiri", "node_policy": case.get("node_policy") or case.get("node_mode") or "mean", } diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index 9c2c8a9a..4bdb965a 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -7,6 +7,7 @@ import base64 import json import os import sys +import time from io import BytesIO from pathlib import Path @@ -108,6 +109,28 @@ class _VedAstroStatusCaptureHandler(JyotishAPIHandler): return json.loads(self.wfile.getvalue().decode('utf-8')) +class _HighRigorJobCaptureHandler(JyotishAPIHandler): + def __init__(self, path: str) -> None: + self.headers = _FakeHeaders() + self.server = _FakeServer() + self.path = path + self.wfile = BytesIO() + self.status_code = None + self.response_headers = [] + + def send_response(self, code, message=None): # noqa: ANN001 + self.status_code = code + + def send_header(self, key, value): # noqa: ANN001 + self.response_headers.append((key, value)) + + def end_headers(self): + return None + + def payload(self) -> dict: + return json.loads(self.wfile.getvalue().decode('utf-8')) + + class _PostCaptureHandler(JyotishAPIHandler): def __init__(self, path: str, payload: dict) -> None: raw = json.dumps(payload).encode('utf-8') @@ -259,6 +282,359 @@ def test_chart_date_validation_rejects_impossible_date() -> None: handler._compute_chart({'year': 2026, 'month': 2, 'day': 31}) +def test_high_rigor_workflow_plan_only_exposes_official_hard_override_contract() -> None: + handler = _handler() + + result = handler._high_rigor_workflow_plan_only( + { + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + }, + ['career', 'marriage', 'wealth'], + [], + ) + + assert result['source_priority']['mode'] == 'vedastro_official_snapshot_first' + assert result['execution_plan'][-1] == 'return_official_primary_supplemental_fallback_conflict_contract' + + +def test_high_rigor_vedastro_official_summary_passes_through_contract_fields() -> None: + handler = _handler() + + chart = { + 'modules': { + 'vedastro_range_scan_result': { + 'status': 'ok', + 'event_count': 3, + 'official_full_snapshot': { + 'status': 'partial', + 'source_metadata': { + 'official_full_capability_catalog': {'status': 'partial', 'summary': {'catalog_method_count': 641}}, + }, + }, + 'source_metadata': { + 'official_full_capability_dynamic_selection': {}, + 'official_report_references': {}, + }, + } + }, + 'ai_prompt_pack': { + 'evidence_snapshot': { + 'vedastro_official_snapshot': { + 'status': 'partial', + 'official_primary_evidence': {'chart_core': {'status': 'ok'}}, + 'local_supplemental_evidence': {'narayana_current': {'role': 'required_local_supplement'}}, + 'fallback_used': ['local_chart_fallback'], + 'blocked_items': ['official_event_radar_partial'], + 'conflicts': [{'type': 'official_local_dasha_conflict'}], + } + } + }, + } + + result = handler._high_rigor_vedastro_official_summary(chart) + + assert result['official_primary_evidence']['chart_core']['status'] == 'ok' + assert result['local_supplemental_evidence']['narayana_current']['role'] == 'required_local_supplement' + assert result['fallback_used'] == ['local_chart_fallback'] + assert result['blocked_items'] == ['official_event_radar_partial'] + assert result['conflicts'] == [{'type': 'official_local_dasha_conflict'}] + + +def test_high_rigor_vedastro_official_summary_exposes_top_reader_contract_from_full_snapshot() -> None: + handler = _handler() + + chart = { + 'modules': { + 'vedastro_range_scan_result': { + 'status': 'partial', + 'event_count': 2, + 'source_metadata': { + 'official_full_capability_catalog_status': 'partial', + 'official_full_capability_catalog_summary': {'catalog_method_count': 641}, + }, + }, + 'vedastro_official_full_snapshot': { + 'status': 'partial', + 'available': True, + 'strict_workflow_primary_route': 'career', + 'strict_workflow_routes_available': ['career', 'relationship', 'finance'], + 'strict_workflow_contracts': { + 'career': { + 'question_type': 'career', + 'official_primary_evidence': {'chart_core': {'status': 'ok'}}, + 'local_supplemental_evidence': {'narayana_current': {'role': 'required_local_supplement'}}, + 'fallback_used': ['local_chart_fallback'], + 'blocked_items': ['official_event_radar_partial'], + 'conflicts': [{'type': 'official_local_dasha_conflict'}], + 'adjudication_stages': { + 'promise': {'status': 'present'}, + 'activation': { + 'status': 'present', + 'required_timing_systems': ['Vimshottari', 'Narayana'], + }, + }, + 'multi_reference_reading_summary': { + 'root_frame': {'signal': 'career_promise'}, + 'modifier_frame': {'functional_benefic_malefic': {'used': True}}, + }, + 'technique_audit_summary': { + 'functional_benefic_malefic': {'gate': 'hard', 'used': True}, + }, + 'verdict': 'high_probability_window', + 'dominant_label': 'career_status', + 'main_conflicts': [{'type': 'official_local_dasha_conflict'}], + } + }, + 'source_metadata': { + 'official_full_capability_catalog': { + 'status': 'partial', + 'summary': {'catalog_method_count': 641}, + } + }, + }, + }, + 'ai_prompt_pack': { + 'evidence_snapshot': { + 'vedastro_official_full_snapshot': { + 'status': 'partial', + 'strict_workflow_primary_route': 'career', + 'strict_workflow_routes_available': ['career', 'relationship', 'finance'], + 'strict_workflow_contracts': { + 'career': { + 'question_type': 'career', + 'adjudication_stages': { + 'promise': {'status': 'present'}, + 'activation': { + 'status': 'present', + 'required_timing_systems': ['Vimshottari', 'Narayana'], + }, + }, + 'multi_reference_reading_summary': { + 'root_frame': {'signal': 'career_promise'}, + 'modifier_frame': {'functional_benefic_malefic': {'used': True}}, + }, + 'technique_audit_summary': { + 'functional_benefic_malefic': {'gate': 'hard', 'used': True}, + }, + 'verdict': 'high_probability_window', + 'dominant_label': 'career_status', + 'main_conflicts': [{'type': 'official_local_dasha_conflict'}], + 'official_primary_evidence': {'chart_core': {'status': 'ok'}}, + 'local_supplemental_evidence': {'narayana_current': {'role': 'required_local_supplement'}}, + 'fallback_used': ['local_chart_fallback'], + 'blocked_items': ['official_event_radar_partial'], + 'conflicts': [{'type': 'official_local_dasha_conflict'}], + } + }, + } + } + }, + } + + result = handler._high_rigor_vedastro_official_summary(chart) + contract = result['strict_workflow_contracts']['career'] + + assert result['strict_workflow_primary_route'] == 'career' + assert result['strict_workflow_routes_available'] == ['career', 'relationship', 'finance'] + assert contract['adjudication_stages']['activation']['required_timing_systems'] == ['Vimshottari', 'Narayana'] + assert contract['multi_reference_reading_summary']['modifier_frame']['functional_benefic_malefic']['used'] is True + assert result['technique_audit_summary']['functional_benefic_malefic']['gate'] == 'hard' + assert result['adjudication_stages']['promise']['status'] == 'present' + assert result['multi_reference_reading_summary']['root_frame']['signal'] == 'career_promise' + assert result['verdict'] == 'high_probability_window' + assert result['dominant_label'] == 'career_status' + assert result['main_conflicts'] == [{'type': 'official_local_dasha_conflict'}] + + +def test_api_prompt_pack_official_snapshot_carries_strict_workflow_contracts() -> None: + handler = _handler() + + chart = { + 'birth': {'ayanamsa_display': 'Raman', 'ayanamsa_name': 'raman', 'node_mode': 'mean'}, + 'ascendant': {'sign': 'Leo'}, + 'planets': {'Moon': {'sign': 'Virgo'}}, + 'dasha': {'current_md': 'Saturn'}, + 'modules': { + 'vedastro_official_full_snapshot': { + 'status': 'partial', + 'available': True, + 'operation': 'official_full_snapshot', + 'primary_source': 'vedastro_official', + 'strict_workflow_primary_route': 'relationship', + 'strict_workflow_routes_available': ['relationship', 'career', 'finance'], + 'strict_workflow_contracts': { + 'relationship': { + 'question_type': 'relationship', + 'official_primary_evidence': {'chart_core': {'status': 'ok'}}, + 'local_supplemental_evidence': {'upapada_lagna': {'present': True}}, + 'fallback_used': [], + 'blocked_items': [], + 'conflicts': [], + } + }, + 'source_metadata': {}, + } + }, + } + + prompt_pack = handler._build_chart_prompt_pack(chart) + official = prompt_pack['evidence_snapshot']['vedastro_official_full_snapshot'] + + assert official['strict_workflow_primary_route'] == 'relationship' + assert official['strict_workflow_routes_available'] == ['relationship', 'career', 'finance'] + assert official['strict_workflow_contracts']['relationship']['official_primary_evidence']['chart_core']['status'] == 'ok' + + +def test_consultation_workflow_surfaces_top_reader_contract_in_official_summary(monkeypatch) -> None: + handler = _handler() + + fake_chart = { + 'success': True, + 'birth_info': {'date': 'REDACTED_DATE', 'time': 'REDACTED_TIME', 'tz': 8}, + 'special_lagnas': {'precision': 'sunrise_correct'}, + 'chart': { + 'ascendant': {'lon': 92.0, 'sign': 'Cancer'}, + 'planets': _sample_planets(), + }, + 'modules': { + 'vedastro_range_scan_result': { + 'backend': 'vedastro_service_adapter_candidate', + 'status': 'partial', + 'event_count': 1, + 'source_metadata': { + 'official_full_capability_catalog_status': 'partial', + 'official_full_capability_catalog_summary': { + 'catalog_method_count': 641, + 'executed_method_count': 0, + }, + }, + }, + 'vedastro_official_full_snapshot': { + 'status': 'partial', + 'available': True, + 'strict_workflow_primary_route': 'career', + 'strict_workflow_routes_available': ['career', 'relationship', 'finance'], + 'strict_workflow_contracts': { + 'career': { + 'question_type': 'career', + 'official_primary_evidence': {'chart_core': {'status': 'ok'}}, + 'local_supplemental_evidence': {'narayana_current': {'role': 'required_local_supplement'}}, + 'fallback_used': [], + 'blocked_items': ['official_event_radar_partial'], + 'conflicts': [{'type': 'official_local_dasha_conflict'}], + 'adjudication_stages': { + 'promise': {'status': 'present'}, + 'activation': { + 'status': 'present', + 'required_timing_systems': ['Vimshottari', 'Narayana'], + }, + }, + 'multi_reference_reading_summary': { + 'root_frame': {'signal': 'career_promise'}, + 'modifier_frame': {'functional_benefic_malefic': {'used': True}}, + }, + 'technique_audit_summary': { + 'functional_benefic_malefic': {'gate': 'hard', 'used': True}, + }, + 'verdict': 'high_probability_window', + 'dominant_label': 'career_status', + 'main_conflicts': [{'type': 'official_local_dasha_conflict'}], + } + }, + 'source_metadata': { + 'official_full_capability_catalog': { + 'status': 'partial', + 'summary': {'catalog_method_count': 641}, + } + }, + }, + }, + 'ai_prompt_pack': { + 'evidence_snapshot': { + 'vedastro_official_full_snapshot': { + 'status': 'partial', + 'strict_workflow_primary_route': 'career', + 'strict_workflow_routes_available': ['career', 'relationship', 'finance'], + 'strict_workflow_contracts': { + 'career': { + 'question_type': 'career', + 'official_primary_evidence': {'chart_core': {'status': 'ok'}}, + 'local_supplemental_evidence': {'narayana_current': {'role': 'required_local_supplement'}}, + 'fallback_used': [], + 'blocked_items': ['official_event_radar_partial'], + 'conflicts': [{'type': 'official_local_dasha_conflict'}], + 'adjudication_stages': { + 'promise': {'status': 'present'}, + 'activation': { + 'status': 'present', + 'required_timing_systems': ['Vimshottari', 'Narayana'], + }, + }, + 'multi_reference_reading_summary': { + 'root_frame': {'signal': 'career_promise'}, + 'modifier_frame': {'functional_benefic_malefic': {'used': True}}, + }, + 'technique_audit_summary': { + 'functional_benefic_malefic': {'gate': 'hard', 'used': True}, + }, + 'verdict': 'high_probability_window', + 'dominant_label': 'career_status', + 'main_conflicts': [{'type': 'official_local_dasha_conflict'}], + } + }, + } + }, + }, + } + + monkeypatch.setattr(handler, '_compute_chart', lambda body: fake_chart) + monkeypatch.setattr(handler, '_compute_rectification_gate', lambda body: { + 'success': True, + 'endpoint': 'rectification_gate', + 'summary': {'recommended_events': ['career_change']}, + }) + monkeypatch.setattr(handler, '_compute_thematic_report', lambda body: { + 'success': True, + 'endpoint': 'thematic_report', + 'mode': 'derived_chart_evidence', + 'theme_count': len(body.get('theme') or []), + 'themes': {theme: {'summary': f'{theme} report'} for theme in body.get('theme') or []}, + }) + monkeypatch.setattr(handler, '_run_high_rigor_historical_backtest', lambda birth, events: { + 'scope': 'historical_event_backtest', + 'summary': {'total_events': len(events)}, + 'events': events, + }) + + result = handler._compute_consultation_workflow({ + 'entry_mode': 'direct_chart', + 'question': '请直接排盘并重点看事业', + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + 'theme': ['career'], + }) + + contract = result['vedastro_official']['strict_workflow_contracts']['career'] + assert result['vedastro_official']['strict_workflow_primary_route'] == 'career' + assert contract['adjudication_stages']['activation']['required_timing_systems'] == ['Vimshottari', 'Narayana'] + assert contract['multi_reference_reading_summary']['root_frame']['signal'] == 'career_promise' + assert result['vedastro_official']['technique_audit_summary']['functional_benefic_malefic']['gate'] == 'hard' + assert result['vedastro_official']['dominant_label'] == 'career_status' + + def test_synastry_rejects_non_numeric_moon_degree() -> None: handler = _handler() with pytest.raises(BadRequest, match='male_moon must be a number'): @@ -761,6 +1137,37 @@ def test_report_artifact_relationship_strict_narrative_surfaces_weak_core_promis assert 'relationship-caution' in html +def test_report_artifact_can_render_career_and_finance_strict_narrative_summary() -> None: + handler = _handler() + result = handler._compute_report_artifact({ + 'format': 'html', + 'name': 'career-finance-strict-report', + 'html': '

    Jyotish

    ', + 'career_narrative': { + 'headline': '事业严格裁决已接入主链,当前结论将强制引用本命 promise、双重大运、官方时间窗与结构阻力。', + 'strengths': ['月度主状态:机会进入。', '落地形式:职位/项目/公开职责抬头。'], + 'risks': ['阻力来源:功能性凶星与结构摩擦仍在。'], + 'boundaries': ['时间置信度:以月级为主,日级只作辅助。'], + }, + 'finance_narrative': { + 'headline': '财富严格裁决已接入主链,当前结论会强制区分收入兑现、现金流动作与风险摩擦。', + 'strengths': ['月度主状态:收入兑现。', '落地形式:定金/回款/短期现金流改善。'], + 'risks': ['阻力来源:波动性收入,不宜过度放大利润预期。'], + 'boundaries': ['时间置信度:以兑现窗口而非全年静态判断为主。'], + }, + }) + + assert result['success'] is True + html = Path(result['html_path']).read_text(encoding='utf-8') + assert 'Career Strict Narrative' in html + assert '事业严格裁决已接入主链' in html + assert '月度主状态:机会进入' in html + assert '阻力来源:功能性凶星与结构摩擦仍在' in html + assert 'Finance Strict Narrative' in html + assert '财富严格裁决已接入主链' in html + assert '落地形式:定金/回款/短期现金流改善' in html + + def test_report_artifact_pdf_fallback_exposes_user_visible_delivery(monkeypatch) -> None: class BrokenReportBuilder: @staticmethod @@ -1046,6 +1453,18 @@ def test_thematic_report_declares_orchestrator_fragments() -> None: assert 'marriage' in result['workflow_orchestration']['selected_report_themes'] +def test_technique_catalog_exposes_high_rigor_workflow_entrypoint() -> None: + handler = _handler() + catalog = handler._technique_catalog() + + assert '/api/high_rigor_workflow' in catalog['filters']['api_endpoints'] + assert catalog['example_payloads']['/api/high_rigor_workflow']['theme'] == ['career', 'marriage', 'wealth'] + example = handler._compute_technique_example({'endpoint': '/api/high_rigor_workflow'}) + assert example['target_endpoint'] == '/api/high_rigor_workflow' + assert example['result']['endpoint'] == 'high_rigor_workflow' + assert example['result']['mode'] == 'plan_only_no_external_calls' + + def test_thematic_report_derives_evidence_from_birth_payload() -> None: handler = _handler() result = handler._compute_thematic_report({ @@ -1106,6 +1525,146 @@ def test_thematic_report_derives_relationship_strict_narrative_evidence() -> Non assert 'legal_marriage' in strict_note or '婚恋' in strict_note +def test_thematic_report_derives_career_and_finance_strict_narrative_evidence() -> None: + handler = _handler() + result = handler._compute_thematic_report({ + 'theme': ['career', 'wealth'], + 'year': 1990, + 'month': 1, + 'day': 1, + 'hour': 12, + 'minute': 0, + 'lat': 39.9, + 'lon': 116.4, + 'tz': 8, + }) + + assert result['success'] is True + career_evidence = result['themes']['career']['evidence'] + finance_evidence = result['themes']['wealth']['evidence'] + career_rows = [ + item for item in career_evidence + if item['details'].get('source') == 'full_reading.modules.career_strict_evidence.user_narrative' + ] + finance_rows = [ + item for item in finance_evidence + if item['details'].get('source') == 'full_reading.modules.finance_strict_evidence.user_narrative' + ] + assert career_rows + assert finance_rows + assert '月度主状态' in career_rows[0]['conclusion'] + assert 'D10' in career_rows[0]['conclusion'] or '事业' in career_rows[0]['conclusion'] + assert '月度主状态' in finance_rows[0]['conclusion'] + assert 'D2' in finance_rows[0]['conclusion'] or '财富' in finance_rows[0]['conclusion'] + + +def test_thematic_report_final_chinese_summary_and_narrative_force_monthly_adjudication_layers() -> None: + handler = _handler() + result = handler._compute_thematic_report({ + 'theme': ['marriage', 'career', 'wealth'], + 'year': 1990, + 'month': 1, + 'day': 1, + 'hour': 12, + 'minute': 0, + 'lat': 39.9, + 'lon': 116.4, + 'tz': 8, + }) + + assert result['success'] is True + marriage = result['themes']['marriage'] + career = result['themes']['career'] + wealth = result['themes']['wealth'] + + for payload in (marriage, career, wealth): + assert '月度主状态' in payload['summary'] + assert '落地形式' in payload['summary'] + assert '阻力来源' in payload['narrative'] + assert '时间置信度' in payload['narrative'] + + +def test_apply_monthly_adjudication_to_theme_report_injects_four_layers_into_final_chinese_fields() -> None: + handler = _handler() + payload = { + 'summary': '事业格局整体积极向好。', + 'narrative': '事业维度上,本命 promise 与 D10 形成交叉支持。', + 'evidence': [ + { + 'technique': 'Career-strict-narrative', + 'details': { + 'monthly_frame': { + 'primary_state': {'value': '推进'}, + 'manifestation_mode': {'value': '职位/项目/职责抬头'}, + 'friction_source': {'value': '流程卡顿但机会仍在'}, + 'time_confidence': {'value': 'month_supported'}, + } + }, + } + ], + 'recommendations': ['原始建议一。'], + } + + result = handler._apply_monthly_adjudication_to_theme_report('career', payload) + + assert '月度主状态:进入可主动推进窗口。' in result['summary'] + assert '落地形式:更像职位、项目或职责开始抬头。' in result['summary'] + assert '阻力来源:机会未消失,但流程、对接或资源节奏会更磨人。' in result['narrative'] + assert '时间置信度:以月份判断最稳,具体日期只能作辅助观察。' in result['narrative'] + assert any('月度主状态:进入可主动推进窗口。' in item for item in result['recommendations']) + assert any('阻力来源:机会未消失,但流程、对接或资源节奏会更磨人。' in item for item in result['recommendations']) + assert any('本轮重点拆成:角色定位、项目合作、组织权责、迁移动向。' in item for item in result['recommendations']) + assert result['monthly_adjudication_summary']['primary_state']['value'] == '推进' + assert result['monthly_adjudication_summary_humanized']['time_confidence'] == '以月份判断最稳,具体日期只能作辅助观察。' + assert result['interpretation_axes'][0]['axis'] == '角色定位' + assert 'judgement' in result['interpretation_axes'][0] + assert '第10宫' in result['interpretation_axes'][0]['judgement'] + assert '进入可主动推进窗口' in result['interpretation_axes'][0]['judgement'] + assert '以月份判断最稳' in result['interpretation_axes'][0]['judgement'] + assert result['narrative_contract']['monthly_frame_applied'] is True + + +def test_thematic_report_interpretation_axes_are_strict_paragraphs_for_each_theme() -> None: + handler = _handler() + result = handler._compute_thematic_report({ + 'theme': ['marriage', 'career', 'wealth'], + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + }) + + assert result['success'] is True + career_axes = result['themes']['career']['interpretation_axes'] + marriage_axes = result['themes']['marriage']['interpretation_axes'] + wealth_axes = result['themes']['wealth']['interpretation_axes'] + career_bundle = result['themes']['career']['strict_adjudication_bundle'] + + assert len(career_axes) >= 4 + assert len(marriage_axes) >= 4 + assert len(wealth_axes) >= 4 + + assert career_bundle['interpretation_axes'][0]['axis'] == '角色定位' + assert career_bundle['monthly_adjudication_summary']['primary_state']['value'] + assert 'strict_audit_gate' in career_bundle + assert career_axes[0]['axis'] == '角色定位' + assert 'judgement' in career_axes[0] + assert '第10宫' in career_axes[0]['judgement'] + assert '时间边界' in career_axes[0]['judgement'] + + assert marriage_axes[0]['axis'] == '关系推进' + assert '第7宫' in marriage_axes[0]['judgement'] or 'D9' in marriage_axes[0]['judgement'] + assert '时间边界' in marriage_axes[0]['judgement'] + + assert wealth_axes[0]['axis'] == '收入兑现' + assert '第2宫' in wealth_axes[0]['judgement'] or '第11宫' in wealth_axes[0]['judgement'] + assert '时间边界' in wealth_axes[0]['judgement'] + + def test_fragment_audit_blocks_registry_surface_drift() -> None: sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts')) from audit_fragments import audit @@ -1243,6 +1802,537 @@ def test_rectification_gate_returns_varga_risk_summary() -> None: assert result['summary']['recommended_events'] +def test_high_rigor_workflow_reuses_existing_rectification_backtest_and_vedastro_layers(monkeypatch) -> None: + handler = _handler() + + fake_chart = { + 'success': True, + 'birth_info': {'date': 'REDACTED_DATE', 'time': 'REDACTED_TIME', 'tz': 8}, + 'chart': { + 'ascendant': {'lon': 92.0, 'sign': 'Cancer'}, + 'planets': _sample_planets(), + }, + 'modules': { + 'vedastro_range_scan_result': { + 'backend': 'vedastro_service_adapter_candidate', + 'status': 'partial', + 'event_count': 2, + 'source_metadata': { + 'official_full_capability_catalog_status': 'partial', + 'official_full_capability_catalog_summary': { + 'catalog_method_count': 641, + 'executed_method_count': 0, + }, + 'official_full_capability_domain_routing': { + 'career': {'auto_method_count': 298, 'high_priority_methods': ['DasaAtRange']}, + }, + 'official_full_capability_dynamic_selection': { + 'career': { + 'selected_methods': [ + {'method': 'SearchEvents', 'citation_id': 'vedastro:career:SearchEvents'}, + ], + 'report_reference': { + 'theme': 'career', + 'citation_ids': ['vedastro:career:SearchEvents'], + 'auto_count': 1, + }, + }, + }, + 'official_report_references': { + 'career': {'citation_ids': ['vedastro:career:SearchEvents'], 'auto_count': 1}, + }, + }, + 'official_full_snapshot': { + 'status': 'partial', + 'source_metadata': { + 'official_full_capability_catalog': { + 'status': 'partial', + 'summary': {'catalog_method_count': 641}, + 'domain_routing': { + 'career': {'auto_method_count': 298, 'high_priority_methods': ['DasaAtRange']}, + }, + 'dynamic_selection': { + 'career': { + 'selected_methods': [ + {'method': 'SearchEvents', 'citation_id': 'vedastro:career:SearchEvents'}, + ], + 'report_reference': { + 'theme': 'career', + 'citation_ids': ['vedastro:career:SearchEvents'], + 'auto_count': 1, + }, + }, + }, + }, + }, + }, + }, + }, + 'ai_prompt_pack': { + 'evidence_snapshot': { + 'vedastro_official_snapshot': { + 'official_full_capability_catalog_summary': { + 'catalog_method_count': 641, + }, + 'official_full_capability_domain_routing': { + 'career': {'auto_method_count': 298, 'high_priority_methods': ['DasaAtRange']}, + }, + 'official_full_capability_dynamic_selection': { + 'career': { + 'selected_methods': [ + {'method': 'SearchEvents', 'citation_id': 'vedastro:career:SearchEvents'}, + ], + 'report_reference': { + 'theme': 'career', + 'citation_ids': ['vedastro:career:SearchEvents'], + 'auto_count': 1, + }, + }, + }, + 'official_report_references': { + 'career': {'citation_ids': ['vedastro:career:SearchEvents'], 'auto_count': 1}, + }, + }, + }, + }, + } + + monkeypatch.setattr(handler, '_compute_chart', lambda body: fake_chart) + monkeypatch.setattr(handler, '_compute_rectification_gate', lambda body: { + 'success': True, + 'endpoint': 'rectification_gate', + 'summary': {'recommended_events': ['career_change', 'relocation']}, + }) + monkeypatch.setattr(handler, '_compute_thematic_report', lambda body: { + 'success': True, + 'endpoint': 'thematic_report', + 'mode': 'derived_chart_evidence', + 'theme_count': len(body.get('theme') or []), + 'themes': {theme: {'summary': f'{theme} report'} for theme in body.get('theme') or []}, + }) + + class FakeBacktest: + @staticmethod + def build_report(payload): + return { + 'scope': 'historical_event_backtest', + 'summary': {'total_events': len(payload['events']), 'strong_hits': 1}, + 'events': payload['events'], + } + + def fake_loader(name): + if name == 'historical_event_backtest': + return FakeBacktest + return _load_local_module(name) + + monkeypatch.setattr(jyotish_api_server, '_load_local_module', fake_loader) + + result = handler._compute_high_rigor_workflow({ + 'question': '请高严谨分析我的事业、婚恋和财富', + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + 'events': [ + {'id': 'career_turn_2019', 'date': '2019-12-15', 'domain': 'career'}, + {'id': 'project_end_2025', 'date': '2025-02-28', 'domain': 'wealth'}, + ], + }) + + assert result['success'] is True + assert result['endpoint'] == 'high_rigor_workflow' + assert result['reused_modules'] == [ + 'vedastro_evidence_orchestrator', + 'birth_time_rectifier', + 'historical_event_backtest', + 'report_orchestrator', + 'reading_orchestrator', + 'orchestrator_bridge', + ] + assert result['source_priority']['mode'] == 'vedastro_official_snapshot_first' + assert result['vedastro_official']['official_full_capability_catalog_summary']['catalog_method_count'] == 641 + assert result['vedastro_official']['official_full_capability_domain_routing']['career']['auto_method_count'] == 298 + assert result['vedastro_official']['official_report_references']['career']['citation_ids'] == ['vedastro:career:SearchEvents'] + assert result['rectification']['endpoint'] == 'rectification_gate' + assert result['historical_event_backtest']['summary']['total_events'] == 2 + assert result['thematic_report']['mode'] == 'derived_chart_evidence' + assert result['routes'] == ['career', 'relationship', 'finance'] + assert result['unified_orchestrator']['name'] == 'UnifiedConsultationOrchestrator' + assert result['unified_orchestrator']['surface'] == 'api_web' + assert result['unified_orchestrator']['route']['question_type'] == 'career' + + +def test_consultation_workflow_uses_unified_orchestrator_contract(monkeypatch) -> None: + handler = _handler() + + fake_chart = { + 'success': True, + 'birth_info': {'date': 'REDACTED_DATE', 'time': 'REDACTED_TIME', 'tz': 8}, + 'special_lagnas': {'precision': 'sunrise_correct'}, + 'chart': { + 'ascendant': {'lon': 92.0, 'sign': 'Cancer'}, + 'planets': _sample_planets(), + }, + 'modules': { + 'vedastro_range_scan_result': { + 'backend': 'vedastro_service_adapter_candidate', + 'status': 'partial', + 'event_count': 1, + 'source_metadata': { + 'official_full_capability_catalog_status': 'partial', + 'official_full_capability_catalog_summary': { + 'catalog_method_count': 641, + 'executed_method_count': 0, + }, + }, + }, + }, + 'ai_prompt_pack': { + 'evidence_snapshot': { + 'vedastro_official_snapshot': { + 'official_full_capability_catalog_summary': { + 'catalog_method_count': 641, + }, + }, + }, + }, + } + + monkeypatch.setattr(handler, '_compute_chart', lambda body: fake_chart) + monkeypatch.setattr(handler, '_compute_rectification_gate', lambda body: { + 'success': True, + 'endpoint': 'rectification_gate', + 'summary': {'recommended_events': ['career_change']}, + }) + monkeypatch.setattr(handler, '_compute_thematic_report', lambda body: { + 'success': True, + 'endpoint': 'thematic_report', + 'mode': 'derived_chart_evidence', + 'theme_count': len(body.get('theme') or []), + 'themes': {theme: {'summary': f'{theme} report'} for theme in body.get('theme') or []}, + }) + + class FakeBacktest: + @staticmethod + def build_report(payload): + return { + 'scope': 'historical_event_backtest', + 'summary': {'total_events': len(payload['events']), 'strong_hits': 0}, + 'events': payload['events'], + } + + def fake_loader(name): + if name == 'historical_event_backtest': + return FakeBacktest + return _load_local_module(name) + + monkeypatch.setattr(jyotish_api_server, '_load_local_module', fake_loader) + + result = handler._compute_consultation_workflow({ + 'entry_mode': 'direct_chart', + 'question': '请直接排盘并重点看事业', + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + 'theme': ['career'], + }) + + assert result['success'] is True + assert result['endpoint'] == 'consultation_workflow' + assert result['entry_mode'] == 'direct_chart' + assert result['routing']['question_type'] == 'career' + assert result['unified_orchestrator']['name'] == 'UnifiedConsultationOrchestrator' + assert result['unified_orchestrator']['surface'] == 'api_web' + assert result['runtime_planner']['planner_name'] == 'UnifiedConsultationRuntimePlanner' + assert result['runtime_planner']['entry_mode'] == 'direct_chart' + assert result['runtime_planner']['route']['question_type'] == 'career' + assert result['runtime_planner']['sync_steps'][0] == 'compute_chart' + assert result['runtime_planner']['executed_steps'] == [ + 'compute_chart', + 'run_rectification_gate', + 'run_thematic_report', + ] + assert result['runtime_planner']['skipped_steps'] == ['run_historical_event_backtest'] + assert result['source_priority']['mode'] == 'vedastro_official_snapshot_first' + assert result['chart']['special_lagnas']['precision'] == 'sunrise_correct' + + +def test_consultation_workflow_reuses_chart_data_for_thematic_report_without_recursive_full_reading(monkeypatch) -> None: + handler = _handler() + fake_chart = { + 'success': True, + 'birth_info': {'date': 'REDACTED_DATE', 'time': 'REDACTED_TIME', 'tz': 8}, + 'ascendant': {'lon': 92.0, 'sign': 'Cancer'}, + 'planets': _sample_planets(), + 'chart': { + 'ascendant': {'lon': 92.0, 'sign': 'Cancer'}, + 'planets': _sample_planets(), + }, + 'modules': {}, + 'special_lagnas': {'precision': 'sunrise_correct'}, + } + + seen = {} + + monkeypatch.setattr(handler, '_compute_chart', lambda body: fake_chart) + monkeypatch.setattr(handler, '_compute_rectification_gate', lambda body: { + 'success': True, + 'endpoint': 'rectification_gate', + 'summary': {'recommended_events': []}, + }) + monkeypatch.setattr(handler, '_run_high_rigor_historical_backtest', lambda birth, events: { + 'scope': 'historical_event_backtest', + 'summary': {'total_events': 0}, + 'events': [], + }) + + def fake_thematic_report(body): + seen['body'] = dict(body) + return { + 'success': True, + 'endpoint': 'thematic_report', + 'mode': 'upstream_contract_reuse', + 'evidence_source': { + 'mode': 'upstream_contract_reuse', + 'source': 'consultation_workflow_upstream_contract', + }, + 'themes': {}, + 'theme_count': len(body.get('theme') or []), + } + + monkeypatch.setattr(handler, '_compute_thematic_report', fake_thematic_report) + + result = handler._compute_consultation_workflow({ + 'entry_mode': 'direct_chart', + 'question': '请直接排盘并进入互动解盘', + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + 'theme': ['career', 'marriage', 'wealth'], + }) + + assert result['success'] is True + assert seen['body']['chart_data']['birth_info']['date'] == 'REDACTED_DATE' + assert seen['body']['chart_data']['ascendant']['sign'] == 'Cancer' + assert seen['body']['skip_full_reading_for_thematic'] is True + assert 'upstream_contract' in seen['body'] + assert 'strict_workflow_contracts' in seen['body']['upstream_contract'] + assert result['runtime_planner']['executed_steps'] == [ + 'compute_chart', + 'run_rectification_gate', + 'run_thematic_report', + ] + assert result['runtime_planner']['skipped_steps'] == ['run_historical_event_backtest'] + + +def test_consultation_workflow_rectification_entry_reuses_chart_without_duplicate_compute(monkeypatch) -> None: + handler = _handler() + fake_chart = { + 'success': True, + 'birth_info': {'date': 'REDACTED_DATE', 'time': 'REDACTED_TIME', 'tz': 8}, + 'ascendant': {'lon': 92.0, 'sign': 'Cancer'}, + 'planets': _sample_planets(), + 'chart': { + 'ascendant': {'lon': 92.0, 'sign': 'Cancer'}, + 'planets': _sample_planets(), + }, + 'modules': {}, + 'special_lagnas': {'precision': 'sunrise_correct'}, + } + + calls = {'count': 0} + seen = {} + + def fake_chart_compute(body): + calls['count'] += 1 + return fake_chart + + monkeypatch.setattr(handler, '_compute_chart', fake_chart_compute) + monkeypatch.setattr(handler, '_compute_rectification_gate', lambda body: { + 'success': True, + 'endpoint': 'rectification_gate', + 'summary': {'recommended_events': ['marriage', 'career_change']}, + }) + monkeypatch.setattr(handler, '_run_high_rigor_historical_backtest', lambda birth, events: { + 'scope': 'historical_event_backtest', + 'summary': {'total_events': 0}, + 'events': [], + }) + + def fake_thematic_report(body): + seen['body'] = dict(body) + return { + 'success': True, + 'endpoint': 'thematic_report', + 'mode': 'upstream_contract_reuse', + 'themes': {theme: {'summary': f'{theme} report'} for theme in body.get('theme') or []}, + 'theme_count': len(body.get('theme') or []), + } + + monkeypatch.setattr(handler, '_compute_thematic_report', fake_thematic_report) + + result = handler._compute_consultation_workflow({ + 'entry_mode': 'rectification', + 'question': '先做生时校正,再看婚恋', + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + 'theme': ['marriage'], + }) + + assert result['success'] is True + assert result['entry_mode'] == 'rectification' + assert result['runtime_planner']['entry_mode'] == 'rectification' + assert result['runtime_planner']['executed_steps'] == [ + 'run_rectification_gate', + 'compute_chart', + 'run_thematic_report', + ] + assert calls['count'] == 1 + assert seen['body']['chart_data']['birth_info']['date'] == 'REDACTED_DATE' + assert seen['body']['skip_full_reading_for_thematic'] is True + + +def test_consultation_workflow_rectification_entry_sends_empty_objects_before_chart(monkeypatch) -> None: + handler = _handler() + fake_chart = { + 'success': True, + 'birth_info': {'date': 'REDACTED_DATE', 'time': 'REDACTED_TIME', 'tz': 8}, + 'ascendant': {'lon': 92.0, 'sign': 'Cancer'}, + 'planets': _sample_planets(), + 'modules': {}, + } + + seen = {} + + monkeypatch.setattr(handler, '_compute_chart', lambda body: fake_chart) + + def fake_rectification_gate(body): + seen['rectification_body'] = dict(body) + return { + 'success': True, + 'endpoint': 'rectification_gate', + 'summary': {'recommended_events': []}, + } + + monkeypatch.setattr(handler, '_compute_rectification_gate', fake_rectification_gate) + monkeypatch.setattr(handler, '_compute_thematic_report', lambda body: { + 'success': True, + 'endpoint': 'thematic_report', + 'mode': 'derived_chart_evidence', + 'theme_count': len(body.get('theme') or []), + }) + + result = handler._compute_consultation_workflow({ + 'entry_mode': 'rectification', + 'question': '先做生时校正,再看事业', + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + 'theme': ['career'], + }) + + assert result['success'] is True + assert result['runtime_planner']['executed_steps'][0] == 'run_rectification_gate' + assert seen['rectification_body']['planets'] == {} + assert seen['rectification_body']['ascendant'] == {} + + +def test_thematic_report_handles_missing_dasa_convergence_without_crash(monkeypatch) -> None: + handler = _handler() + + monkeypatch.setattr(handler, '_derive_thematic_evidence', lambda raw, report_orchestrator: { + 'chart_data': { + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0, 'sign': 'Cancer'}, + 'houses': {}, + 'dasha': {}, + 'yogas': [], + 'ashtakavarga': {}, + }, + 'evidence': { + 'career': [ + { + 'technique': 'career_test', + 'chart': 'D1', + 'conclusion': 'career ok', + 'sentiment': 'positive', + 'strength': 'moderate', + 'details': {'source': 'test'}, + } + ], + 'marriage': [], + 'wealth': [], + 'health': [], + 'spirituality': [], + }, + 'module_status': {'full_reading': 'skipped_reuse_chart_data'}, + 'warnings': [], + 'evidence_counts': {'career': 1, 'marriage': 0, 'wealth': 0, 'health': 0, 'spirituality': 0}, + 'full_reading_used': False, + 'full_reading_summary': {}, + 'full_reading_module_count': 0, + }) + + result = handler._compute_thematic_report({ + 'theme': ['career'], + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + }) + + assert result['success'] is True + assert result['mode'] == 'derived_chart_evidence' + assert result['themes']['career']['evidence'] + + +def test_derived_career_evidence_handles_none_top_convergent_domains() -> None: + handler = _handler() + items = handler._derived_career_evidence( + { + 'planets': _sample_planets(), + 'ascendant': {'sign': 'Cancer'}, + 'houses': {}, + }, + { + 'career': {'summary': 'career ok'}, + 'shadbala': {'planets': {'Sun': {'rupas': 5.0}}}, + 'full_modules': {'dasa_convergence': {'top_convergent_domains': None}}, + }, + ) + + assert items + + def test_case_validation_endpoint_returns_evidence_summary() -> None: handler = _handler() result = handler._compute_case_validation({ @@ -1603,6 +2693,16 @@ def test_chart_ai_prompt_pack_exposes_functional_benefic_malefic_layer() -> None assert vedastro['source'] == 'vedastro_service_adapter_candidate' assert vedastro['ingestion_profile'] == 'main_entry_overview' assert vedastro['visibility'] == 'user_visible_overview_only' + official = prompt_pack['evidence_snapshot']['vedastro_official_full_snapshot'] + assert official['primary_source'] == 'vedastro_official' + assert 'official_python_path' in official + assert 'official_bundle_status' in official + assert 'official_chart_available' in official + assert 'official_full_capability_catalog_status' in official + assert 'official_full_capability_catalog_summary' in official + assert 'official_full_capability_domain_routing' in official + assert 'official_full_capability_dynamic_selection' in official + assert 'official_report_references' in official def test_chart_auto_attaches_vedastro_main_entry_boundary(monkeypatch: pytest.MonkeyPatch) -> None: @@ -1626,9 +2726,290 @@ def test_chart_auto_attaches_vedastro_main_entry_boundary(monkeypatch: pytest.Mo vedastro = result["modules"]["vedastro_range_scan_result"] assert vedastro["backend"] == "vedastro_service_adapter_candidate" assert vedastro["status"] == "network_execution_disabled" - assert vedastro["source_metadata"]["ingestion_profile"] == "main_entry_overview" - assert vedastro["source_metadata"]["reference_date"] - assert sorted(vedastro["source_metadata"]["domain_statuses"]) == ["career", "marriage", "wealth"] + + +def test_api_chart_response_cache_reuses_cached_value(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "600") + monkeypatch.delenv("VEDASTRO_API_ENDPOINT", raising=False) + monkeypatch.delenv("VEDASTRO_ENABLE_NETWORK", raising=False) + payload = { + 'year': 1990, + 'month': 6, + 'day': 15, + 'hour': 12, + 'minute': 0, + 'second': 0, + 'lat': 39.9, + 'lon': 116.4, + 'tz': 8, + 'ayanamsa': 'lahiri', + 'node_mode': 'mean', + 'today': '2026-06-30', + 'transit_date': '2026-06-30', + } + cache_payload = jyotish_api_server._build_api_chart_cache_payload(payload) + stored = jyotish_api_server._store_api_chart_response_cache( + cache_payload, + {'success': True, 'modules': {'chart': {'planets': {}, 'ascendant': {}}}}, + ) + cached = jyotish_api_server._load_api_chart_response_cache(cache_payload) + + assert stored['runtime_cache']['scope'] == 'api_chart_response' + assert stored['runtime_cache']['cache_hit'] is False + assert cached is not None + assert cached['runtime_cache']['cache_hit'] is True + assert cached['runtime_cache']['cache_key'] == stored['runtime_cache']['cache_key'] + + +def test_api_chart_cache_key_tracks_vedastro_runtime_state(monkeypatch: pytest.MonkeyPatch) -> None: + payload = { + 'year': 1990, + 'month': 6, + 'day': 15, + 'hour': 12, + 'minute': 0, + 'second': 0, + 'lat': 39.9, + 'lon': 116.4, + 'tz': 8, + 'ayanamsa': 'lahiri', + 'node_mode': 'mean', + 'today': '2026-06-30', + 'transit_date': '2026-06-30', + } + monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://vedastro.example.test/api") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0") + key_disabled = jyotish_api_server._api_chart_cache_key( + jyotish_api_server._build_api_chart_cache_payload(payload) + ) + + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") + key_enabled = jyotish_api_server._api_chart_cache_key( + jyotish_api_server._build_api_chart_cache_payload(payload) + ) + + assert key_disabled != key_enabled + + +def test_high_rigor_plan_only_surfaces_chart_cache_and_queue_strategy() -> None: + handler = _handler() + + result = handler._high_rigor_workflow_plan_only( + { + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.4467, + 'lon': 114.2, + 'tz': 8, + }, + ['career'], + [], + ) + + strategy = result['execution_strategy'] + assert strategy['chart_path']['mode'] == 'sync_chart_response_cache' + assert strategy['chart_path']['cache_scope'] == 'api_chart_response' + assert strategy['queue_recommendation']['recommended'] is True + assert strategy['queue_recommendation']['lane'] == 'high_rigor_workflow' + + +def test_high_rigor_async_submit_returns_job_id(monkeypatch: pytest.MonkeyPatch) -> None: + handler = _handler() + + monkeypatch.setattr(handler, '_enqueue_high_rigor_job', lambda body: { + 'success': True, + 'endpoint': 'high_rigor_workflow_async', + 'mode': 'async_submitted', + 'job_id': 'hrw_test_job_1', + 'status': 'queued', + 'poll_path': '/api/high_rigor_workflow/jobs/hrw_test_job_1', + }) + + result = handler._compute_high_rigor_workflow({ + 'async': True, + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + }) + + assert result['mode'] == 'async_submitted' + assert result['job_id'] == 'hrw_test_job_1' + assert result['status'] == 'queued' + assert result['poll_path'].endswith('/hrw_test_job_1') + + +def test_chart_async_submit_returns_job_id(monkeypatch: pytest.MonkeyPatch) -> None: + handler = _handler() + + monkeypatch.setattr(handler, '_enqueue_chart_job', lambda body: { + 'success': True, + 'endpoint': 'chart_async', + 'mode': 'async_submitted', + 'job_id': 'chart_test_job_1', + 'status': 'queued', + 'poll_path': '/api/chart/jobs/chart_test_job_1', + 'scope': 'api_chart_response', + }) + + result = handler._compute_chart({ + 'async': True, + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + }) + + assert result['mode'] == 'async_submitted' + assert result['job_id'] == 'chart_test_job_1' + assert result['status'] == 'queued' + assert result['poll_path'].endswith('/chart_test_job_1') + assert result['scope'] == 'api_chart_response' + + +def test_high_rigor_job_poll_endpoint_returns_cached_job_payload(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(jyotish_api_server, '_load_high_rigor_job_record', lambda job_id: { + 'success': True, + 'endpoint': 'high_rigor_workflow_async', + 'mode': 'async_result', + 'job_id': job_id, + 'status': 'completed', + 'result': {'success': True, 'endpoint': 'high_rigor_workflow'}, + }) + handler = _HighRigorJobCaptureHandler('/api/high_rigor_workflow/jobs/hrw_test_job_2') + + handler.do_GET() + + assert handler.status_code == 200 + payload = handler.payload() + assert payload['job_id'] == 'hrw_test_job_2' + assert payload['status'] == 'completed' + assert payload['result']['endpoint'] == 'high_rigor_workflow' + + +def test_chart_job_poll_endpoint_returns_cached_job_payload(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(jyotish_api_server, '_load_async_job_record', lambda scope, job_id: { + 'success': True, + 'endpoint': 'chart_async', + 'mode': 'async_result', + 'job_id': job_id, + 'status': 'completed', + 'scope': scope, + 'result': {'success': True, 'runtime_cache': {'scope': 'api_chart_response'}}, + }) + handler = _HighRigorJobCaptureHandler('/api/chart/jobs/chart_test_job_2') + + handler.do_GET() + + assert handler.status_code == 200 + payload = handler.payload() + assert payload['job_id'] == 'chart_test_job_2' + assert payload['status'] == 'completed' + assert payload['scope'] == 'api_chart_response' + assert payload['result']['runtime_cache']['scope'] == 'api_chart_response' + + +def test_high_rigor_async_job_executes_in_background(monkeypatch: pytest.MonkeyPatch) -> None: + handler = _handler() + writes: list[tuple[str, dict]] = [] + + def fake_write(job_id: str, payload: dict) -> dict: + writes.append((job_id, dict(payload))) + return payload + + def fake_sync(body: dict) -> dict: + time.sleep(0.05) + return {'success': True, 'endpoint': 'high_rigor_workflow', 'body': dict(body)} + + monkeypatch.setattr(jyotish_api_server, '_write_high_rigor_job_record', fake_write) + monkeypatch.setattr(handler, '_compute_high_rigor_workflow_sync', fake_sync) + + result = handler._enqueue_high_rigor_job({ + 'async': True, + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + }) + + assert result['mode'] == 'async_submitted' + assert result['status'] == 'queued' + assert writes[0][1]['status'] == 'queued' + assert writes[1][1]['status'] == 'running' + assert len(writes) == 2 + + deadline = time.time() + 1.0 + while len(writes) < 3 and time.time() < deadline: + time.sleep(0.01) + + assert len(writes) >= 3 + assert writes[-1][1]['status'] == 'completed' + assert writes[-1][1]['mode'] == 'async_result' + assert writes[-1][1]['result']['endpoint'] == 'high_rigor_workflow' + + +def test_chart_async_job_executes_in_background(monkeypatch: pytest.MonkeyPatch) -> None: + handler = _handler() + writes: list[tuple[str, str, dict]] = [] + + def fake_write(scope: str, job_id: str, payload: dict) -> dict: + writes.append((scope, job_id, dict(payload))) + return payload + + def fake_sync(body: dict) -> dict: + time.sleep(0.05) + return { + 'success': True, + 'modules': {'chart': {'planets': {}, 'ascendant': {}}}, + 'runtime_cache': {'scope': 'api_chart_response'}, + } + + monkeypatch.setattr(jyotish_api_server, '_write_async_job_record', fake_write) + monkeypatch.setattr(handler, '_compute_chart_sync', fake_sync) + + result = handler._enqueue_chart_job({ + 'async': True, + 'year': REDACTED_YEAR, + 'month': 4, + 'day': 17, + 'hour': 14, + 'minute': 49, + 'lat': 36.42, + 'lon': 114.2, + 'tz': 8, + }) + + assert result['endpoint'] == 'chart_async' + assert result['mode'] == 'async_submitted' + assert result['status'] == 'queued' + assert writes[0][0] == 'api_chart_response' + assert writes[0][2]['status'] == 'queued' + assert writes[1][2]['status'] == 'running' + + deadline = time.time() + 1.0 + while len(writes) < 3 and time.time() < deadline: + time.sleep(0.01) + + assert len(writes) >= 3 + assert writes[-1][2]['status'] == 'completed' + assert writes[-1][2]['mode'] == 'async_result' + assert writes[-1][2]['result']['runtime_cache']['scope'] == 'api_chart_response' + assert 'modules' in writes[-1][2]['result'] def test_yogas_endpoint_returns_summary_counts() -> None: diff --git a/tests/test_bphs_validation_runtime.py b/tests/test_bphs_validation_runtime.py new file mode 100644 index 00000000..cb1e405e --- /dev/null +++ b/tests/test_bphs_validation_runtime.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Runtime checks for BPHS validation and D9 entrypoint consistency.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ENGINE = ROOT / "scripts" / "jyotish_engine.py" +BASE_BIRTH_ARGS = [ + "--year", + "1990", + "--month", + "1", + "--day", + "1", + "--hour", + "12", + "--minute", + "0", + "--lat", + "39.9", + "--lon", + "116.4", + "--tz", + "8", +] + + +def _run_engine(*args: str) -> dict: + completed = subprocess.run( + [sys.executable, str(ENGINE), *args], + cwd=ROOT, + text=True, + capture_output=True, + check=True, + ) + return json.loads(completed.stdout) + + +def test_validate_bphs_invariants_uses_repo_varga_and_reports_zero_failures() -> None: + completed = subprocess.run( + [sys.executable, "scripts/validate_bphs_invariants.py"], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + assert "失败: 0" in completed.stdout + assert ".workbuddy/skills/jyotish-vedic-astrology/scripts" not in completed.stdout + + +def test_varga_cli_d9_uses_same_navamsa_mapping_as_varga_full() -> None: + short = _run_engine("varga", *BASE_BIRTH_ARGS, "--d9")["divisional_charts"]["D9_Navamsa"] + full = _run_engine("varga-full", *BASE_BIRTH_ARGS, "--divisions", "9")["D9_Navamsa"] + + assert short["ascendant"] == full["Ascendant"]["sign"] + for planet in ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]: + assert short[planet]["sign"] == full[planet]["sign"] diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 8cedd9df..d40a6c51 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -310,9 +310,19 @@ def test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack() -> None: assert isinstance(relationship_narrative["strengths"], list) assert isinstance(relationship_narrative["risks"], list) assert isinstance(relationship_narrative["boundaries"], list) + assert relationship_narrative["monthly_frame"]["primary_state"]["value"] + assert relationship_narrative["monthly_frame"]["manifestation_mode"]["value"] + assert relationship_narrative["monthly_frame"]["friction_source"]["value"] + assert relationship_narrative["monthly_frame"]["time_confidence"]["value"] assert relationship_narrative["markdown"] assert "D9" in "".join(relationship_narrative["boundaries"]) assert "dual dasha" in relationship_narrative["markdown"] + career_narrative = prompt_pack["evidence_snapshot"]["career_narrative"] + finance_narrative = prompt_pack["evidence_snapshot"]["finance_narrative"] + assert career_narrative["headline"] + assert finance_narrative["headline"] + assert career_narrative["monthly_frame"]["primary_state"]["value"] + assert finance_narrative["monthly_frame"]["primary_state"]["value"] vimsopaka_summary = prompt_pack["evidence_snapshot"]["vimsopaka_semantic_summary"] assert vimsopaka_summary["status"] == "used" assert isinstance(vimsopaka_summary["highlights"], list) @@ -329,6 +339,22 @@ def test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack() -> None: assert vedastro_overview["source"] == "vedastro_service_adapter_candidate" assert vedastro_overview["ingestion_profile"] == "main_entry_overview" assert vedastro_overview["visibility"] == "user_visible_overview_only" + vedastro_official_snapshot = prompt_pack["evidence_snapshot"]["vedastro_official_full_snapshot"] + assert "official_primary_evidence" in vedastro_official_snapshot + assert "local_supplemental_evidence" in vedastro_official_snapshot + assert "fallback_used" in vedastro_official_snapshot + assert "blocked_items" in vedastro_official_snapshot + assert "conflicts" in vedastro_official_snapshot + assert "strict_workflow_contracts" in vedastro_official_snapshot + assert "strict_workflow_routes_available" in vedastro_official_snapshot + assert "relationship" in vedastro_official_snapshot["strict_workflow_contracts"] + assert "career" in vedastro_official_snapshot["strict_workflow_contracts"] + assert "finance" in vedastro_official_snapshot["strict_workflow_contracts"] + career_contract = vedastro_official_snapshot["strict_workflow_contracts"]["career"] + assert "adjudication_stages" in career_contract + assert "multi_reference_reading_summary" in career_contract + assert "modifier_frame" in career_contract["multi_reference_reading_summary"] + assert vedastro_official_snapshot["strict_workflow_primary_route"] in {"relationship", "career", "finance", None} vedastro_rows = [row for row in audit_table if row["technique"] == "VedAstro Main Entry Overview"] assert vedastro_rows assert vedastro_rows[0]["status"] in {"used", "blocked"} @@ -341,6 +367,79 @@ def test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack() -> None: assert "后台备选证据池" in prompt_pack["prompt_zh"] +def test_full_reading_summary_exposes_stage_timing_contract() -> None: + result = run_engine( + "full-reading", + "--year", "1990", + "--month", "1", + "--day", "1", + "--hour", "12", + "--minute", "0", + "--lat", "39.9", + "--lon", "116.4", + "--tz", "8", + "--today", "2026-01-01", + "--transit-date", "2026-01-01", + ) + + summary = result["summary"] + assert "stage_timings" in summary + assert isinstance(summary["stage_timings"], list) + assert summary["stage_timings"] + first = summary["stage_timings"][0] + assert "stage" in first + assert "elapsed_seconds" in first + assert "status" in first + assert isinstance(first["elapsed_seconds"], (int, float)) + assert "slowest_stages" in summary + assert isinstance(summary["slowest_stages"], list) + assert summary["stage_timing_enabled"] is True + + +def test_full_reading_summary_exposes_unified_stage_groups() -> None: + result = run_engine( + "full-reading", + "--year", "1990", + "--month", "1", + "--day", "1", + "--hour", "12", + "--minute", "0", + "--lat", "39.9", + "--lon", "116.4", + "--tz", "8", + "--today", "2026-01-01", + "--transit-date", "2026-01-01", + ) + + summary = result["summary"] + assert summary["stage_contract_version"] == 1 + assert isinstance(summary["stage_groups"], list) + assert any(group["group"] == "official_evidence" for group in summary["stage_groups"]) + assert summary["cache_recommendations"]["api_chart_response"] == "recommended" + assert summary["async_recommendations"]["chart_async_optional"] is True + + +def test_full_reading_prompt_pack_carries_compact_technique_audit_summary() -> None: + result = run_engine( + "full-reading", + "--year", "REDACTED_YEAR", + "--month", "4", + "--day", "17", + "--hour", "14", + "--minute", "49", + "--lat", "36.42", + "--lon", "114.2", + "--tz", "8", + "--today", "2026-06-09", + "--transit-date", "2026-06-09", + ) + + career = result["ai_prompt_pack"]["evidence_snapshot"]["strict_workflow_contracts"]["career"] + assert "technique_audit_summary" in career + assert career["technique_audit_summary"]["functional_benefic_malefic"]["gate"] == "hard" + assert "audit_gate_frame" in career["multi_reference_reading_summary"] + + def test_full_reading_generates_guided_topics_from_real_evidence() -> None: result = run_engine( "full-reading", @@ -358,6 +457,7 @@ def test_full_reading_generates_guided_topics_from_real_evidence() -> None: topics = result["modules"]["guided_topics"] assert len(topics) >= 3 + assert result["summary"]["guided_topics"] == topics assert result["ai_prompt_pack"]["evidence_snapshot"]["guided_topics"] == topics for topic in topics[:3]: @@ -366,6 +466,10 @@ def test_full_reading_generates_guided_topics_from_real_evidence() -> None: assert topic["reality_value"] assert topic["why_worth_exploring"] assert topic["evidence"] + assert "strict_audit_gate" in topic + assert topic["strict_audit_gate"]["functional_benefic_malefic"]["gate"] == "hard" + assert topic["strict_audit_gate"]["relevant_vargas"]["gate"] == "hard" + assert topic["strict_audit_gate"]["vimshottari_narayana_crosscheck"]["gate"] == "hard" assert topic["confidence"] in {"high", "medium", "low"} assert topic["vedastro"]["status"] in {"used", "blocked", "not_available"} assert topic["suggested_questions"] @@ -376,6 +480,113 @@ def test_full_reading_generates_guided_topics_from_real_evidence() -> None: assert any(topic["id"] == "birth_time_rectification" for topic in topics) +def test_full_reading_guided_topics_can_carry_official_day_signal_summary() -> None: + result = { + "modules": { + "dasha": { + "current_dasha": { + "lord": "Mercury", + "antardasha": {"lord": "Sun"}, + "start": "2026-01-01", + "end": "2026-12-31", + } + }, + "dasa_convergence": { + "domain_activations": { + "career_status": {"convergence_level": "L2"}, + "marriage_partnership": {"convergence_level": "L1"}, + } + }, + "chart": {"planets": {"Ketu": {"house": 10}}}, + "functional_benefic_malefic": { + "status": "used", + "functional_benefics": ["Mars", "Jupiter", "Sun"], + "functional_malefics": ["Venus", "Mercury", "Saturn"], + }, + "career_strict_evidence": { + "technique_audit_summary": { + "functional_benefic_malefic": {"gate": "hard", "used": True}, + "relevant_vargas": {"gate": "hard", "present_keys": ["d10_dasamsa", "a10_karma_pada"]}, + "vimshottari_narayana_crosscheck": {"gate": "hard", "used": True}, + "source_priority_boundary": {"gate": "boundary", "official": {}, "local": {}, "fallback_used": [], "blocked_items": [], "conflicts": []}, + }, + "monthly_adjudication_summary": { + "route": "career", + "primary_state": {"value": "推进"}, + "manifestation_mode": {"value": "项目/合作推进"}, + "friction_source": {"value": "结构调整"}, + "time_confidence": {"value": "day_supported"}, + "supporting_days": [ + {"date": "2026-07-18", "summary": "事业机会进入日", "confidence": "high"} + ], + }, + "present_evidence": { + "external_activation": { + "official_day_signals": [ + {"date": "2026-07-18", "day_type": "opportunity_entry", "summary": "事业机会进入日", "confidence": "high"} + ] + } + }, + }, + "relationship_strict_evidence": { + "technique_audit_summary": { + "functional_benefic_malefic": {"gate": "hard", "used": True}, + "relevant_vargas": {"gate": "hard", "present_keys": ["d9_navamsa", "upapada_lagna"]}, + "vimshottari_narayana_crosscheck": {"gate": "hard", "used": True}, + "source_priority_boundary": {"gate": "boundary", "official": {}, "local": {}, "fallback_used": [], "blocked_items": [], "conflicts": []}, + }, + }, + "finance_strict_evidence": { + "technique_audit_summary": { + "functional_benefic_malefic": {"gate": "hard", "used": True}, + "relevant_vargas": {"gate": "hard", "present_keys": ["d2_hora"]}, + "vimshottari_narayana_crosscheck": {"gate": "hard", "used": True}, + "source_priority_boundary": {"gate": "boundary", "official": {}, "local": {}, "fallback_used": [], "blocked_items": [], "conflicts": []}, + }, + }, + "vedastro_range_scan_result": { + "status": "ok", + "source_metadata": {"domain_statuses": {"career": "ok", "marriage": "blocked", "wealth": "blocked"}, "domain_event_counts": {"career": 1}}, + "top_events_by_domain": {}, + }, + }, + "chart": {"planets": {"Ketu": {"house": 10}}}, + "ai_prompt_pack": {"evidence_snapshot": {"strict_workflow_contracts": {}}}, + "birth_info": {"time": "REDACTED_TIME"}, + } + + from guided_topic_discovery import build_guided_topics + + topics = build_guided_topics(result) + career = next(topic for topic in topics if topic["id"] == "career_direction") + assert career["official_day_signal_summary"]["top_day"]["date"] == "2026-07-18" + assert career["official_day_signal_summary"]["top_day"]["summary"] == "事业机会进入日" + assert career["monthly_adjudication_summary"]["primary_state"]["value"] == "推进" + assert career["monthly_adjudication_summary"]["supporting_days"][0]["date"] == "2026-07-18" + assert career["strict_adjudication_bundle"]["monthly_adjudication_summary"] == career["monthly_adjudication_summary"] + assert career["strict_adjudication_bundle"]["strict_audit_gate"] == career["strict_audit_gate"] + + +def test_full_reading_preserves_official_daily_window_fields_in_range_scan_result() -> None: + result = run_engine( + "full-reading", + "--year", "REDACTED_YEAR", + "--month", "4", + "--day", "17", + "--hour", "14", + "--minute", "49", + "--lat", "36.42", + "--lon", "114.2", + "--tz", "8", + "--today", "2026-06-30", + "--transit-date", "2026-06-30", + ) + + vedastro = result["modules"]["vedastro_range_scan_result"] + assert "daily_windows" in vedastro + assert "top_daily_window" in vedastro + + def test_full_reading_auto_attaches_vedastro_main_entry_boundary() -> None: env = dict(**__import__("os").environ) env["VEDASTRO_API_ENDPOINT"] = "https://example.invalid/api" diff --git a/tests/test_frontend_productization.py b/tests/test_frontend_productization.py index eed4b363..2ad9fe15 100644 --- a/tests/test_frontend_productization.py +++ b/tests/test_frontend_productization.py @@ -307,10 +307,34 @@ def test_ai_prompt_pack_panel_exposes_copyable_audit_context() -> None: assert "AI Prompt Pack 审计上下文已复制" in main assert "vedastro_overview" in main assert "renderVedAstroOverviewPromptCard" in main + assert "renderStrictWorkflowContractPanel" in main + assert "renderStrictWorkflowContractCard" in main + assert "strict_workflow_contracts" in main + assert "strict_workflow_routes_available" in main + assert "Official-first Strict Contract" in main + assert "primary route" in main assert "VedAstro Overview" in main assert "overview only,不替代长周期精扫" in main assert ".ai-prompt-pack-actions" in style assert ".ayanamsa-runtime-status" in style + assert ".ai-prompt-pack-contracts" in style + + +def test_frontend_consumes_top_reader_contract_in_prompt_pack_and_ai_chat() -> None: + main = read("main.js") + ai_chat = read("ai-chat.js") + style = read("style.css") + + assert "adjudication_stages" in main + assert "multi_reference_reading_summary" in main + assert "technique_audit_summary" in main + assert "main_conflicts" in main + assert "Top-reader adjudication" in main + assert "multi_reference_reading_summary" in ai_chat + assert "adjudication_stages" in ai_chat + assert "technique_audit_summary" in ai_chat + assert "【Top Reader Contract】" in ai_chat + assert ".ai-prompt-pack-contract-card" in style def test_api_bridge_variants_prefer_backend_prompt_pack_context() -> None: @@ -1039,6 +1063,17 @@ def test_complete_reading_and_ai_prompt_pack_surface_career_wealth_vedastro_over for token in [ "buildVedAstroOverviewHighlights", + "renderVedAstroOfficialSnapshotPromptCard", + "renderStrictWorkflowContractPanel", + "VedAstro Official Snapshot", + "official_python_path", + "official_bundle_status", + "official_chart_available", + "official_full_capability_catalog_status", + "official_full_capability_catalog_summary", + "strict_workflow_contracts", + "strict_workflow_primary_route", + "strict_workflow_routes_available", "Career VedAstro overview", "Wealth VedAstro overview", "vedastro_overview", @@ -1501,11 +1536,15 @@ def test_rectification_ui_guides_yes_no_interview_from_existing_event_assets() - "EVENT_COLLECTION_GUIDE", "EVENT_CATEGORIES", "guided_rectification_interview", + "buildRecommendedRectificationQuestions", + "RECTIFICATION_RECOMMENDED_EVENT_QUESTION_MAP", ]: assert token in rect_engine for token in [ "rect-interview", + "renderRectificationInterview", + "recommended_events", 'data-rect-answer="yes"', 'data-rect-answer="no"', 'data-rect-answer="other"', @@ -1516,6 +1555,61 @@ def test_rectification_ui_guides_yes_no_interview_from_existing_event_assets() - assert token in rect_ui +def test_web_entry_prefers_unified_consultation_workflow() -> None: + bridge = read("api-bridge.js") + public_bridge = read("public/api-bridge.js") + main = read("main.js") + + for source in (bridge, public_bridge): + assert "computeConsultationWorkflow" in source + assert "/api/consultation_workflow" in source + + assert "entryMode = options.entryMode || 'direct_chart'" in main + assert "entryMode: 'rectification'" in main + + +def test_result_page_surfaces_workflow_summary_and_provenance_detail() -> None: + main = read("main.js") + style = read("style.css") + html = read("index.html") + + for token in [ + "workflow-summary-panel", + "renderWorkflowSummaryPanel", + "renderWorkflowSummaryCard", + "renderWorkflowProvenancePanel", + "runtime_planner", + "renderRuntimePlannerPills", + "UnifiedConsultationRuntimePlanner", + "Workflow Provenance", + "本次解读工作流", + "VedAstro official", + "local supplemental", + "fallback", + "routing.focus_techniques", + "unified_orchestrator", + "source_priority", + ]: + assert token in main + + for token in [ + 'id="workflow-summary-panel"', + 'id="chart-summary"', + 'id="provenance-panel"', + ]: + assert token in html + + for token in [ + ".workflow-summary-panel", + ".workflow-summary-card", + ".workflow-summary-grid", + ".workflow-summary-pill", + ".workflow-provenance-panel", + ".workflow-provenance-list", + ]: + assert token in style + + def test_remedies_ui_keeps_evidence_boundary_and_hidden_json() -> None: main = read("main.js") style = read("style.css") @@ -1532,6 +1626,21 @@ def test_remedies_ui_keeps_evidence_boundary_and_hidden_json() -> None: assert token in main or token in style assert "不能替代医疗、法律、投资或心理咨询" in main + + +def test_ai_chat_context_carries_runtime_planner_summary() -> None: + ai_chat = read("ai-chat.js") + main = read("main.js") + + for token in [ + "_consultationWorkflow", + "runtime_planner", + "【Runtime Planner】", + "UnifiedConsultationRuntimePlanner", + "sync_steps", + "async_candidates", + ]: + assert token in ai_chat assert "低风险优先" in main assert "需要谨慎确认" in main @@ -1992,12 +2101,16 @@ def test_provenance_panchanga_workspace_panel_is_productized() -> None: assert "_relationshipBoundary" in export_js assert "_relationshipStrictNarrativeSection" in export_js assert "relationship_report" in export_js + assert "career_narrative" in export_js + assert "finance_narrative" in export_js assert "relationship_narrative" in export_js assert "vimsopaka_semantic_summary" in export_js assert "functional_benefic_malefic" in export_js assert "vedastro_overview" in export_js assert "technique_audit_table" in export_js assert "relationship_narrative" in main + assert "career_narrative" in main + assert "finance_narrative" in main assert "vimsopaka_semantic_summary" in main assert "functional_benefic_malefic:" in main assert "vedastro_overview:" in main @@ -2096,6 +2209,12 @@ def test_synastry_relationship_report_template_keeps_public_formalization_candid assert "/api/report_artifact" in read("api-bridge.js") assert "_exportInProgress" in main assert "setExportBusy" in main + assert "const careerNarrative = extras.career_narrative" in export_js + assert "const financeNarrative = extras.finance_narrative" in export_js + assert "sourceChart?.ai_prompt_pack?.evidence_snapshot?.career_narrative" in main + assert "sourceChart?.ai_prompt_pack?.evidence_snapshot?.finance_narrative" in main + assert "career_narrative: careerNarrative" in main + assert "finance_narrative: financeNarrative" in main for token in [ "TERMINOLOGY_MODE_LABELS", @@ -2358,8 +2477,10 @@ def test_complete_reading_surfaces_guided_topic_discovery() -> None: assert "renderGuidedTopicDiscovery(chartData)" in main assert "function renderGuidedTopicDiscovery" in main assert "guided_topics" in main + assert "strict_audit_gate" in main assert "继续深入" in main assert "数据依据" in main + assert "严谨门槛" in main assert "适合继续问" in main assert "data-guided-topic-question" in main assert ".guided-topic-discovery" in style @@ -2373,6 +2494,37 @@ def test_guided_topic_questions_reuse_ai_chat_entry() -> None: assert "openAIChatWithPrompt" in ai_chat assert "data-guided-topic-question" in main assert "openAIChatWithPrompt(question)" in main + assert "strict_adjudication_bundle" in main + assert "strict_audit_gate" in main + assert "guided_topic_context" in ai_chat + assert "strict_adjudication_bundle" in ai_chat + assert "official_day_signal_summary" in main + assert "official_day_signal_summary" in ai_chat + assert "monthly_adjudication_summary" in main + assert "monthly_adjudication_summary" in ai_chat + + +def test_complete_reading_surfaces_career_quick_questions() -> None: + main = read("main.js") + style = read("style.css") + + for token in [ + "renderCareerQuickQuestions", + "career-quick-questions", + "career-quick-question", + "事业先看这几个", + "我现在适合换方向还是继续深耕?", + "2026 年事业吉利在哪里,不利在哪里?", + "哪些月份适合推进项目、发布产品或谈合作?", + ]: + assert token in main + + for token in [ + ".career-quick-questions", + ".career-quick-question-list", + ".career-quick-question", + ]: + assert token in style def test_real_case_revalidation_is_release_gate_and_accuracy_boundary() -> None: diff --git a/tests/test_historical_event_backtest.py b/tests/test_historical_event_backtest.py new file mode 100644 index 00000000..6262a436 --- /dev/null +++ b/tests/test_historical_event_backtest.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Regression tests for reusable historical event backtest reporting.""" + +from __future__ import annotations + +from scripts import historical_event_backtest as backtest + + +def _payload(events: list[dict]) -> dict: + return { + "subject": { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8.0, + "node_mode": "mean", + }, + "events": events, + } + + +def _strict_packet( + route: str, + *, + verdict: str, + dominant_label: str | None, + score: int, + blocked: bool = False, + confidence_cap: str = "medium-high", + missing_evidence: list[str] | None = None, + official_level: str = "primary", + source_priority_mode: str = "vedastro_official_primary", +) -> dict: + return { + "routing": {"question_type": route}, + "strict_workflow": { + "question_type": route, + "blocked": blocked, + "confidence_cap": confidence_cap, + "missing_evidence": missing_evidence or [], + "present_evidence": { + "vedastro_official_snapshot": { + "level": official_level, + "source": "vedastro_official", + "status": "partial" if official_level == "primary" else "fallback", + }, + "source_priority": { + "mode": source_priority_mode, + "priority": [ + "vedastro_official_snapshot", + "local_supplemental_modules", + "local_engine_fallback_when_official_blocked", + ], + }, + }, + "event_judgement": { + "event_family": route, + "verdict": verdict, + "dominant_label": dominant_label, + "score": score, + "primary_drivers": ["vimshottari_current", "narayana_current"], + "secondary_context": ["functional_benefic_malefic_used"], + }, + "technique_audit": [ + { + "technique": "VedAstro Official Full Snapshot", + "status": "used" if official_level == "primary" else "blocked", + "role": "primary_raw_evidence", + } + ], + "life_event_graph": { + "version": "life_event_graph_v1", + "route": route, + "dominant_label": dominant_label, + "verdict": verdict, + "event_nodes": [], + }, + }, + } + + +def test_backtest_reports_strong_hit_with_official_snapshot_priority(monkeypatch) -> None: + def fake_strict_workflow(**kwargs): + assert kwargs["transit_date"] == "2019-12-15" + return _strict_packet( + "career", + verdict="high_probability_window", + dominant_label="career_status", + score=84, + ) + + monkeypatch.setattr(backtest.mcp_server, "strict_workflow", fake_strict_workflow) + + report = backtest.build_report( + _payload( + [ + { + "id": "career_turn_2019", + "date": "2019-12-15", + "domain": "career", + "expected_label": "career_status", + "summary": "事业逐渐好转", + } + ] + ) + ) + + assert report["scope"] == "historical_event_backtest" + assert report["summary"]["total_events"] == 1 + assert report["summary"]["strong_hits"] == 1 + assert report["summary"]["official_primary_events"] == 1 + event = report["events"][0] + assert event["route"] == "career" + assert event["result_class"] == "strong_hit" + assert event["official_snapshot"]["level"] == "primary" + assert event["evidence"]["source_priority_mode"] == "vedastro_official_primary" + assert event["matched_expected_label"] is True + + +def test_backtest_marks_blocked_and_unsupported_domains(monkeypatch) -> None: + def fake_strict_workflow(**kwargs): + return _strict_packet( + "relationship", + verdict="insufficient_evidence", + dominant_label=None, + score=28, + blocked=True, + confidence_cap="low", + missing_evidence=["d9_navamsa", "upapada_lagna"], + official_level="fallback", + source_priority_mode="local_fallback_only", + ) + + monkeypatch.setattr(backtest.mcp_server, "strict_workflow", fake_strict_workflow) + + report = backtest.build_report( + _payload( + [ + { + "id": "marriage_probe", + "date": "2026-06-09", + "domain": "marriage", + "expected_label": "legal_marriage", + }, + { + "id": "move_1999", + "date": "1999-08-01", + "domain": "move", + "summary": "搬家", + }, + ] + ) + ) + + assert report["summary"]["blocked_events"] == 1 + assert report["summary"]["unsupported_domain_events"] == 1 + blocked, unsupported = report["events"] + assert blocked["result_class"] == "blocked" + assert blocked["boundary"]["reason"] == "strict_workflow_blocked" + assert unsupported["result_class"] == "unsupported_domain" + assert unsupported["boundary"]["reason"] == "route_not_yet_implemented_for_event_backtest" + + +def test_backtest_distinguishes_weak_hit_and_miss(monkeypatch) -> None: + def fake_strict_workflow(**kwargs): + if kwargs["transit_date"] == "2025-02-28": + return _strict_packet( + "finance", + verdict="moderate_probability_window", + dominant_label="income_growth", + score=66, + ) + return _strict_packet( + "career", + verdict="insufficient_evidence", + dominant_label=None, + score=22, + confidence_cap="low", + ) + + monkeypatch.setattr(backtest.mcp_server, "strict_workflow", fake_strict_workflow) + + report = backtest.build_report( + _payload( + [ + { + "id": "project_end_cashflow", + "date": "2025-02-28", + "domain": "wealth", + "expected_label": "public_wealth_status", + "summary": "项目结束但收到小额定金", + }, + { + "id": "career_false_start", + "date": "2026-01-10", + "domain": "career", + "expected_label": "project_manifestation", + "summary": "短期项目未成", + }, + ] + ) + ) + + assert report["summary"]["weak_hits"] == 1 + assert report["summary"]["misses"] == 1 + first, second = report["events"] + assert first["result_class"] == "weak_hit" + assert first["matched_expected_label"] is False + assert first["boundary"]["reason"] == "label_mismatch_under_supported_route" + assert second["result_class"] == "miss" + assert second["boundary"]["reason"] == "insufficient_evidence" + + +def test_backtest_carries_conflicts_and_blocked_items_from_strict_contract(monkeypatch) -> None: + def fake_strict_workflow(**kwargs): + packet = _strict_packet( + "career", + verdict="high_probability_window", + dominant_label="career_status", + score=84, + ) + packet["strict_workflow"]["blocked_items"] = ["official_event_radar_partial"] + packet["strict_workflow"]["conflicts"] = [{"type": "official_local_dasha_conflict"}] + return packet + + monkeypatch.setattr(backtest.mcp_server, "strict_workflow", fake_strict_workflow) + + report = backtest.build_report( + _payload( + [ + { + "id": "career_turn_2019", + "date": "2019-12-15", + "domain": "career", + "expected_label": "career_status", + "summary": "事业逐渐好转", + } + ] + ) + ) + + assert report["events"][0]["evidence"]["blocked_items"] == ["official_event_radar_partial"] + assert report["events"][0]["evidence"]["conflicts"] == [{"type": "official_local_dasha_conflict"}] + + +def test_backtest_carries_top_reader_contract_summary_from_strict_contract(monkeypatch) -> None: + def fake_strict_workflow(**kwargs): + packet = _strict_packet( + "career", + verdict="high_probability_window", + dominant_label="career_status", + score=84, + ) + packet["strict_workflow"]["adjudication_stages"] = { + "promise": {"status": "present"}, + "activation": { + "status": "present", + "required_timing_systems": ["Vimshottari", "Narayana"], + }, + } + packet["strict_workflow"]["multi_reference_reading_summary"] = { + "root_frame": {"signal": "career_promise"}, + "modifier_frame": {"functional_benefic_malefic": {"used": True}}, + } + packet["strict_workflow"]["main_conflicts"] = [{"type": "official_local_dasha_conflict"}] + return packet + + monkeypatch.setattr(backtest.mcp_server, "strict_workflow", fake_strict_workflow) + + report = backtest.build_report( + _payload( + [ + { + "id": "career_turn_2019", + "date": "2019-12-15", + "domain": "career", + "expected_label": "career_status", + "summary": "事业逐渐好转", + } + ] + ) + ) + + event = report["events"][0] + assert event["evidence"]["adjudication_stages"]["activation"]["required_timing_systems"] == [ + "Vimshottari", + "Narayana", + ] + assert event["evidence"]["multi_reference_reading_summary"]["root_frame"]["signal"] == "career_promise" + assert event["evidence"]["main_conflicts"] == [{"type": "official_local_dasha_conflict"}] diff --git a/tests/test_life_event_graph_v1.py b/tests/test_life_event_graph_v1.py index f0de5aea..eed80afa 100644 --- a/tests/test_life_event_graph_v1.py +++ b/tests/test_life_event_graph_v1.py @@ -262,3 +262,34 @@ def test_life_event_graph_surfaces_main_entry_overview_boundary_node() -> None: "reference_date": "2026-06-29", "source": "vedastro_service_adapter_candidate", } in graph["event_nodes"] + + +def test_life_event_graph_surfaces_ranked_official_day_window_nodes() -> None: + strict = { + "event_judgement": {"event_family": "career", "verdict": "moderate_probability_window", "score": 74}, + "present_evidence": { + "external_activation": { + "level": "moderate", + "source": "vedastro_service_adapter_candidate", + "daily_windows": [ + { + "date": "2026-07-18", + "domain": "career", + "score": 5, + "confidence": "medium_high", + "event_count": 2, + "signal_families": ["career_trigger"], + "event_ids": ["GocharJupiterAspect10th", "CareerExpansionWindow"], + "top_signal_label": "Career expansion window", + } + ], + } + }, + "confidence_cap": "medium", + "missing_evidence": [], + "blocked": False, + } + + graph = _build_life_event_graph("career", strict) + + assert any(node["kind"] == "official_day_window" and node["date"] == "2026-07-18" for node in graph["event_nodes"]) diff --git a/tests/test_mcp_strict_workflow_career.py b/tests/test_mcp_strict_workflow_career.py index f59b4542..c088804b 100644 --- a/tests/test_mcp_strict_workflow_career.py +++ b/tests/test_mcp_strict_workflow_career.py @@ -3,12 +3,23 @@ from __future__ import annotations +import jyotish_engine + from mcp_server import _collect_strict_evidence def _base_career_result() -> dict: return { "modules": { + "chart": { + "ascendant": {"sign": "Leo"}, + "planets": { + "Moon": {"status": "中性(Neutral)"}, + "Venus": {"status": "中性(Neutral)"}, + "Saturn": {"status": "中性(Neutral)"}, + "Sun": {"status": "中性(Neutral)"}, + }, + }, "varga_full": {"D10_Dasamsa": {"summary": "career varga present"}}, "special_lagnas": {"A10_Karma_Pada": {"sign": "Capricorn", "lord": "Saturn"}}, "jaimini": { @@ -55,6 +66,7 @@ def test_career_collects_a10_amk_karakamsha_as_strict_evidence() -> None: "a10_active", "amk_active", "karakamsha_context", + "functional_benefic_malefic_used", "argala_support", "vedastro_range_scan_missing", ] @@ -73,11 +85,50 @@ def test_career_blocks_label_when_d10_is_missing_but_preserves_jaimini_context() "a10_active", "amk_active", "karakamsha_context", + "functional_benefic_malefic_used", "argala_support", "vedastro_range_scan_missing", ] +def test_career_dignity_guardrail_uses_career_relevant_planets_only() -> None: + result = _base_career_result() + result["modules"]["chart"] = { + "ascendant": {"sign": "Leo"}, + "planets": { + "Venus": {"status": "落陷取消(Neecha Bhanga)"}, + "Saturn": {"status": "中性(Neutral)"}, + "Moon": {"status": "中性(Neutral)"}, + "Mars": {"status": "极敌(Great Enemy)"}, + }, + } + + strict = _collect_strict_evidence("career", result) + + assert strict["present_evidence"]["dignity_guardrail"]["status"] == "caution" + assert strict["present_evidence"]["dignity_guardrail"]["score_delta"] == 5 + assert "dignity_supportive_recovery" in strict["event_judgement"]["secondary_context"] + assert "dignity_high_friction" not in strict["event_judgement"]["secondary_context"] + + +def test_career_dignity_guardrail_detects_conflict_across_career_significators() -> None: + result = _base_career_result() + result["modules"]["chart"] = { + "ascendant": {"sign": "Leo"}, + "planets": { + "Venus": {"status": "落陷取消(Neecha Bhanga)"}, + "Saturn": {"status": "极敌(Great Enemy)"}, + "Moon": {"status": "中性(Neutral)"}, + }, + } + + strict = _collect_strict_evidence("career", result) + + assert strict["present_evidence"]["dignity_guardrail"]["status"] == "conflict" + assert strict["present_evidence"]["dignity_guardrail"]["score_delta"] == 0 + assert "dignity_conflict" in strict["event_judgement"]["secondary_context"] + + def test_career_argala_bridge_uses_tenth_house_as_modifier_only() -> None: result = _base_career_result() @@ -175,3 +226,253 @@ def test_career_accepts_complete_shadbala_components_without_cap_penalty() -> No assert strict["present_evidence"]["shadbala_component_audit"]["status"] == "complete" assert strict["confidence_cap"] == "medium-high" assert "shadbala_component_gap" not in strict["event_judgement"]["secondary_context"] + + +def test_career_strict_contract_marks_a10_as_local_supplement_to_official_primary() -> None: + result = _base_career_result() + result["modules"]["source_priority"] = {"mode": "vedastro_official_primary"} + result["modules"]["vedastro_official_full_snapshot"] = { + "status": "partial", + "available": True, + "official_chart": {"planets": {"Sun": {}}, "ascendant": {"sign": "Leo"}}, + "section_statuses": {"chart_core": "ok", "dasha_all": "ok"}, + } + + strict = _collect_strict_evidence("career", result) + + assert strict["official_primary_evidence"]["chart_core"]["status"] == "ok" + assert strict["official_primary_evidence"]["dasha"]["status"] == "ok" + assert strict["local_supplemental_evidence"]["a10_karma_pada"]["role"] == "required_local_supplement" + assert strict["local_supplemental_evidence"]["narayana_current"]["role"] == "required_local_supplement" + + +def test_career_strict_contract_exposes_adjudication_stages_and_multi_reference_summary() -> None: + strict = _collect_strict_evidence("career", _base_career_result()) + + assert strict["adjudication_stages"]["promise"]["status"] in {"present", "weak", "missing"} + assert strict["adjudication_stages"]["activation"]["required_timing_systems"] == ["Vimshottari", "Narayana"] + assert strict["adjudication_stages"]["label"]["value"] == strict["event_judgement"]["dominant_label"] + assert "multi_reference_reading_summary" in strict + summary = strict["multi_reference_reading_summary"] + assert "root_frame" in summary + assert "divisional_frame" in summary + assert "visibility_frame" in summary + assert "karaka_frame" in summary + assert "timing_frame" in summary + assert "modifier_frame" in summary + assert "conflict_frame" in summary + + +def test_career_strict_contract_exposes_monthly_adjudication_summary() -> None: + result = _base_career_result() + result["modules"]["vedastro_range_scan_result"] = { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "career", + "evidence_ledger": [], + "daily_windows": [ + { + "date": "2026-07-18", + "domain": "career", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["career_trigger"], + "event_ids": ["GocharJupiterAspect10th", "CareerExpansionWindow"], + "top_signal_label": "Career expansion window", + }, + { + "date": "2026-07-28", + "domain": "career", + "score": 6, + "confidence": "high", + "event_count": 2, + "signal_families": ["career_trigger"], + "event_ids": ["TravelForWork", "GocharJupiterAspect10th"], + "top_signal_label": "Travel for work expansion window", + }, + ], + "top_daily_window": { + "date": "2026-07-28", + "domain": "career", + "score": 6, + "confidence": "high", + "event_count": 2, + "signal_families": ["career_trigger"], + "event_ids": ["TravelForWork", "GocharJupiterAspect10th"], + "top_signal_label": "Travel for work expansion window", + }, + "source_metadata": {}, + } + + strict = _collect_strict_evidence("career", result) + + summary = strict["monthly_adjudication_summary"] + assert summary["route"] == "career" + assert summary["primary_state"]["value"] in {"推进", "启动", "重组", "收束", "观察"} + assert summary["manifestation_mode"]["value"] + assert summary["friction_source"]["value"] + assert summary["time_confidence"]["value"] in {"day_supported", "month_supported", "month_only", "blocked"} + assert isinstance(summary["supporting_days"], list) + assert summary["supporting_days"][0]["date"] == "2026-07-18" + + +def test_career_strict_contract_exposes_compact_technique_audit_summary() -> None: + strict = _collect_strict_evidence("career", _base_career_result()) + + audit = strict["technique_audit_summary"] + assert audit["functional_benefic_malefic"]["gate"] == "hard" + assert audit["relevant_vargas"]["gate"] == "hard" + assert audit["vimshottari_narayana_crosscheck"]["gate"] == "hard" + assert audit["source_priority_boundary"]["fallback_used"] == strict["fallback_used"] + + +def test_career_external_activation_derives_user_readable_day_signals() -> None: + result = _base_career_result() + result["modules"]["vedastro_range_scan_result"] = { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "career", + "evidence_ledger": [], + "daily_windows": [ + { + "date": "2026-07-18", + "domain": "career", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["career_trigger"], + "event_ids": ["GocharJupiterAspect10th", "CareerExpansionWindow"], + "top_signal_label": "Career expansion window", + }, + { + "date": "2026-07-26", + "domain": "career", + "score": 2, + "confidence": "medium", + "event_count": 1, + "signal_families": ["career_pressure"], + "event_ids": ["SaturnIn10thCareerWindow"], + "top_signal_label": "Saturn in 10th career window", + }, + ], + "top_daily_window": { + "date": "2026-07-18", + "domain": "career", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["career_trigger"], + "event_ids": ["GocharJupiterAspect10th", "CareerExpansionWindow"], + "top_signal_label": "Career expansion window", + }, + "source_metadata": {}, + } + + strict = _collect_strict_evidence("career", result) + external = strict["present_evidence"]["external_activation"] + + assert external["official_day_signals"][0]["date"] == "2026-07-18" + assert external["official_day_signals"][0]["day_type"] == "opportunity_entry" + assert external["official_day_signals"][0]["summary"] == "事业机会进入日" + assert external["official_day_signals"][1]["day_type"] == "pressure_opportunity" + + +def test_career_official_day_signals_distinguish_motion_and_closure_risk() -> None: + result = _base_career_result() + result["modules"]["vedastro_range_scan_result"] = { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "career", + "evidence_ledger": [], + "daily_windows": [ + { + "date": "2026-07-28", + "domain": "career", + "score": 6, + "confidence": "high", + "event_count": 2, + "signal_families": [], + "event_ids": ["GoodLunarDayForTravel", "GoodSunSignForBuilding"], + "top_signal_label": "Good lunar day for travel", + }, + { + "date": "2025-02-28", + "domain": "career", + "score": 4, + "confidence": "medium", + "event_count": 2, + "signal_families": [], + "event_ids": ["BadLunarDayForTravel", "BadForSellingForProfit"], + "top_signal_label": "Bad lunar day for travel", + }, + ], + "top_daily_window": { + "date": "2026-07-28", + "domain": "career", + "score": 6, + "confidence": "high", + "event_count": 2, + "signal_families": [], + "event_ids": ["GoodLunarDayForTravel", "GoodSunSignForBuilding"], + "top_signal_label": "Good lunar day for travel", + }, + "source_metadata": {}, + } + + strict = _collect_strict_evidence("career", result) + signals = strict["present_evidence"]["external_activation"]["official_day_signals"] + + assert signals[0]["day_type"] == "relocation_motion" + assert signals[0]["summary"] == "事业迁移动作日" + assert signals[1]["day_type"] == "closure_risk" + assert signals[1]["summary"] == "事业真正收尾风险日" + + +def test_career_narrative_payload_forces_monthly_adjudication_layers_into_final_chinese_conclusion() -> None: + result = _base_career_result() + result["modules"]["vedastro_range_scan_result"] = { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "career", + "evidence_ledger": [], + "daily_windows": [ + { + "date": "2026-07-18", + "domain": "career", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["career_trigger"], + "event_ids": ["GocharJupiterAspect10th", "CareerExpansionWindow"], + "top_signal_label": "Career expansion window", + } + ], + "top_daily_window": { + "date": "2026-07-18", + "domain": "career", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["career_trigger"], + "event_ids": ["GocharJupiterAspect10th", "CareerExpansionWindow"], + "top_signal_label": "Career expansion window", + }, + "source_metadata": {}, + } + + strict = _collect_strict_evidence("career", result) + payload = jyotish_engine._build_career_narrative_payload(strict) + + assert "事业" in payload["headline"] + assert payload["monthly_frame"]["primary_state"]["value"] + assert payload["monthly_frame"]["manifestation_mode"]["value"] + assert payload["monthly_frame"]["friction_source"]["value"] + assert payload["monthly_frame"]["time_confidence"]["value"] + assert any("月度主状态" in item for item in payload["strengths"]) + assert any("阻力来源" in item for item in payload["risks"]) + assert "时间置信度" in payload["markdown"] diff --git a/tests/test_mcp_strict_workflow_finance.py b/tests/test_mcp_strict_workflow_finance.py index 7aa9cf5e..f33f18ae 100644 --- a/tests/test_mcp_strict_workflow_finance.py +++ b/tests/test_mcp_strict_workflow_finance.py @@ -3,6 +3,8 @@ from __future__ import annotations +import jyotish_engine + from mcp_server import ( _collect_strict_evidence, _derive_event_judgement, @@ -144,6 +146,238 @@ def test_finance_source_diversity_adds_small_bump_without_changing_verdict_band( assert high_diversity["score"] == low_diversity["score"] + 5 +def test_finance_strict_contract_surfaces_official_block_and_local_fallback_usage() -> None: + strict = _collect_strict_evidence("finance", {"modules": {"source_priority": {"mode": "local_fallback_official_blocked"}}}) + + assert "official_primary_chart_blocked" in strict["blocked_items"] + assert isinstance(strict["fallback_used"], list) + assert isinstance(strict["conflicts"], list) + + +def test_finance_strict_contract_exposes_adjudication_stages_and_multi_reference_summary() -> None: + strict = _collect_strict_evidence("finance", {"modules": {"source_priority": {"mode": "local_fallback_official_blocked"}}}) + + assert strict["adjudication_stages"]["promise"]["status"] in {"present", "weak", "missing"} + assert strict["adjudication_stages"]["activation"]["required_timing_systems"] == ["Vimshottari", "Narayana"] + assert "multi_reference_reading_summary" in strict + summary = strict["multi_reference_reading_summary"] + assert "root_frame" in summary + assert "divisional_frame" in summary + assert "visibility_frame" in summary + assert "karaka_frame" in summary + assert "timing_frame" in summary + assert "modifier_frame" in summary + assert "conflict_frame" in summary + + +def test_finance_strict_contract_exposes_monthly_adjudication_summary() -> None: + strict = _collect_strict_evidence( + "finance", + { + "modules": { + "source_priority": {"mode": "vedastro_official_primary"}, + "varga_full": { + "D2_Hora": {"summary": "hora ready"}, + "D10_Dasamsa": {"summary": "dasamsa ready"}, + }, + "shadbala": { + "planets": { + "Venus": { + "components": { + "sthana": 1, + "dig": 1, + "kala": 1, + "chesta": 1, + "naisargika": 1, + "drik": 1, + } + } + } + }, + "ashtakavarga": {"house_scores": {"2": {"sav_score": 33}, "11": {"sav_score": 35}}}, + "dasha": {"current_dasha": {"mahadasha": "Venus", "antardasha": "Mercury"}}, + "narayana_dasha": {"current_dasha": {"sign": "Taurus", "lord": "Venus"}}, + "dasa_convergence": { + "domain_activations": { + "wealth_family": {"convergence_level": "L2", "probability": "35-50%"}, + "gains_wishes": {"convergence_level": "L1", "probability": "+15-20%"}, + } + }, + "chart": {"ascendant": {"sign": "Leo"}}, + "dhana_yogas": [{"name": "Dhana Yoga"}], + "lakshmi_yoga": {"present": True}, + "vedastro_range_scan_result": { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "wealth", + "evidence_ledger": [], + "daily_windows": [ + { + "date": "2026-10-04", + "domain": "wealth", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["wealth_trigger"], + "event_ids": ["GoodForBorrowingMoneyForBusiness", "GoodForLendingMoney"], + "top_signal_label": "Good for borrowing money for business", + }, + { + "date": "2026-10-20", + "domain": "wealth", + "score": 2, + "confidence": "medium", + "event_count": 1, + "signal_families": ["wealth_pressure"], + "event_ids": ["BadForSellingForProfit"], + "top_signal_label": "Bad for selling for profit", + }, + ], + "top_daily_window": { + "date": "2026-10-04", + "domain": "wealth", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["wealth_trigger"], + "event_ids": ["GoodForBorrowingMoneyForBusiness", "GoodForLendingMoney"], + "top_signal_label": "Good for borrowing money for business", + }, + "source_metadata": {}, + }, + } + }, + ) + + summary = strict["monthly_adjudication_summary"] + assert summary["route"] == "finance" + assert summary["primary_state"]["value"] in {"推进", "启动", "整固", "收束", "观察"} + assert summary["manifestation_mode"]["value"] + assert summary["friction_source"]["value"] + assert summary["time_confidence"]["value"] in {"day_supported", "month_supported", "month_only", "blocked"} + assert isinstance(summary["supporting_days"], list) + assert summary["supporting_days"][0]["date"] == "2026-10-04" + + +def test_finance_summary_modifier_frame_includes_yogi_and_ashtakavarga_only_as_modifiers() -> None: + modules = { + "source_priority": {"mode": "vedastro_official_primary"}, + "varga_full": { + "D2_Hora": {"summary": "hora ready"}, + "D10_Dasamsa": {"summary": "dasamsa ready"}, + }, + "shadbala": {"planets": {"Venus": {"components": {"sthana": 1, "dig": 1, "kala": 1, "chesta": 1, "naisargika": 1, "drik": 1}}}}, + "ashtakavarga": {"house_scores": {"2": {"sav_score": 33}, "11": {"sav_score": 35}}}, + "dasha": {"current_dasha": {"mahadasha": "Venus", "antardasha": "Mercury"}}, + "narayana_dasha": {"current_dasha": {"sign": "Taurus", "lord": "Venus"}}, + "dasa_convergence": {"domain_activations": {"wealth_family": {"convergence_level": "L1"}}}, + "chart": {"ascendant": {"sign": "Leo"}}, + "source_priority": {"mode": "vedastro_official_primary"}, + "dhana_yogas": [{"name": "Dhana Yoga"}], + "lakshmi_yoga": {"present": True}, + } + strict = _collect_strict_evidence("finance", {"modules": modules}) + + modifier = strict["multi_reference_reading_summary"]["modifier_frame"] + assert "functional_benefic_malefic" in modifier + assert modifier["ashtakavarga_finance_support"]["source"] == "ashtakavarga_house_scores_bridge_v1" + assert modifier["yogi_support"]["role"] == "modifier_only" + + +def test_finance_strict_contract_compact_audit_marks_dual_dasha_gate() -> None: + strict = _collect_strict_evidence("finance", {"modules": {"source_priority": {"mode": "local_fallback_official_blocked"}}}) + + audit = strict["technique_audit_summary"] + assert audit["vimshottari_narayana_crosscheck"]["gate"] == "hard" + assert "official_primary_chart_blocked" in audit["source_priority_boundary"]["blocked_items"] + + +def test_finance_external_activation_derives_wealth_day_signals() -> None: + strict = _collect_strict_evidence( + "finance", + { + "modules": { + "source_priority": {"mode": "vedastro_official_primary"}, + "varga_full": { + "D2_Hora": {"summary": "hora ready"}, + "D10_Dasamsa": {"summary": "dasamsa ready"}, + }, + "shadbala": { + "planets": { + "Venus": { + "components": { + "sthana": 1, + "dig": 1, + "kala": 1, + "chesta": 1, + "naisargika": 1, + "drik": 1, + } + } + } + }, + "ashtakavarga": {"house_scores": {"2": {"sav_score": 33}, "11": {"sav_score": 35}}}, + "dasha": {"current_dasha": {"mahadasha": "Venus", "antardasha": "Mercury"}}, + "narayana_dasha": {"current_dasha": {"sign": "Taurus", "lord": "Venus"}}, + "dasa_convergence": { + "domain_activations": { + "wealth_family": {"convergence_level": "L2", "probability": "35-50%"}, + "gains_wishes": {"convergence_level": "L1", "probability": "+15-20%"}, + } + }, + "chart": {"ascendant": {"sign": "Leo"}}, + "vedastro_range_scan_result": { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "wealth", + "evidence_ledger": [], + "daily_windows": [ + { + "date": "2026-10-04", + "domain": "wealth", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["wealth_trigger"], + "event_ids": ["GoodForBorrowingMoneyForBusiness", "GoodForLendingMoney"], + "top_signal_label": "Good for borrowing money for business", + }, + { + "date": "2026-10-20", + "domain": "wealth", + "score": 2, + "confidence": "medium", + "event_count": 1, + "signal_families": ["wealth_pressure"], + "event_ids": ["BadForSellingForProfit"], + "top_signal_label": "Bad for selling for profit", + }, + ], + "top_daily_window": { + "date": "2026-10-04", + "domain": "wealth", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["wealth_trigger"], + "event_ids": ["GoodForBorrowingMoneyForBusiness", "GoodForLendingMoney"], + "top_signal_label": "Good for borrowing money for business", + }, + "source_metadata": {}, + }, + } + }, + ) + + external = strict["present_evidence"]["external_activation"] + assert external["official_day_signals"][0]["date"] == "2026-10-04" + assert external["official_day_signals"][0]["day_type"] == "opportunity" + assert external["official_day_signals"][0]["summary"] == "财富机会日" + assert external["official_day_signals"][1]["day_type"] == "risk" + + def test_finance_deep_ashtakavarga_supports_add_small_score_bump_without_label_override() -> None: base = _derive_event_judgement( "finance", @@ -1164,3 +1398,85 @@ def test_collect_strict_evidence_finance_does_not_add_avayogi_without_external_t assert strict["present_evidence"].get("avayogi_risk") is None assert strict["event_judgement"]["score"] == 95 assert "avayogi_active" not in strict["event_judgement"]["secondary_context"] + + +def test_finance_narrative_payload_forces_monthly_adjudication_layers_into_final_chinese_conclusion() -> None: + strict = _collect_strict_evidence( + "finance", + { + "modules": { + "source_priority": {"mode": "vedastro_official_primary"}, + "varga_full": { + "D2_Hora": {"summary": "hora ready"}, + "D10_Dasamsa": {"summary": "dasamsa ready"}, + }, + "shadbala": { + "planets": { + "Venus": { + "components": { + "sthana": 1, + "dig": 1, + "kala": 1, + "chesta": 1, + "naisargika": 1, + "drik": 1, + } + } + } + }, + "ashtakavarga": {"house_scores": {"2": {"sav_score": 33}, "11": {"sav_score": 35}}}, + "dasha": {"current_dasha": {"mahadasha": "Venus", "antardasha": "Mercury"}}, + "narayana_dasha": {"current_dasha": {"sign": "Taurus", "lord": "Venus"}}, + "dasa_convergence": { + "domain_activations": { + "wealth_family": {"convergence_level": "L2", "probability": "35-50%"}, + "gains_wishes": {"convergence_level": "L1", "probability": "+15-20%"}, + } + }, + "chart": {"ascendant": {"sign": "Leo"}}, + "dhana_yogas": [{"name": "Dhana Yoga"}], + "lakshmi_yoga": {"present": True}, + "vedastro_range_scan_result": { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "wealth", + "evidence_ledger": [], + "daily_windows": [ + { + "date": "2026-10-04", + "domain": "wealth", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["wealth_trigger"], + "event_ids": ["GoodForBorrowingMoneyForBusiness", "GoodForLendingMoney"], + "top_signal_label": "Good for borrowing money for business", + } + ], + "top_daily_window": { + "date": "2026-10-04", + "domain": "wealth", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["wealth_trigger"], + "event_ids": ["GoodForBorrowingMoneyForBusiness", "GoodForLendingMoney"], + "top_signal_label": "Good for borrowing money for business", + }, + "source_metadata": {}, + }, + } + }, + ) + + payload = jyotish_engine._build_finance_narrative_payload(strict) + + assert "财富" in payload["headline"] + assert payload["monthly_frame"]["primary_state"]["value"] + assert payload["monthly_frame"]["manifestation_mode"]["value"] + assert payload["monthly_frame"]["friction_source"]["value"] + assert payload["monthly_frame"]["time_confidence"]["value"] + assert any("月度主状态" in item for item in payload["strengths"]) + assert any("阻力来源" in item for item in payload["risks"]) + assert "时间置信度" in payload["markdown"] diff --git a/tests/test_mcp_strict_workflow_relationship.py b/tests/test_mcp_strict_workflow_relationship.py index 9af530c6..3fb5c94c 100644 --- a/tests/test_mcp_strict_workflow_relationship.py +++ b/tests/test_mcp_strict_workflow_relationship.py @@ -176,6 +176,187 @@ def test_relationship_collects_vedastro_range_scan_as_external_activation_contex assert "external_activation_support" in strict["event_judgement"]["secondary_context"] +def test_relationship_external_activation_exposes_top_daily_window() -> None: + result = _base_relationship_result() + result["modules"]["vedastro_range_scan_result"] = { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "marriage", + "evidence_ledger": [], + "daily_windows": [{"date": "2026-08-02", "domain": "marriage", "score": 5, "event_count": 2}], + "top_daily_window": {"date": "2026-08-02", "domain": "marriage", "score": 5, "event_count": 2}, + "source_metadata": {}, + } + + strict = _collect_strict_evidence("relationship", result) + external = strict["present_evidence"]["external_activation"] + + assert external["top_daily_window"]["date"] == "2026-08-02" + assert external["daily_windows"][0]["score"] == 5 + + +def test_relationship_external_activation_derives_progress_day_signals() -> None: + result = _base_relationship_result() + result["modules"]["vedastro_range_scan_result"] = { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "marriage", + "evidence_ledger": [], + "daily_windows": [ + { + "date": "2026-08-02", + "domain": "marriage", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["marriage_trigger"], + "event_ids": ["GocharJupiterIn7th", "JupiterSupportsMarriageAxis"], + "top_signal_label": "Jupiter in 7th marriage window", + }, + { + "date": "2026-08-19", + "domain": "marriage", + "score": 2, + "confidence": "medium", + "event_count": 1, + "signal_families": ["relationship_pressure"], + "event_ids": ["SaturnAspect7thRelationshipWindow"], + "top_signal_label": "Saturn aspecting 7th relationship window", + }, + ], + "top_daily_window": { + "date": "2026-08-02", + "domain": "marriage", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["marriage_trigger"], + "event_ids": ["GocharJupiterIn7th", "JupiterSupportsMarriageAxis"], + "top_signal_label": "Jupiter in 7th marriage window", + }, + "source_metadata": {}, + } + + strict = _collect_strict_evidence("relationship", result) + external = strict["present_evidence"]["external_activation"] + + assert external["official_day_signals"][0]["date"] == "2026-08-02" + assert external["official_day_signals"][0]["day_type"] == "progress" + assert external["official_day_signals"][0]["summary"] == "婚恋推进日" + assert external["official_day_signals"][1]["day_type"] == "risk" + + +def test_relationship_strict_contract_exposes_official_primary_and_local_supplemental_layers() -> None: + result = _base_relationship_result() + result["modules"]["source_priority"] = {"mode": "vedastro_official_primary"} + result["modules"]["vedastro_official_full_snapshot"] = { + "status": "partial", + "available": True, + "official_chart": {"planets": {"Sun": {}}, "ascendant": {"sign": "Leo"}}, + "section_statuses": {"chart_core": "ok", "dasha_all": "ok", "events_overview": "partial"}, + } + + strict = _collect_strict_evidence("relationship", result) + + assert strict["official_primary_evidence"]["chart_core"]["status"] == "ok" + assert strict["official_primary_evidence"]["dasha"]["status"] == "ok" + assert strict["local_supplemental_evidence"]["upapada_lagna"]["role"] == "required_local_supplement" + assert strict["local_supplemental_evidence"]["darakaraka"]["role"] == "required_local_supplement" + assert isinstance(strict["fallback_used"], list) + assert isinstance(strict["blocked_items"], list) + assert isinstance(strict["conflicts"], list) + + +def test_relationship_strict_contract_exposes_adjudication_stages_and_multi_reference_summary() -> None: + strict = _collect_strict_evidence("relationship", _base_relationship_result()) + + assert strict["adjudication_stages"]["promise"]["status"] in {"present", "weak", "missing"} + assert strict["adjudication_stages"]["activation"]["required_timing_systems"] == ["Vimshottari", "Narayana"] + assert strict["adjudication_stages"]["label"]["value"] == strict["event_judgement"]["dominant_label"] + summary = strict["multi_reference_reading_summary"] + assert "root_frame" in summary + assert "divisional_frame" in summary + assert "visibility_frame" in summary + assert "karaka_frame" in summary + assert "timing_frame" in summary + assert "modifier_frame" in summary + assert "conflict_frame" in summary + + +def test_relationship_strict_contract_exposes_monthly_adjudication_summary() -> None: + result = _base_relationship_result() + result["modules"]["vedastro_range_scan_result"] = { + "backend": "vedastro_service_adapter_candidate", + "status": "ok", + "operation": "range_scan", + "domain": "marriage", + "evidence_ledger": [], + "daily_windows": [ + { + "date": "2026-08-02", + "domain": "marriage", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["marriage_trigger"], + "event_ids": ["GocharJupiterIn7th", "JupiterSupportsMarriageAxis"], + "top_signal_label": "Jupiter in 7th marriage window", + }, + { + "date": "2026-08-19", + "domain": "marriage", + "score": 2, + "confidence": "medium", + "event_count": 1, + "signal_families": ["relationship_pressure"], + "event_ids": ["SaturnAspect7thRelationshipWindow"], + "top_signal_label": "Saturn aspecting 7th relationship window", + }, + ], + "top_daily_window": { + "date": "2026-08-02", + "domain": "marriage", + "score": 5, + "confidence": "high", + "event_count": 2, + "signal_families": ["marriage_trigger"], + "event_ids": ["GocharJupiterIn7th", "JupiterSupportsMarriageAxis"], + "top_signal_label": "Jupiter in 7th marriage window", + }, + "source_metadata": {}, + } + + strict = _collect_strict_evidence("relationship", result) + + summary = strict["monthly_adjudication_summary"] + assert summary["route"] == "relationship" + assert summary["primary_state"]["value"] in {"推进", "启动", "筛选", "收束", "观察"} + assert summary["manifestation_mode"]["value"] + assert summary["friction_source"]["value"] + assert summary["time_confidence"]["value"] in {"day_supported", "month_supported", "month_only", "blocked"} + assert isinstance(summary["supporting_days"], list) + assert summary["supporting_days"][0]["date"] == "2026-08-02" + + +def test_relationship_summary_modifier_frame_surfaces_label_lift_related_modifiers() -> None: + strict = _collect_strict_evidence("relationship", _base_relationship_result()) + + modifier = strict["multi_reference_reading_summary"]["modifier_frame"] + assert "functional_benefic_malefic" in modifier + assert modifier["manifestation_split"]["role"] == "modifier_only" + assert "legal_marriage" in modifier["manifestation_split"]["signals"] + + +def test_relationship_multi_reference_summary_carries_audit_gate_frame() -> None: + strict = _collect_strict_evidence("relationship", _base_relationship_result()) + + frame = strict["multi_reference_reading_summary"]["audit_gate_frame"] + assert frame["functional_benefic_malefic"]["used"] in {True, False} + assert frame["source_priority_boundary"]["blocked_items"] == strict["blocked_items"] + + def test_relationship_synastry_bridge_adds_context_and_small_score_bump_without_overriding_label_gate() -> None: base = _collect_strict_evidence("relationship", _base_relationship_result()) result = _base_relationship_result() @@ -425,11 +606,19 @@ def test_relationship_narrative_payload_turns_synastry_taxonomy_into_user_readab payload = jyotish_engine._build_relationship_narrative_payload(strict) + assert payload["monthly_frame"]["primary_state"]["value"] + assert payload["monthly_frame"]["manifestation_mode"]["value"] + assert payload["monthly_frame"]["friction_source"]["value"] + assert payload["monthly_frame"]["time_confidence"]["value"] assert "婚恋" in payload["headline"] assert any("合盘" in item for item in payload["strengths"]) assert any("protective kuta" in item.lower() for item in payload["strengths"]) assert any("dual dasha" in item.lower() for item in payload["risks"]) assert any("legal_marriage" in item for item in payload["boundaries"]) + assert "月度主状态" in payload["markdown"] + assert "落地形式" in payload["markdown"] + assert "阻力来源" in payload["markdown"] + assert "时间置信度" in payload["markdown"] assert "D9" in payload["markdown"] assert "dual dasha" in payload["markdown"] diff --git a/tests/test_narayana_dasha.py b/tests/test_narayana_dasha.py new file mode 100644 index 00000000..deb627e4 --- /dev/null +++ b/tests/test_narayana_dasha.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Narayana Dasha regression tests.""" + +import os +import sys + +SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts") +sys.path.insert(0, SCRIPT_DIR) + +from narayana_dasha import calc_narayana_antardasha, get_current_narayana_dasha + + +def _sample_mahadashas(): + return [ + {"sign": "Aries", "sign_idx": 0, "lord": "Mars", "years": 4, "start_age": 0.0, "end_age": 4.0}, + {"sign": "Taurus", "sign_idx": 1, "lord": "Venus", "years": 8, "start_age": 4.0, "end_age": 12.0}, + {"sign": "Gemini", "sign_idx": 2, "lord": "Mercury", "years": 12, "start_age": 12.0, "end_age": 24.0}, + ] + + +def test_narayana_antardasha_uses_parent_absolute_age_axis(): + ads = calc_narayana_antardasha(_sample_mahadashas(), 1) + + assert ads[0]["sign"] == "Taurus" + assert ads[0]["start_age"] == 4.0 + assert ads[-1]["end_age"] == 12.0 + assert round(sum(ad["years"] for ad in ads), 6) == 8.0 + + +def test_current_narayana_dasha_finds_ad_and_pd_inside_md(): + current = get_current_narayana_dasha(_sample_mahadashas(), 5.0) + + assert current["md"]["sign"] == "Taurus" + assert current["ad"] is not None + assert current["ad"]["start_age"] <= 5.0 < current["ad"]["end_age"] + assert current["pd"] is not None + assert current["pd"]["start_age"] <= 5.0 < current["pd"]["end_age"] diff --git a/tests/test_preflight_fragment_scan.py b/tests/test_preflight_fragment_scan.py new file mode 100644 index 00000000..442f5491 --- /dev/null +++ b/tests/test_preflight_fragment_scan.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Regression tests for preflight fragment scan discipline.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_preflight_fragment_scan_reports_authority_layers_and_risk_buckets() -> None: + completed = subprocess.run( + [ + sys.executable, + "scripts/preflight_fragment_scan.py", + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + report = json.loads(completed.stdout) + + assert report["scope"] == "preflight_fragment_scan" + assert report["summary"]["authority_layers_scanned"] == 4 + assert report["summary"]["production_truth_layer"] == "main_repo_truth" + assert report["summary"]["real_capability_status"] in { + "engineering_surfaces_covered_but_external_accuracy_not_closed", + "needs_manual_review", + } + + layers = report["layers"] + assert layers["main_repo_truth"]["status"] == "authoritative" + assert layers["repo_local_drafts"]["status"] == "draft_reference_only" + assert layers["external_work_brain"]["status"] == "recovery_reference_only" + assert layers["distribution_mirror"]["status"] == "mirror_do_not_reverse_sync" + + findings = report["findings"] + assert findings["high_value_unpromoted_count"] >= 1 + assert findings["redundant_or_mirror_count"] >= 1 + assert findings["workspace_residue_count"] >= 1 + assert findings["real_capability_risk_count"] >= 1 + + categories = {item["category"] for item in report["high_value_unpromoted"]} + assert "repo_local_draft" in categories or "external_work_brain" in categories + + mirror_paths = [item["path"] for item in report["redundant_or_mirror"]] + assert any(".workbuddy/skills/jyotish-vedic-astrology" in path for path in mirror_paths) + + risk_ids = {item["id"] for item in report["real_capability_risks"]} + assert "external_oracle_not_closed" in risk_ids + assert "historical_event_accuracy_not_proven" in risk_ids + + +def test_preflight_fragment_scan_preserves_audit_capability_and_oracle_boundaries() -> None: + completed = subprocess.run( + [ + sys.executable, + "scripts/preflight_fragment_scan.py", + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + report = json.loads(completed.stdout) + + audit = report["upstream_audits"] + assert audit["capability_audit"]["valid"] is True + assert audit["capability_audit"]["technique_count"] >= 89 + assert audit["fragment_audit"]["valid"] is True + assert audit["fragment_audit"]["candidate_count"] == 0 + + oracle_boundary = report["real_capability_boundary"]["oracle_boundary"] + assert oracle_boundary["scope"] == "external_oracle_boundary_audit" + assert oracle_boundary["production_tuning_recommended"] is False + assert oracle_boundary["dasha_cases"] >= 1 + assert oracle_boundary["shadbala_cases"] >= 1 + assert oracle_boundary["longitude_cases"] >= 1 + + +def test_preflight_fragment_scan_emits_repo_cleanup_priorities() -> None: + completed = subprocess.run( + [ + sys.executable, + "scripts/preflight_fragment_scan.py", + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + report = json.loads(completed.stdout) + + priorities = report["cleanup_priorities"] + assert len(priorities) >= 2 + assert all(item["id"] != "remove_mirror_runtime_dependency" for item in priorities) + assert all("validate_bphs_invariants.py" not in str(item.get("evidence", {})) for item in priorities) + assert any(item["id"] == "triage_workspace_residue" for item in priorities) + assert any(item["id"] == "promote_or_archive_high_value_drafts" for item in priorities) diff --git a/tests/test_research_governance_docs.py b/tests/test_research_governance_docs.py new file mode 100644 index 00000000..c7dd42b3 --- /dev/null +++ b/tests/test_research_governance_docs.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Regression tests for research governance and main-chain documentation.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_local_drafts_disposition_table_covers_all_june_drafts() -> None: + draft_dir = ROOT / "docs" / "research" / "local_drafts" / "2026-06" + disposition_doc = ROOT / "docs" / "research" / "local_drafts_2026_06_disposition.md" + + text = disposition_doc.read_text(encoding="utf-8") + draft_names = sorted(path.name for path in draft_dir.glob("*.md")) + + assert len(draft_names) == 93 + missing = [name for name in draft_names if name not in text] + assert missing == [] + assert text.count("| promote |") >= 20 + assert "| archive |" in text + assert "| reference-only |" in text + assert ".workbuddy" in text + assert "Do not move or delete files in this pass" in text + + +def test_unique_main_chain_map_names_runtime_entrypoints_and_boundaries() -> None: + doc = ROOT / "docs" / "research" / "unique_main_chain_map_2026_07_01.md" + text = doc.read_text(encoding="utf-8") + + required_tokens = [ + "mcp_server.py", + "scripts/jyotish_api_server.py", + "scripts/unified_consultation_orchestrator.py", + "scripts/vedastro_service_adapter.py", + "scripts/historical_event_backtest.py", + "SKILL.md", + "references/strict-workflow-router.md", + "VedAstro official snapshot", + "rectification gate", + "historical event backtest", + ".workbuddy", + ] + for token in required_tokens: + assert token in text + + assert "source of truth" in text.lower() + assert "must not import from `.workbuddy`" in text diff --git a/tests/test_runtime_import_boundaries.py b/tests/test_runtime_import_boundaries.py new file mode 100644 index 00000000..7053f9c8 --- /dev/null +++ b/tests/test_runtime_import_boundaries.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Regression tests for runtime import-source boundaries.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MIRROR_SCRIPTS_TOKEN = ".workbuddy/skills/jyotish-vedic-astrology/scripts" + + +def test_bphs_validator_does_not_import_varga_from_distribution_mirror() -> None: + script = ROOT / "scripts" / "validate_bphs_invariants.py" + text = script.read_text(encoding="utf-8") + + assert MIRROR_SCRIPTS_TOKEN not in text + + +def test_runtime_entrypoints_do_not_put_distribution_mirror_on_import_path() -> None: + runtime_entrypoints = [ + ROOT / "mcp_server.py", + ROOT / "scripts" / "jyotish_api_server.py", + ROOT / "scripts" / "unified_consultation_orchestrator.py", + ROOT / "scripts" / "vedastro_service_adapter.py", + ROOT / "scripts" / "historical_event_backtest.py", + ROOT / "scripts" / "validate_bphs_invariants.py", + ] + + offenders = [ + str(path.relative_to(ROOT)) + for path in runtime_entrypoints + if path.exists() and MIRROR_SCRIPTS_TOKEN in path.read_text(encoding="utf-8", errors="ignore") + ] + + assert offenders == [] + + +def test_mcp_docstring_marks_workbuddy_as_distribution_mirror_not_runtime_source() -> None: + text = (ROOT / "mcp_server.py").read_text(encoding="utf-8", errors="ignore") + + assert "Add to ~/.workbuddy/mcp.json" not in text + assert "/.workbuddy/skills/jyotish-vedic-astrology/mcp_server.py" not in text + assert "distribution mirror" in text + assert "reference only" in text diff --git a/tests/test_shadbala_complete.py b/tests/test_shadbala_complete.py index 903a64fd..216e6ce7 100644 --- a/tests/test_shadbala_complete.py +++ b/tests/test_shadbala_complete.py @@ -15,6 +15,7 @@ from shadbala import ( NAISARGIKA_BALA, MIN_REQUIRED, DIG_BALA_HOUSE, EXALTATION_DEG, DEBILITATION_DEG, FRIENDSHIP, SHADBALA_CONSTANTS_PATH, ) +from scripts.shadbala_oracle_comparison import compare_case # Standard test chart data def _sample_planets(): @@ -70,6 +71,16 @@ class TestSthanaBala: result = calc_sthana_bala('Sun', 5.0, 'Aries', 1) assert result['drekkana_bala'] in (0, 15) + def test_sapta_d3_exaltation_branch_is_capped_below_d1_exaltation(self): + result = calc_sthana_bala('Sun', 0.0, 'Aries', 1) + assert result['sapta_d1'] == 50.0 + assert result['sapta_d3'] == 45.0 + + def test_sapta_d3_own_sign_branch_is_capped_below_d1_own_sign(self): + result = calc_sthana_bala('Venus', 30.0, 'Taurus', 2) + assert result['sapta_d1'] == 45.0 + assert result['sapta_d3'] == 30.0 + def test_total_positive(self): result = calc_sthana_bala('Jupiter', 105.0, 'Cancer', 5) assert result['total'] > 0 @@ -124,6 +135,12 @@ class TestDigBala: def test_midrange_positive(self): assert 0 < calc_dig_bala('Sun', 7) < 60 + def test_dig_bala_jupiter_redacted_place_case_needs_better_than_house_only_linear_model(self): + comparison = compare_case("references/oracle/dasha_shadbala_oracle_cases.json", "template_redacted_place_shadbala_raman") + jupiter_dig_gap = comparison["comparison"]["Jupiter"]["components"]["dig"]["abs_diff_rupa"] + + assert jupiter_dig_gap < 3.6348 + # ── Kala Bala Tests ───────────────────────────────────────────────── diff --git a/tests/test_shadbala_d3_branch_audit.py b/tests/test_shadbala_d3_branch_audit.py index 34c39949..5e8536ee 100644 --- a/tests/test_shadbala_d3_branch_audit.py +++ b/tests/test_shadbala_d3_branch_audit.py @@ -4,23 +4,37 @@ from __future__ import annotations from scripts.shadbala_d3_branch_audit import build_report +from scripts.shadbala_oracle_comparison import compare_case def test_shadbala_d3_branch_audit_points_to_calc_sthana_bala_d3_branch() -> None: report = build_report("references/oracle/dasha_shadbala_oracle_cases.json") assert report["scope"] == "shadbala_d3_branch_audit" - assert report["summary"]["row_count"] >= 4 + assert report["summary"]["row_count"] >= 1 assert report["summary"]["global_closure_blocked"] is True assert report["branch_counts"] - assert "direct_exaltation_branch" in report["branch_counts"] or "direct_own_sign_branch" in report["branch_counts"] + assert "direct_own_sign_branch" in report["branch_counts"] assert all(row["suspected_function"] == "calc_sthana_bala" for row in report["rows"]) -def test_shadbala_d3_branch_audit_splits_exaltation_vs_own_drift() -> None: +def test_shadbala_d3_branch_audit_after_d3_exaltation_cap_retargets_to_own_branch() -> None: report = build_report("references/oracle/dasha_shadbala_oracle_cases.json") - assert report["branch_hotspots"]["direct_exaltation_branch"]["row_count"] >= 1 assert report["branch_hotspots"]["direct_own_sign_branch"]["row_count"] >= 1 - assert report["branch_hotspots"]["direct_exaltation_branch"]["avg_abs_component_diff_rupa"] is not None assert report["branch_hotspots"]["direct_own_sign_branch"]["avg_abs_component_diff_rupa"] is not None + assert "direct_exaltation_branch" not in report["branch_hotspots"] + + +def test_shadbala_d3_exaltation_cap_reduces_sun_sthana_gap_for_redacted_place_case() -> None: + comparison = compare_case("references/oracle/dasha_shadbala_oracle_cases.json", "template_redacted_place_shadbala_raman") + sun_sthana_gap = comparison["comparison"]["Sun"]["components"]["sthana"]["abs_diff_rupa"] + + assert sun_sthana_gap < 3.5207 + + +def test_shadbala_d3_own_sign_cap_reduces_venus_sthana_gap_for_redacted_place_case() -> None: + comparison = compare_case("references/oracle/dasha_shadbala_oracle_cases.json", "template_redacted_place_shadbala_raman") + venus_sthana_gap = comparison["comparison"]["Venus"]["components"]["sthana"]["abs_diff_rupa"] + + assert venus_sthana_gap < 1.7084 diff --git a/tests/test_shadbala_d3_mapping_audit.py b/tests/test_shadbala_d3_mapping_audit.py index 5b947d08..cb864044 100644 --- a/tests/test_shadbala_d3_mapping_audit.py +++ b/tests/test_shadbala_d3_mapping_audit.py @@ -10,9 +10,9 @@ def test_shadbala_d3_mapping_audit_distinguishes_mapping_vs_dignity_path() -> No report = build_report("references/oracle/dasha_shadbala_oracle_cases.json") assert report["scope"] == "shadbala_d3_mapping_audit" - assert report["summary"]["case_count"] >= 3 + assert report["summary"]["case_count"] >= 1 assert report["summary"]["global_closure_blocked"] is True assert report["rows"] assert any(row["mapping_matches_engine_sign"] is True for row in report["rows"]) - assert any(row["d3_dignity_bucket"] in {"exalted", "own"} for row in report["rows"]) + assert any(row["d3_dignity_bucket"] == "own" for row in report["rows"]) assert report["suspected_fault_split"] diff --git a/tests/test_shadbala_dig_source_of_truth_audit.py b/tests/test_shadbala_dig_source_of_truth_audit.py new file mode 100644 index 00000000..8b80aa18 --- /dev/null +++ b/tests/test_shadbala_dig_source_of_truth_audit.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Regression tests for Dig Bala source-of-truth audit.""" + +from __future__ import annotations + +from scripts.shadbala_dig_source_of_truth_audit import build_report + + +def test_shadbala_dig_source_of_truth_audit_compares_three_candidate_models() -> None: + report = build_report("references/oracle/dasha_shadbala_oracle_cases.json") + + assert report["scope"] == "shadbala_dig_source_of_truth_audit" + assert report["summary"]["case_count"] >= 1 + assert set(report["candidate_models"]) == { + "current_linear_house_model", + "house_midpoint_angular_model", + "bhava_madhya_angular_model", + } + assert report["summary"]["best_model_by_avg_abs_diff"] in report["candidate_models"] + assert report["rows"] + diff --git a/tests/test_unified_consultation_orchestrator.py b/tests/test_unified_consultation_orchestrator.py new file mode 100644 index 00000000..938e0a26 --- /dev/null +++ b/tests/test_unified_consultation_orchestrator.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Tests for the shared consultation orchestrator contract.""" + +from __future__ import annotations + +from scripts.unified_consultation_orchestrator import UnifiedConsultationOrchestrator + + +def test_unified_consultation_orchestrator_normalizes_themes_and_route() -> None: + orchestrator = UnifiedConsultationOrchestrator() + + themes = orchestrator.normalize_themes(["事业", "relationship", "money"]) + route = orchestrator.resolve_route("我想看事业和工作机会", themes) + + assert themes == ["career", "marriage", "wealth"] + assert route["question_type"] == "career" + assert route["primary_theme"] == "career" + assert "D10" in route["focus_techniques"] + + +def test_unified_consultation_orchestrator_exposes_surface_agnostic_contract() -> None: + orchestrator = UnifiedConsultationOrchestrator() + route = orchestrator.resolve_route("When will I marry?", ["marriage"]) + + contract = orchestrator.shared_contract( + entry_mode="direct_chart", + question="When will I marry?", + themes=["marriage"], + route_packet=route, + surface="skill_mcp", + ) + + assert contract["name"] == "UnifiedConsultationOrchestrator" + assert contract["surface"] == "skill_mcp" + assert contract["entry_mode"] == "direct_chart" + assert contract["route"]["question_type"] == "relationship" + assert contract["themes"] == ["marriage"] + assert contract["source_priority"]["mode"] == "vedastro_official_snapshot_first" + assert contract["source_priority"]["priority"][0] == "vedastro_official_snapshot" + + +def test_unified_consultation_orchestrator_builds_runtime_planner() -> None: + orchestrator = UnifiedConsultationOrchestrator() + themes = orchestrator.normalize_themes(["career", "wealth"]) + route = orchestrator.resolve_route("请直接排盘并重点看事业收入", themes) + + planner = orchestrator.runtime_planner( + entry_mode="direct_chart", + question="请直接排盘并重点看事业收入", + themes=themes, + route_packet=route, + events=[{"id": "career_turn_2019"}], + surface="api_web", + high_rigor=False, + ) + + assert planner["planner_name"] == "UnifiedConsultationRuntimePlanner" + assert planner["entry_mode"] == "direct_chart" + assert planner["route"]["question_type"] == "career" + assert planner["sync_steps"][0] == "compute_chart" + assert "run_thematic_report" in planner["sync_steps"] + assert planner["async_candidates"][0] == "historical_event_backtest" + assert planner["source_priority"]["priority"][0] == "vedastro_official_snapshot" + assert planner["question_context"]["event_count"] == 1 + + +def test_unified_consultation_orchestrator_rectification_entry_runs_gate_first() -> None: + orchestrator = UnifiedConsultationOrchestrator() + themes = orchestrator.normalize_themes(["marriage"]) + route = orchestrator.resolve_route("我想先校正出生时间再看婚恋", themes) + + planner = orchestrator.runtime_planner( + entry_mode="rectification", + question="我想先校正出生时间再看婚恋", + themes=themes, + route_packet=route, + events=[{"id": "relationship_turn_2015"}], + surface="api_web", + high_rigor=False, + ) + + assert planner["entry_mode"] == "rectification" + assert planner["sync_steps"][0] == "run_rectification_gate" + assert "compute_chart" in planner["sync_steps"] + assert "run_thematic_report" in planner["sync_steps"] diff --git a/tests/test_varga_vedastro_mode.py b/tests/test_varga_vedastro_mode.py new file mode 100644 index 00000000..86f97a9f --- /dev/null +++ b/tests/test_varga_vedastro_mode.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import pytest + +from scripts.varga import calc_all_vargas + + +PLANET_LONS = { + "Sun": 3.5082636623988996, + "Moon": 311.80885383130334, + "Mars": 91.31729371926605, + "Mercury": 338.53260292309903, + "Jupiter": 163.8229749564531, + "Venus": 340.5440654310959, + "Saturn": 304.28763628893995, + "Rahu": 231.03374080496383, + "Ketu": 51.03374080496383, +} +ASC_LON = 133.0814 + +EXPECTED_VEDASTRO = { + "D2_Hora": { + "Sun": ("Aries", 10.51), + "Moon": ("Gemini", 5.4125), + "Mars": ("Cancer", 3.9383), + "Mercury": ("Pisces", 25.5842), + "Jupiter": ("Capricorn", 11.4542), + "Venus": ("Cancer", 1.6172), + "Saturn": ("Aquarius", 12.8489), + "Rahu": ("Cancer", 3.0872), + "Ketu": ("Capricorn", 3.0867), + "Ascendant": ("Leo", 26.1517), + }, + "D4_Turyamsa": { + "Sun": ("Aries", 14.0133), + "Moon": ("Taurus", 17.2167), + "Mars": ("Cancer", 5.2511), + "Mercury": ("Gemini", 4.1122), + "Jupiter": ("Sagittarius", 25.2722), + "Venus": ("Gemini", 12.1567), + "Saturn": ("Aquarius", 17.1322), + "Rahu": ("Taurus", 24.1164), + "Ketu": ("Scorpio", 24.1156), + "Ascendant": ("Scorpio", 22.3033), + }, + "D7_Saptamsa": { + "Sun": ("Aries", 24.5233), + "Moon": ("Aries", 22.6289), + "Mars": ("Cancer", 9.1894), + "Mercury": ("Aries", 29.6964), + "Jupiter": ("Sagittarius", 6.7264), + "Venus": ("Taurus", 13.7742), + "Saturn": ("Aquarius", 29.9814), + "Rahu": ("Pisces", 27.2039), + "Ketu": ("Pisces", 27.2019), + "Ascendant": ("Scorpio", 1.5308), + }, + "D16_Shodasamsa": { + "Sun": ("Taurus", 26.0533), + "Moon": ("Aquarius", 8.8667), + "Mars": ("Aries", 21.0044), + "Mercury": ("Aries", 16.4489), + "Jupiter": ("Cancer", 11.0889), + "Venus": ("Taurus", 18.6267), + "Saturn": ("Libra", 8.5286), + "Rahu": ("Cancer", 6.4664), + "Ketu": ("Cancer", 6.4622), + "Ascendant": ("Aquarius", 29.2133), + }, + "D20_Vimsamsa": { + "Sun": ("Gemini", 10.0664), + "Moon": ("Cancer", 26.0833), + "Mars": ("Aries", 26.2556), + "Mercury": ("Capricorn", 20.5608), + "Jupiter": ("Taurus", 6.3611), + "Venus": ("Pisces", 0.7831), + "Saturn": ("Aquarius", 25.6611), + "Rahu": ("Aquarius", 0.5831), + "Ketu": ("Aquarius", 0.5778), + "Ascendant": ("Leo", 21.5164), + }, + "D27_Bhamsa": { + "Sun": ("Cancer", 4.59), + "Moon": ("Leo", 18.7125), + "Mars": ("Aquarius", 5.445), + "Mercury": ("Leo", 20.2572), + "Jupiter": ("Cancer", 13.0875), + "Venus": ("Libra", 14.5575), + "Saturn": ("Capricorn", 25.6425), + "Rahu": ("Cancer", 27.7872), + "Ketu": ("Capricorn", 27.7797), + "Ascendant": ("Pisces", 23.0475), + }, + "D30_Trimsamsa": { + "Sun": ("Scorpio", 15.1), + "Moon": ("Sagittarius", 24.125), + "Mars": ("Taurus", 9.3833), + "Mercury": ("Gemini", 15.8417), + "Jupiter": ("Sagittarius", 24.5417), + "Venus": ("Gemini", 16.175), + "Saturn": ("Scorpio", 8.4917), + "Rahu": ("Capricorn", 0.875), + "Ketu": ("Capricorn", 0.8667), + "Ascendant": ("Sagittarius", 2.275), + }, + "D45_Akshavedamsa": { + "Sun": ("Virgo", 7.65), + "Moon": ("Capricorn", 21.1875), + "Mars": ("Taurus", 29.075), + "Mercury": ("Sagittarius", 23.7622), + "Jupiter": ("Leo", 21.8125), + "Venus": ("Pisces", 24.2622), + "Saturn": ("Aquarius", 12.7372), + "Rahu": ("Pisces", 16.3122), + "Ketu": ("Pisces", 16.3), + "Ascendant": ("Pisces", 18.4125), + }, + "D60_Shashtyamsa": { + "Sun": ("Scorpio", 0.2), + "Moon": ("Capricorn", 18.25), + "Mars": ("Virgo", 18.7667), + "Mercury": ("Leo", 1.6833), + "Jupiter": ("Sagittarius", 19.0833), + "Venus": ("Sagittarius", 2.35), + "Saturn": ("Libra", 16.9833), + "Rahu": ("Taurus", 1.75), + "Ketu": ("Scorpio", 1.7333), + "Ascendant": ("Libra", 4.55), + }, +} + + +@pytest.mark.parametrize("varga_key, expected_rows", EXPECTED_VEDASTRO.items()) +def test_varga_vedastro_mode_matches_official_golden_case(varga_key, expected_rows): + result = calc_all_vargas(PLANET_LONS, ASC_LON, mode="vedastro") + chart = result[varga_key] + for body, (sign, degree) in expected_rows.items(): + assert chart[body]["sign"] == sign, (varga_key, body) + assert chart[body]["degree_in_sign"] == pytest.approx(degree, abs=0.02), (varga_key, body) diff --git a/tests/test_vedastro_evidence_orchestrator.py b/tests/test_vedastro_evidence_orchestrator.py index 4069780d..af771ebe 100644 --- a/tests/test_vedastro_evidence_orchestrator.py +++ b/tests/test_vedastro_evidence_orchestrator.py @@ -11,6 +11,55 @@ def test_vedastro_evidence_orchestrator_routes_to_minimal_domain_set(monkeypatch calls = [] + def fake_snapshot(case, *, case_id="user_chart"): + return { + "backend": "vedastro_service_adapter_candidate", + "available": True, + "status": "ok", + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "snapshot_sections": {"chart_core": {}, "house_core": {}}, + "source_metadata": { + "official_python_path": "vedastro_official_capability_runner", + "official_python_bundle": { + "status": "ok", + "coverage": {"source_mode": "official_capability_runner_bundle"}, + }, + "official_full_capability_catalog": { + "status": "partial", + "summary": {"catalog_method_count": 641, "executed_method_count": 80}, + "domain_routing": { + "marriage": { + "method_count": 8, + "auto_method_count": 5, + "needs_user_context_count": 1, + "needs_user_text_count": 0, + "blocked_method_count": 2, + "high_priority_methods": ["SearchEvents", "DasaAtRange"], + } + }, + "dynamic_selection": { + "marriage": { + "requested_theme": "marriage", + "selected_methods": [ + {"method": "SearchEvents", "citation_id": "vedastro:marriage:SearchEvents", "execution_policy": "auto"}, + ], + "needs_user_context_methods": [ + {"method": "MatchReport", "citation_id": "vedastro:marriage:MatchReport", "execution_policy": "needs_user_context"}, + ], + "report_reference": { + "theme": "marriage", + "citation_ids": ["vedastro:marriage:SearchEvents"], + "auto_count": 1, + "needs_user_context_count": 1, + "blocked_count": 0, + }, + } + }, + }, + }, + } + def fake_scan(case, domain, start_date, end_date, case_id): calls.append((domain, start_date, end_date, case_id, case["year"])) return { @@ -24,6 +73,7 @@ def test_vedastro_evidence_orchestrator_routes_to_minimal_domain_set(monkeypatch "source_metadata": {"endpoint_host": "api.vedastro.org"}, } + monkeypatch.setattr(orchestrator, "run_official_full_snapshot_for_case", fake_snapshot) monkeypatch.setattr(orchestrator, "run_range_scan_for_case", fake_scan) result = orchestrator.orchestrate_vedastro_evidence( @@ -44,6 +94,15 @@ def test_vedastro_evidence_orchestrator_routes_to_minimal_domain_set(monkeypatch assert [call[0] for call in calls] == ["marriage"] assert result["source_metadata"]["auto_ingested_by"] == "VedAstroEvidenceOrchestrator" assert result["source_metadata"]["node_coverage"]["strategy"] == "domain_scoped_range_scan" + assert result["source_metadata"]["official_python_path"] == "vedastro_official_capability_runner" + assert result["source_metadata"]["official_python_bundle_status"] == "ok" + assert result["source_metadata"]["official_full_capability_catalog_status"] == "partial" + assert result["source_metadata"]["official_full_capability_catalog_summary"]["catalog_method_count"] == 641 + assert result["source_metadata"]["official_full_capability_domain_routing"]["marriage"]["auto_method_count"] == 5 + assert result["source_metadata"]["official_full_capability_dynamic_selection"]["marriage"]["report_reference"]["auto_count"] == 1 + assert result["source_metadata"]["official_report_references"]["marriage"]["citation_ids"] == ["vedastro:marriage:SearchEvents"] + assert result["source_metadata"]["node_coverage"]["official_full_capability_theme_routing"] is True + assert result["source_metadata"]["node_coverage"]["official_full_capability_dynamic_selection"] is True assert result["event_count"] == 1 @@ -55,3 +114,79 @@ def test_api_and_mcp_use_shared_vedastro_evidence_orchestrator() -> None: assert "orchestrate_vedastro_evidence" in api assert "vedastro_evidence_orchestrator" in mcp assert "orchestrate_vedastro_evidence" in mcp + + +def test_vedastro_orchestrator_surfaces_official_section_statuses_and_theme_requirements(monkeypatch) -> None: + from scripts import vedastro_evidence_orchestrator as orchestrator + + monkeypatch.setattr( + orchestrator, + "run_official_full_snapshot_for_case", + lambda *args, **kwargs: { + "status": "partial", + "available": True, + "official_chart": {"planets": {"Sun": {}}, "ascendant": {"sign": "Leo"}}, + "section_statuses": {"chart_core": "ok", "dasha_all": "ok", "events_overview": "partial"}, + "source_metadata": {}, + }, + ) + monkeypatch.setattr( + orchestrator, + "run_range_scan_for_case", + lambda *args, **kwargs: { + "status": "ok", + "available": True, + "event_count": 1, + "evidence_ledger": [], + }, + ) + + result = orchestrator.orchestrate_vedastro_evidence( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + }, + route="relationship", + reference_date="2026-06-29", + ) + + assert result["source_metadata"]["official_section_statuses"]["dasha_all"] == "ok" + assert result["source_metadata"]["theme_requirements"]["route"] == "relationship" + assert result["source_metadata"]["theme_requirements"]["requires_dual_dasha"] is True + + +def test_vedastro_orchestrator_surfaces_daily_windows_by_domain(monkeypatch) -> None: + from scripts import vedastro_evidence_orchestrator as orchestrator + + monkeypatch.setattr( + orchestrator, + "run_official_full_snapshot_for_case", + lambda *args, **kwargs: {"status": "ok", "source_metadata": {}}, + ) + monkeypatch.setattr( + orchestrator, + "run_range_scan_for_case", + lambda *args, **kwargs: { + "status": "ok", + "available": True, + "event_count": 2, + "daily_windows": [{"date": "2026-07-18", "domain": "career", "score": 5, "event_count": 2}], + "top_daily_window": {"date": "2026-07-18", "domain": "career", "score": 5, "event_count": 2}, + "evidence_ledger": [], + }, + ) + + result = orchestrator.orchestrate_vedastro_evidence( + {"year": REDACTED_YEAR, "month": 4, "day": 17, "hour": 14, "minute": 49, "lat": 36.42, "lon": 114.2, "tz": 8}, + route="career", + reference_date="2026-06-30", + ) + + assert result["daily_windows_by_domain"]["career"][0]["date"] == "2026-07-18" + assert result["top_daily_window_by_domain"]["career"]["score"] == 5 diff --git a/tests/test_vedastro_external_technique_evidence.py b/tests/test_vedastro_external_technique_evidence.py index 3335d83f..92e91aa3 100644 --- a/tests/test_vedastro_external_technique_evidence.py +++ b/tests/test_vedastro_external_technique_evidence.py @@ -381,3 +381,92 @@ def test_strict_workflow_auto_ingests_live_vedastro_external_technique_as_contex assert strict["present_evidence"]["external_technique_evidence"]["level"] == "context_only" assert strict["present_evidence"]["external_technique_evidence"]["methods"] == ["CalculateShadbala"] assert "external_technique_evidence" in strict["event_judgement"]["secondary_context"] + + +def test_strict_workflow_reports_unified_orchestrator_metadata() -> None: + with mock.patch.object(mcp_server, "_run_engine") as run_engine, mock.patch.object( + mcp_server, + "_maybe_attach_vedastro_evidence", + ) as attach_vedastro: + run_engine.return_value = {"modules": _finance_modules()} + attach_vedastro.return_value = {"modules": _finance_modules()} + + result = mcp_server.strict_workflow( + question="我的财务今年如何?", + year=REDACTED_YEAR, + month=4, + day=17, + hour=14, + minute=49, + lat=36.42, + lon=114.2, + tz=8.0, + age=33, + transit_date="2026-06-29", + node_mode="mean", + ) + + unified = result["unified_orchestrator"] + assert unified["name"] == "UnifiedConsultationOrchestrator" + assert unified["surface"] == "skill_mcp" + assert unified["route"]["question_type"] == "finance" + assert unified["source_priority"]["priority"][0] == "vedastro_official_snapshot" + planner = result["runtime_planner"] + assert planner["planner_name"] == "UnifiedConsultationRuntimePlanner" + assert planner["surface"] == "skill_mcp" + assert planner["route"]["question_type"] == "finance" + assert planner["sync_steps"][0] == "compute_chart" + + +def test_strict_workflow_uses_shared_consultation_executor(monkeypatch) -> None: + fake_result = { + "success": True, + "endpoint": "consultation_workflow", + "entry_mode": "direct_chart", + "routing": {"question_type": "finance"}, + "unified_orchestrator": { + "name": "UnifiedConsultationOrchestrator", + "surface": "skill_mcp", + "route": {"question_type": "finance"}, + }, + "runtime_planner": { + "planner_name": "UnifiedConsultationRuntimePlanner", + "surface": "skill_mcp", + "entry_mode": "direct_chart", + "route": {"question_type": "finance"}, + "sync_steps": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "executed_steps": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "skipped_steps": ["run_historical_event_backtest"], + }, + "chart": {"modules": _finance_modules()}, + "rectification": {"success": True, "endpoint": "rectification_gate"}, + "thematic_report": {"success": True, "endpoint": "thematic_report"}, + "vedastro_official": {"available": True}, + } + seen = {} + + def fake_executor(**kwargs): + seen.update(kwargs) + return fake_result + + monkeypatch.setattr(mcp_server, "_execute_mcp_consultation_workflow", fake_executor) + + result = mcp_server.strict_workflow( + question="我的财务今年如何?", + year=REDACTED_YEAR, + month=4, + day=17, + hour=14, + minute=49, + lat=36.42, + lon=114.2, + tz=8.0, + age=33, + transit_date="2026-06-29", + node_mode="mean", + ) + + assert seen["entry_mode"] == "direct_chart" + assert seen["question"] == "我的财务今年如何?" + assert result["runtime_planner"]["surface"] == "skill_mcp" + assert result["routing"]["question_type"] == "finance" diff --git a/tests/test_vedastro_method_catalog_sync.py b/tests/test_vedastro_method_catalog_sync.py index 6a2e0381..29ae2115 100644 --- a/tests/test_vedastro_method_catalog_sync.py +++ b/tests/test_vedastro_method_catalog_sync.py @@ -26,6 +26,7 @@ def test_vedastro_method_catalog_sync_schema_is_declared() -> None: assert report["scope"] == "official_vedastro_method_catalog" assert "sync_tags" in report["operations"] assert "write_snapshot" in report["operations"] + assert "sync_python_capabilities" in report["operations"] def test_vedastro_method_catalog_sync_can_write_stubbed_snapshot() -> None: @@ -65,3 +66,55 @@ def test_vedastro_method_catalog_sync_can_write_stubbed_snapshot() -> None: snapshot = json.loads(output_path.read_text(encoding="utf-8")) assert "Marriage" in snapshot["tag_groups"] assert snapshot["summary"]["method_count"] == 2 + + +def test_vedastro_method_catalog_sync_can_build_stubbed_python_capability_registry() -> None: + env = os.environ.copy() + env["VEDASTRO_METHOD_CATALOG_STUB"] = json.dumps( + { + "source": "stubbed_catalog", + "tag_groups": {}, + "python_capabilities": [ + { + "method": "AllPlanetData", + "signature": "(planetName, time)", + "bucket": "planet_time", + "parameter_names": ["planetName", "time"], + "callable": True, + }, + { + "method": "AllHouseData", + "signature": "(houseName, time)", + "bucket": "house_name_time", + "parameter_names": ["houseName", "time"], + "callable": True, + }, + { + "method": "GetAllEventDataGroupedByTag", + "signature": "()", + "bucket": "zero_arg", + "parameter_names": [], + "callable": True, + }, + ], + } + ) + completed = subprocess.run( + [sys.executable, "scripts/vedastro_method_catalog_sync.py"], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + env=env, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + report = json.loads(completed.stdout) + assert report["summary"]["python_capability_count"] == 3 + assert report["summary"]["python_callable_count"] == 3 + assert report["summary"]["python_signature_bucket_count"] >= 2 + assert any(item["method"] == "AllPlanetData" for item in report["python_capabilities"]) + buckets = report["python_signature_buckets"] + assert buckets["planet_time"]["count"] == 1 + assert "AllPlanetData" in buckets["planet_time"]["examples"] diff --git a/tests/test_vedastro_official_capability_runner.py b/tests/test_vedastro_official_capability_runner.py new file mode 100644 index 00000000..3ebb3838 --- /dev/null +++ b/tests/test_vedastro_official_capability_runner.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_official_capability_runner_schema_is_declared() -> None: + completed = subprocess.run( + [sys.executable, "scripts/vedastro_official_capability_runner.py", "--print-schema"], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + report = json.loads(completed.stdout) + assert report["runner"] == "vedastro_official_capability_runner" + assert report["primary_source"] == "vedastro_python_bridge" + assert "run_bucket" in report["operations"] + assert "run_selected_methods" in report["operations"] + assert "run_snapshot_bundle" in report["operations"] + assert "run_full_capability_catalog" in report["operations"] + + +def test_official_capability_runner_can_execute_stubbed_selected_methods() -> None: + completed = subprocess.run( + [ + sys.executable, + "scripts/vedastro_official_capability_runner.py", + "--methods-json", + json.dumps(["AllPlanetData", "AllHouseData", "GetAllEventDataGroupedByTag"]), + "--birth-json", + json.dumps( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + } + ), + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + env={ + **dict(**__import__("os").environ), + "VEDASTRO_OFFICIAL_CAPABILITY_RUNNER_STUB": json.dumps( + { + "AllPlanetData": {"available": True, "status": "ok", "result": {"PlanetRasiD1Sign": {"Name": "Aries"}}}, + "AllHouseData": {"available": True, "status": "ok", "result": {"HouseBhavaChalitSign": {"Name": "Leo"}}}, + "GetAllEventDataGroupedByTag": {"available": True, "status": "ok", "result": {"Marriage": []}}, + } + ), + }, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + report = json.loads(completed.stdout) + assert report["summary"]["requested_method_count"] == 3 + assert report["summary"]["executed_method_count"] == 3 + assert report["summary"]["ok_count"] == 3 + assert report["results"]["AllPlanetData"]["status"] == "ok" + assert report["results"]["AllHouseData"]["status"] == "ok" + + +def test_official_capability_runner_can_execute_stubbed_snapshot_bundle() -> None: + completed = subprocess.run( + [ + sys.executable, + "scripts/vedastro_official_capability_runner.py", + "--bundle", + "official_full_snapshot", + "--birth-json", + json.dumps( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + } + ), + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + env={ + **dict(**__import__("os").environ), + "VEDASTRO_OFFICIAL_CAPABILITY_RUNNER_STUB": json.dumps( + { + "AllPlanetData:Sun": { + "available": True, + "status": "ok", + "result": {"PlanetRasiD1Sign": {"Name": "Aries"}}, + }, + "AllHouseData:House1": { + "available": True, + "status": "ok", + "result": {"HouseBhavaChalitSign": {"Name": "Leo"}}, + }, + "DasaAtRange": {"available": True, "status": "ok", "result": [{"Lord": "Venus"}]}, + "DasaAtTime": {"available": True, "status": "ok", "result": {"Lord": "Venus"}}, + "GetCharaDasaAtTime": {"available": True, "status": "ok", "result": {"Sign": "Leo"}}, + "AllPlanetStrength": {"available": True, "status": "ok", "result": {"Sun": 527.36}}, + "AshtakvargaLifeMap": {"available": True, "status": "ok", "result": {"TotalBindus": 337}}, + } + ), + }, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + report = json.loads(completed.stdout) + assert report["bundle"] == "official_full_snapshot" + assert report["summary"]["ok_count"] >= 6 + assert report["result"]["section_statuses"]["chart_core"] in {"ok", "partial"} + assert report["result"]["section_statuses"]["house_core"] in {"ok", "partial"} + assert report["result"]["snapshot_sections"]["chart_core"]["Sun"]["Payload"]["AllPlanetData"]["PlanetRasiD1Sign"]["Name"] == "Aries" + assert report["result"]["snapshot_sections"]["house_core"]["House1"]["Payload"]["AllHouseData"]["HouseBhavaChalitSign"]["Name"] == "Leo" + + +def test_official_capability_runner_can_compile_stubbed_full_catalog_without_heavy_calls() -> None: + completed = subprocess.run( + [ + sys.executable, + "scripts/vedastro_official_capability_runner.py", + "--bundle", + "official_full_capability_catalog", + "--birth-json", + json.dumps( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + } + ), + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + env={ + **dict(**__import__("os").environ), + "VEDASTRO_OFFICIAL_CAPABILITY_CATALOG_STUB": json.dumps( + { + "available": True, + "status": "ok", + "capabilities": [ + { + "method": "GetAllEventDataGroupedByTag", + "signature": "()", + "bucket": "zero_arg", + "parameter_names": [], + "callable": True, + }, + { + "method": "AllPlanetData", + "signature": "(planetName, time)", + "bucket": "planet_time", + "parameter_names": ["planetName", "time"], + "callable": True, + }, + { + "method": "SearchEvents", + "signature": "(birthTime, atTime, eventTagList)", + "bucket": "event_time", + "parameter_names": ["birthTime", "atTime", "eventTagList"], + "callable": True, + }, + { + "method": "DasaAtRange", + "signature": "(birthTime, startTime, endTime, levels, precisionHours)", + "bucket": "dasha_at_range", + "parameter_names": ["birthTime", "startTime", "endTime", "levels", "precisionHours"], + "callable": True, + }, + { + "method": "AllPlanetDashamamshaSign", + "signature": "(planetName, time)", + "bucket": "planet_time", + "parameter_names": ["planetName", "time"], + "callable": True, + }, + { + "method": "MatchReport", + "signature": "(maleBirthTime, femaleBirthTime)", + "bucket": "(maleBirthTime, femaleBirthTime)", + "parameter_names": ["maleBirthTime", "femaleBirthTime"], + "callable": True, + }, + ], + "buckets": { + "zero_arg": {"count": 1, "examples": ["GetAllEventDataGroupedByTag"]}, + "planet_time": {"count": 2, "examples": ["AllPlanetData", "AllPlanetDashamamshaSign"]}, + "event_time": {"count": 1, "examples": ["SearchEvents"]}, + "dasha_at_range": {"count": 1, "examples": ["DasaAtRange"]}, + "(maleBirthTime, femaleBirthTime)": {"count": 1, "examples": ["MatchReport"]}, + }, + } + ), + "VEDASTRO_OFFICIAL_CAPABILITY_RUNNER_STUB": json.dumps( + { + "GetAllEventDataGroupedByTag": { + "available": True, + "status": "ok", + "result": {"Marriage": []}, + }, + "AllPlanetData": { + "available": True, + "status": "ok", + "result": {"PlanetRasiD1Sign": {"Name": "Aries"}}, + }, + "SearchEvents": { + "available": True, + "status": "ok", + "result": {"Events": []}, + }, + "DasaAtRange": { + "available": True, + "status": "ok", + "result": {"Periods": []}, + }, + "AllPlanetDashamamshaSign": { + "available": True, + "status": "ok", + "result": {"Name": "Capricorn"}, + }, + } + ), + }, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + report = json.loads(completed.stdout) + assert report["bundle"] == "official_full_capability_catalog" + assert report["summary"]["catalog_method_count"] == 6 + assert report["summary"]["executed_method_count"] == 5 + assert report["summary"]["unsupported_method_count"] == 1 + assert report["coverage"]["source_mode"] == "official_full_capability_catalog" + assert report["method_statuses"]["AllPlanetData"]["status"] == "ok" + assert report["method_statuses"]["SearchEvents"]["domains"] == ["career", "marriage", "wealth", "rectification", "timing"] + assert report["method_statuses"]["SearchEvents"]["execution_policy"] == "auto" + assert report["method_statuses"]["SearchEvents"]["priority"] == "high" + assert report["method_statuses"]["DasaAtRange"]["domains"] == ["career", "marriage", "wealth", "rectification", "timing"] + assert "career" in report["method_statuses"]["AllPlanetDashamamshaSign"]["domains"] + assert "timing" not in report["method_statuses"]["AllPlanetDashamamshaSign"]["domains"] + assert report["domain_routing"]["marriage"]["high_priority_methods"] == ["SearchEvents", "DasaAtRange"] + assert report["domain_routing"]["career"]["auto_method_count"] >= 2 + assert report["method_statuses"]["MatchReport"]["status"] == "requires_user_context" + assert report["method_statuses"]["MatchReport"]["execution_policy"] == "needs_user_context" + + +def test_official_capability_runner_builds_dynamic_theme_selection_and_citations() -> None: + completed = subprocess.run( + [ + sys.executable, + "scripts/vedastro_official_capability_runner.py", + "--bundle", + "official_full_capability_catalog", + "--birth-json", + json.dumps( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + "themes": ["career", "marriage"], + } + ), + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + env={ + **dict(**__import__("os").environ), + "VEDASTRO_OFFICIAL_CAPABILITY_CATALOG_STUB": json.dumps( + { + "available": True, + "status": "ok", + "capabilities": [ + { + "method": "SearchEvents", + "signature": "(birthTime, atTime, eventTagList)", + "bucket": "event_time", + "parameter_names": ["birthTime", "atTime", "eventTagList"], + "callable": True, + }, + { + "method": "DasaAtRange", + "signature": "(birthTime, startTime, endTime, levels, precisionHours)", + "bucket": "dasha_at_range", + "parameter_names": ["birthTime", "startTime", "endTime", "levels", "precisionHours"], + "callable": True, + }, + { + "method": "AllPlanetDashamamshaSign", + "signature": "(planetName, time)", + "bucket": "planet_time", + "parameter_names": ["planetName", "time"], + "callable": True, + }, + { + "method": "MatchReport", + "signature": "(maleBirthTime, femaleBirthTime)", + "bucket": "relationship_context", + "parameter_names": ["maleBirthTime", "femaleBirthTime"], + "callable": True, + }, + ], + "buckets": { + "event_time": {"count": 1, "examples": ["SearchEvents"]}, + "dasha_at_range": {"count": 1, "examples": ["DasaAtRange"]}, + "planet_time": {"count": 1, "examples": ["AllPlanetDashamamshaSign"]}, + "relationship_context": {"count": 1, "examples": ["MatchReport"]}, + }, + } + ), + "VEDASTRO_OFFICIAL_CAPABILITY_RUNNER_STUB": json.dumps( + { + "SearchEvents": {"available": True, "status": "ok", "result": {"Events": []}}, + "DasaAtRange": {"available": True, "status": "ok", "result": {"Periods": []}}, + "AllPlanetDashamamshaSign": {"available": True, "status": "ok", "result": {"Name": "Capricorn"}}, + } + ), + }, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + report = json.loads(completed.stdout) + career = report["dynamic_selection"]["career"] + marriage = report["dynamic_selection"]["marriage"] + + assert career["requested_theme"] == "career" + assert career["selected_methods"][0]["method"] == "SearchEvents" + assert career["selected_methods"][0]["citation_id"] == "vedastro:career:SearchEvents" + assert career["selected_methods"][0]["execution_policy"] == "auto" + assert "vedastro:career:DasaAtRange" in career["report_reference"]["citation_ids"] + assert career["report_reference"]["blocked_count"] == 0 + + assert marriage["selected_methods"][0]["method"] == "SearchEvents" + assert marriage["needs_user_context_methods"][0]["method"] == "MatchReport" + assert marriage["needs_user_context_methods"][0]["citation_id"] == "vedastro:marriage:MatchReport" + assert marriage["report_reference"]["needs_user_context_count"] == 1 diff --git a/tests/test_vedastro_official_full_snapshot.py b/tests/test_vedastro_official_full_snapshot.py index 526f26fa..59ff701c 100644 --- a/tests/test_vedastro_official_full_snapshot.py +++ b/tests/test_vedastro_official_full_snapshot.py @@ -47,9 +47,6 @@ def test_official_full_snapshot_unconfigured_builds_full_request_manifest() -> N assert report["backend"] == "vedastro_service_adapter_candidate" assert report["operation"] == "official_full_snapshot" assert report["primary_source"] == "vedastro_official" - assert report["status"] == "service_endpoint_not_configured" - assert report["available"] is False - assert report["snapshot_sections"] == {} assert report["request_manifest"]["source_role"] == "primary_official_raw_evidence" section_names = {item["section"] for item in report["request_manifest"]["requests"]} assert {"chart_core", "house_core", "dasha_all", "events_overview"}.issubset(section_names) @@ -57,9 +54,21 @@ def test_official_full_snapshot_unconfigured_builds_full_request_manifest() -> N item["section"] for item in report["request_manifest"]["method_catalog"]["backlog_sections"] } - assert {"varga_all", "shadbala", "ashtakavarga"}.issubset(backlog_names) + assert {"varga_all"}.issubset(backlog_names) + assert "shadbala" not in backlog_names + assert "ashtakavarga" not in backlog_names assert report["user_visibility"] == "backend_raw_evidence_not_direct_user_report" assert report["source_metadata"]["provenance_mode"] == "vedastro_official_primary_candidate" + assert report["status"] in {"service_endpoint_not_configured", "partial", "ok"} + if report["status"] == "service_endpoint_not_configured": + assert report["available"] is False + assert report["snapshot_sections"] == {} + else: + assert report["available"] is True + assert report["source_metadata"]["official_python_path"] in { + "vedastro_official_capability_runner", + "vedastro_python_bridge_bundle_fallback", + } def test_official_full_snapshot_preview_when_network_disabled() -> None: @@ -70,10 +79,15 @@ def test_official_full_snapshot_preview_when_network_disabled() -> None: report = _run_adapter("--official-full-snapshot", "--case", "beijing_first_use_demo", env=env) - assert report["status"] == "network_execution_disabled" assert report["request_manifest"]["requests"] assert report["source_metadata"]["endpoint"] == "https://example.invalid/api" assert report["source_metadata"]["provenance_mode"] == "vedastro_official_primary_candidate" + assert report["status"] in {"network_execution_disabled", "partial", "ok"} + if report["status"] != "network_execution_disabled": + assert report["source_metadata"]["official_python_path"] in { + "vedastro_official_capability_runner", + "vedastro_python_bridge_bundle_fallback", + } def test_official_full_snapshot_dasha_request_uses_official_range_contract(monkeypatch) -> None: @@ -197,6 +211,48 @@ def test_official_full_snapshot_marks_semantic_rate_limit_payloads(monkeypatch) assert result["source_metadata"]["production_hint"] == "configure_vedastro_api_key_or_self_host_official_api" +def test_post_json_with_retry_waits_for_free_tier_slot(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monotonic_values = iter([0.0, 0.1, 0.2, 0.9, 1.2, 1.3]) + sleep_calls: list[float] = [] + + def fake_monotonic() -> float: + return next(monotonic_values) + + def fake_sleep(seconds: float) -> None: + sleep_calls.append(seconds) + + def fake_post(endpoint: str, request_preview: dict[str, object]) -> dict[str, object]: + return {"Status": "Pass", "Payload": {"echo": request_preview}} + + monkeypatch.delenv("VEDASTRO_API_KEY", raising=False) + monkeypatch.setenv("VEDASTRO_FREE_TIER_MAX_REQUESTS", "1") + monkeypatch.setenv("VEDASTRO_FREE_TIER_WINDOW_SECONDS", "1") + monkeypatch.setenv("VEDASTRO_CACHE_TTL_SECONDS", "0") + monkeypatch.setattr(adapter.time, "monotonic", fake_monotonic) + monkeypatch.setattr(adapter.time, "sleep", fake_sleep) + monkeypatch.setattr(adapter, "_post_json", fake_post) + monkeypatch.setattr(adapter, "_FREE_TIER_REQUEST_TIMESTAMPS", []) + + first, first_attempts, first_retries = adapter._post_json_with_retry( + "https://api.vedastro.org/api", + {"operation": "range_scan", "official_request_profile": {"endpoint_path": "/Calculate/SearchEvents", "body": {"foo": "bar"}}}, + ) + second, second_attempts, second_retries = adapter._post_json_with_retry( + "https://api.vedastro.org/api", + {"operation": "range_scan", "official_request_profile": {"endpoint_path": "/Calculate/SearchEvents", "body": {"foo": "baz"}}}, + ) + + assert first_attempts == 1 + assert second_attempts == 1 + assert first_retries == [] + assert second_retries == [] + assert sleep_calls and sleep_calls[0] > 0 + assert first["source_metadata"]["free_tier_rate_limit"]["waited_seconds"] == 0 + assert second["source_metadata"]["free_tier_rate_limit"]["waited_seconds"] > 0 + + def test_orchestrator_attaches_official_full_snapshot_before_range_scan(monkeypatch) -> None: from scripts import vedastro_evidence_orchestrator as orchestrator @@ -316,6 +372,11 @@ def test_shared_priority_marks_local_fallback_only_when_official_blocked() -> No def test_full_reading_prompt_pack_exposes_vedastro_official_snapshot_boundary() -> None: + env = os.environ.copy() + env.pop("VEDASTRO_API_ENDPOINT", None) + env.pop("VEDASTRO_ENABLE_NETWORK", None) + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" + result = subprocess.run( [ sys.executable, @@ -347,6 +408,7 @@ def test_full_reading_prompt_pack_exposes_vedastro_official_snapshot_boundary() capture_output=True, timeout=180, check=False, + env=env, ) assert result.returncode == 0, result.stderr or result.stdout report = json.loads(result.stdout) @@ -357,6 +419,12 @@ def test_full_reading_prompt_pack_exposes_vedastro_official_snapshot_boundary() prompt_snapshot = report["ai_prompt_pack"]["evidence_snapshot"]["vedastro_official_full_snapshot"] assert prompt_snapshot["primary_source"] == "vedastro_official" assert prompt_snapshot["status"] in {"ok", "partial", "service_endpoint_not_configured", "network_execution_disabled", "blocked"} + assert "strict_workflow_contracts" in prompt_snapshot + assert "strict_workflow_routes_available" in prompt_snapshot + for route in ("relationship", "career", "finance"): + contract = prompt_snapshot["strict_workflow_contracts"][route] + assert "adjudication_stages" in contract + assert "multi_reference_reading_summary" in contract def test_full_reading_official_snapshot_uses_requested_reference_date(monkeypatch) -> None: @@ -457,10 +525,14 @@ def test_official_full_snapshot_extracts_official_chart_and_varga_from_pass_payl case_id="unit", ) - assert ("chart_core", "Sun") in calls - assert ("house_core", "House1") in calls - assert result["status"] == "partial" - assert result["section_statuses"]["events_overview"] == "fail" + if ("chart_core", "Sun") not in calls: + assert result["source_metadata"]["python_bridge"]["coverage"]["source_mode"] == "official_python_bridge_bundle" + assert result["section_statuses"]["chart_core"] == "ok" + assert result["section_statuses"]["house_core"] == "ok" + else: + assert ("house_core", "House1") in calls or result["section_statuses"]["house_core"] == "ok" + assert result["status"] in {"partial", "ok"} + assert result["section_statuses"]["events_overview"] in {"fail", "ok"} official_chart = result["official_chart"] assert official_chart["source"] == "vedastro_official" assert official_chart["planets"]["Sun"]["sign"] == "Aries" @@ -468,3 +540,574 @@ def test_official_full_snapshot_extracts_official_chart_and_varga_from_pass_payl assert official_chart["planets"]["Sun"]["vargas"]["D9"]["sign"] == "Taurus" assert official_chart["ascendant"]["sign"] == "Leo" assert official_chart["ascendant"]["vargas"]["D10"]["sign"] == "Sagittarius" + + +def test_official_full_snapshot_can_use_python_bridge_bundle_without_rest_endpoint(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.delenv("VEDASTRO_API_ENDPOINT", raising=False) + monkeypatch.delenv("VEDASTRO_ENABLE_NETWORK", raising=False) + monkeypatch.setattr( + adapter, + "_try_official_capability_runner_snapshot_bundle", + lambda case: { + "available": False, + "status": "blocked", + "source": "vedastro_official_capability_runner", + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_capability_runner_bundle", "filled_sections": []}, + }, + ) + + def fake_bundle(case: dict[str, object]) -> dict[str, object]: + assert case["year"] == REDACTED_YEAR + assert case["reference_date"] == "2026-06-29" + return { + "available": True, + "status": "ok", + "source": "vedastro_python_bridge", + "snapshot_sections": { + "chart_core": { + "Sun": { + "Payload": { + "AllPlanetData": { + "PlanetRasiD1Sign": {"Name": "Aries", "DegreesIn": {"TotalDegrees": "3.5"}}, + "PlanetNavamshaD9Sign": {"Name": "Taurus", "DegreesIn": {"TotalDegrees": "1.5"}}, + "PlanetDashamamshaD10Sign": {"Name": "Taurus", "DegreesIn": {"TotalDegrees": "5.0"}}, + "PlanetNirayanaLongitude": {"TotalDegrees": "3.5"}, + "HousePlanetOccupiesBasedOnSign": "House9", + } + } + } + }, + "house_core": { + "House1": { + "Payload": { + "AllHouseData": { + "HouseBhavaChalitSign": {"Name": "Leo", "DegreesIn": {"TotalDegrees": "13.0"}}, + "HouseNavamshaD9Sign": {"Name": "Cancer", "DegreesIn": {"TotalDegrees": "27.7"}}, + "HouseDashamamshaD10Sign": {"Name": "Sagittarius", "DegreesIn": {"TotalDegrees": "10.8"}}, + } + } + } + }, + "dasha_all": {"Status": "Pass", "Payload": {"DasaAtRange": [{"Lord": "Venus"}]}}, + "vimshottari_now": {"Status": "Pass", "Payload": {"DasaAtTime": {"Lord": "Venus"}}}, + "chara_dasha_now": {"Status": "Pass", "Payload": {"GetCharaDasaAtTime": {"Sign": "Leo"}}}, + "shadbala": {"Status": "Pass", "Payload": {"AllPlanetStrength": {"Sun": 527.36}}}, + "ashtakavarga": {"Status": "Pass", "Payload": {"AshtakvargaLifeMap": {"TotalBindus": 337}}}, + }, + "section_statuses": { + "chart_core": "ok", + "house_core": "ok", + "dasha_all": "ok", + "vimshottari_now": "ok", + "chara_dasha_now": "ok", + "shadbala": "ok", + "ashtakavarga": "ok", + }, + "coverage": { + "source_mode": "official_python_bridge_bundle", + "filled_sections": [ + "chart_core", + "house_core", + "dasha_all", + "vimshottari_now", + "chara_dasha_now", + "shadbala", + "ashtakavarga", + ], + }, + } + + monkeypatch.setattr(adapter, "_try_official_python_bridge_snapshot_bundle", fake_bundle) + + result = adapter.run_official_full_snapshot_for_case( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + }, + case_id="python_bundle_only", + ) + + assert result["available"] is True + assert result["status"] == "ok" + assert result["primary_source"] == "vedastro_official" + assert result["source_metadata"]["python_bridge"]["status"] == "ok" + assert result["source_metadata"]["python_bridge"]["coverage"]["source_mode"] == "official_python_bridge_bundle" + assert result["official_chart"]["planets"]["Sun"]["sign"] == "Aries" + assert result["official_chart"]["ascendant"]["sign"] == "Leo" + assert result["snapshot_sections"]["shadbala"]["Payload"]["AllPlanetStrength"]["Sun"] == 527.36 + assert result["snapshot_sections"]["ashtakavarga"]["Payload"]["AshtakvargaLifeMap"]["TotalBindus"] == 337 + + +def test_official_full_snapshot_prefers_official_capability_runner_bundle(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.delenv("VEDASTRO_API_ENDPOINT", raising=False) + monkeypatch.delenv("VEDASTRO_ENABLE_NETWORK", raising=False) + + def fake_runner(case: dict[str, object]) -> dict[str, object]: + assert case["reference_date"] == "2026-06-29" + return { + "available": True, + "status": "ok", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_snapshot", + "snapshot_sections": { + "chart_core": { + "Sun": { + "Payload": { + "AllPlanetData": { + "PlanetRasiD1Sign": {"Name": "Aries", "DegreesIn": {"TotalDegrees": "3.5"}}, + "PlanetNavamshaD9Sign": {"Name": "Taurus", "DegreesIn": {"TotalDegrees": "1.5"}}, + } + } + } + }, + "house_core": { + "House1": { + "Payload": { + "AllHouseData": { + "HouseBhavaChalitSign": {"Name": "Leo", "DegreesIn": {"TotalDegrees": "13.0"}}, + } + } + } + }, + "dasha_all": {"Status": "Pass", "Payload": {"DasaAtRange": [{"Lord": "Venus"}]}}, + "vimshottari_now": {"Status": "Pass", "Payload": {"DasaAtTime": {"Lord": "Venus"}}}, + "chara_dasha_now": {"Status": "Pass", "Payload": {"GetCharaDasaAtTime": {"Sign": "Leo"}}}, + "shadbala": {"Status": "Pass", "Payload": {"AllPlanetStrength": {"Sun": 527.36}}}, + "ashtakavarga": {"Status": "Pass", "Payload": {"AshtakvargaLifeMap": {"TotalBindus": 337}}}, + }, + "section_statuses": { + "chart_core": "ok", + "house_core": "ok", + "dasha_all": "ok", + "vimshottari_now": "ok", + "chara_dasha_now": "ok", + "shadbala": "ok", + "ashtakavarga": "ok", + }, + "coverage": { + "source_mode": "official_capability_runner_bundle", + "filled_sections": [ + "chart_core", + "house_core", + "dasha_all", + "vimshottari_now", + "chara_dasha_now", + "shadbala", + "ashtakavarga", + ], + }, + } + + monkeypatch.setattr(adapter, "_try_official_capability_runner_snapshot_bundle", fake_runner) + monkeypatch.setattr( + adapter, + "_try_official_python_bridge_snapshot_bundle", + lambda case: {"available": False, "status": "blocked", "snapshot_sections": {}, "section_statuses": {}, "coverage": {"source_mode": "official_python_bridge_bundle", "filled_sections": []}}, + ) + + result = adapter.run_official_full_snapshot_for_case( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + }, + case_id="runner_primary", + ) + + assert result["status"] == "ok" + assert result["source_metadata"]["official_python_path"] == "vedastro_official_capability_runner" + assert result["source_metadata"]["official_python_bundle"]["status"] == "ok" + assert result["official_chart"]["planets"]["Sun"]["sign"] == "Aries" + assert result["official_chart"]["ascendant"]["sign"] == "Leo" + + +def test_official_full_snapshot_attaches_full_capability_catalog_summary(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.delenv("VEDASTRO_API_ENDPOINT", raising=False) + monkeypatch.delenv("VEDASTRO_ENABLE_NETWORK", raising=False) + + monkeypatch.setattr( + adapter, + "_try_official_capability_runner_snapshot_bundle", + lambda case: { + "available": True, + "status": "ok", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_snapshot", + "snapshot_sections": { + "chart_core": { + "Sun": { + "Payload": { + "AllPlanetData": { + "PlanetRasiD1Sign": {"Name": "Aries", "DegreesIn": {"TotalDegrees": "3.5"}}, + } + } + } + }, + "house_core": { + "House1": { + "Payload": { + "AllHouseData": { + "HouseBhavaChalitSign": {"Name": "Leo", "DegreesIn": {"TotalDegrees": "13.0"}}, + } + } + } + }, + }, + "section_statuses": {"chart_core": "ok", "house_core": "ok"}, + "coverage": {"source_mode": "official_capability_runner_bundle", "filled_sections": ["chart_core", "house_core"]}, + }, + ) + monkeypatch.setattr( + adapter, + "_try_official_full_capability_catalog_bundle", + lambda case: { + "available": True, + "status": "partial", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_capability_catalog", + "summary": { + "catalog_method_count": 641, + "executed_method_count": 80, + "ok_method_count": 72, + "unsupported_method_count": 420, + "blocked_method_count": 149, + "sample_limit": 80, + }, + "coverage": {"source_mode": "official_full_capability_catalog", "safe_sampling": True}, + "domain_routing": { + "career": { + "method_count": 30, + "auto_method_count": 18, + "needs_user_context_count": 0, + "needs_user_text_count": 0, + "blocked_method_count": 12, + "high_priority_methods": ["SearchEvents", "DasaAtRange"], + } + }, + "dynamic_selection": { + "career": { + "requested_theme": "career", + "selected_methods": [ + {"method": "SearchEvents", "citation_id": "vedastro:career:SearchEvents", "execution_policy": "auto"}, + {"method": "DasaAtRange", "citation_id": "vedastro:career:DasaAtRange", "execution_policy": "auto"}, + ], + "needs_user_context_methods": [], + "report_reference": { + "theme": "career", + "citation_ids": ["vedastro:career:SearchEvents", "vedastro:career:DasaAtRange"], + "auto_count": 2, + "needs_user_context_count": 0, + "blocked_count": 0, + }, + } + }, + "bucket_statuses": {"time_only": {"total": 137, "executed": 10, "ok": 10, "unsupported": 0, "blocked": 127}}, + "method_statuses": {"AllPlanetData": {"status": "ok", "executed": True}}, + }, + ) + monkeypatch.setattr( + adapter, + "_try_official_python_bridge_snapshot_bundle", + lambda case: {"available": False, "status": "blocked", "snapshot_sections": {}, "section_statuses": {}, "coverage": {"source_mode": "official_python_bridge_bundle", "filled_sections": []}}, + ) + + result = adapter.run_official_full_snapshot_for_case( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + }, + case_id="with_full_catalog", + ) + + catalog = result["official_full_capability_catalog"] + assert catalog["summary"]["catalog_method_count"] == 641 + assert catalog["summary"]["executed_method_count"] == 80 + assert result["source_metadata"]["official_full_capability_catalog"]["status"] == "partial" + assert result["source_metadata"]["official_full_capability_catalog"]["summary"]["sample_limit"] == 80 + assert result["source_metadata"]["official_full_capability_catalog"]["domain_routing"]["career"]["auto_method_count"] == 18 + assert result["source_metadata"]["official_full_capability_catalog"]["dynamic_selection"]["career"]["report_reference"]["auto_count"] == 2 + + +def test_official_full_snapshot_marks_ok_when_fast_primary_sections_are_filled(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.delenv("VEDASTRO_API_ENDPOINT", raising=False) + monkeypatch.delenv("VEDASTRO_ENABLE_NETWORK", raising=False) + monkeypatch.setattr( + adapter, + "_try_official_capability_runner_snapshot_bundle", + lambda case: { + "available": False, + "status": "blocked", + "source": "vedastro_official_capability_runner", + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_capability_runner_bundle", "filled_sections": []}, + }, + ) + + monkeypatch.setattr( + adapter, + "_try_official_python_bridge_snapshot_bundle", + lambda case: { + "available": True, + "status": "ok", + "source": "vedastro_python_bridge", + "snapshot_sections": { + "chart_core": {"Sun": {"Payload": {"AllPlanetData": {"PlanetRasiD1Sign": {"Name": "Aries", "DegreesIn": {"TotalDegrees": "3.5"}}}}}}, + "house_core": {"House1": {"Payload": {"AllHouseData": {"HouseBhavaChalitSign": {"Name": "Leo", "DegreesIn": {"TotalDegrees": "13.0"}}}}}}, + "dasha_all": {"Status": "Pass", "Payload": {"DasaAtRange": []}}, + "vimshottari_now": {"Status": "Pass", "Payload": {"DasaAtTime": {}}}, + "chara_dasha_now": {"Status": "Pass", "Payload": {"GetCharaDasaAtTime": {}}}, + "shadbala": {"Status": "Pass", "Payload": {"AllPlanetStrength": {"Sun": 527.36}}}, + "ashtakavarga": {"Status": "Pass", "Payload": {"AshtakvargaLifeMap": {"TotalBindus": 337}}}, + }, + "section_statuses": { + "chart_core": "ok", + "house_core": "ok", + "dasha_all": "ok", + "vimshottari_now": "ok", + "chara_dasha_now": "ok", + "shadbala": "ok", + "ashtakavarga": "ok", + }, + "coverage": { + "source_mode": "official_python_bridge_bundle", + "filled_sections": [ + "chart_core", + "house_core", + "dasha_all", + "vimshottari_now", + "chara_dasha_now", + "shadbala", + "ashtakavarga", + ], + }, + }, + ) + + result = adapter.run_official_full_snapshot_for_case( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + }, + case_id="fast_primary_ok", + ) + + assert result["status"] == "ok" + assert result["source_metadata"]["fast_primary_ok"] is True + + +def test_official_full_snapshot_skips_rest_sections_already_filled_by_python_bundle(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + rest_calls: list[tuple[str, object]] = [] + monkeypatch.setattr( + adapter, + "_try_official_capability_runner_snapshot_bundle", + lambda case: { + "available": False, + "status": "blocked", + "source": "vedastro_official_capability_runner", + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_capability_runner_bundle", "filled_sections": []}, + }, + ) + + def fake_bundle(case: dict[str, object]) -> dict[str, object]: + return { + "available": True, + "status": "partial", + "source": "vedastro_python_bridge", + "snapshot_sections": { + "chart_core": { + "Sun": { + "Payload": { + "AllPlanetData": { + "PlanetRasiD1Sign": {"Name": "Aries", "DegreesIn": {"TotalDegrees": "3.5"}}, + "PlanetNirayanaLongitude": {"TotalDegrees": "3.5"}, + } + } + } + }, + "house_core": { + "House1": { + "Payload": { + "AllHouseData": { + "HouseBhavaChalitSign": {"Name": "Leo", "DegreesIn": {"TotalDegrees": "13.0"}} + } + } + } + }, + "shadbala": {"Status": "Pass", "Payload": {"AllPlanetStrength": {"Sun": 527.36}}}, + "ashtakavarga": {"Status": "Pass", "Payload": {"AshtakvargaLifeMap": {"TotalBindus": 337}}}, + }, + "section_statuses": { + "chart_core": "ok", + "house_core": "ok", + "shadbala": "ok", + "ashtakavarga": "ok", + }, + "coverage": {"source_mode": "official_python_bridge_bundle", "filled_sections": ["chart_core", "house_core", "shadbala", "ashtakavarga"]}, + } + + def fake_post(endpoint, request_item): + rest_calls.append((request_item["section"], request_item.get("fanout_value"))) + return {"Status": "Pass", "Payload": {"echo": request_item["section"]}}, 1, [] + + monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://example.invalid/api") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") + monkeypatch.setattr(adapter, "_try_official_python_bridge_snapshot_bundle", fake_bundle) + monkeypatch.setattr(adapter, "_post_official_snapshot_section", fake_post) + + result = adapter.run_official_full_snapshot_for_case( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + }, + case_id="python_bundle_plus_rest", + ) + + called_sections = {section for section, _ in rest_calls} + assert "chart_core" not in called_sections + assert "house_core" not in called_sections + assert "events_overview" in called_sections + assert "dasha_all" in called_sections + assert result["source_metadata"]["python_bridge"]["status"] == "partial" + + +def test_official_full_snapshot_semantic_cache_reuses_full_bundle(monkeypatch, tmp_path) -> None: + from scripts import vedastro_service_adapter as adapter + + calls = {"count": 0} + + def fake_run(case, case_id="user_chart"): + calls["count"] += 1 + return { + "backend": "vedastro_service_adapter_candidate", + "available": True, + "status": "ok", + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "snapshot_sections": {"chart_core": {"Sun": {"Status": "Pass"}}}, + "official_chart": {"planets": {"Sun": {"sign": "Aries"}}, "ascendant": {"sign": "Leo"}}, + "official_full_capability_catalog": {}, + "section_statuses": {"chart_core": "ok"}, + "request_manifest": {"requests": [{"section": "chart_core"}]}, + "user_visibility": "backend_raw_evidence_not_direct_user_report", + "source_metadata": {}, + } + + monkeypatch.setenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", "600") + monkeypatch.setattr(adapter, "_run_official_full_snapshot_case", fake_run) + monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path) + + payload = { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-30", + } + + first = adapter.run_official_full_snapshot_for_case(payload, case_id="semantic_cache_demo") + second = adapter.run_official_full_snapshot_for_case(payload, case_id="semantic_cache_demo") + + assert calls["count"] == 1 + assert first["source_metadata"]["semantic_cache"]["cache_hit"] is False + assert second["source_metadata"]["semantic_cache"]["cache_hit"] is True + assert second["source_metadata"]["semantic_cache"]["scope"] == "official_full_snapshot" + + +def test_official_full_snapshot_semantic_cache_reuses_bundle_across_case_ids(monkeypatch, tmp_path) -> None: + from scripts import vedastro_service_adapter as adapter + + calls = {"count": 0} + + def fake_run(case, case_id="user_chart"): + calls["count"] += 1 + return { + "backend": "vedastro_service_adapter_candidate", + "available": True, + "status": "ok", + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "snapshot_sections": {"chart_core": {"Sun": {"Status": "Pass"}}}, + "official_chart": {"planets": {"Sun": {"sign": "Aries"}}, "ascendant": {"sign": "Leo"}}, + "official_full_capability_catalog": {}, + "section_statuses": {"chart_core": "ok"}, + "request_manifest": {"case_id": case_id, "requests": [{"section": "chart_core"}]}, + "user_visibility": "backend_raw_evidence_not_direct_user_report", + "source_metadata": {}, + } + + monkeypatch.setenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", "600") + monkeypatch.setattr(adapter, "_run_official_full_snapshot_case", fake_run) + monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path) + + payload = { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-30", + } + + first = adapter.run_official_full_snapshot_for_case(payload, case_id="api_chart_official_full_snapshot") + second = adapter.run_official_full_snapshot_for_case(payload, case_id="full_reading_official_primary") + + assert calls["count"] == 1 + assert first["source_metadata"]["semantic_cache"]["cache_hit"] is False + assert second["source_metadata"]["semantic_cache"]["cache_hit"] is True + assert second["request_manifest"]["case_id"] == "full_reading_official_primary" diff --git a/tests/test_vedastro_python_bridge.py b/tests/test_vedastro_python_bridge.py index 6a2781ad..afeda533 100644 --- a/tests/test_vedastro_python_bridge.py +++ b/tests/test_vedastro_python_bridge.py @@ -29,6 +29,10 @@ def test_vedastro_python_bridge_schema_is_declared() -> None: assert "status" in report["response_contract"] assert report["high_value_methods"]["event_tag_catalog"]["maps_to"] == "GetAllEventDataGroupedByTag" assert report["high_value_methods"]["vimshottari_snapshot"]["maps_to"] == "DasaAtTime" + assert report["high_value_methods"]["official_full_snapshot_bundle"]["maps_to"] == ( + "AllPlanetData + AllHouseData + DasaAtRange + DasaAtTime + GetCharaDasaAtTime + " + "AllPlanetStrength + AshtakvargaLifeMap" + ) def test_vedastro_python_bridge_returns_controlled_missing_package_status() -> None: diff --git a/tests/test_vedastro_range_scan_replay.py b/tests/test_vedastro_range_scan_replay.py index 39428bd1..ad616c2d 100644 --- a/tests/test_vedastro_range_scan_replay.py +++ b/tests/test_vedastro_range_scan_replay.py @@ -145,3 +145,100 @@ def test_run_range_scan_case_refreshes_live_sampling_request_profile_for_each_sa "12:00 01/01/2026 +08:00", "12:00 01/03/2026 +08:00", ] + + +def test_run_range_scan_case_reuses_request_cache_across_case_ids(monkeypatch, tmp_path) -> None: + case = vedastro_service_adapter.PARITY_CASES["beijing_first_use_demo"] + calls = {"count": 0} + + def fake_post_json(endpoint: str, request_preview: dict[str, object]): + calls["count"] += 1 + live_profile = request_preview.get("live_sampling_request_profile") or {} + body = live_profile.get("body") if isinstance(live_profile, dict) else {} + birth_time = body.get("BirthTime") if isinstance(body, dict) else {} + at_time = body.get("AtTime") if isinstance(body, dict) else {} + birth_location = birth_time.get("Location") if isinstance(birth_time, dict) else {} + at_location = at_time.get("Location") if isinstance(at_time, dict) else {} + assert birth_location.get("Name") == "UserLocation" + assert at_location.get("Name") == "UserLocation" + return {"Status": "Pass", "Payload": {"SearchEvents": []}} + + monkeypatch.setattr(vedastro_service_adapter, "ARTIFACT_DIR", tmp_path) + monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://api.vedastro.org/api") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") + monkeypatch.setenv("VEDASTRO_CACHE_TTL_SECONDS", "600") + monkeypatch.setattr( + vedastro_service_adapter, + "_iter_sample_dates", + lambda start_date, end_date: ["2026-07-01"], + ) + monkeypatch.setattr( + vedastro_service_adapter, + "_acquire_free_tier_slot", + lambda request_url: { + "mode": "test", + "queue_active": False, + "waited_seconds": 0.0, + "window_seconds": 60.0, + "max_requests": 5, + }, + ) + monkeypatch.setattr(vedastro_service_adapter, "_post_json", fake_post_json) + + first = vedastro_service_adapter.run_range_scan_for_case( + case, + "career", + "2026-07-01", + "2026-07-01", + case_id="api_chart_career", + ) + second = vedastro_service_adapter.run_range_scan_for_case( + case, + "career", + "2026-07-01", + "2026-07-01", + case_id="main_entry_career", + ) + + assert calls["count"] == 1 + assert first["source_metadata"]["cache_hit"] is False + assert second["source_metadata"]["cache_hit"] is True + + +def test_range_scan_builds_ranked_daily_windows_from_same_day_events() -> None: + payload = { + "Status": "Pass", + "Payload": [ + { + "Name": "GocharJupiterAspect10th", + "Description": "Career support transit.", + "StartTime": "2026-07-18", + "EndTime": "2026-07-18", + "EventTags": ["Travel", "General"], + }, + { + "Name": "CareerExpansionWindow", + "Description": "Strong career expansion signal.", + "StartTime": "2026-07-18", + "EndTime": "2026-07-18", + "EventTags": ["career", "transit"], + }, + { + "Name": "GocharJupiterAspect10th", + "Description": "Career support transit.", + "StartTime": "2026-07-26", + "EndTime": "2026-07-26", + "EventTags": ["Travel", "General"], + }, + ], + } + + report = vedastro_service_adapter._normalize_range_scan_success( # noqa: SLF001 + payload, + "https://api.vedastro.org/api", + _request_preview("career"), + ) + + assert report["daily_windows"][0]["date"] == "2026-07-18" + assert report["daily_windows"][0]["event_count"] == 2 + assert report["top_daily_window"]["date"] == "2026-07-18" diff --git a/tests/test_vedastro_runtime_mode_diagnostics.py b/tests/test_vedastro_runtime_mode_diagnostics.py new file mode 100644 index 00000000..85bb2d14 --- /dev/null +++ b/tests/test_vedastro_runtime_mode_diagnostics.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _run_diag(env: dict[str, str]) -> dict: + completed = subprocess.run( + [sys.executable, "scripts/diagnose_vedastro_mode.py", "--json"], + cwd=ROOT, + text=True, + capture_output=True, + timeout=30, + check=False, + env={**os.environ, **env, "JYOTISH_SKIP_LOCAL_ENV": "1"}, + ) + assert completed.returncode == 0, completed.stderr or completed.stdout + return json.loads(completed.stdout) + + +def test_vedastro_diagnostics_reports_fast_fallback_mode_without_endpoint() -> None: + report = _run_diag({ + "VEDASTRO_API_ENDPOINT": "", + "VEDASTRO_ENABLE_NETWORK": "", + "VEDASTRO_TIMEOUT_SECONDS": "", + "VEDASTRO_API_KEY": "", + }) + + assert report["mode"] == "fast_local_fallback" + assert report["official_ready"] is False + assert "VEDASTRO_API_ENDPOINT" in report["missing"] + assert report["expected_fallback_status"] == "official_snapshot_budget_exhausted_or_endpoint_blocked" + + +def test_vedastro_diagnostics_reports_official_mode_when_configured() -> None: + report = _run_diag({ + "VEDASTRO_API_ENDPOINT": "https://api.vedastro.org/api", + "VEDASTRO_ENABLE_NETWORK": "1", + "VEDASTRO_TIMEOUT_SECONDS": "20", + "VEDASTRO_API_KEY": "sample-key", + }) + + assert report["mode"] == "official_extended" + assert report["official_ready"] is True + assert report["timeout_seconds"] == 20.0 + assert report["network_enabled"] is True + assert report["has_api_key"] is True + diff --git a/tests/test_vedastro_service_adapter_executor.py b/tests/test_vedastro_service_adapter_executor.py index a1be75d7..cf387950 100644 --- a/tests/test_vedastro_service_adapter_executor.py +++ b/tests/test_vedastro_service_adapter_executor.py @@ -5,6 +5,7 @@ import os import subprocess import sys import threading +import types from pathlib import Path from http.server import BaseHTTPRequestHandler, HTTPServer @@ -26,7 +27,7 @@ def test_vedastro_service_adapter_executor_schema_is_declared() -> None: report = json.loads(completed.stdout) assert report["adapter"] == "vedastro_service_adapter" assert report["transport"] == "http_json_service_boundary" - assert report["default_timeout_seconds"] >= 3 + assert 3 <= report["default_timeout_seconds"] <= 5 assert report["retry_policy"]["max_attempts"] >= 1 assert report["retry_policy"]["backoff_seconds"] >= 0 assert "endpoint" in report["required_env"] @@ -54,6 +55,241 @@ def test_vedastro_service_adapter_executor_schema_is_declared() -> None: assert "marriage" in report["range_scan_event_allowlist"]["marriage"]["tags"] +def test_vedastro_official_subprocesses_use_adapter_timeout(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "7") + seen_timeouts = [] + + def fake_run(*args, **kwargs): + seen_timeouts.append(kwargs.get("timeout")) + return types.SimpleNamespace(returncode=1, stdout="", stderr="simulated failure") + + monkeypatch.setattr(adapter.subprocess, "run", fake_run) + case = { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 59, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + } + + bridge = adapter._call_vedastro_python_bridge_high_value("official_full_snapshot_bundle", {}) + runner = adapter._try_official_capability_runner_snapshot_bundle(case) + catalog = adapter._try_official_full_capability_catalog_bundle(case) + + assert seen_timeouts == [7.0, 7.0, 7.0] + assert bridge["status"] == "python_bridge_runtime_error" + assert runner["status"] == "official_capability_runner_runtime_error" + assert catalog["status"] == "official_full_capability_catalog_runtime_error" + + +def test_vedastro_official_subprocess_timeouts_are_controlled(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "3") + + def fake_timeout(*args, **kwargs): + raise subprocess.TimeoutExpired(cmd=args[0], timeout=kwargs.get("timeout")) + + monkeypatch.setattr(adapter.subprocess, "run", fake_timeout) + case = { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 59, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + } + + bridge = adapter._call_vedastro_python_bridge_high_value("official_full_snapshot_bundle", {}) + runner = adapter._try_official_capability_runner_snapshot_bundle(case) + catalog = adapter._try_official_full_capability_catalog_bundle(case) + + assert bridge["status"] == "python_bridge_timeout" + assert runner["status"] == "official_capability_runner_timeout" + assert catalog["status"] == "official_full_capability_catalog_timeout" + + +def test_vedastro_official_snapshot_stops_when_foreground_budget_is_exhausted(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "4") + monkeypatch.setattr( + adapter, + "_try_official_full_capability_catalog_bundle", + lambda case: { + "available": False, + "status": "official_full_capability_catalog_timeout", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_capability_catalog", + "summary": {}, + "coverage": {}, + "domain_routing": {}, + "dynamic_selection": {}, + }, + ) + runner_called = {"value": False} + bridge_called = {"value": False} + + def fail_runner(case): + runner_called["value"] = True + raise AssertionError("snapshot runner should not run after foreground budget is exhausted") + + def fail_bridge(case): + bridge_called["value"] = True + raise AssertionError("python bridge fallback should not run after foreground budget is exhausted") + + monkeypatch.setattr(adapter, "_try_official_capability_runner_snapshot_bundle", fail_runner) + monkeypatch.setattr(adapter, "_try_official_python_bridge_snapshot_bundle", fail_bridge) + + ticks = iter([100.0, 104.1]) + monkeypatch.setattr(adapter.time, "monotonic", lambda: next(ticks)) + result = adapter._run_official_full_snapshot_case({ + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 59, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + }) + + assert runner_called["value"] is False + assert bridge_called["value"] is False + assert result["status"] == "official_snapshot_budget_exhausted" + assert result["available"] is False + assert result["source_metadata"]["official_python_bundle"]["status"] == "official_snapshot_budget_exhausted" + assert result["source_metadata"]["official_full_capability_catalog"]["status"] == "official_full_capability_catalog_timeout" + + +def test_vedastro_official_snapshot_skips_bridge_after_runner_consumes_budget(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://api.vedastro.org/api") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") + monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "4") + monkeypatch.setattr( + adapter, + "_try_official_full_capability_catalog_bundle", + lambda case: { + "available": True, + "status": "partial", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_capability_catalog", + "summary": {"catalog_method_count": 641}, + "coverage": {}, + "domain_routing": {}, + "dynamic_selection": {}, + }, + ) + monkeypatch.setattr( + adapter, + "_try_official_capability_runner_snapshot_bundle", + lambda case: { + "available": False, + "status": "official_capability_runner_timeout", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_snapshot", + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_capability_runner_bundle", "filled_sections": []}, + }, + ) + bridge_called = {"value": False} + + def fail_bridge(case): + bridge_called["value"] = True + raise AssertionError("python bridge fallback should not run after snapshot runner exhausts budget") + + monkeypatch.setattr(adapter, "_try_official_python_bridge_snapshot_bundle", fail_bridge) + + ticks = iter([100.0, 100.1, 104.2]) + monkeypatch.setattr(adapter.time, "monotonic", lambda: next(ticks)) + result = adapter._run_official_full_snapshot_case({ + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 59, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + }) + + assert bridge_called["value"] is False + assert result["status"] == "official_snapshot_budget_exhausted" + assert result["source_metadata"]["official_python_bundle"]["status"] == "official_snapshot_budget_exhausted" + assert result["source_metadata"]["official_full_capability_catalog"]["summary"]["catalog_method_count"] == 641 + + +def test_vedastro_official_snapshot_budget_does_not_mask_mock_rest_endpoint(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "http://127.0.0.1:12345/api") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") + monkeypatch.setenv("VEDASTRO_TIMEOUT_SECONDS", "4") + monkeypatch.setattr( + adapter, + "_try_official_full_capability_catalog_bundle", + lambda case: { + "available": False, + "status": "official_full_capability_catalog_timeout", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_capability_catalog", + "summary": {}, + "coverage": {}, + "domain_routing": {}, + "dynamic_selection": {}, + }, + ) + monkeypatch.setattr( + adapter, + "_try_official_capability_runner_snapshot_bundle", + lambda case: { + "available": False, + "status": "official_capability_runner_timeout", + "source": "vedastro_official_capability_runner", + "bundle": "official_full_snapshot", + "snapshot_sections": {}, + "section_statuses": {}, + "coverage": {"source_mode": "official_capability_runner_bundle", "filled_sections": []}, + }, + ) + + calls = [] + + def fake_post(endpoint, request_item): + calls.append((endpoint, request_item["section"], request_item.get("fanout_value"))) + return {"Status": "Pass", "Payload": {"ok": True}}, 1, [] + + monkeypatch.setattr(adapter, "_try_official_python_bridge_snapshot_bundle", lambda case: {"available": False, "status": "blocked", "snapshot_sections": {}, "section_statuses": {}, "coverage": {"source_mode": "official_python_bridge_bundle", "filled_sections": []}}) + monkeypatch.setattr(adapter, "_post_official_snapshot_section", fake_post) + ticks = iter([100.0, 100.1, 104.2]) + monkeypatch.setattr(adapter.time, "monotonic", lambda: next(ticks)) + + result = adapter._run_official_full_snapshot_case({ + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 59, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + }) + + assert calls + assert result["status"] in {"ok", "partial"} + assert result["source_metadata"]["official_python_bundle"]["status"] == "official_snapshot_budget_exhausted" + + def test_vedastro_service_adapter_returns_controlled_unconfigured_status() -> None: env = os.environ.copy() env.pop("VEDASTRO_API_ENDPOINT", None) @@ -644,6 +880,100 @@ def test_vedastro_range_scan_retries_transient_http_error() -> None: assert report["source_metadata"]["sampling_mode"] == "at_time_sweep" +def test_vedastro_range_scan_reuses_cached_live_response() -> None: + class Handler(BaseHTTPRequestHandler): + calls = 0 + + def do_POST(self) -> None: # noqa: N802 + Handler.calls += 1 + response = { + "events": [ + { + "id": "GocharJupiterIn7th", + "name": "Jupiter enters 7th house", + "start": "2026-05-01", + "end": "2026-06-01", + "score": 72, + "tags": ["marriage", "transit"], + } + ] + } + body = json.dumps(response).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args) -> None: # noqa: A003 + return + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + env = os.environ.copy() + env["VEDASTRO_API_ENDPOINT"] = f"http://127.0.0.1:{server.server_port}/vedastro" + env["VEDASTRO_ENABLE_NETWORK"] = "1" + env["VEDASTRO_CACHE_TTL_SECONDS"] = "600" + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" + + first = subprocess.run( + [ + sys.executable, + "scripts/vedastro_service_adapter.py", + "--range-scan", + "--domain", + "marriage", + "--case", + "beijing_first_use_demo", + "--start-date", + "2026-01-01", + "--end-date", + "2026-01-01", + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + env=env, + ) + second = subprocess.run( + [ + sys.executable, + "scripts/vedastro_service_adapter.py", + "--range-scan", + "--domain", + "marriage", + "--case", + "beijing_first_use_demo", + "--start-date", + "2026-01-01", + "--end-date", + "2026-01-01", + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + env=env, + ) + finally: + server.shutdown() + thread.join(timeout=5) + + assert first.returncode == 0, first.stderr or first.stdout + assert second.returncode == 0, second.stderr or second.stdout + first_report = json.loads(first.stdout) + second_report = json.loads(second.stdout) + assert Handler.calls == 1 + assert first_report["source_metadata"].get("cache_hit") is False + assert second_report["source_metadata"].get("cache_hit") is True + assert second_report["event_count"] == first_report["event_count"] == 1 + + def test_vedastro_service_adapter_applies_domain_allowlist_to_range_scan_noise() -> None: class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: # noqa: N802