diff --git a/README.md b/README.md index f9699fd0..339982cd 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,22 @@ This is a **Vedic (Jyotish) astrology analysis system** designed for deep, audit ## Quick Start +### 普通用户启动路径 + +如果只是打开网页/app,请按同一条路径走,不要在多个入口之间猜: + +1. 先启动网页服务:cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173 +2. 再启动本地 API 服务:python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200 +3. 打开 Trust Center,点击运行健康检查;页面地址是 `http://127.0.0.1:5173`。 +4. 如果只安装 PWA:PWA 安装壳只包装网页服务,本地 API 服务仍需单独启动;无 API 时网页会保留基础浏览器 fallback,但 PDF/高级技法需要本地 API 服务。 +5. 开发者做完整自检时运行:`python3 scripts/run_quality_gate.py --frontend-click-timeout 240`。 + +### 质量门分层 + +- quick:快速开发守门,适合普通代码/文案修改后先跑:`python3 scripts/run_quality_gate.py --profile quick` +- browser:完整浏览器守门,覆盖 runtime smoke 与真实浏览器用户路径:`python3 scripts/run_quality_gate.py --profile browser` +- release:发布前守门,包含关键产品文件未跟踪检查、慢速 golden cases 与 Yoga 逻辑报告:`python3 scripts/run_quality_gate.py --profile release` + ### Prerequisites - Python 3.11+ @@ -278,7 +294,7 @@ Examples: - `v6.9.12` — Shadbala precision upgrade + Ashtakoot 36-point compatibility + expanded subcommands. - `v6.9.6` — Field mapping fixes (degree→degree_in_sign + toFixed null safety); PyPI publishing config. - `v6.9.5` — birth_info null safety + API field mapping fixes. -- `v6.9.4` — AI interpretation integration (GPT relay via copse.top); api-bridge.js bundled into public/. +- `v6.9.4` — AI interpretation integration; current browser build disables direct model API keys and routes AI through server-side `/api/chat` or a backend proxy. - `v6.9.3` — 35 Dasha systems, 405+ Yoga rules, KP complete system, Prashna, 16-factor synastry, Remedies, Sahams 36, Sudarshana, PMC, Tajika. - `v6.1.12` — Chara Dasha KN Rao Method rewrite, PyJHora benchmark 95.83% PASS. - `v6.1.10` — Darakaraka deep reader wired into `full-reading.modules.jaimini.darakaraka`; thematic reports now consume real DK and Rashi Tulya Navamsa evidence. @@ -289,7 +305,7 @@ Examples: ### Actively Working On (P0) -1. **Release hygiene** — rebuild v6.9.14 wheel/sdist and align GitHub tags with source version +1. **Release hygiene** — run the release profile, keep product-critical files tracked, rebuild wheel/sdist, and align GitHub tags with source version 2. **README / package metadata sync** — keep public docs, registry counts and distribution artifacts consistent 3. **Shadbala external absolute calibration** — align full tables with JHora / PyJHora / BV Raman 4. **Benchmark expansion** — add more oracle cases for Vimshottari, Shadbala, KP and annual-chart modules @@ -300,6 +316,7 @@ Examples: - Docker image publishing and smoke-test docs - English documentation examples and API tutorials - Multi-Ayanamsa UX polish and benchmark examples +- Desktop packaging path: PWA now, Pake URL shell for quick wrappers, Tauri sidecar after API lifecycle/signing decisions. See `docs/research/desktop_packaging_spike_2026_06_23.md`. --- @@ -314,6 +331,12 @@ python3 -m py_compile scripts/*.py # Capability audit (must pass with 0 problems, 0 warnings) python3 scripts/audit_capabilities.py --mode validate +# Desktop packaging readiness +python3 scripts/desktop_packaging_preflight.py + +# Installed-shell / first-launch browser smoke +python3 tests/run_frontend_click_smoke.py --mode all + # Full-reading regression test (use FICTIONAL data only) python3 scripts/jyotish_engine.py full-reading \ --year 1990 --month 6 --day 15 \ diff --git a/docs/research/desktop_packaging_spike_2026_06_23.md b/docs/research/desktop_packaging_spike_2026_06_23.md new file mode 100644 index 00000000..548159fd --- /dev/null +++ b/docs/research/desktop_packaging_spike_2026_06_23.md @@ -0,0 +1,86 @@ +# Desktop Packaging Spike — Jyotish App + +Date: 2026-06-23 + +## Decision + +Use a staged desktop path: + +1. **Now: PWA install** for the current Vite app. This is already live through `manifest.webmanifest`, `sw.js`, and the Trust Center install state. +2. **Short term: Pake shell** for a lightweight Mac/Windows/Linux desktop wrapper when the user can run both local services: + - Vite/static app served from `jyotish-app/dist` or `npm run preview`. + - Python API served by `scripts/jyotish_api_server.py` on `127.0.0.1:5200`. +3. **Later: Tauri shell with sidecar** when the app needs a one-click bundle that starts the Python API automatically and ships stricter desktop permissions. + +This avoids prematurely adding Rust/Tauri scaffolding before the local API sidecar and signing strategy are fixed. + +## Product Requirements + +- Desktop shell must keep the current local-first data model: browser storage, local Python API, no cloud dependency for chart calculation. +- API calls must stay local-only by default: `127.0.0.1:5200` or explicit user override. +- Packaging must preserve installability signals: app name, icon, theme color, standalone display, cached shell, and Trust Center notes. +- PDF/HTML/JSON export and local case libraries must work in the shell. +- Offline behavior can cache the UI shell, but Python-backed calculations still require the local API process. + +## Pake Path + +Use when speed matters and the user accepts running the API separately. + +Candidate flow: + +```bash +cd jyotish-app +npm run build +npm run preview -- --host 127.0.0.1 --port 4173 +python3 ../scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200 +``` + +Then package the local web URL with Pake using the project icon/name. The exact Pake command should be pinned only after verifying the installed Pake CLI version, because CLI flags can change. + +Risk: Pake wraps a URL. It does not solve local API lifecycle, signing/notarization, or multi-process supervision by itself. + +## Tauri Path + +Use when the app needs a true desktop artifact. + +Tauri is the better long-term fit because it uses the system webview, has a security-focused Rust base, supports arbitrary frontends, and can model desktop permissions. It can later run the Python API as a sidecar or replace it with a Rust/native command layer. + +Candidate architecture: + +- `src-tauri/tauri.conf.json` points `frontendDist` to `../dist` and `devUrl` to Vite. +- A sidecar starts `python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200`. +- Frontend still talks to `http://127.0.0.1:5200/api/...`. +- Permissions initially allow only app shell, local file download/export, and loopback HTTP. + +Risk: Requires Rust toolchain, platform signing decisions, API sidecar packaging, and explicit lifecycle handling. Do not add scaffolding until these are tested on the target OS. + +## Preflight Checklist + +Run: + +```bash +python3 scripts/desktop_packaging_preflight.py +python3 tests/run_frontend_click_smoke.py --mode all +``` + +Expected output: + +- Vite package has `build` and `preview`. +- Manifest has name, standalone display, scope, start URL, theme color, and icon. +- Service worker caches shell files and excludes `/api/`. +- HTML links the manifest and icon. +- API server binds to `127.0.0.1` by default. +- Trust Center exposes install/local-first status. +- Browser click smoke reports `offline_recovery_guidance_visible: true` when the API is absent. + +## 安装后首次打开 + +普通用户路径必须先验证“壳能打开”,再验证“本地 API 可诊断”: + +1. **PWA installed shell**:运行 `python3 tests/run_frontend_click_smoke.py --mode all`。预期结果包含 `manifest.webmanifest`、`serviceWorker: true`、移动首屏检查、在线核心流程和离线恢复提示。 +2. **Pake first launch**:先 `cd jyotish-app && npm run build && npm run preview -- --host 127.0.0.1 --port 4173`,再启动 `python3 ../scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200`。Pake 只包装 URL,不负责 Python API 生命周期。 +3. **Tauri sidecar readiness**:在真正生成 `src-tauri` 前先跑 `python3 scripts/desktop_packaging_preflight.py`,确认 loopback API、manifest、service worker、Trust Center、离线恢复都仍可检查。若 `offline_recovery_guidance_visible` 不为 true,不应进入 Tauri sidecar 打包。 + +## Next Build Step + +After this spike, the next practical step is a Pake smoke artifact only if the machine has Pake installed. If not, keep PWA as the release path and move to the ephemeris abstraction spike. diff --git a/docs/research/ephemeris_abstraction_feasibility_2026_06_23.md b/docs/research/ephemeris_abstraction_feasibility_2026_06_23.md new file mode 100644 index 00000000..097bb2cf --- /dev/null +++ b/docs/research/ephemeris_abstraction_feasibility_2026_06_23.md @@ -0,0 +1,34 @@ +# Ephemeris Abstraction Feasibility - 2026-06-23 + +Purpose: make the ephemeris roadmap probeable instead of relying on UI labels or memory from prior windows. + +## candidate_backends + +| Backend | Current role | replacement_readiness | license_posture | Decision | +|---|---|---|---|---| +| `swisseph_python` | Primary local Python API path through `scripts/jyotish_api_server.py` | `primary` | Current Swiss Ephemeris boundary must stay explicit in settings, exports, and docs | Keep as canonical longitude source | +| `swisseph_wasm` | Browser/local-first degradation path through bundled WASM assets and `@swisseph/browser` / `swisseph-wasm` dependencies | `fallback` | Same Swiss Ephemeris boundary as the Python path | Keep as fallback, not a separate accuracy oracle | +| `xalen_ephemeris` | External Apache-2.0 Rust candidate from `vedika-io/xalen-ephemeris` | `spike_only` | Favorable for experiments, but no local adapter or parity matrix exists yet | Do not expose as real runtime replacement until a parity spike passes | +| `vedastro` | MIT full-stack product/API benchmark | `product_api_benchmark` | Can inform API, OpenAPI, chat, Panchanga, and product workflow design; C# core should stay behind an API/service boundary if reused | Reuse product/API ideas, not as a drop-in Python ephemeris | +| `pyjhora_benchmark` | Broad JHora-style behavior/oracle benchmark | `benchmark_only` | AGPL; do not copy implementation code into this app unless the whole downstream license posture is changed | Use only expected outputs, public examples, and behavior comparisons | + +## Probe + +Run: + +```bash +python3 scripts/ephemeris_backend_probe.py +``` + +The probe returns JSON with `candidate_backends`, `license_posture`, and `replacement_readiness`. It is intentionally read-only and network-free, so it can be used in regression checks without mutating user data or depending on GitHub availability. + +## Engineering Decision + +The app already records `ephemerisBackend` in calculation settings and export provenance. That is useful, but it is not enough to claim backend replacement. The real next step is an adapter contract: + +- input: UTC datetime, latitude, longitude, ayanamsa policy, node policy, body list +- output: tropical longitude, sidereal longitude, speed, retrograde flag, ayanamsa value, backend metadata +- parity gate: compare Moon, Sun, Ascendant, Rahu/Ketu, and daily boundary cases against `swisseph_python` +- acceptance: document max deltas before any new backend can be selectable as a runtime calculation source + +Until that exists, `swisseph_python` remains the production source, `swisseph_wasm` remains the fallback, `xalen_ephemeris` remains a spike candidate, `vedastro` remains a product/API benchmark, and `pyjhora_benchmark` remains an AGPL behavior benchmark only. diff --git a/docs/research/ephemeris_adapter_contract_2026_06_23.md b/docs/research/ephemeris_adapter_contract_2026_06_23.md new file mode 100644 index 00000000..4848e778 --- /dev/null +++ b/docs/research/ephemeris_adapter_contract_2026_06_23.md @@ -0,0 +1,50 @@ +# Ephemeris Adapter Contract - 2026-06-23 + +Purpose: define the real engineering gate before `xalen_ephemeris`, `vedastro`, or any other `candidate_backend` can become a selectable runtime ephemeris source. + +## Contract + +The executable contract lives in: + +```bash +python3 scripts/ephemeris_adapter_contract.py +``` + +It defines `EphemerisAdapterContract`, `PARITY_CASES`, and JSON output fields that every backend must satisfy: + +- input fields: UTC-adjusted birth date/time, latitude, longitude, timezone, `ayanamsa_policy`, `node_policy`, and `body_list` +- output fields: sidereal longitude, sign, degree in sign, speed, `retrograde`, nakshatra metadata when available, `ayanamsa_value`, backend name, and source metadata +- baseline: `swisseph_python` +- candidate slot: `candidate_backend` + +## acceptance_thresholds + +The first parity gate is `sun_moon_asc_nodes`: + +| Body | Max `longitude_delta_arcsec` | +|---|---:| +| Sun | 1.0 | +| Moon | 1.0 | +| Ascendant | 5.0 | +| Rahu | 2.0 | +| Ketu | 2.0 | + +These thresholds are strict enough to catch accidental tropical/sidereal, timezone, node, and ayanamsa mismatches, while leaving a small practical tolerance for backend representation differences. + +## Parity Matrix + +Current `PARITY_CASES`: + +| Case | Why it exists | +|---|---| +| `beijing_first_use_demo` | Reuses the first-use demo chart so product smoke and ephemeris smoke share a reference | +| `delhi_lagna_boundary` | Guards Ascendant, Lahiri ayanamsa, timezone, and mean-node behavior in an India-centered case | +| `new_york_moon_boundary` | Guards western timezone conversion and Moon/nakshatra boundary behavior | + +Current decision: + +- `swisseph_python` remains the production baseline. +- `swisseph_wasm` remains the browser fallback and can later be compared through the same matrix. +- `xalen_ephemeris` remains `spike_only` until a local adapter can produce this exact contract. +- `vedastro` remains a product/API benchmark unless used behind a service boundary that emits this contract. +- `pyjhora_benchmark` remains AGPL benchmark-only and should provide oracle expected values, not copied implementation code. diff --git a/docs/research/ephemeris_candidate_adapter_spike_2026_06_23.md b/docs/research/ephemeris_candidate_adapter_spike_2026_06_23.md new file mode 100644 index 00000000..1a13523d --- /dev/null +++ b/docs/research/ephemeris_candidate_adapter_spike_2026_06_23.md @@ -0,0 +1,40 @@ +# Ephemeris Candidate Adapter Spike - 2026-06-23 + +Purpose: decide whether a non-default `candidate_backend` is ready to enter the `EphemerisAdapterContract` parity gate. + +Run: + +```bash +python3 scripts/ephemeris_candidate_adapter_spike.py +``` + +## swisseph_wasm_candidate + +`swisseph_wasm_candidate` has local browser assets and package dependencies, so it is a plausible offline/PWA candidate. It is not a separate accuracy authority and it is not ready for `runtime_setting_exposure`. + +The `license_gate` is the important blocker: Swiss Ephemeris WASM still follows Swiss Ephemeris licensing boundaries, so distribution claims must be reviewed before this becomes a user-selectable backend. + +The local package metadata currently reports: + +| Package | `package_license` | +|---|---| +| `@swisseph/browser` | `AGPL-3.0` | +| `swisseph-wasm` | `GPL-3.0-or-later` | + +That means the WASM path is useful for local/PWA fallback experiments, but it must not be treated as a low-risk proprietary desktop dependency without a license decision. + +## xalen_ephemeris_candidate + +`xalen_ephemeris_candidate` remains the best permissive-direction spike because `vedika-io/xalen-ephemeris` is tracked as Apache-2.0. There is no local executable mirror in this workspace yet, so the current spike status is documentation-only. + +## Gate + +Both candidates are blocked until `parity_gate_required` is satisfied: + +1. Produce rows matching `EphemerisAdapterContract`. +2. Compare against `swisseph_python` baseline rows. +3. Pass `longitude_delta_arcsec` thresholds for Sun, Moon, Ascendant, Rahu, and Ketu. +4. Preserve `ayanamsa_value`, `retrograde`, backend metadata, node policy, and house policy. +5. Keep `runtime_setting_exposure` blocked until the above is verified. + +Decision: do not expose any non-SwissEph runtime setting yet. The next implementation step is an isolated executable adapter only after local assets and license review are complete. diff --git a/docs/research/open_source_scan_2026_06_22.md b/docs/research/open_source_scan_2026_06_22.md new file mode 100644 index 00000000..50790005 --- /dev/null +++ b/docs/research/open_source_scan_2026_06_22.md @@ -0,0 +1,55 @@ +# Open Source Jyotish Scan — 2026-06-22 + +Purpose: keep the product audit grounded in current open-source projects instead of relying only on older comparison notes. + +## Live GitHub Checks + +The live GitHub API scan on 2026-06-22 confirmed these current reference points: + +| Project | Current signal | License signal | Reuse posture | Product lesson | +|---|---:|---|---|---| +| VedAstro/VedAstro | ~568 stars, active full-stack C#/web/API project | MIT | Direct for API/product ideas; C# code not copied into Python path | Product completeness: website, API, OpenAPI-style surface, AI/chat experience | +| CNWU16/vedic-astro-skills | ~338 stars, fast-growing skill toolkit | MIT | Direct for skill/report workflow ideas already mirrored under `references/open_source_sources/vedic-astro-skills` | Methodology depth: report rules, house framework, P1-P12 audit, reader/rectifier flows | +| naturalstupid/PyJHora | ~190 stars, AGPL | Caution: benchmark and behavioral reference, not copy-paste into permissive code | Breadth benchmark: JHora-style dasha, varga, yoga, AV, Tajika, GUI/test corpus | +| adarshj322/dashaflow | Low-star but focused Python package; query matched Shadbala/Ashtakavarga/Muhurta | MIT | Direct; local mirrored code exists under `references/open_source_sources/dashaflow` | Practical reusable kernels for Muhurta, AV, Shadbala, Jaimini, matching, career | +| northtara/jyotishganit | Local mirror available | MIT | Direct for modern Python data-layer comparisons | Deterministic component separation: varga, panchanga, strengths, AV, JSON-LD | +| diliprk/VedicAstro | Local mirror available | MIT/research noted | Direct for adapted KP sublord logic | KP-specific RL/NL/SL/SSL and horary workflow | +| RoxyAPI/jyotish-vedic-astrology-app | 2026 Next.js template with Kundli, Panchang, Ashtakoot Gun Milan, Vimshottari Dasha, dosha analysis | MIT | Product benchmark; depends on external RoxyAPI rather than local copy | Treat matching as a guided first-class workflow | +| RoxyAPI/vedic-astrology-starter-app | 2026 React Native/Expo starter with Gun Milan, Manglik, Navamsa, Panchang, Sade Sati | MIT | Mobile/product benchmark | Saved profiles and mobile matching flow matter | +| emmetCode/nakshatra | 2026 JS Ashtakoot/Nadi-focused project | No license detected | Benchmark only | Kuta/Nadi factors need explanatory UI, not just a score | +| Akshay-S-PY/RashAi | Vite/vanilla JS Vedic app with kundali, matching, panchang, muhurta, AI insights | No license detected | Product benchmark | Lightweight apps still surface matching as a primary module | +| VedAstro/Vedic-Astrology-AI-MCP-Server | MCP/AI wrapper with compatibility matching topic | No license detected in search result | API/agent benchmark | Compatibility should be callable from agent/API surfaces | + +## Local Mirror Status + +`scripts/audit_fragments.py` currently detects seven local source mirrors: + +- `references/open_source_sources/VedicAstro` +- `references/open_source_sources/dashaflow` +- `references/open_source_sources/jaimini-tropical` +- `references/open_source_sources/jyotishganit` +- `references/open_source_sources/panchanga_api` +- `references/open_source_sources/rishi-ai-mcp` +- `references/open_source_sources/vedic-astro-skills` + +## Immediate Product Rules + +1. Prefer MIT/Apache local source mirrors for direct code reuse. +2. Treat AGPL/GPL projects such as PyJHora as behavioral benchmarks unless the whole downstream license posture is explicitly accepted. +3. Any registry technique marked `covered` or `complete` must have at least one real CLI/API/script surface and at least one real output path. +4. UI productization is not just a tab: it must expose readable conclusions, evidence, next action, mobile-safe layout, and hidden raw JSON for audit. +5. Matching/synastry productization is not just Moon-degree input: it must support saved partner selection, full birth-data calculation, explanatory Kuta/Nadi factors, D9/Kuja/Dasha context, and exportable pair records. + +## New Guardrail + +`python3 scripts/audit_fragments.py --strict` now cross-checks: + +- `references/technique_registry.json` +- `scripts/jyotish_engine.py` CLI commands +- `scripts/jyotish_api_server.py` API routes +- `jyotish-app/` frontend API markers +- `tests/` references +- local open-source mirrors +- untracked workspace residue and `.git/lost-found` fragments + +This is the repeatable check for the “different windows left scattered fragments” concern. diff --git a/docs/research/product_gap_matrix_2026_06_22.md b/docs/research/product_gap_matrix_2026_06_22.md new file mode 100644 index 00000000..a4315326 --- /dev/null +++ b/docs/research/product_gap_matrix_2026_06_22.md @@ -0,0 +1,114 @@ +# Jyotish Product Gap Matrix — 2026-06-22 + +Purpose: keep the web/app roadmap tied to same-category product expectations, live open-source scans, and local fragment audits. The registry audit can be 65/65 while product parity is still incomplete. + +## Evidence Baseline + +| Source | Signal | Product lesson | Reuse posture | +|---|---|---|---| +| `scripts/audit_fragments.py --strict` | 65 registry techniques, 37 CLI commands, 35 API endpoints, 42 frontend files, 0 hard gaps | Capability registry is covered; product gaps must be tracked separately | Local guardrail | +| `references/open_source_sources/vedic-astro-skills` | MIT skill/report/rectifier toolkit mirrored locally | Report workflow, P1-P12 audit, rectifier flow, validation rules | Direct for workflow and report packaging | +| `references/open_source_sources/dashaflow` | MIT Python kernels mirrored locally | Panchanga, Muhurta, Shadbala, Ashtakavarga, matching, career kernels | Direct where architecture fits | +| `references/open_source_sources/jyotishganit` | MIT Python component model mirrored locally | Separate deterministic components, JSON-LD style output, Panchanga objects | Direct for data-contract ideas | +| `references/open_source_sources/jaimini-tropical` | Local web app with Panchanga cards and export | Product UX pattern for Panchanga and plain-text export | Direct after license review | +| `VedAstro/VedAstro` GitHub scan | 568 stars, MIT, API/web/OpenAPI/chat/Panchanga topics | Full-stack product surface matters as much as calculation breadth | Direct for product/API ideas, not direct C# copy | +| `vedika-io/xalen-ephemeris` GitHub scan | 879 stars, Apache-2.0, Rust ephemeris, active in 2026 | Ephemeris abstraction and settings provenance should be a first-class long-term track | Candidate benchmark/integration spike | +| `naturalstupid/PyJHora` GitHub scan | Broad JHora-style feature reference, AGPL | Breadth benchmark and validation fixture source | Benchmark only unless license posture changes | +| `dineshpaudel/acharavidya` GitHub scan | MIT, Python/PyPI Panchanga calculations including Muhurta and Rahu Kala | Panchanga product parity includes inauspicious day parts, not just Tithi/Nakshatra | Candidate direct reference after source review | +| `jayeshmepani/panchang-core` GitHub scan | MIT, Panchang/Muhurta/raw JSON export product surface | Calendar APIs need exportable structured rows and event windows | Product/API benchmark; PHP code not copied | +| `RoxyAPI/jyotish-vedic-astrology-app` GitHub scan | MIT Next.js app with Panchang, Ashtakoot Gun Milan, Vimshottari Dasha, dosha analysis | Same-category apps expose Gun Milan as a guided product flow | Product/API benchmark; external API dependency not copied | +| `emmetCode/nakshatra` GitHub scan | JS Ashtakoot/Nadi-focused app, no license detected | Matching UX should explain Kuta/Nadi factors, not only return a total score | Benchmark only unless license becomes clear | +| `Akshay-S-PY/RashAi` GitHub scan | Vite/vanilla JS Vedic app with kundali, matching, panchang, muhurta, AI insights | Lightweight web apps still make matching a first-class module | Product benchmark | +| `VedAstro/Vedic-Astrology-AI-MCP-Server` GitHub scan | AI/MCP wrapper with compatibility matching topic | Agent/API surfaces should expose compatibility as callable workflow | Product/API benchmark | + +## P0 Product Parity Gaps + +| Gap | Current state | Same-category expectation | Next implementation task | Done when | +|---|---|---|---|---| +| Calculation settings and provenance center | 参数/日历面板 now shows engine, ayanamsa, ephemeris, house/node strategy, chart style, saved-chart status, and export provenance | User can inspect and later change calculation settings; exports preserve assumptions | Add setting selectors for ayanamsa/node/house/sunrise/geocoder policies | User can see the calculation basis before trusting a reading | +| Panchanga calendar product | Birth-time Panchanga + Tithi Lord are visible; `/api/panchanga_range` returns date ranges, month grid, activity filtering, Rahu Kala/Yamaganda/Gulika, day/night Choghadiya, planetary Hora windows, SwissEph `rise_trans` sunrise/sunset, SwissEph Tithi/Nakshatra/Yoga end times, richer tithi/nakshatra/vara vrata tags, masa-dependent festival candidates, condition tags, all/any combined condition search, `search_summary`, `festival_details`, location-aware summary, CSV and ICS export | Daily/weekly/monthly Panchanga cards, Rahu Kala/Yamaganda/Gulika, calendar export, end times, vrata/festival rules, day/night sub-windows | Add masa-aware festival naming and dedicated festival drill-down pages | User can use the app as an almanac, not only a natal chart | +| Saved chart workspace | Main UI now reuses `jyotish_chart_library`: save current chart, open selected chart, delete with confirmation, export selected chart, show local library status, use time-aware chart IDs with legacy ID compatibility, export case libraries, unified chart/pair/prashna case list, group/relation/tag metadata, and group/relation filters | Chart library, case workspace, profile compare, share/export workflow | Add editable metadata and profile compare shortcuts | User can return to charts without opening chat | +| Professional report pipeline | Export JSON/SVG/PNG exists; HTML report export produces a standalone printable artifact; `/api/report_artifact` generates backend HTML/PDF artifacts through `report_builder.py`; `/api/thematic_report` now derives real chart/dasha/yoga/shadbala/AV/relationship/career/Jaimini evidence when birth/chart payload is present | Printable HTML/PDF report with evidence, settings, boundaries, and narrative | Add method docs/cURL/OpenAPI snippets and fold thematic sections into final report artifact UX | One-click HTML/PDF and thematic reports work with evidence provenance and safe fallbacks | +| Product-grade chart workspace | South/North Indian charts exist | North/South/East styles, varga grid, print layout, comparison chart | Stabilize chart style controls and add varga grid/report print states | Professional user recognizes the app as chart software | + +## P1 Deep Parity Gaps + +| Gap | Current state | Same-category expectation | Next implementation task | +|---|---|---|---| +| Calculation catalog/API explorer | `/api/technique_catalog` now exposes searchable registry/productization/UX/API metadata, `api_docs`, row-level `method_docs`, cURL snippets, and minimal OpenAPI operations; `/api/technique_example` runs whitelisted sample payloads; Skill workbench directory cards can run examples with current chart payloads and displays thematic evidence source | Searchable calculation catalog, method docs, API examples | Continue polishing method explanations and expose copy affordances in UI | +| Rectification scanner UX | Rectification panel exists | Event list, time scan candidates, heatmap, reasoned winner | Reuse `vedic-astro-skills/.../time_scan.py` concepts and local rectifier | +| Rule explorer and variant toggles | Settings/export chain records rule variants; `/api/yogas`, `/api/ashtakavarga`, `/api/shadbala` now return result-level `rule_variants`; Skill workbench renders Yoga/Shadbala rule evidence | Yoga/KP/Jaimini/AV variants visible and switchable | Extend realtime variant metadata to KP/Jaimini result cards and add copyable method docs | +| Benchmark dashboard | Tests and benchmark reports exist | Per-module precision table against JHora/PyJHora/VedAstro-style references | Build static benchmark summary from `benchmarks/jyotish/reports` | +| Relationship/family workspace | Synastry has manual birth-data flow, quick Moon longitude flow, deep D9/Kuja/Dasha context, relationship report templates, bi-wheel/composite-style comparison view, spouse-status yoga evidence, UL/DK relationship timing evidence, print-polished relationship HTML/PDF report export, saved partner selection, saved pair records, pair reopen/delete controls, editable chart/pair/prashna case metadata, current-pair JSON/HTML/PDF export, and pair records now carry partner/group metadata in the unified case workspace | Saved partners, bi-wheel/composite-like comparison, dasha sync, relationship case records | Continue with Panchanga search/details and calculation-settings selectors | + +## P2 Platform Gaps + +| Gap | Current state | Same-category expectation | Next implementation task | +|---|---|---|---| +| Offline/PWA/desktop packaging | Vite app now has manifest, SVG icon, service worker shell cache, install prompt handling, PWA status, desktop packaging spike, and `scripts/desktop_packaging_preflight.py` | Installable PWA or Pake/Tauri-style desktop shell | Run Pake smoke only when CLI is available; defer Tauri until Python sidecar/signing strategy | +| Privacy and trust center | Trust Center now explains local-first storage, local API boundary, AI/remote boundary, local record counts, export local data, terminology preference, and confirm-clear local data | Clear data location, API-key handling, deletion/export controls | Add deeper account/API-key documentation after desktop path | +| First-use onboarding and empty-state path | First screen now offers API health check, demo birth fill, import focus, and actionable saved-library empty copy | New users can create or inspect a chart without reading docs first | Browser smoke the first-run path on mobile and desktop after the next full build | +| Localization modes | Chinese/English labels plus入门/专业/梵文优先 terminology mode | Beginner/pro terminology switch, glossary depth, Sanskrit spellings | Continue copy QA as features expand | +| Ephemeris abstraction | SwissEph/WASM + Python API, calculation provenance records `ephemerisBackend`; backend probe, adapter contract, and candidate spike now report readiness, `package_license`, runtime exposure gates, `PARITY_CASES`, `sun_moon_asc_nodes`, and `longitude_delta_arcsec` thresholds | Replaceable ephemeris backend and explicit accuracy notes | Add a real executable candidate adapter only after local assets/license review are complete | + +## Fragment Triage Queue + +These files are not hard failures, but they must be classified before the product can be called fully audited: + +| Fragment | Likely value | Triage decision needed | +|---|---|---| +| `reading_orchestrator.py` | Full-reading workflow | Referenced by `/api/thematic_report.workflow_orchestration`; keep as workflow vocabulary and future registry execution layer | +| `report_orchestrator.py` | Report packaging | Reused by `/api/thematic_report`; now supports sample, custom, and derived real-evidence modes | +| `report_builder.py` | Report generation | Reused by `/api/report_artifact` for backend HTML/PDF artifact generation; keep for future thematic report assembly | +| `tithi_analyzer.py` | Panchanga depth | Merged into `/api/chart` as `tithi_lord_analysis`; surfaced in 参数/日历 and HTML report | +| `shadbala_advanced.py` | Strength calibration | Integrated into `/api/shadbala.advanced_layer`; keep as evidence layer, not total-score override | +| `dasha_analyzer.py` | Dasha narrative | Integrated into `/api/dasha.vimshottari_analysis` and Dasha UI cards | +| `spouse_status_yoga.py` | Relationship depth | Fold into relationship workspace | +| `curse_yoga_detector.py` | Yoga special cases | Integrated into `/api/yogas.curse_yogas` and Skill workbench boundary cards | +| `hermes_bridge.py` | Agent bridge | External personal WorkBuddy/Hermes automation; archived outside product surface because it writes `~/.workbuddy` | +| `orchestrator_bridge.py` | Agent bridge | Referenced by `/api/thematic_report.workflow_orchestration` as report pipeline bridge | +| `mevg_automation.py` | Validation automation | Integrated as read-only `/api/case_validation.mevg_gate` protocol/status source | + +## Immediate Execution Order + +1. Completed: visible provenance/Panchanga/workspace panel in the main web app. +2. Completed: static tests guard the panel, Tithi Lord, HTML report export, and Panchanga starter entry. +3. Completed: JSON export metadata carries provenance; HTML report exports a readable artifact. +4. Completed MVP: `tithi_analyzer.py` and `/api/muhurta` are wired into product flow; `report_builder.py` is triaged as PDF backend pattern. +5. Completed MVP: Panchanga starter upgraded to `/api/panchanga_range`, Rahu Kala/Yamaganda/Gulika rows, and CSV export. +6. Completed MVP: Panchanga range now uses chart location for sunrise/sunset when available and supports ICS export. +7. Completed MVP: Panchanga month grid, activity filters, and SwissEph `rise_trans` sunrise/sunset precision are wired into API and web panel. +8. Completed MVP: Tithi/Nakshatra/Yoga end times and Ekadashi/Pradosham/Purnima/Amavasya tags are wired into API, month grid, table, CSV, and ICS export. +9. Completed MVP: Choghadiya and planetary Hora sub-day windows are wired into API, month grid, table, CSV, and ICS export. +10. Completed MVP: dedicated saved chart workspace reuses `jyotish_chart_library` with save/open/delete/export in the main UI. +11. Completed MVP: Synastry can select a saved partner chart from the local library and reuse the full Ashtakoot + D9 + Kuja + Dasha comparison path. +12. Completed MVP: Synastry pair records now save to `jyotish_synastry_pair_library`, show recent saved pairs, and export the current comparison JSON. +13. Completed data fix: saved chart IDs now include birth time and still recognize legacy IDs, preventing same-day/same-place charts from overwriting each other. +14. Completed MVP: pair reopen/delete controls and current synastry HTML report export are wired into the saved case workflow. +15. Completed MVP: richer Panchanga vrata/festival-candidate rules and search-by-condition are wired into API, month grid, table, CSV, ICS and tests. +16. Completed MVP: unified chart/pair/prashna case workspace now has group/relation/tag metadata, group/relation filters, chart rows in case search, selected chart export/delete, and open-chart actions. +17. Completed MVP: relationship report templates now summarize Ashtakoot/D9/Kuja/Dasha evidence, persist with saved pair records, replay from old/new case data, and export into HTML reports. +18. Completed MVP: bi-wheel/composite-style comparison view now shows relationship axes, planet overlay houses, sign relationship tone, and Sun/Moon/Venus/Mars midpoints in the full synastry flow. +19. Completed MVP: `spouse_status_yoga.py` is folded into `/api/relationship`, full synastry deep context, relationship report evidence, saved pair replay/export, and HTML report output. +20. Completed MVP: relationship HTML report export now has a print-polished deliverable section with conclusion hero, evidence cards, bi-wheel axes, overlay table, midpoint cards, spouse-status cards, action lists, and boundaries. +21. Completed MVP: unified case workspace now edits chart/pair/prashna label, group, relation, and tags in place, preserving existing JSON import/export shape. +22. Completed MVP: backend report artifact/PDF pipeline now exposes `/api/report_artifact`, reuses `report_builder.py`, blocks active HTML, returns `pdf_base64` or HTML fallback, and is wired to the web export menu. +23. Completed MVP: richer relationship timing/UL-DK fold-in now returns `/api/relationship.relationship_timing`, reuses `darakaraka_reader.py` and `jaimini.py`, renders UL/DK+Dasha trigger cards in full synastry, and exports `uldk-print-grid` in HTML/PDF reports. +24. Completed MVP: Panchanga search enhancement now supports all/any condition combinations, festival explanation cards, backend `search_summary`/`festival_details`, and location-aware calendar summaries. +25. Completed MVP: calculation settings selectors now persist ayanamsa/node/house/sunrise/geocoder policy, attach settings to chart payload/provenance, and preserve assumptions in JSON/HTML/PDF export paths. +26. Completed MVP: calculation catalog/API Explorer now exposes `/api/technique_catalog`, whitelisted `/api/technique_example`, endpoint/action mapping, current-chart sample payloads, and searchable/runnable Skill workbench cards. +27. Completed MVP: Yoga/Shadbala rule fragments now run through product APIs: `/api/yogas` reuses `curse_yoga_detector.py` and `/api/shadbala` reuses `shadbala_advanced.py`; Skill workbench renders both with rule boundaries. +28. Completed MVP: `dasha_analyzer.py` now powers Vimshottari analysis inside `/api/dasha`, while `dasha_calculator_enhanced.py` supplies five-level Dasha context for UI cards. +29. Completed MVP: remaining workflow/bridge fragments are classified: thematic report exposes reading/report/orchestrator bridge metadata, case validation exposes MEVG gate status, Hermes bridge is archived as external personal automation rather than product UI. +30. Completed MVP: `/api/thematic_report` now upgrades from sample evidence to `derived_chart_evidence` when birth/chart payload is provided, and the UI exposes evidence source/module status. +31. Completed MVP: Technique Directory/API Explorer now exposes method docs, cURL snippets, minimal OpenAPI operations, endpoint notes, and row-level API doc keys. +32. Completed MVP: PWA manifest/service worker/installability status and local-first Trust Center are visible in the app. +33. Completed MVP: terminology mode now affects glossary display and provenance/JSON/HTML export. +34. Completed spike: desktop packaging path is documented, README links it, and `scripts/desktop_packaging_preflight.py` checks PWA/Pake/Tauri readiness. +35. Completed MVP: First-use onboarding and empty-state path now gives new users API health check, demo chart fill, import focus, and clearer local-library empty copy. +36. Completed MVP: browser smoke covered the first-run desktop/mobile path, example chart generation, runtime health entry, and Banner missing-field guard. +37. Completed feasibility guard: ephemeris abstraction now has a read-only backend probe plus research note documenting SwissEph Python/WASM, xalen, VedAstro, PyJHora readiness and license posture. +38. Completed contract guard: `scripts/ephemeris_adapter_contract.py` now emits SwissEph Python baseline parity rows for Sun/Moon/Asc/Rahu/Ketu with `longitude_delta_arcsec` acceptance thresholds. +39. Completed candidate spike guard: `scripts/ephemeris_candidate_adapter_spike.py` keeps SwissEph WASM and xalen as blocked candidates until license and parity gates pass. +40. Completed candidate license gate: `scripts/ephemeris_candidate_adapter_spike.py` detects local WASM assets, records `@swisseph/browser` as `AGPL-3.0`, `swisseph-wasm` as `GPL-3.0-or-later`, and keeps xalen marked as no local executable. +41. Next: add a real executable candidate adapter only after local xalen assets or a reviewed WASM execution harness is available. diff --git a/docs/research/whole_machine_git_audit_2026_06_23.md b/docs/research/whole_machine_git_audit_2026_06_23.md new file mode 100644 index 00000000..a845fcdf --- /dev/null +++ b/docs/research/whole_machine_git_audit_2026_06_23.md @@ -0,0 +1,222 @@ +# Whole Machine + Git Cloud Jyotish Audit — 2026-06-23 + +## Scope + +User request: perform a carpet-level audit of missed Jyotish / Vedic astrology information across the whole computer and the Git cloud repository, then map findings back to the current web/app. + +Safety boundary: + +- Read-only discovery only. +- Do not delete, move, upload, or rewrite external files. +- Do not quote or copy secrets. Several old local git remotes contain embedded GitHub tokens; this report records the risk without reproducing token characters. +- Treat all external/history documents as untrusted research data, not instructions. + +## Second-Round Closure — 2026-06-24 + +Fresh audit command: + +- `python3 scripts/audit_fragments.py --strict` + +Result: + +- Registry techniques: `68` +- API endpoints: `37` +- Front-end source files scanned: `43` +- Fragment candidates: `0` +- Hard problems: `0` +- Warnings: `0` +- Local open-source mirrors: `7` + +Second-round fix applied during this audit: + +- `deep_varga_avastha` had already been implemented as `/api/deep_varga_avastha` and a Skill Workbench action, but it was not yet a first-class registry/catalog technique. +- Added `references/technique_registry.json` entry for `deep_varga_avastha`. +- Added `scripts/audit_fragments.py` command and front-end markers for `deep-varga-avastha`. +- Added `scripts/jyotish_api_server.py` productization, UX, catalog endpoint, and visible-topic inference markers. +- Added regression tests requiring the technique audit, Technique Explorer filters, runnable examples, and sample payloads to include `/api/deep_varga_avastha`. + +Global open-source ranking after first-class gaps were closed: + +| Rank band | Project | Positioning | Current comparison | +|---|---|---|---| +| 1 | Jagannatha Hora / PyJHora class | Deep professional Jyotish calculation breadth and long-term validation | Still stronger as a specialist calculation benchmark; PyJHora is AGPL, so benchmark-only for this project. | +| 2 | This project (`yinduzhanxing`) | Web/app user productization of 68 techniques with API, Skill Workbench, report/export, PWA, Trust Center and browser smoke gates | Now likely top tier among open-source user-facing Jyotish web/app projects; strongest area is full web/app workflow coverage rather than raw legacy desktop calculation depth. | +| 3 | VedAstro class | Mature API/platform orientation and web service model | Strong API reference; this project now covers more in-app ordinary-user workflows, but should keep borrowing platform/API discipline. | +| 4 | VedicAstro / panchanga API / RoxyAPI templates | Focused API or starter-app coverage | Useful references for KP, panchanga and app scaffolding; current project is broader and more productized. | +| 5 | Single-purpose libraries | Panchanga, dasha, ephemeris or MCP fragments | Useful as specialized references, not comparable as full ordinary-user apps. | + +Current conclusion: + +- The previous first-class productization gap list is closed at registry/API/front-end/catalog level: Ashtakavarga PAV/Yoga Pinda, Sripathi/Placidus switch, KP Horary, Tajika Harsha/Panchavargiya Bala, Muhurta date-range solver, and Sayanadi/Shayanadi + D24/D30/D60 templates are now implemented and guarded. +- Remaining competitive gap is no longer "missing obvious skill capability"; it is release hardening: cloud sync/branch hygiene, full browser/release quality gate, external benchmark fixtures, and production packaging. +- The largest current project risk is workspace hygiene: many important product files are still untracked locally, so cloud deploys or ordinary-user builds from GitHub may miss them until the work is staged/committed. + +## Git Cloud Evidence + +Primary remote in current repo: + +- `origin`: `git@github.com:732642856/yinduzhanxing.git` +- current branch: `codex/release-hygiene-ci` + +Cloud access attempts: + +- SSH `git ls-remote` failed because port 22 timed out. +- HTTPS `git ls-remote https://github.com/732642856/yinduzhanxing.git` succeeded. +- GitHub REST API anonymous request was rate-limited. +- HTTPS mirror clone succeeded into `/tmp/yinduzhanxing-mirror.git`, then checked out into `/tmp/yinduzhanxing-cloud`. + +Cloud refs found: + +- `refs/heads/main` -> `4ff624812c7b9ec762a801f7219f9c2f5079e907` +- `refs/heads/codex/release-hygiene-ci` -> `11bdee3ba1f480aff38440ad58cfbb81bfa5567d` +- tags: `v6.0.47`, `v6.0.48`, `v6.0.49`, `v6.0.50`, `v6.0.51`, `v6.0.52` + +Cloud checkout facts: + +- `/tmp/yinduzhanxing-cloud` checked out `codex/release-hygiene-ci`. +- Cloud HEAD: `11bdee3ba1f480aff38440ad58cfbb81bfa5567d` +- Cloud tree: `d3a89944bb1c319120f61f66a88c879d0fa28375` +- Cloud file count excluding `.git`: `720` +- Cloud text/code-like file count: `654` + +Local workspace facts: + +- Local file count excluding `.git`: `1525` +- Local text/code-like file count: `938` +- Local contains many build/cache/runtime outputs not present in cloud: `.pytest_cache`, `.ruff_cache`, `build/`, `dist/`, `jyotish-app/dist/`, `jyotish-app/node_modules/`, `__pycache__`, generated benchmark outputs. +- Tracked local diff is much larger than the cloud branch diff and includes active productization work in app/API/tests/docs. + +Cloud branch diff against `origin/main`: + +- 17 files changed. +- 192 insertions, 125 deletions. +- Mostly release hygiene, CI, README/SKILL docs, packaging manifest, quality gate, and small API/build fixes. + +## Whole-Machine High-Value Sources + +High-signal local sources found outside the current project: + +- `/Users/wuyongnaren/.workbuddy/skills/jyotish-vedic-astrology` +- `/Users/wuyongnaren/Projects/星轨资料恢复/17-Skills技能库/jyotish-vedic-astrology` +- `/Users/wuyongnaren/Projects/星轨资料恢复/25-相关Skills补充/jyotish-vedic-astrology` +- `/Users/wuyongnaren/engines-repo/jyotish` +- `/Users/wuyongnaren/Documents/星轨talk/engines-repo/jyotish` +- `/Users/wuyongnaren/WorkBuddy/engines-repo/jyotish` +- `/Users/wuyongnaren/WorkBuddy/2026-06-09-20-03-34/jyotish-fragments` +- `/Users/wuyongnaren/WorkBuddy/2026-06-10-21-30-47` +- `/Users/wuyongnaren/WorkBuddy/2026-06-12-15-22-12` +- `/Users/wuyongnaren/.workbuddy/brain` +- `/Users/wuyongnaren/文件仓库/印度占星文章` +- `/Users/wuyongnaren/文件仓库/中外🔮占星/国外占星/印度占星书` + +Important historical reports found: + +- `印度占星Skill_真实Bug与遗漏清单_v6.1.11.md` +- `Yinduzhanxing_开源对标与优化报告_v6.1.11.md` +- `开源印度占星项目搜索报告.md` +- `vedic-astrology-open-source-research.md` +- `印度占星Skill全面审计与能力评估报告-v3.0.md` +- `jyotish_improvement_plan.md` +- `jyotish_capability_assessment.md` + +## Open Source Sources Already Present Locally + +Current project already contains local mirrors under `references/open_source_sources`: + +- `jyotishganit` +- `jaimini-tropical` +- `VedicAstro` +- `rishi-ai-mcp` +- `dashaflow` +- `panchanga_api` +- `vedic-astro-skills` + +Historical reports repeatedly classify reuse posture as: + +- MIT / suitable for direct reuse or adaptation: `VedicAstro`, `dashaflow`, `vedic-astro-skills`, parts of `jyotishganit`, `happyalu/panchang-muhurt` where license permits. +- AGPL/GPL / benchmark or independent rewrite only: `PyJHora`, `vedic-calc`, some SwissEph WASM packages. + +## Historical Missing-Feature Consensus + +The recurring non-UI gaps from older reports: + +- Ashtakavarga Prashtara / PAV source contribution table +- Ashtakavarga Kakshya +- Yoga Pinda +- Bhava Bala +- Navatara / Tara Bala +- Kantaka Shani +- Pushkara Navamsa / Pushkara Bhaga +- Ishta / Kashta Phala +- Sripathi / Placidus house systems +- 36 Sahams +- Tajika strength layers: Harsha Bala, Panchavargiya Bala +- KP Horary +- Deeper Prashna: Sphuta / Trisphuta / Prana-Deha-Mrityu +- Muhurta solver +- Vimshottari multiple start points +- Sayanadi / Shayanadi Avastha +- D24 / D30 / D60 deeper interpretive templates + +## Current Coverage Matrix + +Legend: + +- `covered`: registry/API/engine or front-end has a meaningful implementation path. +- `partial`: some backend or docs exist, but the product/API/user flow is incomplete. +- `reference-only`: only docs or open-source reference exists; no first-class product implementation. + +| Area | Current Status | Evidence | Gap | +|---|---:|---|---| +| Navatara / Tara Bala | partial | `scripts/nakshatra_advanced.py`, `tests/test_nakshatra.py`, registry has Nakshatra Advanced | Tara Bala exists, but Navatara itself is not first-class in skill-map/product cards. | +| Kantaka Shani | covered | `scripts/sade_sati.py`, `/api/sade_sati`, registry note | User-facing depth may still be limited to the Sade Sati surface. | +| Pushkara | covered | `scripts/jyotish_engine.py`, `jyotish-app/main.js`, registry | Covered in D9/marriage and full-reading contexts. | +| Ishta / Kashta Phala | partial | Shadbala references and tests mention Ishta/Kashta | No standalone registry/API/front-end module. | +| Ashtakavarga Prashtara | reference-only | `references/open_source_sources/dashaflow/ashtakavarga.py`, old reports | Current `scripts/ashtakavarga.py` has BAV/SAV; Prashtara source contribution table not productized. | +| Yoga Pinda | reference-only | `references/feature-gap-matrix-2026.md` | No engine/API/front-end implementation found. | +| Kakshya | covered | `scripts/kakshya.py`, `/api/kakshya`, `computeKakshya`, tests | Covered as backend/API; check if product surface is prominent enough. | +| Bhava Bala | covered | `scripts/bhava_bala.py`, `/api/bhava_bala`, skill-map card | Covered as backend/API and app card. | +| Sripathi / Placidus | partial | `scripts/bhava_chalit.py`, API example uses `house_system: sripati`; VedicAstro has Placidus references | Setting exists around Bhava Chalit, but not a clear user-facing house-system selector/parity gate. | +| 36 Sahams | covered | `scripts/tajika.py`, `scripts/varshaphala.py`, `/api/annual`, `/api/prashna` | Covered in annual/prashna contexts. | +| Harsha / Panchavargiya Bala | reference-only | roadmap and feature matrix | No first-class engine/API/front-end implementation found. | +| KP Horary | partial | `references/open_source_sources/VedicAstro/vedicastro/horary_chart.py`, `scripts/prashna.py` has simplified KP Prashna | MIT source exists but VedicAstro horary is not directly integrated as a product module. | +| Prashna Sphuta / Trisphuta | covered | `scripts/prashna.py`, tests assert `trisphuta`, front-end renders Sphuta points | Covered. | +| Muhurta solver | partial | `scripts/muhurta.py`, `/api/muhurta`, tests | Scoring exists; full constraint solver/date-range search is still unclear. | +| Vimshottari multiple start points | partial | `scripts/extended_dashas.py`, `nakshatra_dasha.py`, registry Dasha variants | No clear setting for all traditional 12 Vimshottari start points. | +| Sayanadi / Shayanadi Avastha | partial | `scripts/avastha_calculator.py` includes Shayanadi | Registry does not expose Sayanadi/Shayanadi as a separate capability; front-end status is partial. | +| D24 / D30 / D60 deep reading | partial | `scripts/divisional_charts_extended.py`, `trimshamsa_d30.py`, tests | Calculations exist; deep interpretive templates for D24/D30/D60 remain thinner than D1/D9/D10. | + +## Highest Priority Remaining Gaps + +1. Ashtakavarga Prashtara + Yoga Pinda + - Why: Professional Ashtakavarga tools need source contribution tables, not only BAV/SAV totals. + - Reuse: `dashaflow/ashtakavarga.py` can be studied/adapted if license allows; current local `scripts/ashtakavarga.py` already has calibrated BAV/SAV. + +2. Sripathi / Placidus house-system productization + - Why: Settings mention house systems, but the user-facing app still needs a trustworthy switch, provenance, and parity warning. + - Reuse: VedicAstro horary/houses references and current `bhava_chalit.py`. + +3. KP Horary via VedicAstro + - Why: Historical reports rank VedicAstro as the best MIT source for KP Horary. + - Reuse: `references/open_source_sources/VedicAstro/vedicastro/horary_chart.py`. + +4. Harsha / Panchavargiya Bala for Tajika + - Why: Annual Varshaphala already exists, but Tajika strength judgement is incomplete without these layers. + - Reuse: historical notes and reference docs; avoid AGPL copying. + +5. Muhurta constraint/date-range solver + - Why: Current Muhurta scoring is not the same as a usable search workflow. + - Reuse: `dashaflow/muhurtha.py`, `panchanga_api`, `happyalu/panchang-muhurt` if license permits. + +6. Sayanadi/Shayanadi Avastha + D24/D30/D60 deep templates + - Why: These are expert-depth gaps, not first-minute UX gaps. + - Reuse: current `avastha_calculator.py`, `divisional_charts_extended.py`, and existing reference templates. + +## Next Action + +Start with Ashtakavarga Prashtara + Yoga Pinda because it is: + +- Repeated across historical reports. +- Still not present in registry/API/frontend as a first-class capability. +- Close to existing calibrated Ashtakavarga code, so implementation risk is bounded. +- Highly visible for professional users comparing this app against real Jyotish tools. diff --git a/findings.md b/findings.md new file mode 100644 index 00000000..5815f0fb --- /dev/null +++ b/findings.md @@ -0,0 +1,111 @@ +# 印度占星产品化发现记录 + +## 开源与本地基线 + +- `references/open-source-jyotish-scan-2026.md` 已记录可直接复用/对标项目:dashaflow、jyotishganit、panchanga_api、jaimini-tropical、VedicAstro、KPAstroDashboard、vedic_astro_npm、PyJHora、VedAstro、xalen-ephemeris。 +- 本轮 GitHub 搜索 `panchanga vedic astrology` 命中 VedAstro/VedAstro、VedAstro/VedAstro.Python、kunjara/jyotish、bidyashish/vedicpanchanga.com、degen0root/panchanga_api、asitsa-dotcom/jyotidarshan、vedic-astrology-starter-kit 等。 +- 产品层结论:同品类 Panchanga 不是只给 Tithi/Nakshatra,至少应有日历范围、节日/vrata 标记、吉凶时段、活动筛选、结构化导出与可检索条件。 +- 许可证策略:MIT/Apache 可直接复用;AGPL/GPL 仅作行为基准,不能复制代码。 + +## Panchanga 当前差距 + +- 当前已有:range API、月历、activity filter、Rahu Kala/Yamaganda/Gulika、Choghadiya、Hora、end times、基础 Ekadashi/Pradosham/Purnima/Amavasya tags、CSV/ICS。 +- 仍缺:更丰富的 vrata/festival candidate 标签、按条件检索、导出中保留 condition tags、用户能快速找“适合商业/旅行/修行/避免新开始”的日期。 + +## 当前实现策略 + +- 先在 `scripts/muhurta.py` 增加保守规则:基于已有 tithi/nakshatra/vara 数据生成可解释标签。 +- 对需要 lunar masa 或太阳入宫才能精确判断的节日,只标记为 candidate,并在说明中提示需要月份/太阳过境确认。 +- 前端只增加轻量条件筛选,不改核心 API 数据结构,避免破坏已有工作流。 + +## Panchanga 本轮结论 + +- 已实现:Ekadashi/Pradosham/Purnima/Amavasya 之外,新增 Chaturthi、Shashthi、Ashtami、Navami、Akshaya Tritiya candidate、Shivaratri candidate、Pushya/Guru Pushya/Ravi Pushya、Rohini 等保守标签。 +- 已实现:`condition_tags` 支持 `has_vrata`、`festival_candidate`、`spiritual_practice`、`auspicious_activity`、`avoid_new_start`、`good_choghadiya`、selected activity good/avoid。 +- 产品判断:下一个工作区瓶颈不是算法,而是案例管理。保存星盘、配对、问事已经存在,但缺少多人/家庭分组、关系维度和更强过滤。 + +## 案例工作区本轮结论 + +- 2026-06-22 网络检索 `vedic astrology kundli matching app` 命中 JyotiDarshan、VedicAstrologyAndroid、kundali、MyRashifal、CosmicBond 等,产品信号仍然是 Kundli profile + Panchang + matching + reports。 +- 当前项目已实现统一案例工作区:星盘、配对、问事都能进入同一列表,支持分组、关系类型、标签、搜索、批量选择、导出和删除。 +- 下一个高价值缺口是关系报告模板:同品类 matching workflow 不应只显示 36 分,还要给“关系主题、风险、D9/Kuja/Dasha 证据、行动建议、边界说明”的报告结构。 + +## 关系工作区本轮结论 + +- 2026-06-22 网络检索 `ashtakoot kundli matching`、`vedic astrology compatibility matching`、`jyotish matchmaking python`:直接可复用且许可证清楚的合盘内核仍以本地已镜像的 `dashaflow` MIT 代码最贴合;若干新仓库无许可证或只是产品壳,不能直接复制。 +- 已实现关系报告模板:把 Ashtakoot 总分/分项、D9 平均质量、Kuja Dosha 平衡、Dasha 同步、强弱 Kuta、风险与下一步建议统一成 `relationshipReport` / `relationship_report` 数据结构。 +- 已实现 bi-wheel/composite-style 比较视图:完整出生盘合盘后展示上升/月亮/金星/火星轴线、行星 overlay 宫位、星座关系 tone,以及 Sun/Moon/Venus/Mars midpoint。 +- 已实现 `spouse_status_yoga.py` 折叠:`/api/relationship` 返回 `spouse_status_yoga` 与 fragment source;完整合盘上下文生成本人/对方 spouse-status 快照,关系报告、保存配对复盘和 HTML 报告都会展示配偶/婚后成长证据。 +- 已实现关系报告打印 polish:HTML 报告中的合盘段落升级为 `relationship-deliverable`,包含结论 hero、证据卡、双人轴线、overlay 表、midpoint、spouse-status、行动列表和边界说明,打印时避免关键卡片拆页。 +- 已实现可编辑关系元数据:统一案例工作区可编辑 chart/pair/prashna 的标题、分组、关系类型和标签,保存后刷新列表并保留 JSON 导入/导出形状。 +- 2026-06-23 网络/本地扫描报告与 PDF 项目:`vedic-astro-skills` 的 `scripts/report_builder.py` 是最贴合且许可证清晰的本地可复用代码;GitHub 命中的 report/PDF 项目多为无许可证、API SDK、产品壳或 notebook,不适合直接复制为核心管线。 +- 已实现后端 PDF 管线:新增 `/api/report_artifact`,限制 HTML 大小、阻断 script/iframe/object/embed/on* 事件属性与 javascript: URL,复用 `report_builder._html_to_pdf` 生成 PDF;Playwright 不可用时返回后端生成 HTML 作为降级工件。 +- 已实现前端 PDF 导出:导出菜单新增“导出 PDF 报告”,`exportPDFReport` 将现有单文件 HTML 报告送往后端,下载 `pdf_base64`;若后端 PDF 不可用则下载 `html_base64`。 +- 已实现更深关系时机/UL-DK 折叠:完整合盘会从本地 `computeKaraka`/`computeArudha` 和当前 Dasha 中提取 7星制 DK、8星制 DK、UL、7宫主与 Venus/Jupiter/Moon/Mars 触发,把它们折叠进关系报告证据和可读卡片。 +- 碎片审计补充:本轮发现前端 UL/DK 函数已存在但未完全闭合到导出与测试;已补 `/api/relationship.relationship_timing`、`uldk-print-grid` HTML/PDF 导出和产品化断言,后续继续优先用 `rg` 排查半接入函数。 +- 已实现导出体验 polish:PDF/HTML/JSON/SVG/PNG 导出期间锁定菜单,显示 `aria-live` 状态;PDF 后端渲染不可用时保守下载 HTML fallback,并明确提示用户。 +- 已实现 Panchanga 组合条件筛选:用户可同时勾选 vrata/节日候选/修行/活动适配/避免新开始/吉利 Choghadiya,结果按 AND 语义过滤,并展示每个条件的保守说明,避免把候选节日误命名为确定节日。 +- 已实现 Panchanga search/details 后端字段:`panchanga_range_report` 返回 `search_summary` 和逐日 `festival_details`,说明候选节日的 tithi/nakshatra/vara basis、confirmation note 与 query examples。 +- 已实现前端组合模式与 location-aware 摘要:条件筛选新增“满足全部/满足任一”,摘要展示使用的出生地坐标/时区或手动日出日落来源,节日说明卡在移动端单列展示。 +- 已实现计算设置选择器:参数中心可保存 ayanamsa/node/house/sunrise/geocoder 策略,排盘 payload、星盘对象、provenance 和导出报告都会携带;对尚未真正切换底层黄经的选项明确标注为 staged policy。 +- 已实现规则/技法检索目录与 API Explorer:后端新增 `/api/technique_catalog` 和白名单 `/api/technique_example`,由 capability audit/technique registry 自动生成 65 个技法目录、domain/status/level 筛选、API endpoint 映射、示例 payload 和可运行样例;前端完整解盘的 Skill workbench 目录卡片可用当前星盘试算,后端不可用时保留原 workbench fallback。 +- 产品判断:关系、Panchanga、导出、参数透明度和技法目录已具备普通用户可用基础;下一缺口是规则变体/流派 toggles 与候选碎片归档,让 Yoga/KP/Jaimini/AV 等流派差异不只藏在源码或 JSON。 +- 已实现规则变体/流派 toggles 可见化:Yoga、Jaimini Karaka、KP significator、Ashtakavarga、Shadbala、Dasha reference 已进入同一 Calculation Settings 存储和导出链路;其中 Yoga/Ashtakavarga/Shadbala 的 API 结果已开始返回 `rule_variants`,前端 Skill workbench 会展示结果口径。 +- 已实现候选碎片真实接入:`curse_yoga_detector.py` 已复用到 `/api/yogas`,返回 `curse_yogas`、风险等级、命中数量和边界提示;`shadbala_advanced.py` 已复用到 `/api/shadbala` 的 `advanced_layer`,补充 Kala VMDH、Yuddha Bala 与 Sputa Drishti 证据,但不覆盖主 Shadbala 排名。 +- 已实现 Dasha 叙事/时间线碎片接入:`dasha_analyzer.py` 进入 `/api/dasha.vimshottari_analysis`,补充真实 Mahadasha 起点、当前 Antardasha、Moon Nakshatra/Pada;`dasha_calculator_enhanced.py` 作为五级层级证据层,主周期合同不变。 +- 碎片审计发现:候选队列已从 6 个降至 4 个,剩余集中在 reading/orchestrator/bridge/automation 归属;这些更像工作流/代理桥接,需要决定是否属于用户端产品,不能盲目塞进前端。 +- 已实现主题化报告编排接入:新增 `thematic_report_orchestrator` registry 条目和 `/api/thematic_report`,将 `report_orchestrator.py` 的五主题叙事、冲突裁决、证据链和 Dasha 时间锚点变成可调用 API;前端 Skill workbench/API Explorer 用 `computeThematicReport` 渲染主题卡。 +- 碎片审计结论更新:`reading_orchestrator.py`、`report_orchestrator.py`、`orchestrator_bridge.py` 已有 registry/API/frontend/test 引用链,不再是漂浮碎片;`mevg_automation.py` 作为只读门控状态进入 `/api/case_validation.mevg_gate`;`hermes_bridge.py` 判定为外部个人 agent/WorkBuddy 学习桥,会写用户 home 目录,不纳入占星网页/app 默认产品面。 +- 已实现主题报告真实证据链:`/api/thematic_report` 现在区分 `custom_evidence`、`derived_chart_evidence`、`sample_evidence`。传入出生数据或星盘数据时,会 best-effort 调用 chart、dasha、yogas、shadbala、ashtakavarga、relationship、career、Jaimini 模块,生成五主题证据;单个模块失败只进入 warning,不让整份报告不可用。 +- 产品判断:这一步解决了“主题报告只有表面叙事/样例证据”的关键问题。下一缺口转向方法透明度:Technique Directory/API Explorer 应给用户可复制 cURL/OpenAPI 片段、方法边界和算法来源说明。 +- 已实现 Technique Directory/API Explorer 方法透明度:后端 catalog 返回 `api_docs`、`method_docs`、cURL、最小 OpenAPI operation 和 endpoint notes;前端卡片显示方法摘要/边界/API doc key,试算结果显示 `cURL / OpenAPI` 折叠区。 +- 产品判断:技法目录已从“能搜索/能试算”升级为“能复用 API/能解释接口边界”。下一类缺口属于平台与信任层:PWA/桌面包装、隐私/数据位置、术语模式、星历抽象。 +- 已实现 PWA/信任中心 MVP:manifest、service worker shell cache、SVG icon、install prompt 状态和 Trust Center 已进入用户端;本地资料可导出,清空本地资料保留二次确认,不自动执行破坏性动作。 +- 产品判断:平台层已从普通 Vite 页面升级为可安装/可说明数据边界的 local-first 工具;下一缺口是术语模式与星历抽象,让初学者/专业用户和未来替换 ephemeris backend 都有明确入口。 +- 已实现术语模式:入门/专业/梵文优先已进入 Trust Center、tooltip、provenance、JSON/HTML 导出;这补齐了同品类 Jyotish 软件常见的 Sanskrit/英文/本地语言对照体验。 +- 产品判断:术语层已不再只是点击 glossary,而是可配置的解释口径。下一缺口继续集中在平台化交付:PWA 之后的 Pake/Tauri 桌面包装说明,以及 SwissEph/VedAstro/Xalen 等星历底座替换可行性。 +- 2026-06-23 首次使用对标补充:Hora Prakash 的无注册/PWA/隐私本地化、VedAstro 的 API/skill/chat/API doc surface、Maitreya/HinduVahini 类桌面/功能软件都说明,同品类产品不能只堆计算标签,首屏必须让用户知道“如何开始、环境是否可用、无 API 时能做什么、已有星盘如何导入”。 +- 已实现首次使用与空状态路径:首屏提供运行健康检查、示例盘填入、已有星盘导入聚焦;本地星盘库空状态从静态说明改为引导用户用示例盘/导入/手动输入生成第一张盘。 +- 产品判断:这一步补的是普通用户的第一分钟体验,降低“页面功能很多但不知道从哪里开始”的风险。下一缺口不再是静态入口,而是真机/浏览器首跑验证:桌面和移动端是否无重叠、示例盘能否生成、健康检查失败文案是否能指导启动本地 API。 +- 官方安全口径补充:OpenAI API key 属于 secret,不应出现在浏览器/app 客户端代码、localStorage、URL 参数或公开仓库;Jyotish AI 聊天应经由服务端 `/api/chat` 或本地后端代理读取服务端 `OPENAI_API_KEY`。 +- 已实现 AI/导出信任层 polish:AI 聊天默认回复改为服务端密钥处理说明;PDF 导出失败或后端 PDF 渲染不可用时保守导出 HTML,并提示 Trust Center 健康检查与 Python API 启动命令。 +- 产品判断:首次使用路径之后,普通用户的下一类卡点是“PDF/AI 点击失败但不知道为什么”。现在错误恢复已经从技术异常变成可行动路径;下一缺口可以转向星历抽象可行性,把 SwissEph/VedAstro/Xalen 的长期替换边界从文档推进到可测试探针。 +- 已实现星历抽象可行性探针:`scripts/ephemeris_backend_probe.py` 会输出 `candidate_backends`、`license_posture` 与 `replacement_readiness`,把 `swisseph_python` 标为 primary,`swisseph_wasm` 标为 fallback,`xalen_ephemeris` 标为 spike_only,`vedastro` 标为 product_api_benchmark,`pyjhora_benchmark` 标为 benchmark_only。 +- 许可证与替换结论:当前不能把 xalen/VedAstro/PyJHora 说成已替换核心计算。SwissEph 仍是生产黄经来源;xalen 需要本地 adapter 和 parity matrix;VedAstro 适合作为 MIT 产品/API 对标;PyJHora 因 AGPL 只做行为基准或 oracle,不复制实现。 +- 产品判断:下一步不应继续堆 UI 标签,而应建立后端 adapter contract 与 longitude parity matrix,要求 Sun/Moon/Asc/Rahu/Ketu 和 Panchanga 边界案例在可接受 delta 内,才允许非 SwissEph 后端进入运行时选择。 +- 已实现后端 adapter contract:`scripts/ephemeris_adapter_contract.py` 定义 `EphemerisAdapterContract` 与三组 `PARITY_CASES`,复用当前生产 `swisseph_python` 计算 Sun/Moon/Asc/Rahu/Ketu baseline,并保留 `candidate_backend` 插槽。 +- 接入标准:任何后续 SwissEph WASM、xalen 或 VedAstro 服务边界都必须输出相同字段,包括 `ayanamsa_value`、sidereal longitude、speed、`retrograde`、backend metadata,并通过 `longitude_delta_arcsec` 阈值后才能进入运行时设置。 +- 候选 adapter gate 结论:本地没有 xalen 可执行来源;SwissEph WASM 资产可用但包许可证分别为 `AGPL-3.0` 与 `GPL-3.0-or-later`,因此只能作为本地 fallback/实验路径,不能无审查地进入商业桌面/PWA 分发叙事。 +- 真实浏览器点击级 smoke 结论:静态产品化断言和 curl runtime smoke 仍不足以发现用户点击路径问题;新增 `tests/run_frontend_click_smoke.py` 后,真实覆盖了示例盘生成、AI chat、HTML 导出、Transit、合盘、问事,并发现 HTML 报告导出实际会因 `sub_lord is not defined` 失败。 +- HTML 导出 Bug 根因:`jyotish-app/jyotish-advanced.js` 的 `computeNakshatraAdvanced` 定义 `subLord`,但返回对象误用 `sub_lord,` 简写;真实用户生成星盘后导出 HTML 会在构建 extras 时抛错。已修为 `sub_lord: subLord`,并增加测试守门。 +- 产品判断:当前桌面在线 happy path 已有真实浏览器守门;下一缺口是移动端/离线/PWA 安装、无 API 启动失败、PDF fallback 与浮层遮挡等更接近普通用户环境的交互守门。 +- 移动端点击守门结论:390px 视口下 AI FAB 原本会被星盘 SVG/行星表截获,且 chart/table 容器会把页面撑到 528px。真实用户在手机上可能无法打开 AI 面板。已将移动 FAB 放到左侧安全区、AI panel 锁定 `100vw`,并收紧 chart/table 容器宽度。 +- 离线/无 API 结论:无 API 时前端可以通过浏览器 fallback 生成基础星盘,健康检查会显示 `npm run web` 与 `python3 scripts/jyotish_api_server.py` 恢复路径;console 中的 `ERR_CONNECTION_REFUSED` 是预期 API 探测噪声,click smoke 已分入 `expected_offline_console_errors`。 +- 桌面包装探测结论:本机 Rust/Node 基础可用,但未安装 `pake`/`tauri` CLI;`xcodebuild` 只有 CommandLineTools,不能视为 macOS signing/notarization ready。Pake 上游 GPL-3.0,Tauri 仍需 sidecar 生命周期、权限、签名/公证策略;当前不能声称桌面包已可发布。 +- PDF fallback 点击结论:真实用户点击 PDF 导出时,如果后端 PDF 渲染不可用,前端必须明确“已改为导出 HTML 报告”,并提供 Trust Center 与本地 API 启动路径。`run_pdf_fallback_smoke` 现在把这条路径纳入浏览器守门。 +- PWA 离线 shell 结论:service worker 控制后的二次 reload 可以保留首屏 shell;离线状态下 JS module/API 请求会产生 `ERR_FAILED`,这是预期离线噪声,脚本单独放入 `offline_shell_expected_console_errors`,避免把真实 JS error 混进去。 +- 移动端长标签结论:Complete/Vargas/Synastry/Prashna/Transit Compare 已可在 390px 视口真实切换;下一类风险转向导入 PDF/文本星盘、保存案例库、移动端导出菜单和 Trust Center 长内容。 +- 报告 artifact 契约结论:只返回 `html_base64/pdf_base64` 不足以支撑普通用户体验;后端必须显式返回 `artifact_status`、`primary_artifact`、`download_filename`、`download_mime`、`fallback_reason`、`user_message` 与 `next_action`,前端才能在 PDF 不可用、API 未连接或 HTML fallback 时给出一致的下载名和恢复动作。 +- 验证结论:runtime smoke 现在会检查 report artifact 的状态、下载文件名和用户指引,不再停留在“有 base64 就算通过”的浅层检查。下一类高风险用户路径是导入 PDF/文本星盘、保存/重开案例库,以及移动端长内容中的导出菜单和 Trust Center。 +- 导入/案例库结论:文本星盘导入、填表、生成盘、保存本地星盘、重开保存星盘、工作区保存与案例库导出已经进入真实浏览器守门;这类路径不能只看按钮存在,因为之前 smoke 本身先后暴露了隐藏 logo 和未切换 provenance tab 两个真实选择器/可见性问题。 +- 下一风险:移动端已经验证过长标签切换,但还没有专门验证导出菜单、Trust Center 长面板、健康检查、本地资料导出/安装说明在 390px 视口下是否可读、可点击、无横向溢出。 +- 移动 Trust Center 结论:健康检查 API 成功不等于用户能看到成功;原实现会在 `renderAll()` 后把“健康检查通过”覆盖回 PWA 默认说明。状态文案必须由 runtime health 派生,才能在移动端和普通用户长面板中稳定可见。 +- 移动工作区布局结论:案例库内部控件在 390px 视口下会因 `case-workspace-counts`、`case-workspace-controls` 等块宽度叠加父级 padding 产生右侧溢出。移动端不仅要让主 grid 单列,也要给嵌套工作区控件 `min-width:0` 和单列/最大宽度约束。 +- 质量门结论:长链路浏览器 smoke 必须有命令级超时和进程快照,否则失败时容易留下 API/Vite 子进程并让用户不知道卡在哪里。`--timeout` 与 `--frontend-click-timeout` 已成为默认质量门的一部分。 +- 文件导入结论:粘贴文本通过不代表文件上传也可用;真实浏览器守门需要覆盖 `set_input_files`、文本文件读取、PDF API 抽取失败和移动端上传入口触控尺寸。移动端“上传文件”低于 40px 时虽然视觉可见,但不适合普通用户触控。 +- 下载稳定性结论:在长链路 `--mode all` 中仅靠 `page.on("download")` 收集文件名会有偶发遗漏;关键导出动作应使用 `page.expect_download()` 包裹点击,才能把案例库导出失败和测试竞态区分开。 +- 启动路径结论:普通用户不应同时面对“npm run web / npm run dev / python API / PWA / Pake / Tauri”多套入口。当前 README 和质量门失败摘要已统一为“先网页、再本地 API、然后 Trust Center 健康检查”,并明确 PWA 只包装网页壳。 +- 术语一致性结论:可复制命令属于 README/质量门摘要,应用内恢复文案属于普通用户语言。界面只给“普通用户启动路径 / 网页服务 / 本地 API 服务 / PWA 安装壳 / Trust Center”,避免把用户推回开发者命令细节;真实浏览器 mobile-trust smoke 已验证新 Trust Center 成功文案可见。 +- 下一风险:质量门已经覆盖真实浏览器全链路,但默认 full click smoke 成本较高。需要把 fast/default/release 三层验证写清楚,确保主动迭代时不跳过核心路径,发布前仍跑 `--mode all`。 +- 整机初扫发现:当前项目并非唯一资料源。高相关目录包括 `/Users/wuyongnaren/.workbuddy/skills/jyotish-vedic-astrology`、`/Users/wuyongnaren/Projects/星轨资料恢复/17-Skills技能库/jyotish-vedic-astrology`、`/Users/wuyongnaren/Projects/星轨资料恢复/25-相关Skills补充/jyotish-vedic-astrology`、`/Users/wuyongnaren/engines-repo/jyotish`、`/Users/wuyongnaren/Documents/星轨talk/engines-repo/jyotish`、`/Users/wuyongnaren/WorkBuddy/2026-06-09-20-03-34/jyotish-fragments`、`/Users/wuyongnaren/文件仓库/印度占星文章`、`/Users/wuyongnaren/文件仓库/中外🔮占星/国外占星/印度占星书`。 +- 整机初扫还发现多份可能包含遗漏结论的历史报告:`.workbuddy/brain/*/印度占星Skill全面审计与能力评估报告-v3.0.md`、`.workbuddy/brain/*/jyotish_improvement_plan.md`、`WorkBuddy/2026-06-10-21-30-47/印度占星Skill_真实Bug与遗漏清单_v6.1.11.md`、`WorkBuddy/2026-06-10-21-30-47/开源印度占星项目搜索报告.md`、`WorkBuddy/2026-06-12-15-22-12/vedic-astrology-open-source-research.md`。 +- 云端探测结论:SSH `git ls-remote` 因 22 端口连接超时失败;HTTPS `git ls-remote https://github.com/732642856/yinduzhanxing.git` 成功,远端 refs 包含 `refs/heads/main`、`refs/heads/codex/release-hygiene-ci`、tags `v6.0.47` 至 `v6.0.52`。GitHub REST API 匿名访问被 rate limit,因此云端字符级审计应使用 HTTPS git mirror。 +- 历史遗漏报告共识:旧报告反复指出的非表层缺口不是 UI,而是专业技法深度和 benchmark:Ashtakavarga PAV/Prashtara/Kakshya/Yoga Pinda、Bhava Bala、Navatara/Tara Bala、Kantaka Shani、Pushkar Navamsa、Ishta/Kashta Phala、36 Sahams、Tajika 强度体系、KP Horary/Prashna 裁决、Muhurta 求解器、Vimshottari 多起算点、精微分盘 D24/D30/D60 深度解读。 +- 许可证边界:历史报告中 `VedicAstro`、`dashaflow`、`vedic-astro-skills`、`happyalu/panchang-muhurt` 标记为 MIT 或可复用;`PyJHora`、`vedic-calc` 标记为 AGPL/GPL 参考/benchmark-only,不能直接复制进当前产品。 +- 覆盖矩阵结论:`scripts/sade_sati.py`、`scripts/kakshya.py`、`scripts/bhava_bala.py`、`scripts/prashna.py`、`scripts/varshaphala.py` 说明旧缺口里很多已进入后端/API;但 `Prashtara`、`Yoga Pinda`、`Panchavargiya`、`Sayanadi` 仍没有 registry/API/frontend 闭环。 +- 云端/本地差异结论:GitHub `codex/release-hygiene-ci` 云端快照只有 720 文件,本地工作区 1525 文件且包含大量 build/cache 产物和未提交开发文件。后续判断“是否遗漏”必须以当前工作区 + 云端 mirror + `.workbuddy/skills/jyotish-vedic-astrology` 三方对照,不能只看当前目录。 +- 下一实现判断:Ashtakavarga Prashtara/Yoga Pinda 是优先级最高的真实遗漏,因为当前项目已具备校准后的 BAV/SAV 与 Kakshya,但缺少专业软件常见的源贡献展开和 Yoga Pinda 层;本地 `dashaflow/ashtakavarga.py` 是最贴近可复用参考。 +- Ashtakavarga 复核结论:当前代码并非完全没有 Prashtara,`calc_prastara_av()` 已存在;真实缺口是 PAV 形状、Yoga Pinda、API summary、Skill workbench 和 registry 没有形成一等产品闭环,导致用户看不到专业贡献追溯。 +- Ashtakavarga 本轮修复:新增 `calc_yoga_pinda()`,让 Yoga/Shodhya Pinda 可被 API 与测试直接调用;`/api/ashtakavarga` 返回 `yoga_pinda_summary`;前端 Skill workbench 新增 Yoga Pinda 卡片与校验标签;registry 新增 `ashtakavarga_yoga_pinda` 条目并更新主 Ashtakavarga covered 状态。 +- Ashtakavarga 剩余边界:当前 Yoga Pinda 复用项目既有 v2.1 Shodhya Pinda 权重口径,并已明示 validation note;若后续要对齐更严格传统流派,需要引入外部书例/benchmark,而不是把当前权重伪装成全部流派通用标准。 +- 下一高价值遗漏:Sripathi/Placidus 房宫算法切换。当前设置层已有 house policy 叙事,但用户还不能验证切换后房宫、Bhava Chalit 与报告证据如何变化;应先地毯式查本地 `bhava_chalit`、历史碎片和开源 references,再补 parity tests/API provenance/frontend selector。 diff --git a/jyotish-app/ai-chat.js b/jyotish-app/ai-chat.js index 64f1496c..fb9ca648 100644 --- a/jyotish-app/ai-chat.js +++ b/jyotish-app/ai-chat.js @@ -293,13 +293,21 @@ function buildChartContext(cd) { return ctx; } +function buildAISetupGuidance() { + const lang = getLang(); + if (lang === 'en') { + return `💡 **${t('ai.setup.title')}**\n${t('ai.setup.server')}\n${t('ai.setup.secret')}\n${t('ai.setup.trust')}`; + } + return `💡 **${t('ai.setup.title')}**\n${t('ai.setup.server')}\n${t('ai.setup.secret')}\n${t('ai.setup.trust')}`; +} + // ============================================================================ // AI API 调用(对接后端 Jyotish Server) // ============================================================================ function getApiBase() { - if (window.Capacitor?.isNativePlatform?.()) { - return localStorage.getItem('jyotish_api_base') || 'https://your-server.com'; - } + if (window.JYOTISH_API_BASE) return window.JYOTISH_API_BASE; + if (import.meta.env?.VITE_JYOTISH_API_BASE) return import.meta.env.VITE_JYOTISH_API_BASE; + if (window.Capacitor?.isNativePlatform?.()) return localStorage.getItem('jyotish_api_base') || ''; return ''; // 同域部署 } @@ -322,7 +330,7 @@ async function callAI(userMessage, chartContext) { chart_data: cd, }), }); - const data = await resp.json(); + const data = await parseAIResponse(resp); if (!resp.ok) { // 401 = token 过期 if (resp.status === 401) { @@ -334,7 +342,7 @@ async function callAI(userMessage, chartContext) { const limit = data.limit ?? 3; return `**今日对话次数已用完 (${todayUsage}/${limit})**\n\n免费用户每日 3 次 AI 对话。升级高级会员可享无限对话。\n\n点击右上角头像 → 升级高级会员。`; } - throw new Error(data.error || `服务器错误 (${resp.status})`); + throw new Error(data.error || data.message || `服务器错误 (${resp.status})`); } // 更新用量显示 if (data.todayUsage !== undefined && _authUser) { @@ -354,39 +362,33 @@ async function callAI(userMessage, chartContext) { return err.message; } console.warn('[AI Chat] Backend API failed, falling back:', err); - } - } - - // 检查旧版自定义 endpoint - const customEndpoint = localStorage.getItem('jyotish_ai_endpoint'); - if (customEndpoint) { - try { - const resp = await fetch(customEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message: userMessage, - chart_context: chartContext, - chart_data: getSelectedChartData(), - }), - }); - if (!resp.ok) throw new Error(`API 返回错误: ${resp.status}`); - const data = await resp.json(); - return data.reply || data.message || data.content || 'AI 未返回内容'; - } catch (err) { - console.warn('[AI Chat] Custom endpoint failed:', err); + return `${buildAIRecoveryMessage(err)}\n\n${generateLocalReply(userMessage, chartContext)}`; } } // 未登录提示 if (!_authToken) { - return '**请先登录以使用 AI 占星师对话**\n\n登录后每日可免费对话 3 次。\n\n点击页面右上角「登录」按钮即可注册/登录。\n\n💡 支持邮箱注册和 Apple 登录。'; + return `**请先登录以使用 AI 占星师对话**\n\n登录后每日可免费对话 3 次。\n\n点击页面右上角「登录」按钮即可注册/登录。\n\n${buildAISetupGuidance()}`; } // 兜底:本地回复 return generateLocalReply(userMessage, chartContext); } +async function parseAIResponse(resp) { + const raw = await resp.text(); + try { + return raw ? JSON.parse(raw) : {}; + } catch { + return { message: raw?.slice(0, 180) || '' }; + } +} + +function buildAIRecoveryMessage(error) { + const message = error?.message || error || '本地 API 未连接'; + return `**服务端 AI 对话暂不可用**\n\n${message}\n\n请到 Trust Center 运行健康检查;如本地 API 未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。\n\n${buildAISetupGuidance()}`; +} + function generateLocalReply(message, ctx) { const cd = getSelectedChartData(); const asc = cd?.ascendant; @@ -419,8 +421,7 @@ function generateLocalReply(message, ctx) { : `**健康分析(基于 D1 本命盘)**\n\n上升 ${signName(asc?.sign)}:\n- 第1宫代表身体和生命力\n- 第6宫代表疾病\n- 第8宫代表慢性健康问题\n\n⚠️ 完整分析需 D6。\n\n💡 配置 AI 后端获取深度健康解读。`; } - // 默认回复 return lang === 'en' - ? `**Chart Overview**\n\nAscendant: ${signName(asc?.sign)} ${asc?.degree?.toFixed(2) || ''}°\n\nYou can ask about:\n- Career\n- Marriage & relationships\n- Wealth\n- Health\n- Dasha analysis\n- Transit impacts\n\n💡 **Configure AI backend for full AI reading**\nIn browser console:\n\`localStorage.setItem('jyotish_ai_endpoint', 'YOUR_API_URL')\`` - : `**星盘概览**\n\n上升: ${signName(asc?.sign)} ${asc?.degree?.toFixed(2) || ''}°\n\n你可以询问以下话题:\n- 事业运 / 工作方向\n- 婚姻感情\n- 财运分析\n- 健康运势\n- Dasha 大运分析\n- Transit 过境影响\n\n💡 **配置 AI 后端可获得完整 AI 解读**\n在浏览器控制台输入:\n\`localStorage.setItem('jyotish_ai_endpoint', '你的API地址')\`\n即可连接你的 Jyotish AI Skill。`; + ? `**Chart Overview**\n\nAscendant: ${signName(asc?.sign)} ${asc?.degree?.toFixed(2) || ''}°\n\nYou can ask about:\n- Career\n- Marriage & relationships\n- Wealth\n- Health\n- Dasha analysis\n- Transit impacts\n\n${buildAISetupGuidance()}` + : `**星盘概览**\n\n上升: ${signName(asc?.sign)} ${asc?.degree?.toFixed(2) || ''}°\n\n你可以询问以下话题:\n- 事业运 / 工作方向\n- 婚姻感情\n- 财运分析\n- 健康运势\n- Dasha 大运分析\n- Transit 过境影响\n\n${buildAISetupGuidance()}`; } diff --git a/jyotish-app/analysis-renderers.js b/jyotish-app/analysis-renderers.js index e44f23fc..da3fabfa 100644 --- a/jyotish-app/analysis-renderers.js +++ b/jyotish-app/analysis-renderers.js @@ -5,8 +5,10 @@ import { PLANET_CN, PLANET_SYMBOLS, SIGNS_CN, SIGNS } from './jyotish-engine.js'; import { RAMAN_TABLES, HOUSE_DOMAINS } from './analysis-deep.js'; import { t, signName, planetName, houseLabel } from './i18n.js'; +import { escapeHtml, safeNumber } from './security.js'; const $ = id => document.getElementById(id); +const ARGALA_TARGETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu']; // ============================================================================ // 1. Raman 功能吉凶星表 @@ -23,11 +25,11 @@ export function renderFunctionalNature(ascSign) { worst: { text: '最凶', cls: 'fn-worst' }, }; const mkBadges = (list, key) => list.map(p => - `${PLANET_SYMBOLS[p]||''} ${PLANET_CN[p]}` + `${escapeHtml(PLANET_SYMBOLS[p]||'')} ${escapeHtml(PLANET_CN[p])}` ).join(''); let html = `
最佳吉星
${mkBadges(t.best,'best_benefic')}
`; - if (t.yk) html += `
Yogakaraka
${PLANET_SYMBOLS[t.yk]||''} ${PLANET_CN[t.yk]}
`; + if (t.yk) html += `
Yogakaraka
${escapeHtml(PLANET_SYMBOLS[t.yk]||'')} ${escapeHtml(PLANET_CN[t.yk])}
`; html += `
吉星
${mkBadges(t.good,'benefic')}
`; html += `
中性
${mkBadges(t.neutral,'neutral')}
`; html += `
凶星
${mkBadges(t.bad,'malefic')}
`; @@ -70,14 +72,14 @@ export function renderPACDARES(pac) { function renderPACItem(key, it) { switch(key) { - case 'P': return `
${PLANET_SYMBOLS[it.planet]||''} ${it.pcn}${it.scn} H${it.house} ${it.status||''} ${it.retro?'℞':''} ${it.combust?'[燃]':''}${fnLabel(it.fn)}主管H${it.hr.join('/')}
`; - case 'A': return `
${PLANET_SYMBOLS[it.planet]||''} ${it.pcn} (H${it.house})${it.received.map(r=>`${PLANET_CN[r.from]} ${r.type} → ${r.impact}`).join('')}
`; - case 'C': return `
${it.p1cn} + ${it.p2cn}H${it.house}${it.pollution?'[!] 能量污染':''}${fnLabel(it.f1)}${fnLabel(it.f2)}
`; - case 'D': return `
${it.type}${it.lcn}H${it.house}${it.str}
`; - case 'Ar': return `
${it.type}${it.desc}
`; - case 'R': return `
${it.type}${it.lcn}H${it.house}${it.str}
`; - case 'E': return `
${it.type} (${it.nature})${it.desc}
`; - case 'S': return `
${it.type}${it.desc}
`; + case 'P': return `
${escapeHtml(PLANET_SYMBOLS[it.planet]||'')} ${escapeHtml(it.pcn)}${escapeHtml(it.scn)} H${escapeHtml(it.house)} ${escapeHtml(it.status||'')} ${it.retro?'℞':''} ${it.combust?'[燃]':''}${fnLabel(it.fn)}主管H${escapeHtml((it.hr || []).join('/'))}
`; + case 'A': return `
${escapeHtml(PLANET_SYMBOLS[it.planet]||'')} ${escapeHtml(it.pcn)} (H${escapeHtml(it.house)})${(it.received || []).map(r=>`${escapeHtml(PLANET_CN[r.from])} ${escapeHtml(r.type)} → ${escapeHtml(r.impact)}`).join('')}
`; + case 'C': return `
${escapeHtml(it.p1cn)} + ${escapeHtml(it.p2cn)}H${escapeHtml(it.house)}${it.pollution?'[!] 能量污染':''}${fnLabel(it.f1)}${fnLabel(it.f2)}
`; + case 'D': return `
${escapeHtml(it.type)}${escapeHtml(it.lcn)}H${escapeHtml(it.house)}${escapeHtml(it.str)}
`; + case 'Ar': return `
${escapeHtml(it.type)}${escapeHtml(it.desc)}
`; + case 'R': return `
${escapeHtml(it.type)}${escapeHtml(it.lcn)}H${escapeHtml(it.house)}${escapeHtml(it.str)}
`; + case 'E': return `
${escapeHtml(it.type)} (${escapeHtml(it.nature)})${escapeHtml(it.desc)}
`; + case 'S': return `
${escapeHtml(it.type)}${escapeHtml(it.desc)}
`; default: return ''; } } @@ -86,7 +88,70 @@ function fnCls(fn) { return fn==='yogakaraka'?'yk':fn==='best_benefic'?'best':fn function fnLabel(fn) { return fn==='yogakaraka'?'YK':fn==='best_benefic'?'大吉':fn==='benefic'?'吉':fn==='malefic'?'凶':fn==='worst'?'大凶':'中'; } // ============================================================================ -// 3. 宫位互影响矩阵 +// 3. Argala / Virodhargala 干预分析 +// ============================================================================ +export function renderArgala(argala) { + const c = $('argala-section'); if (!c) return; + if (!argala || Object.keys(argala).length === 0) { + c.innerHTML = '
未取得 Argala 数据
'; + return; + } + const entries = ARGALA_TARGETS + .map(pn => ({ planet: pn, ...(argala[pn] || {}) })) + .filter(item => item.argala_on_this || item.virodha_on_this) + .sort((a, b) => Math.abs(safeNumber(b.net_score)) - Math.abs(safeNumber(a.net_score))); + const supported = entries.filter(item => item.net_assessment === 'supported' || item.net_assessment === 'strongly_supported').length; + const blocked = entries.filter(item => item.net_assessment === 'blocked').length; + const neutral = entries.length - supported - blocked; + const cards = entries.slice(0, 9).map(item => renderArgalaCard(item)).join(''); + c.innerHTML = ` +
+
+
${supported}被支持
+
${blocked}被阻挡
+
${neutral}中性
+
+
+ 读法 +

Argala 看“结果能否被打开”:2/4/11 宫形成资源、基础、收益干预;12/10/3 宫形成 Virodhargala 阻挡。这里按行星主题展示净支持或净阻碍。

+
+
${cards}
+
+ `; +} + +function renderArgalaCard(item) { + const cls = item.net_assessment === 'strongly_supported' ? 'argala-strong' + : item.net_assessment === 'supported' ? 'argala-supported' + : item.net_assessment === 'blocked' ? 'argala-blocked' : 'argala-neutral'; + const support = (item.argala_on_this || []).slice(0, 3).map(a => + `${escapeHtml(planetName(a.source))} · ${escapeHtml(a.house_from)}宫 · ${escapeHtml(a.effect)}` + ).join(''); + const blockers = (item.virodha_on_this || []).slice(0, 3).map(v => + `${escapeHtml(planetName(v.source))} 阻挡${escapeHtml(v.blocks_argala_from)}宫Argala` + ).join(''); + const label = { + strongly_supported: '强支持', + supported: '支持', + blocked: '受阻', + neutral: '中性', + }[item.net_assessment] || '中性'; + return ` +
+
+ ${escapeHtml(PLANET_SYMBOLS[item.planet] || '')} ${escapeHtml(planetName(item.planet))} + ${escapeHtml(label)} ${safeNumber(item.net_score) >= 0 ? '+' : ''}${escapeHtml(item.net_score ?? 0)} +
+
+
Argala${support || '无主要干预'}
+
Virodha${blockers || '无明显阻挡'}
+
+
+ `; +} + +// ============================================================================ +// 4. 宫位互影响矩阵 // ============================================================================ export function renderHouseInfluence(matrix) { const c = $('house-influence-grid'); if (!c) return; @@ -94,10 +159,10 @@ export function renderHouseInfluence(matrix) { for (const h of matrix) { const vd = h.verdict; const vc = vd==='强力'?'vd-strong':vd==='吉'?'vd-good':vd==='中'?'vd-neut':vd==='弱'?'vd-weak':'vd-afflicted'; - html += `
H${h.house}${h.scn}主: ${h.lcn}
${h.total>0?'+':''}${h.total}
${h.verdict}
`; + html += `
H${escapeHtml(h.house)}${escapeHtml(h.scn)}主: ${escapeHtml(h.lcn)}
${safeNumber(h.total)>0?'+':''}${escapeHtml(h.total)}
${escapeHtml(h.verdict)}
`; for (const it of h.items) { const ic = it.sc > 0 ? 'hi-pos' : it.sc < 0 ? 'hi-neg' : 'hi-zero'; - html += `
${it.src}${it.sc>0?'+':''}${it.sc}
`; + html += `
${escapeHtml(it.src)}${safeNumber(it.sc)>0?'+':''}${escapeHtml(it.sc)}
`; } html += `
`; } @@ -112,7 +177,7 @@ export function renderVargottama(vgList) { if (!vgList.length) { c.innerHTML = '
未检测到 Vargottama 行星
'; return; } let html = '
'; for (const v of vgList) { - html += `
${PLANET_SYMBOLS[v.planet]||''} ${v.pcn}Vargottama${v.d1}
`; + html += `
${escapeHtml(PLANET_SYMBOLS[v.planet]||'')} ${escapeHtml(v.pcn)}Vargottama${escapeHtml(v.d1)}
`; } html += '
'; html += '
Vargottama:行星在本命盘(D1)和九分盘(D9)落入同一星座,能量倍增。无论吉凶,该行星能量都会被放大。
'; @@ -127,7 +192,8 @@ export function renderFrequency(freq) { let html = ''; for (const [pn, f] of Object.entries(freq)) { const lc = f.pct>=50?'freq-vhigh':f.pct>=40?'freq-high':f.pct>=30?'freq-mid':'freq-low'; - html += `
${PLANET_SYMBOLS[pn]||''} ${f.pcn}${f.pct}%
${f.topScn} (${f.count}/${16})
${f.level}
${f.dist.map(d=>`${d.scn} ${d.p}%`).join(' · ')}
`; + const pct = Math.max(0, Math.min(100, safeNumber(f.pct))); + html += `
${escapeHtml(PLANET_SYMBOLS[pn]||'')} ${escapeHtml(f.pcn)}${pct}%
${escapeHtml(f.topScn)} (${escapeHtml(f.count)}/${16})
${escapeHtml(f.level)}
${(f.dist || []).map(d=>`${escapeHtml(d.scn)} ${escapeHtml(d.p)}%`).join(' · ')}
`; } c.innerHTML = html; } @@ -140,7 +206,7 @@ export function renderTriangle(tri) { let html = ``; for (const t of tri) { const vc = t.verdict.includes('双重强势')?'tri-best':t.verdict.includes('未兑现')?'tri-break':t.verdict.includes('反击')?'tri-comeback':t.verdict.includes('双重弱势')?'tri-worst':'tri-ok'; - html += ``; + html += ``; } html += '
行星D1D1状态D9D9状态D10D10状态Vg判定
${PLANET_SYMBOLS[t.planet]||''} ${t.pcn}${t.d1sign}${t.d1st}${t.d9sign}${t.d9st}${t.d10sign}${t.d10st}${t.vargottama?'✓':'—'}${t.verdict}
${escapeHtml(PLANET_SYMBOLS[t.planet]||'')} ${escapeHtml(t.pcn)}${escapeHtml(t.d1sign)}${escapeHtml(t.d1st)}${escapeHtml(t.d9sign)}${escapeHtml(t.d9st)}${escapeHtml(t.d10sign)}${escapeHtml(t.d10st)}${t.vargottama?'✓':'—'}${escapeHtml(t.verdict)}
'; c.innerHTML = html; @@ -151,14 +217,20 @@ function stCls(st) { return st==='入旺'||st==='入庙'?'st-good':st==='落陷' // ============================================================================ // 7. Raman 宫位评级(六步法) // ============================================================================ +function sanitizeRamanDetail(detail) { + return String(detail || '') + .replace(/\(undefined\)/g, '(缺星座)') + .replace(/\bundefined\b/g, '缺资料'); +} + export function renderRamanGrades(scores) { const c = $('raman-grades-grid'); if (!c) return; let html = ''; for (const s of scores) { const gc = s.grade==='A'?'grade-a':s.grade==='B'?'grade-b':s.grade==='C'?'grade-c':s.grade==='D'?'grade-d':'grade-f'; const domain = HOUSE_DOMAINS[s.house]; - html += `
H${s.house}${s.scn}${s.grade}
${domain?domain.name:''}
+${s.pos} / -${s.neg} = ${s.net>0?'+':''}${s.net}
宫主: ${s.lcn}
`; - for (const d of s.details) html += `
${d}
`; + html += `
H${escapeHtml(s.house)}${escapeHtml(s.scn)}${escapeHtml(s.grade)}
${escapeHtml(domain?domain.name:'')}
+${escapeHtml(s.pos)} / -${escapeHtml(s.neg)} = ${safeNumber(s.net)>0?'+':''}${escapeHtml(s.net)}
宫主: ${escapeHtml(s.lcn)}
`; + for (const d of s.details) html += `
${escapeHtml(sanitizeRamanDetail(d))}
`; html += '
'; } c.innerHTML = html; @@ -185,8 +257,8 @@ export function renderTrikonaKendra(data) { const grp = groupTag(h); html += `
H${h} ${tag} ${grp}
-
${hl.lord_cn} (${hl.lord})
-
→ H${hl.lordInHouse || '?'} ${hl.lordStatus || ''} ${hl.lordRetro ? '℞' : ''}
+
${escapeHtml(hl.lord_cn)} (${escapeHtml(hl.lord)})
+
→ H${escapeHtml(hl.lordInHouse || '?')} ${escapeHtml(hl.lordStatus || '')} ${hl.lordRetro ? '℞' : ''}
`; } html += '
'; @@ -196,7 +268,7 @@ export function renderTrikonaKendra(data) { html += `
Trikona 三方连接 (1-5-9)
`; for (const conn of trikonaConnections) { const qClass = conn.quality === 'excellent' ? 'tk-exc' : conn.quality === 'strong' ? 'tk-str' : 'tk-good'; - html += `
${conn.quality === 'excellent' ? '★' : conn.quality === 'strong' ? '◆' : '→'} ${conn.desc}
`; + html += `
${conn.quality === 'excellent' ? '★' : conn.quality === 'strong' ? '◆' : '→'} ${escapeHtml(conn.desc)}
`; } html += '
'; } else { @@ -208,7 +280,7 @@ export function renderTrikonaKendra(data) { html += `
Kendra 四正连接 (1-4-7-10)
`; for (const conn of kendraConnections) { const qClass = conn.quality === 'excellent' ? 'tk-exc' : conn.quality === 'strong' ? 'tk-str' : 'tk-good'; - html += `
${conn.quality === 'excellent' ? '★' : conn.quality === 'strong' ? '◆' : '→'} ${conn.desc}
`; + html += `
${conn.quality === 'excellent' ? '★' : conn.quality === 'strong' ? '◆' : '→'} ${escapeHtml(conn.desc)}
`; } html += '
'; } else { @@ -220,7 +292,7 @@ export function renderTrikonaKendra(data) { html += `
Trikona × Kendra 交叉连接 (Raja Yoga)
`; for (const conn of crossConnections) { const qClass = conn.quality === 'maha-raja' ? 'tk-maha' : 'tk-raja'; - html += `
${conn.quality === 'maha-raja' ? '★★' : '★'} ${conn.desc}
`; + html += `
${conn.quality === 'maha-raja' ? '★★' : '★'} ${escapeHtml(conn.desc)}
`; } html += '
'; } else { diff --git a/jyotish-app/api-bridge.js b/jyotish-app/api-bridge.js index a905b1e0..a4ba110b 100644 --- a/jyotish-app/api-bridge.js +++ b/jyotish-app/api-bridge.js @@ -2,11 +2,92 @@ * API Bridge v4.0 * * 架构: - * 计算 → 纯本地 (SwissEph WASM 或 localhost:5200 Python API) - * AI解读 → copse.top (GPT API 中转) + * 计算 → 纯本地 (SwissEph WASM 或 localhost:5200 本地 API 服务) + * AI解读 → 登录后的服务端 /api/chat;浏览器不保存模型 API key */ -const AI_BASE = 'https://copse.top'; -const AI_KEY = window.YINDUZHANXING_AI_KEY || localStorage.getItem('YINDUZHANXING_AI_KEY') || ''; +let activeApiBase = ''; +const AI_DISABLED_MESSAGE = 'AI_BROWSER_KEY_DISABLED: AI 解读需通过服务端 /api/chat 或后端代理;不要把 OpenAI API key 放进浏览器。'; + +function getApiBases(preferActive = false) { + const configured = window.YINDUZHANXING_API_BASE || localStorage.getItem('YINDUZHANXING_API_BASE') || ''; + const bases = [ + preferActive ? activeApiBase : '', + configured, + 'http://localhost:5200', + 'http://127.0.0.1:5200', + 'http://localhost:5201', + 'http://127.0.0.1:5201', + ].filter(Boolean); + return [...new Set(bases.map(base => base.replace(/\/$/, '')))]; +} + +async function postJson(path, payload, { requireModernChart = false } = {}) { + let firstSuccessful = null; + let lastError = null; + let lastAttempt = null; + for (const base of getApiBases(true)) { + try { + const resp = await fetch(`${base}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await parseApiResponse(resp); + lastAttempt = `${base}${path}`; + if (!resp.ok) { + lastError = new Error(buildAPIRecoveryMessage(path, data?.error || data?.message || `API请求失败: ${path}`, lastAttempt)); + continue; + } + if (data?.success === false) { + lastError = new Error(buildAPIRecoveryMessage(path, data?.error || data?.message || `API返回失败: ${path}`, lastAttempt)); + continue; + } + if (requireModernChart && data?.success && data?.special_lagnas?.precision !== 'sunrise_correct') { + firstSuccessful ||= { base, data }; + continue; + } + activeApiBase = base; + return data; + } catch (error) { + lastAttempt = `${base}${path}`; + lastError = new Error(buildAPIRecoveryMessage(path, error, lastAttempt)); + } + } + if (firstSuccessful) { + activeApiBase = firstSuccessful.base; + return firstSuccessful.data; + } + throw lastError || new Error(buildAPIRecoveryMessage(path, '本地 API 未连接', lastAttempt)); +} + +async function parseApiResponse(resp) { + const raw = await resp.text(); + try { + return raw ? JSON.parse(raw) : {}; + } catch { + return { message: raw?.slice(0, 180) || '' }; + } +} + +function buildAPIRecoveryMessage(path, error, lastAttempt = '') { + const message = typeof error === 'string' ? error : (error?.message || '本地 API 未连接'); + return `${message}。请到 Trust Center 运行健康检查;如未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。${lastAttempt ? ` lastAttempt=${lastAttempt}` : ''}`; +} + +function normalizeChartData(data) { + if (data.birth && !data.birth_info) data.birth_info = data.birth; + if (data.ascendant?.degree != null && data.ascendant.degree_in_sign == null) { + data.ascendant.degree_in_sign = data.ascendant.degree; + } + if (data.planets) { + for (const [, v] of Object.entries(data.planets)) { + if (v?.degree != null && v.degree_in_sign == null) { + v.degree_in_sign = v.degree % 30; + } + } + } + return data; +} // ═══════════════════════════════════════════════════════════════ // 计算层 — 纯本地,不需要服务器 @@ -14,73 +95,211 @@ const AI_KEY = window.YINDUZHANXING_AI_KEY || localStorage.getItem('YINDUZHANXIN async function computeWithPython(birthData) { try { - const resp = await fetch('http://localhost:5200/api/chart', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(birthData), - }); - const data = await resp.json(); + const data = await postJson('/api/chart', birthData, { requireModernChart: true }); if (data.success) { - console.log('[Compute] ✅ Python API v' + data.version); - // Python API → JS引擎字段映射 - if (data.birth && !data.birth_info) data.birth_info = data.birth; - // ascendant.degree → ascendant.degree_in_sign - if (data.ascendant?.degree != null && data.ascendant.degree_in_sign == null) { - data.ascendant.degree_in_sign = data.ascendant.degree; - } - // planet degree → degree_in_sign - if (data.planets) { - for (const [k, v] of Object.entries(data.planets)) { - if (v?.degree != null && v.degree_in_sign == null) { - v.degree_in_sign = v.degree % 30; - } - } - } - return data; + console.log('[Compute] ✅ 本地 API 服务 v' + data.version + ' @ ' + activeApiBase); + return normalizeChartData(data); } } catch(e) { - console.log('[Compute] Python API 不可用, 回退JS引擎'); + console.log('[Compute] 本地 API 服务不可用, 回退JS引擎'); } return null; } +async function computeSynastry(payload) { + return postJson('/api/synastry', payload); +} + +async function computePrashna(payload) { + return postJson('/api/prashna', payload); +} + +async function computeKP(payload) { + return postJson('/api/kp', payload); +} + +async function computeDashaSystem(payload) { + return postJson('/api/dasha', payload); +} + +async function computeRemedies(payload) { + return postJson('/api/remedies', payload); +} + +async function computeSadeSati(payload) { + return postJson('/api/sade_sati', payload); +} + +async function computePanchaMahapurusha(payload) { + return postJson('/api/pancha_mahapurusha', payload); +} + +async function computeCareer(payload) { + return postJson('/api/career', payload); +} + +async function computeRelationship(payload) { + return postJson('/api/relationship', payload); +} + +async function importChart(payload) { + return postJson('/api/import_chart', payload); +} + +async function generateReportArtifact(payload) { + return postJson('/api/report_artifact', payload); +} + +async function computeThematicReport(payload) { + return postJson('/api/thematic_report', payload); +} + +async function getAPIHealth() { + let lastError = null; + for (const base of getApiBases(true)) { + try { + const startedAt = Date.now(); + const resp = await fetch(`${base}/api/health`); + const data = await parseApiResponse(resp); + if (!resp.ok || data?.status !== 'ok') { + lastError = new Error(buildAPIRecoveryMessage('/api/health', data?.error || data?.message || '本地 API 健康检查失败', `${base}/api/health`)); + continue; + } + activeApiBase = base; + return { + success: true, + base, + latencyMs: Date.now() - startedAt, + ...data, + }; + } catch (error) { + lastError = new Error(buildAPIRecoveryMessage('/api/health', error, `${base}/api/health`)); + } + } + throw lastError || new Error(buildAPIRecoveryMessage('/api/health', '本地 API 健康检查不可用')); +} + +async function getCapabilityAudit() { + let lastError = null; + for (const base of getApiBases(true)) { + try { + const resp = await fetch(`${base}/api/capability_audit`); + const data = await parseApiResponse(resp); + if (!resp.ok || data?.success === false) { + lastError = new Error(buildAPIRecoveryMessage('/api/capability_audit', data?.error || data?.message || '能力审计接口不可用', `${base}/api/capability_audit`)); + continue; + } + activeApiBase = base; + return data; + } catch (error) { + lastError = new Error(buildAPIRecoveryMessage('/api/capability_audit', error, `${base}/api/capability_audit`)); + } + } + throw lastError || new Error(buildAPIRecoveryMessage('/api/capability_audit', '能力审计接口不可用')); +} + +async function getTechniqueCatalog() { + let lastError = null; + for (const base of getApiBases(true)) { + try { + const resp = await fetch(`${base}/api/technique_catalog`); + const data = await parseApiResponse(resp); + if (!resp.ok || data?.success === false) { + lastError = new Error(buildAPIRecoveryMessage('/api/technique_catalog', data?.error || data?.message || '技法目录接口不可用', `${base}/api/technique_catalog`)); + continue; + } + activeApiBase = base; + return data; + } catch (error) { + lastError = new Error(buildAPIRecoveryMessage('/api/technique_catalog', error, `${base}/api/technique_catalog`)); + } + } + throw lastError || new Error(buildAPIRecoveryMessage('/api/technique_catalog', '技法目录接口不可用')); +} + +async function runTechniqueExample(payload) { + return postJson('/api/technique_example', payload); +} + +async function computeAnnual(payload) { + return postJson('/api/annual', payload); +} + +async function computeMuhurta(payload) { + return postJson('/api/muhurta', payload); +} + +async function computePanchangaRange(payload) { + return postJson('/api/panchanga_range', payload); +} + +async function computeBhavaChalit(payload) { + return postJson('/api/bhava_chalit', payload); +} + +async function computeSudarshana(payload) { + return postJson('/api/sudarshana', payload); +} + +async function computeNakshatraFull(payload) { + return postJson('/api/nakshatra_full', payload); +} + +async function computeVargaFull(payload) { + return postJson('/api/varga_full', payload); +} + +async function computeJaimini(payload) { + return postJson('/api/jaimini', payload); +} + +async function computeAshtakavarga(payload) { + return postJson('/api/ashtakavarga', payload); +} + +async function computeShadbala(payload) { + return postJson('/api/shadbala', payload); +} + +async function computeYogas(payload) { + return postJson('/api/yogas', payload); +} + +async function computeAspects(payload) { + return postJson('/api/aspects', payload); +} + +async function computeRectificationGate(payload) { + return postJson('/api/rectification_gate', payload); +} + +async function computeCaseValidation(payload) { + return postJson('/api/case_validation', payload); +} + +async function computeDivisionalYoga(payload) { + return postJson('/api/divisional_yoga', payload); +} + +async function computeKakshya(payload) { + return postJson('/api/kakshya', payload); +} + +async function computeBhavaBala(payload) { + return postJson('/api/bhava_bala', payload); +} + +async function computeTransitTriggers(payload) { + return postJson('/api/transit', payload); +} + // ═══════════════════════════════════════════════════════════════ -// AI 解读层 — 通过 copse.top 调用 GPT +// AI 解读层 — 浏览器端禁用密钥直连;由 ai-chat.js 走服务端 /api/chat // ═══════════════════════════════════════════════════════════════ async function aiReading(chartData, options = {}) { const { style = 'deep', focus = '全部' } = options; - - if (!AI_KEY) { - return { success: false, error: 'AI_KEY_NOT_CONFIGURED: set window.YINDUZHANXING_AI_KEY or localStorage.YINDUZHANXING_AI_KEY' }; - } - - const prompt = buildReadingPrompt(chartData, style, focus); - - try { - const resp = await fetch(AI_BASE + '/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + AI_KEY, - }, - body: JSON.stringify({ - model: 'gpt-4', - messages: [ - { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: prompt }, - ], - temperature: 0.7, - max_tokens: 2000, - }), - }); - const data = await resp.json(); - const text = data?.choices?.[0]?.message?.content || ''; - return { success: true, reading: text, style, focus }; - } catch(e) { - console.warn('[AI] 解读失败:', e.message); - return { success: false, error: e.message }; - } + return { success: false, error: AI_DISABLED_MESSAGE, style, focus, chartDataPresent: Boolean(chartData) }; } const SYSTEM_PROMPT = `你是印度占星(Jyotish/Vedic Astrology)专业解盘师。 @@ -147,8 +366,44 @@ async function aiQuickInsight(chartData) { // ═══════════════════════════════════════════════════════════════ window.JyotishAPI = { + get apiBase() { return activeApiBase || getApiBases()[0] || ''; }, + aiKeyPolicy: 'server_side_only', // 计算 computeWithPython, + computeSynastry, + computePrashna, + computeKP, + computeDashaSystem, + computeRemedies, + computeSadeSati, + computePanchaMahapurusha, + computeCareer, + computeRelationship, + importChart, + generateReportArtifact, + computeThematicReport, + getAPIHealth, + getCapabilityAudit, + getTechniqueCatalog, + runTechniqueExample, + computeAnnual, + computeMuhurta, + computePanchangaRange, + computeBhavaChalit, + computeSudarshana, + computeNakshatraFull, + computeVargaFull, + computeJaimini, + computeAshtakavarga, + computeShadbala, + computeYogas, + computeAspects, + computeRectificationGate, + computeCaseValidation, + computeDivisionalYoga, + computeKakshya, + computeBhavaBala, + computeTransitTriggers, // AI 解读 aiReading, aiFullReading, diff --git a/jyotish-app/auth.js b/jyotish-app/auth.js index c7c5366c..d061ddfd 100644 --- a/jyotish-app/auth.js +++ b/jyotish-app/auth.js @@ -4,6 +4,7 @@ */ import { aiChatSetAuth } from './ai-chat.js'; import { t, getLang } from './i18n.js'; +import { escapeAttr, escapeHtml } from './security.js'; // ============================================================================ // 配置 @@ -11,6 +12,7 @@ import { t, getLang } from './i18n.js'; const API_BASE = ''; // 同域部署,留空;Capacitor 打包时改为服务器地址 const TOKEN_KEY = 'jyotish_auth_token'; const USER_KEY = 'jyotish_auth_user'; +const API_BASE_KEY = 'jyotish_api_base'; // ============================================================================ // 状态 @@ -25,8 +27,8 @@ let _onAuthChange = null; // 外部回调 // ============================================================================ function loadSavedAuth() { try { - _token = localStorage.getItem(TOKEN_KEY); - const raw = localStorage.getItem(USER_KEY); + _token = sessionStorage.getItem(TOKEN_KEY); + const raw = sessionStorage.getItem(USER_KEY) || localStorage.getItem(USER_KEY); _user = raw ? JSON.parse(raw) : null; } catch { _token = null; _user = null; } } @@ -35,10 +37,12 @@ function saveAuth(token, user) { _token = token; _user = user; if (token) { - localStorage.setItem(TOKEN_KEY, token); + sessionStorage.setItem(TOKEN_KEY, token); + sessionStorage.setItem(USER_KEY, JSON.stringify(user)); localStorage.setItem(USER_KEY, JSON.stringify(user)); } else { - localStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(TOKEN_KEY); + sessionStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY); } // 同步给 ai-chat @@ -57,11 +61,7 @@ export function getUser() { return _user; } export function isLoggedIn() { return !!_token && !!_user; } export function getApiBase() { - // Capacitor 环境用服务器地址 - if (window.Capacitor?.isNativePlatform?.()) { - return localStorage.getItem('jyotish_api_base') || 'https://your-server.com'; - } - return API_BASE; + return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || localStorage.getItem(API_BASE_KEY) || API_BASE; } export function onAuthChange(cb) { _onAuthChange = cb; } @@ -109,8 +109,8 @@ function updateHeaderUI() { const planLabel = sub.plan === 'premium' ? '∞' : `${sub.todayUsage ?? 0}/3`; el.innerHTML = ` `; @@ -222,8 +222,7 @@ function bindLoginForm(container) { saveAuth(res.token, res.user); closeModal(); } catch (err) { - errEl.textContent = err.message || t('auth.login.failed'); - errEl.classList.remove('hidden'); + showAuthError(errEl, err, 'login'); } finally { btnText.classList.remove('hidden'); btnLoading.classList.add('hidden'); @@ -308,8 +307,7 @@ function bindRegisterForm(container) { saveAuth(res.token, res.user); closeModal(); } catch (err) { - errEl.textContent = err.message || t('auth.register.failed'); - errEl.classList.remove('hidden'); + showAuthError(errEl, err, 'register'); } finally { btnText.classList.remove('hidden'); btnLoading.classList.add('hidden'); @@ -358,7 +356,7 @@ async function handleAppleToken(idToken) { saveAuth(res.token, res.user); closeModal(); } catch (err) { - alert(t('auth.apple.failed') + (err.message || t('auth.error.unknown'))); + alert(buildAuthRecoveryMessage(err, 'apple')); } } @@ -375,10 +373,10 @@ function renderProfileView(container) { container.innerHTML = `
-
${(_user.email || 'U')[0].toUpperCase()}
-

${_user.email || t('auth.profile')}

+
${escapeHtml((_user.email || 'U')[0].toUpperCase())}
+

${escapeHtml(_user.email || t('auth.profile'))}

- ${isPremium ? t('auth.premium.plan') : t('auth.free.plan').replace('{0}', usage).replace('{1}', limit)} + ${escapeHtml(isPremium ? t('auth.premium.plan') : t('auth.free.plan').replace('{0}', usage).replace('{1}', limit))}

${!isPremium ? ` @@ -477,28 +475,64 @@ async function apiPost(path, body) { const headers = { 'Content-Type': 'application/json' }; if (_token) headers['Authorization'] = `Bearer ${_token}`; - const resp = await fetch(`${base}${path}`, { - method: 'POST', - headers, - body: JSON.stringify(body), - }); + let resp; + try { + resp = await fetch(`${base}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + } catch (err) { + throw new Error(buildAuthRecoveryMessage(err, 'network')); + } - const data = await resp.json(); + const data = await parseResponseJson(resp); if (!resp.ok) { - throw new Error(data.error || data.message || t('auth.request.failed') + ` (${resp.status})`); + throw new Error(buildAuthRecoveryMessage(data.error || data.message || `${t('auth.request.failed')} (${resp.status})`, 'api')); } return data; } +async function parseResponseJson(resp) { + const raw = await resp.text(); + try { + return raw ? JSON.parse(raw) : {}; + } catch { + return { message: raw?.slice(0, 160) || '' }; + } +} + +function buildAuthRecoveryMessage(error, context = 'api') { + const message = typeof error === 'string' ? error : (error?.message || t('auth.error.unknown')); + const apiHint = `${t('auth.recovery.api')} ${t('auth.recovery.retry')}`; + const commandHint = `Trust Center / 网页服务 /本地 API 服务`; + if (context === 'login') return `${t('auth.login.failed')}:${message}\n${apiHint}\n${commandHint}`; + if (context === 'register') return `${t('auth.register.failed')}:${message}\n${apiHint}\n${commandHint}`; + if (context === 'apple') return `${t('auth.apple.failed')}${message}\n${apiHint}\n${commandHint}`; + return `${message}\n${apiHint}\n${commandHint}`; +} + +function showAuthError(errEl, error, context = 'api') { + if (!errEl) return; + errEl.textContent = buildAuthRecoveryMessage(error, context); + errEl.classList.remove('hidden'); +} + async function fetchUser() { const base = getApiBase(); - const resp = await fetch(`${base}/api/auth/me`, { - headers: { 'Authorization': `Bearer ${_token}` }, - }); - if (!resp.ok) throw new Error(t('auth.token.expired')); - const data = await resp.json(); + let resp; + try { + resp = await fetch(`${base}/api/auth/me`, { + headers: { 'Authorization': `Bearer ${_token}` }, + }); + } catch (err) { + throw new Error(buildAuthRecoveryMessage(err, 'network')); + } + const data = await parseResponseJson(resp); + if (!resp.ok) throw new Error(buildAuthRecoveryMessage(data.error || data.message || t('auth.token.expired'), 'api')); // 更新本地用户数据 _user = data.user || data; + sessionStorage.setItem(USER_KEY, JSON.stringify(_user)); localStorage.setItem(USER_KEY, JSON.stringify(_user)); updateHeaderUI(); aiChatSetAuth(_token, _user); diff --git a/jyotish-app/export.js b/jyotish-app/export.js index 1fcf319c..74444ecc 100644 --- a/jyotish-app/export.js +++ b/jyotish-app/export.js @@ -4,7 +4,7 @@ * dasha, yoga, varga_full, aspects, jaimini, nakshatra_advanced, * argala, tajika, shadbala, ashtakavarga, validation, audit, actionable_context * - * 导出格式: JSON / SVG / PNG + * 导出格式: JSON / HTML / SVG / PNG */ import { SIGNS, SIGN_LORDS, PLANET_CN } from './jyotish-engine.js'; @@ -20,6 +20,9 @@ export function exportJSON(chartData, extras = {}) { version: '4.4', export_date: new Date().toISOString(), ayanamsa: 'Lahiri (Chitrapaksha)', + provenance: extras.provenance || chartData?._client_audit?.provenance || null, + calculation_settings: extras.provenance?.calculationSettings || chartData?._calculation_settings || null, + terminology_mode: extras.provenance?.terminologyMode || chartData?._terminology_mode || null, }, birth_info, ascendant: _cleanAscendant(ascendant), @@ -140,6 +143,12 @@ export function exportJSON(chartData, extras = {}) { payload.modules.actionable_context = extras.actionableContext; } + // ── 16. User Workflows ── + if (extras.workflows) { + payload.modules = payload.modules || {}; + payload.modules.workflows = extras.workflows; + } + downloadFile( JSON.stringify(payload, null, 2), `jyotish-chart-${birth_info?.date || 'export'}.json`, @@ -147,6 +156,95 @@ export function exportJSON(chartData, extras = {}) { ); } +// ============================================================================ +// HTML 报告导出 — 可直接打开/打印的单文件报告 +// ============================================================================ +export function exportHTMLReport(chartData, extras = {}) { + if (!chartData) return; + const birth = chartData.birth_info || {}; + const html = _buildHTMLReport(chartData, extras); + downloadFile( + html, + `jyotish-report-${_safeFilename(birth.date || 'chart')}.html`, + 'text/html;charset=utf-8' + ); +} + +// ============================================================================ +// PDF 报告导出 — 复用后端 report_builder.py 的 Playwright PDF 流水线 +// ============================================================================ +export async function exportPDFReport(chartData, extras = {}) { + if (!chartData) return null; + const birth = chartData.birth_info || {}; + const name = `jyotish-report-${_safeFilename(birth.date || birth.name || 'chart')}`; + const html = _buildHTMLReport(chartData, extras); + const api = window.JyotishAPI?.generateReportArtifact; + if (!api) { + const downloaded_filename = `${name}.html`; + downloadFile(html, downloaded_filename, 'text/html;charset=utf-8'); + return { + success: false, + fallback: 'html', + report_artifact_fallback: true, + fallback_reason: 'report_artifact API unavailable', + error: 'report_artifact API unavailable', + artifact_status: 'local_html_fallback_ready', + primary_artifact: 'html', + download_filename: downloaded_filename, + download_mime: 'text/html;charset=utf-8', + user_message: '本地 API 未连接,已改为生成 HTML 报告。', + downloaded_filename, + delivery: { + artifact_status: 'local_html_fallback_ready', + format: 'html', + filename: downloaded_filename, + mime: 'text/html;charset=utf-8', + fallback: true, + fallback_reason: 'report_artifact API unavailable', + user_message: '本地 API 未连接,已改为生成 HTML 报告。', + next_action: '可直接打开,或用浏览器打印为 PDF', + }, + }; + } + + const result = await api({ format: 'pdf', name, html }); + let downloaded_filename = ''; + if (result?.pdf_base64) { + downloaded_filename = result.download_filename || result.delivery?.filename || result.pdf_filename || `${name}.pdf`; + downloadBase64File(result.pdf_base64, downloaded_filename, result.download_mime || result.delivery?.mime || result.mime || 'application/pdf'); + } else if (result?.html_base64) { + downloaded_filename = result.download_filename || result.delivery?.filename || result.html_filename || `${name}.html`; + downloadBase64File(result.html_base64, downloaded_filename, result.download_mime || result.delivery?.mime || 'text/html;charset=utf-8'); + } else { + downloaded_filename = `${name}.html`; + downloadFile(html, downloaded_filename, 'text/html;charset=utf-8'); + } + return { + ...result, + downloaded_filename, + report_artifact_fallback: result?.fallback === 'html' || result?.delivery?.fallback === true, + fallback_reason: result?.fallback_reason || result?.delivery?.fallback_reason || result?.pdf_error || result?.message || '', + artifact_status: formatReportArtifactStatus({ ...result, downloaded_filename }), + }; +} + +export function formatReportArtifactStatus(result = {}) { + const delivery = result.delivery || {}; + const filename = result.downloaded_filename || result.download_filename || delivery.filename || result.pdf_filename || result.html_filename || '报告文件'; + const message = result.user_message || delivery.user_message || ''; + if (result.pdf_base64 || delivery.format === 'pdf') { + return `${message || 'PDF 报告已生成并开始下载。'}已下载:${filename}。`; + } + if (result.report_artifact_fallback || result.fallback === 'html' || delivery.fallback) { + const reason = result.fallback_reason || delivery.fallback_reason || result.error || 'PDF 渲染器不可用'; + return `${message || '后端已生成 HTML 报告。'}已下载:${filename}。${reason}。可直接打开,或用浏览器打印为 PDF。`; + } + if (result.html_base64 || delivery.format === 'html') { + return `${message || '后端已生成 HTML 报告。'}已下载:${filename}。可直接打开,或用浏览器打印为 PDF。`; + } + return `报告已开始下载。已下载:${filename}。`; +} + // ============================================================================ // SVG 导出 // ============================================================================ @@ -223,6 +321,19 @@ function downloadFile(content, filename, mimeType) { URL.revokeObjectURL(url); } +function downloadBase64File(base64, filename, mimeType) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + const blob = new Blob([bytes], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); +} + function _cleanAscendant(asc) { if (!asc) return {}; return { @@ -245,3 +356,506 @@ function _getTrikonaLords(ascSign) { const trikona = [5, 9]; return [...new Set(trikona.map(h => SIGN_LORDS[SIGNS[(ai + h - 1) % 12]]))]; } + +function _buildHTMLReport(chartData, extras) { + const birth = chartData.birth_info || {}; + const asc = _cleanAscendant(chartData.ascendant || {}); + const provenance = extras.provenance || chartData?._client_audit?.provenance || {}; + const panchanga = extras.panchanga || {}; + const tithiLord = chartData.tithi_lord_analysis || {}; + const yogas = Array.isArray(extras.yogas) ? extras.yogas : []; + const dashaPeriods = Array.isArray(extras.dasha?.periods) ? extras.dasha.periods : []; + const validation = extras.validation || {}; + const audit = extras.audit || {}; + const workflows = extras.workflows || chartData._client_workflows || {}; + const generatedAt = new Date().toLocaleString('zh-CN'); + + return ` + + + + + Jyotish Report - ${_h(birth.name || birth.date || 'Chart')} + + + +
+
+
Jyotish Web App Report
+

${_h(birth.name || '印度占星报告')}

+

生成时间:${_h(generatedAt)}。本报告复用当前网页星盘、Panchanga、校验审计和导出模块,可直接用浏览器打开或打印为 PDF。

+
+ +
+

出生资料与计算参数

+
+ ${_kv('日期时间', `${birth.date || '-'} ${birth.time || ''} ${birth.tz || ''}`)} + ${_kv('地点坐标', `${birth.lat ?? '-'}, ${birth.lon ?? '-'}`)} + ${_kv('上升', `${asc.sign_cn || asc.sign || '-'} ${_fmt(asc.degree_in_sign ?? asc.degree)}°`)} + ${_kv('Ayanamsa', provenance.ayanamsa || 'Lahiri')} + ${_kv('Node', provenance.nodeMode || '-')} + ${_kv('House', provenance.houseSystem || '-')} + ${_kv('Sunrise', provenance.sunrisePolicy || '-')} + ${_kv('Geocoder', provenance.geocoderPolicy || '-')} + ${_kv('Ephemeris Backend', provenance.ephemerisBackend || '-')} + ${_kv('Terminology', provenance.terminologyMode || '-')} + ${_kv('Yoga 口径', provenance.yogaVariant || '-')} + ${_kv('Jaimini 口径', provenance.jaiminiKarakaVariant || '-')} + ${_kv('KP 口径', provenance.kpSignificatorVariant || '-')} + ${_kv('Ashtakavarga 口径', provenance.ashtakavargaVariant || '-')} + ${_kv('Shadbala 口径', provenance.shadbalaVariant || '-')} + ${_kv('Dasha 口径', provenance.dashaReference || '-')} + ${_kv('术语模式', provenance.terminologyLabel || provenance.terminologyMode || '-')} + ${_kv('星历/引擎', provenance.engine || 'Jyotish Web App')} + ${_kv('盘式', provenance.chartStyle || '-')} +
+
+ +
+

Panchanga 与 Tithi Lord

+
+
+

Birth-time Panchanga

+ ${_table([ + ['Vara', panchanga.vara || '-'], + ['Tithi', `${panchanga.tithi?.number || '-'} · ${panchanga.tithi?.name || '-'}`], + ['Paksha', panchanga.tithi?.paksha || '-'], + ['Karana', panchanga.karana?.name || panchanga.karana || '-'], + ['Yoga', `${panchanga.yoga?.number || '-'} · ${panchanga.yoga?.name || '-'}`], + ])} +
+
+

Tithi Lord Analysis

+ ${_table([ + ['Tithi Lord', tithiLord.tithi_lord || '-'], + ['分数', tithiLord.tithi_score == null ? '-' : `${Math.round(Number(tithiLord.tithi_score) * 100)}%`], + ['位置', `${tithiLord.lord_sign || '-'} H${tithiLord.lord_house || '-'}`], + ['关系风格', tithiLord.relationship_style || tithiLord.emotional_pattern || '-'], + ])} +
+
+
+ +
+

行星位置

+ + + ${_planetRows(chartData.planets || {})} +
行星星座度数宫位星宿状态
+
+ +
+

Yoga 摘要

+ ${yogas.length ? `
${yogas.slice(0, 16).map(y => ` +
+ ${_h(y.name_cn || y.name || 'Yoga')} + ${_h(y.combination || y.strength || '-')} +
+ `).join('')}
` : '

当前导出未检测到 Yoga 摘要。

'} + ${yogas.length > 16 ? `

另有 ${yogas.length - 16} 条 Yoga 已保留在 JSON 导出中。

` : ''} +
+ +
+

Dasha 摘要

+ ${dashaPeriods.length ? ` + + ${dashaPeriods.slice(0, 12).map(p => ` + + + + + `).join('')} +
周期开始结束层级/说明
${_h(p.lord || p.planet || p.name || '-')}${_h(p.start || '-')}${_h(p.end || '-')}${_h(p.level || p.type || p.precision || '-')}
` : '

当前导出未附带 Dasha 时间线。

'} +
+ + ${_workflowReportSection(workflows)} + +
+

校验与审计

+
+ ${_kv('Validation', validation.status || validation.overall || '-')} + ${_kv('Audit', audit.overall || audit.status || '-')} + ${_kv('Provenance', provenance.exportStatus || 'HTML/JSON/SVG/PNG export available')} +
+
+ 65/65 capability registry + Panchanga preview + Tithi Lord connected + 可浏览器打印 PDF +
+
+ +
+ 印度占星报告属于传统文化与自我反思工具,不能替代医疗、法律、投资或心理咨询。 +
+
+ +`; +} + +function _workflowReportSection(workflows = {}) { + const sections = [ + _kpWorkflowReport(workflows.kp), + _prashnaWorkflowReport(workflows.prashna), + _synastryWorkflowReport(workflows.synastry), + _caseLibraryWorkflowReport(workflows.case_library), + ].filter(Boolean).join(''); + if (!sections) { + return `
+

用户工作流结果

+

当前报告未附带 KP、Prashna 或合盘运行结果。运行对应 Tab 后再次导出,报告会自动带入最新结果。

+
`; + } + return `

用户工作流结果

${sections}
`; +} + +function _caseLibraryWorkflowReport(caseLibrary) { + if (!caseLibrary) return ''; + const pairs = Array.isArray(caseLibrary.synastry_pairs) ? caseLibrary.synastry_pairs : []; + const prashnas = Array.isArray(caseLibrary.prashna_cases) ? caseLibrary.prashna_cases : []; + if (!pairs.length && !prashnas.length) return ''; + const pairRows = pairs.slice(0, 6).map(record => [ + record.label || record.id || '未命名配对', + `${record.score?.total ?? 0} / ${record.score?.max ?? 36}`, + record.verdict || '-', + _dateLabel(record.updatedAt || record.generatedAt), + ]); + const prashnaRows = prashnas.slice(0, 6).map(record => [ + record.question_text || record.label || record.question_type || '未命名问事', + record.conclusion || '-', + record.confidence || '-', + _dateLabel(record.updatedAt || record.generatedAt), + ]); + return `
+

保存案例库 ${pairs.length} 配对 / ${prashnas.length} 问事

+ ${pairRows.length ? `

最近配对

${_table([['案例', '分数', '判断', '更新时间'], ...pairRows])}` : ''} + ${prashnaRows.length ? `

最近问事

${_table([['问题', '结论', '置信度', '更新时间'], ...prashnaRows])}` : ''} +
`; +} + +function _kpWorkflowReport(kp) { + if (!kp) return ''; + const houseCards = (kp.focus_houses || []).map(house => { + const data = kp.houses?.[house] || {}; + const sig = data.significators || {}; + const lords = data.kp_lords || {}; + return `
+ ${_h(house)}宫 · ${_h(data.sign || '-')} + Sub ${_h(lords.sub_lord || '-')} + A ${_h(_list(sig.A))} + B ${_h(_list(sig.B))} +
`; + }).join(''); + return `
+

KP Sublord ${_h(kp.label || kp.focus || '-')}

+

${_h(kp.note || 'KP 事件判断摘要。')}

+
${houseCards || '

暂无 KP 宫位摘要。

'}
+
`; +} + +function _prashnaWorkflowReport(prashna) { + if (!prashna) return ''; + const answer = prashna.kp_answer_v2 || prashna.kp_answer || {}; + const summary = prashna.summary || {}; + const chart = prashna.prashna_chart || {}; + const timing = prashna.timing || {}; + const conclusion = summary.conclusion || answer.kp_answer || 'MAYBE — 需要更多信息确认'; + return `
+

Prashna 问事 ${_h(prashna.question_type || 'general')}

+ ${prashna.question_text ? `

问题:${_h(prashna.question_text)}

` : ''} + ${_table([ + ['结论', conclusion], + ['置信度', summary.confidence || answer.confidence || '-'], + ['问事上升', `${chart.asc_sign || '-'} ${chart.asc_degree ?? '-'}°`], + ['问题宫', `${answer.primary_house || summary.primary_house || '-'}宫`], + ['Sub Lord', answer.kp_sub_lord || '-'], + ['时机', `${timing.score ?? '-'} / 100 · ${timing.rating || '-'}`], + ['下一步', summary.next_action || timing.recommendation || '结合现实证据复核。'], + ])} +
`; +} + +function _synastryWorkflowReport(synastry) { + if (!synastry) return ''; + const score = `${synastry.total_score ?? 0} / ${synastry.max_score ?? 36}`; + const percent = Number(synastry.match_percentage); + const status = synastry.is_match_approved ? '匹配通过' : '需要谨慎综合判断'; + const deep = synastry.deep || {}; + const relationshipReport = synastry.relationship_report || deep.relationshipReport || {}; + const spouseStatus = deep.spouseStatus || {}; + const ulDkTiming = deep.ulDkTiming || {}; + const evidenceRows = Array.isArray(relationshipReport.evidence) + ? relationshipReport.evidence.map(item => [item.label || '-', item.value || '-', item.note || '-']) + : []; + const evidenceCards = Array.isArray(relationshipReport.evidence) + ? relationshipReport.evidence.map(item => ` +
+ ${_h(item.label || '-')} + ${_h(item.value || '-')} + ${_h(item.note || '-')} +
+ `).join('') + : ''; + const spouseRows = ['self', 'partner'] + .map(key => spouseStatus[key] ? [ + key === 'self' ? '本人' : '对方', + spouseStatus[key].verdict || '-', + `${spouseStatus[key].score ?? '-'} / 5`, + Array.isArray(spouseStatus[key].evidence) ? spouseStatus[key].evidence.slice(0, 2).join(';') : '-', + ] : null) + .filter(Boolean); + const spouseCards = ['self', 'partner'] + .map(key => spouseStatus[key] ? ` +
+ ${key === 'self' ? '本人' : '对方'} spouse-status + ${_h(spouseStatus[key].verdict || '-')} + ${_h(spouseStatus[key].score ?? '-')} / 5 · ${_h((spouseStatus[key].evidence || []).slice(0, 2).join(';') || '-')} +
+ ` : '') + .join(''); + const ulDkCards = ['self', 'partner'] + .map(key => ulDkTiming[key] ? ` +
+ ${key === 'self' ? '本人' : '对方'} UL/DK timing + ${_h(ulDkTiming[key].summary || '-')} + ${_h((ulDkTiming[key].triggers || []).slice(0, 2).join(';') || (ulDkTiming[key].evidence || []).slice(0, 2).join(';') || '-')} +
+ ` : '') + .join(''); + const comparison = deep.comparison || {}; + const axisCards = Array.isArray(comparison.axes) + ? comparison.axes.slice(0, 4).map(axis => ` +
+ ${_h(axis.label || '-')} + ${_h(axis.relation?.label || '-')} + 本人 ${_h(axis.self?.label || '-')} / 对方 ${_h(axis.partner?.label || '-')} +
+ `).join('') + : ''; + const comparisonRows = Array.isArray(comparison.rows) + ? comparison.rows.slice(0, 9).map(row => [ + row.label || row.planet || '-', + row.self?.label || '-', + row.partner?.label || '-', + row.relation?.label || '-', + `对方落本人${row.partnerOverlayHouse || '-'}宫 / 本人落对方${row.selfOverlayHouse || '-'}宫`, + ]) + : []; + const midpointCards = Array.isArray(comparison.compositeStyle) + ? comparison.compositeStyle.map(item => ` +
+ ${_h(item.label || '-')} midpoint + ${_h(item.sign || '-')} ${_h(_fmt(item.degree))}° + ${_h(item.note || '-')} +
+ `).join('') + : ''; + const scoreCards = Object.entries(synastry.scores || {}).slice(0, 8).map(([name, value]) => ` +
${_h(name)}${_h(value)} / ${_h(_kutaMax(name))}
+ `).join(''); + return `
+

合盘 Synastry ${_h(synastry.mode || 'synastry')}

+
+
+ ${_h(relationshipReport.headline || '关系报告需要结合完整星盘、D9、Kuja 与 Dasha 复核。')} + 总分 ${_h(score)}${Number.isFinite(percent) ? ` · ${_h(percent.toFixed(1))}%` : ''} +
+
${_h(relationshipReport.statusLabel || status)}
+
+ ${_table([ + ['总分', `${score}${Number.isFinite(percent) ? ` · ${percent.toFixed(1)}%` : ''}`], + ['状态', status], + ['对方摘要', deep.partnerSummary ? `${deep.partnerSummary.ascendant || '-'} · Moon ${deep.partnerSummary.moon || '-'}` : '-'], + ['Dasha 同步', deep.dasha?.note || '-'], + ['UL/DK 时机', ['self', 'partner'].map(key => ulDkTiming[key]?.summary).filter(Boolean).join(' / ') || '-'], + ['Kuja', deep.kuja?.note || '-'], + ['D9', deep.d9 ? `本人 ${deep.d9.self?.score ?? '-'} / 对方 ${deep.d9.partner?.score ?? '-'}` : '-'], + ])} + ${evidenceCards ? `

关系证据卡

${evidenceCards}
` : (evidenceRows.length ? _table([['证据', '结果', '说明'], ...evidenceRows]) : '')} + ${axisCards ? `

双人轴线比较 bi-wheel

${axisCards}
` : ''} + ${comparisonRows.length ? _table([['行星', '本人', '对方', '互动', 'Overlay'], ...comparisonRows], 'comparison-print-table') : ''} + ${midpointCards ? `

Composite-style Midpoints

${midpointCards}
` : ''} + ${ulDkCards ? `

UL/DK 与关系时机 Jaimini · Dasha trigger

${ulDkCards}
` : ''} + ${spouseCards ? `

配偶/婚后成长 Yoga spouse_status_yoga.py

${spouseCards}
` : (spouseRows.length ? _table([['对象', '判断', '得分', '证据'], ...spouseRows]) : '')} +
+ ${_relationshipReportList('支持证据', relationshipReport.strengths)} + ${_relationshipReportList('需要观察', relationshipReport.risks)} + ${_relationshipReportList('下一步', relationshipReport.nextSteps)} +
+
${scoreCards || '

暂无 Kuta 分项。

'}
+ ${_relationshipBoundary(relationshipReport.boundaries)} +
`; +} + +function _relationshipReportBullets(label, items) { + if (!Array.isArray(items) || !items.length) return ''; + return `

${_h(label)}:${_h(items.join(';'))}

`; +} + +function _relationshipReportList(label, items) { + if (!Array.isArray(items) || !items.length) return ''; + return `
${_h(label)}
    ${items.map(item => `
  • ${_h(item)}
  • `).join('')}
`; +} + +function _relationshipBoundary(items) { + if (!Array.isArray(items) || !items.length) return ''; + return `
${items.map(item => `${_h(item)}`).join('')}
`; +} + +function _planetRows(planets) { + const rows = Object.entries(planets) + .filter(([, p]) => p && !p.error) + .map(([name, p]) => { + const degree = p.degree_in_sign ?? (Number.isFinite(Number(p.lon ?? p.degree)) ? Number(p.lon ?? p.degree) % 30 : p.degree); + return ` + ${_h(PLANET_CN[name] || name)} + ${_h(p.sign_cn || p.sign || '-')} + ${_h(_fmt(degree))}° + H${_h(p.house || '-')} + ${_h(p.nakshatra || '-')} ${p.nakshatra_pada ? `p${_h(p.nakshatra_pada)}` : ''} + ${_h(p.status || '')}${p.retrograde ? ' · R' : ''}${p.combust ? ' · combust' : ''} + `; + }); + return rows.join('') || '无行星数据'; +} + +function _kv(label, value) { + return `
${_h(label)}${_h(value || '-')}
`; +} + +function _table(rows, className = '') { + const classAttr = className ? ` class="${_h(className)}"` : ''; + return `${rows.map(row => { + const cells = Array.isArray(row) ? row : [row]; + if (cells.length <= 2) { + return `${_h(cells[0])}${_h(cells[1])}`; + } + return `${cells.map((cell, index) => index === 0 ? `${_h(cell)}` : `${_h(cell)}`).join('')}`; + }).join('')}`; +} + +function _list(value) { + if (Array.isArray(value)) return value.length ? value.join('、') : '-'; + if (value === null || value === undefined || value === '') return '-'; + return String(value); +} + +function _kutaMax(name) { + return { + Varna: 1, + Vashya: 2, + Tara: 3, + Yoni: 4, + GrahaMaitri: 5, + Gana: 6, + Bhakoot: 7, + Nadi: 8, + }[name] ?? '-'; +} + +function _fmt(value, digits = 2) { + const n = Number(value); + return Number.isFinite(n) ? n.toFixed(digits) : '-'; +} + +function _dateLabel(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function _h(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function _safeFilename(value) { + return String(value || 'chart').replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'chart'; +} diff --git a/jyotish-app/glossary.js b/jyotish-app/glossary.js index 4ae36233..9c3f8257 100644 --- a/jyotish-app/glossary.js +++ b/jyotish-app/glossary.js @@ -236,19 +236,93 @@ function scanBindings(text) { // ===== Tooltip 显示/隐藏 ===== const CAT_MAP = {graha:'行星 Graha',bhava:'宫位 Bhava',rashi:'星座 Rashi',status:'状态',concept:'概念 Concept'}; +const TERMINOLOGY_MODE_LABELS = { + balanced: '平衡模式', + beginner: '入门解释', + professional: '专业对照', +}; +const TERMINOLOGY_MODES = Object.keys(TERMINOLOGY_MODE_LABELS); + +export function setGlossaryTerminologyMode(mode = 'balanced') { + window.__jyotishTerminologyMode = normalizeGlossaryTerminologyMode(mode); + if (tooltipEl) tooltipEl.dataset.mode = window.__jyotishTerminologyMode; + const visibleKey = tooltipEl?.dataset?.termKey; + if (visibleKey && tooltipEl && !tooltipEl.classList.contains('hidden')) showTooltip(visibleKey); + return window.__jyotishTerminologyMode; +} + +export function getGlossaryTerminologyMode() { + return normalizeGlossaryTerminologyMode(window.__jyotishTerminologyMode); +} + +function normalizeGlossaryTerminologyMode(mode) { + return TERMINOLOGY_MODES.includes(mode) ? mode : 'balanced'; +} + +function buildTerminologyDisplay(entry, mode) { + if (mode === 'professional') { + return { + title: `${entry.cn} · ${entry.en}`, + names: [entry.sk, entry.cat].filter(Boolean), + desc: `${entry.desc} 专业模式保留中文、英文与 Sanskrit 名称,方便和 API、报告及古典术语交叉核对。`, + }; + } + if (mode === 'beginner') { + return { + title: entry.cn, + names: [entry.en, '初学解释'].filter(Boolean), + desc: modernizeGlossaryDescription(entry.desc), + }; + } + return { + title: entry.cn, + names: [entry.sk, entry.en].filter(Boolean), + desc: entry.desc, + }; +} + +function modernizeGlossaryDescription(text) { + return String(text || '') + .replace(/业力/g, '长期模式') + .replace(/前世课题/g, '深层习惯与未完成议题') + .replace(/凶宫/g, '压力宫') + .replace(/致命/g, '高风险') + .replace(/凶星/g, '压力星') + .replace(/吉星/g, '支持星') + .replace(/最黑暗处有最光明/g, '困难条件中可能出现反转机会'); +} function showTooltip(key) { const entry = G[key]; if (!entry) return; - tooltipEl.querySelector('.jt-cat').textContent = CAT_MAP[entry.cat] || entry.cat; - tooltipEl.querySelector('.jt-title').textContent = entry.cn; - tooltipEl.querySelector('.jt-names').innerHTML = `${entry.sk}${entry.en}`; - tooltipEl.querySelector('.jt-desc').textContent = entry.desc; + const mode = getGlossaryTerminologyMode(); + const display = buildTerminologyDisplay(entry, mode); + tooltipEl.dataset.termKey = key; + tooltipEl.dataset.mode = mode; + tooltipEl.querySelector('.jt-cat').textContent = `${CAT_MAP[entry.cat] || entry.cat} · ${TERMINOLOGY_MODE_LABELS[mode]}`; + tooltipEl.querySelector('.jt-title').textContent = display.title; + tooltipEl.querySelector('.jt-names').innerHTML = display.names.map((name, index) => ( + `${escapeHtml(String(name))}` + )).join(''); + tooltipEl.querySelector('.jt-desc').textContent = display.desc; tooltipEl.classList.remove('hidden'); } function hideTooltip() { - if (tooltipEl) tooltipEl.classList.add('hidden'); + if (tooltipEl) { + tooltipEl.classList.add('hidden'); + delete tooltipEl.dataset.termKey; + } +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ch])); } // ===== 视觉标记:bindTerms 仍然用于添加高亮样式 ===== @@ -335,4 +409,4 @@ function mapKaraka() { return 'Karaka'; } function mapCombust(el) { if(el.textContent.includes('[燃]')){el.dataset.term='Combustion';el.classList.add('jt-clickable');} return null; -} \ No newline at end of file +} diff --git a/jyotish-app/i18n.js b/jyotish-app/i18n.js index e49ef3ce..4057f5ab 100644 --- a/jyotish-app/i18n.js +++ b/jyotish-app/i18n.js @@ -87,8 +87,8 @@ const D = { 'karaka.title': { zh:'Jaimini Karaka 灵魂指针', en:'Jaimini Karaka — Soul Pointers' }, 'karaka.7k': { zh:'7-Karaka(古典系统)', en:'7-Karaka (Classical)' }, 'karaka.8k': { zh:'8-Karaka(含 Rahu)', en:'8-Karaka (incl. Rahu)' }, - 'karaka.note': { zh:'Karaka 是 Jaimini 系统的核心概念。AK(Atmakaraka)代表灵魂目标,DK(Darakaraka)代表配偶征象星。度数越高,Karaka 力量越强。', - en:'Karaka is the core concept of the Jaimini system. AK (Atmakaraka) represents the soul\'s purpose, DK (Darakaraka) represents the spouse significator. The higher the degree, the stronger the Karaka.' }, + 'karaka.note': { zh:'Karaka 是 Jaimini 系统的核心概念。AK(Atmakaraka)代表灵魂目标,DK(Darakaraka)代表配偶征象星。7-Karaka 与 8-Karaka 是两种传承口径,Rahu 是否纳入会改变部分角色归属;实际解读需先固定所用体系。', + en:'Karaka is a core concept in the Jaimini system. AK (Atmakaraka) represents the soul purpose, and DK (Darakaraka) represents the spouse significator. 7-Karaka and 8-Karaka are two lineage conventions; including Rahu can change role assignments, so readings should first fix the chosen convention.' }, // Karaka 标签 'k.ak': { zh:'AK 自我灵魂', en:'AK Soul Self' }, @@ -147,7 +147,7 @@ const D = { en:'Shadbala measures planetary strength across 6 dimensions: Positional (Sthana), Directional (Dig), Temporal (Kala), Motional (Chesta), Natural (Naisargika), and Aspectual (Drik) strength. Ratio ≥100% = strong, 75-99% = medium, <75% = weak.' }, // ---- Dasha Tab ---- - 'dasha.title': { zh:'Vimshottari Dasha 大运', en:'Vimshottari Dasha' }, + 'dasha.title': { zh:'Dasha 大运系统', en:'Dasha Systems' }, 'dasha.maha': { zh:'大运', en:'Mahadasha' }, 'dasha.antar': { zh:'次运', en:'Antardasha' }, 'dasha.praty': { zh:'三运', en:'Pratyantardasha' }, @@ -407,6 +407,10 @@ const D = { 'ai.error.prefix': { zh:'对话出错:', en:'Chat error: ' }, 'ai.no.data': { zh:'无星盘数据', en:'No chart data' }, 'ai.no.reply': { zh:'AI 未返回内容', en:'AI returned no content' }, + 'ai.setup.title': { zh:'配置 AI 后端可获得完整 AI 解读', en:'Configure an AI backend for full AI reading' }, + 'ai.setup.server': { zh:'请通过本地/服务端 API 代理连接模型,例如 /api/chat。', en:'Connect through a local or server-side API proxy, for example /api/chat.' }, + 'ai.setup.secret': { zh:'不要把 OpenAI API key 放进浏览器;在服务端环境变量 OPENAI_API_KEY 或密钥管理服务中读取。', en:'Do not put an OpenAI API key in the browser; load it from server-side OPENAI_API_KEY or a key management service.' }, + 'ai.setup.trust': { zh:'可在 Trust Center 先运行健康检查,确认本地 API 与能力目录可用。', en:'Run the Trust Center health check first to confirm the local API and capability catalog are available.' }, // ---- Auth ---- 'auth.login': { zh:'登录', en:'Login' }, @@ -452,6 +456,8 @@ const D = { 'auth.request.failed': { zh:'请求失败', en:'Request failed' }, 'auth.token.expired': { zh:'Token 已过期', en:'Token expired' }, 'auth.error.unknown': { zh:'未知错误', en:'Unknown error' }, + 'auth.recovery.api': { zh:'登录/注册需要本地或服务端 API 在线;请先到 Trust Center 运行健康检查。', en:'Login/register requires the local or server API to be online; run the Trust Center health check first.' }, + 'auth.recovery.retry': { zh:'若未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。', en:'If disconnected, follow the README first-use startup path to start the web service and local API service, then retry.' }, // ---- Subscription ---- 'sub.iap.unavail': { zh:'IAP 功能不可用,请在 iOS 设备上使用', en:'IAP unavailable. Please use on an iOS device' }, @@ -478,6 +484,7 @@ const D = { 'sub.contact': { zh:'如有问题请联系: jyotish.app@proton.me', en:'Questions? Contact: jyotish.app@proton.me' }, 'sub.check.login': { zh:'请先登录以使用 AI 对话', en:'Please login to use AI chat' }, 'sub.check.limit': { zh:'今日免费对话已用完', en:'Daily free chat limit reached' }, + 'sub.recovery.iap': { zh:'订阅/恢复购买需要 iOS IAP 或后端验证服务;网页端可继续使用免费功能,并可先到 Trust Center 检查 API。', en:'Subscription/restore needs iOS IAP or backend receipt verification; on web you can keep using free features and check the API in Trust Center.' }, // ---- Shadbala 状态 ---- 'sb.strong': { zh:'强', en:'Strong' }, diff --git a/jyotish-app/import-chart.js b/jyotish-app/import-chart.js new file mode 100644 index 00000000..c1269121 --- /dev/null +++ b/jyotish-app/import-chart.js @@ -0,0 +1,245 @@ +import { searchCities } from './jyotish-engine.js'; +import { escapeHtml, escapeAttr, safeNumber } from './security.js'; + +const MONTHS = { + jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, + apr: 4, april: 4, may: 5, jun: 6, june: 6, jul: 7, july: 7, + aug: 8, august: 8, sep: 9, sept: 9, september: 9, oct: 10, + october: 10, nov: 11, november: 11, dec: 12, december: 12, +}; + +function normalizeText(text) { + return String(text || '') + .replace(/\r/g, '\n') + .replace(/[:]/g, ':') + .replace(/[,]/g, ',') + .replace(/\u00a0/g, ' ') + .replace(/[ \t]+/g, ' ') + .trim(); +} + +function sectionAfter(text, labels) { + const pattern = new RegExp(`(?:${labels.join('|')})\\s*[::]?\\s*([^\\n]{1,80})`, 'i'); + return text.match(pattern)?.[1]?.trim() || ''; +} + +function parseDate(text) { + const normalized = normalizeText(text); + const labelValue = sectionAfter(normalized, [ + 'date of birth', 'birth date', 'dob', '出生日期', '生日', '出生', + ]); + const candidates = [labelValue, normalized].filter(Boolean); + for (const value of candidates) { + let m = value.match(/\b((?:18|19|20)\d{2})[-/.年 ]+(\d{1,2})[-/.月 ]+(\d{1,2})/); + if (m) return { year: +m[1], month: +m[2], day: +m[3] }; + m = value.match(/\b(\d{1,2})[-/. ]+(\d{1,2})[-/. ]+((?:18|19|20)\d{2})\b/); + if (m) return { year: +m[3], month: +m[2], day: +m[1] }; + m = value.match(/\b(\d{1,2})\s+([A-Za-z]{3,9})\s+((?:18|19|20)\d{2})\b/); + if (m && MONTHS[m[2].toLowerCase()]) return { year: +m[3], month: MONTHS[m[2].toLowerCase()], day: +m[1] }; + m = value.match(/\b([A-Za-z]{3,9})\s+(\d{1,2}),?\s+((?:18|19|20)\d{2})\b/); + if (m && MONTHS[m[1].toLowerCase()]) return { year: +m[3], month: MONTHS[m[1].toLowerCase()], day: +m[2] }; + } + return null; +} + +function parseTime(text) { + const normalized = normalizeText(text); + const labelValue = sectionAfter(normalized, [ + 'time of birth', 'birth time', 'tob', '出生时间', '时间', + ]); + const candidates = [labelValue, normalized].filter(Boolean); + for (const value of candidates) { + const m = value.match(/\b([01]?\d|2[0-3])[::.](\d{2})(?:\s*(am|pm|AM|PM))?\b/); + if (!m) continue; + let hour = +m[1]; + const minute = +m[2]; + const suffix = m[3]?.toLowerCase(); + if (suffix === 'pm' && hour < 12) hour += 12; + if (suffix === 'am' && hour === 12) hour = 0; + return { hour, minute }; + } + return null; +} + +function parseCoord(text, kind) { + const label = kind === 'lat' + ? '(?:latitude|lat|纬度)' + : '(?:longitude|lon|lng|经度)'; + const re = new RegExp(`${label}\\s*[::]?\\s*([+-]?\\d{1,3}(?:\\.\\d+)?)\\s*([NSEW东西南北])?`, 'i'); + const m = normalizeText(text).match(re); + if (!m) return null; + let value = +m[1]; + const dir = m[2]?.toUpperCase(); + if (['S', 'W', '南', '西'].includes(dir)) value = -Math.abs(value); + return Number.isFinite(value) ? value : null; +} + +function parseTimezone(text) { + const normalized = normalizeText(text); + const m = normalized.match(/(?:timezone|time zone|tz|UTC|GMT|时区)\s*[::]?\s*(?:UTC|GMT)?\s*([+-]?\d{1,2}(?:\.\d+)?)(?::?(\d{2}))?/i); + if (!m) return null; + const base = +m[1]; + const minutes = m[2] ? (+m[2] / 60) * (base < 0 ? -1 : 1) : 0; + const tz = base + minutes; + return Number.isFinite(tz) ? tz : null; +} + +function parseCity(text) { + const value = sectionAfter(normalizeText(text), [ + 'place of birth', 'birth place', 'birth city', 'place', 'city', '出生地', '出生城市', '地点', '城市', + ]); + const cleaned = value.replace(/[,,].*$/, '').trim(); + if (cleaned.length >= 2) return cleaned; + return ''; +} + +function resolveCity(city) { + if (!city) return null; + const results = searchCities(city); + return results[0] || null; +} + +export function parseImportedChartText(text) { + const normalized = normalizeText(text); + const date = parseDate(normalized); + const time = parseTime(normalized); + const city = parseCity(normalized); + const cityMatch = resolveCity(city); + const lat = parseCoord(normalized, 'lat') ?? cityMatch?.lat ?? null; + const lon = parseCoord(normalized, 'lon') ?? cityMatch?.lon ?? null; + const tz = parseTimezone(normalized) ?? cityMatch?.tz ?? null; + const fields = { + ...(date || {}), + ...(time || {}), + city: city || cityMatch?.name || '', + lat, + lon, + tz, + }; + const missing = []; + if (!date) missing.push('出生日期'); + if (!time) missing.push('出生时间'); + if (lat == null || lon == null) missing.push('出生地经纬度'); + if (tz == null) missing.push('时区'); + const quality = Math.round([ + date ? 30 : 0, + time ? 25 : 0, + lat != null && lon != null ? 25 : 0, + tz != null ? 15 : 0, + city || cityMatch ? 5 : 0, + ].reduce((sum, score) => sum + score, 0)); + return { + fields, + missing, + quality, + text_length: normalized.length, + preview: normalized.slice(0, 600), + }; +} + +function applyImportedFields(result) { + const f = result?.fields || {}; + const yearEl = document.getElementById('birth-year'); + const monthEl = document.getElementById('birth-month'); + const dayEl = document.getElementById('birth-day'); + if (f.year) yearEl.value = String(f.year); + if (f.month) monthEl.value = String(f.month); + if (f.year || f.month) monthEl.dispatchEvent(new Event('change')); + if (f.day) dayEl.value = String(f.day); + if (f.hour != null && f.minute != null) { + document.getElementById('birth-time').value = `${String(f.hour).padStart(2, '0')}:${String(f.minute).padStart(2, '0')}`; + } + if (f.city) document.getElementById('birth-city').value = f.city; + if (f.lat != null) document.getElementById('birth-lat').value = safeNumber(f.lat); + if (f.lon != null) document.getElementById('birth-lon').value = safeNumber(f.lon); + if (f.tz != null) document.getElementById('birth-tz').value = String(safeNumber(f.tz)); +} + +function renderImportResult(resultEl, result) { + const missingHtml = result.missing.length + ? `

仍需手动补充:${escapeHtml(result.missing.join('、'))}

` + : '

已识别完整出生信息,可直接生成星盘。

'; + const f = result.fields || {}; + resultEl.innerHTML = ` +
+
${escapeHtml(result.quality)}质量分
+
+ 日期:${escapeHtml([f.year, f.month, f.day].filter(Boolean).join('-') || '-')} + 时间:${escapeHtml(f.hour != null ? `${String(f.hour).padStart(2, '0')}:${String(f.minute).padStart(2, '0')}` : '-')} + 地点:${escapeHtml(f.city || '-')} + 坐标:${escapeHtml(f.lat != null && f.lon != null ? `${safeNumber(f.lat).toFixed(4)}, ${safeNumber(f.lon).toFixed(4)}` : '-')} + 时区:${escapeHtml(f.tz != null ? `UTC${f.tz >= 0 ? '+' : ''}${f.tz}` : '-')} +
+
+ ${missingHtml} + `; +} + +async function readFileAsText(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(reader.error || new Error('文件读取失败')); + reader.readAsText(file); + }); +} + +async function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(reader.error || new Error('文件读取失败')); + reader.readAsDataURL(file); + }); +} + +export function initChartImport() { + const panel = document.getElementById('chart-import-panel'); + if (!panel || panel.dataset.bound) return; + panel.dataset.bound = 'true'; + const textEl = document.getElementById('chart-import-text'); + const fileEl = document.getElementById('chart-import-file'); + const parseBtn = document.getElementById('chart-import-parse'); + const applyBtn = document.getElementById('chart-import-apply'); + const resultEl = document.getElementById('chart-import-result'); + let lastResult = null; + + async function parseCurrentInput() { + resultEl.innerHTML = '

正在识别出生信息...

'; + let text = textEl.value || ''; + const file = fileEl.files?.[0]; + if (file) { + if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) { + try { + const dataUrl = await readFileAsDataUrl(file); + const apiResult = await window.JyotishAPI?.importChart?.({ + filename: file.name, + content_base64: dataUrl.split(',')[1], + }); + text = apiResult?.text || ''; + } catch (error) { + resultEl.innerHTML = `

PDF文本抽取失败:${escapeHtml(error?.message || '本地 API 服务不可用')}。可复制PDF文字后粘贴到文本框。

`; + return; + } + } else { + text = await readFileAsText(file); + } + } + lastResult = parseImportedChartText(text); + renderImportResult(resultEl, lastResult); + applyBtn.disabled = lastResult.quality < 50; + } + + parseBtn.addEventListener('click', parseCurrentInput); + applyBtn.addEventListener('click', () => { + if (!lastResult) return; + applyImportedFields(lastResult); + resultEl.insertAdjacentHTML('beforeend', `

已填入表单。${lastResult.missing.length ? '请补齐缺失字段后生成星盘。' : '可以生成星盘了。'}

`); + }); + fileEl.addEventListener('change', () => { + if (fileEl.files?.[0]) { + textEl.value = ''; + resultEl.innerHTML = `

已选择:${escapeHtml(fileEl.files[0].name)}

`; + } + }); +} diff --git a/jyotish-app/index.html b/jyotish-app/index.html index d216aabb..731c6593 100644 --- a/jyotish-app/index.html +++ b/jyotish-app/index.html @@ -3,7 +3,10 @@ + Jyotish · 印度占星 + + @@ -31,6 +34,30 @@

输入出生信息

+
+
+ 首次使用 + 先确认运行环境,或用示例盘快速进入完整解盘。 +
+
+ + + +
+
+
@@ -78,10 +105,28 @@
+
+
+ 已有 PDF / 文本星盘? + 上传文本层 PDF,或粘贴出生资料,识别后先校验再填表。 +
+ +
+ + + +
+
+
+
+
@@ -99,13 +144,17 @@
+
+
@@ -126,6 +175,8 @@
+ +
+

完整解盘总览

+
+
+
+
+ + +
+

参数、历法与工作区

+
+
+

Jaimini Karaka 灵魂指针

@@ -211,7 +278,7 @@
-

Karaka 是 Jaimini 系统的核心概念。AK(Atmakaraka)代表灵魂目标,DK(Darakaraka)代表配偶征象星。度数越高,Karaka 力量越强。

+

Karaka 是 Jaimini 系统的核心概念。AK(Atmakaraka)代表灵魂目标,DK(Darakaraka)代表配偶征象星。7-Karaka 与 8-Karaka 是两种传承口径,Rahu 是否纳入会改变部分角色归属;实际解读需先固定所用体系。

@@ -259,6 +326,7 @@ +
@@ -288,7 +356,8 @@
-

Vimshottari Dasha 大运

+

Dasha 大运系统

+
@@ -366,6 +435,12 @@
+ +
+

Argala / Virodhargala 干预与阻挡

+
+
+

Bhava Sadhana 宫位六步评级 (A-F)

@@ -421,6 +496,107 @@
+ +
+

完整合盘匹配

+
+
+
+ + +
+ +
+
+
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ 只知道月亮黄经时使用快算 +
+
+ + +
+
+ + +
+ +
+
+
+

输入对方完整出生资料后,可同时计算 36 分 Ashtakoot、双方星盘摘要、D9 婚姻专题、Kuja Dosha 平衡与当前 Dasha 同步。

+
+
+
+
+ + +
+

Prashna 问事占星

+
+
+
+ + +
+
+ + +
+ +
+
+

Prashna 使用提问当下的时刻与当前星盘数据判断具体问题,适合一次只问一个清晰问题。

+
+
+
+
+ + +
+

KP Sublord 分析

+
+

KP 分析将在星盘计算完成后展示。

+
+
+

🔍 验前事 / 案例验证

@@ -431,8 +607,8 @@
- -
+ +

🪐 过境与对比

diff --git a/jyotish-app/jyotish-advanced.js b/jyotish-app/jyotish-advanced.js index 73e10191..5df21204 100644 --- a/jyotish-app/jyotish-advanced.js +++ b/jyotish-app/jyotish-advanced.js @@ -604,7 +604,7 @@ export function computeNakshatraAdvanced(planets, birthNakIdx) { gana: NAK_GANA[nakIdx], element: NAK_ELEMENT[nakIdx], tara_bala: { tara: taraNames[taraDist], index: taraDist }, - sub_lord, + sub_lord: subLord, sub_index: subIdx, }; } diff --git a/jyotish-app/main.js b/jyotish-app/main.js index ab83e8b4..1719b755 100644 --- a/jyotish-app/main.js +++ b/jyotish-app/main.js @@ -42,7 +42,7 @@ import { } from './analysis-deep.js'; import { renderFunctionalNature, renderPACDARES, renderHouseInfluence, renderVargottama, - renderFrequency, renderTriangle, renderRamanGrades, renderTrikonaKendra, + renderFrequency, renderTriangle, renderRamanGrades, renderTrikonaKendra, renderArgala, } from './analysis-renderers.js'; import { computeBhavaBala, computeVimsopaka, computeVaiseshikamsas, @@ -52,26 +52,206 @@ import { import { renderBhavaBala, renderVimsopakaVaiseshikamsa, renderPlanetStates, renderAVPinda, renderExtraDasas, } from './renderers.js'; -import { exportJSON, exportSVG, exportPNG } from './export.js'; import { computeArgala } from './argala.js'; import { computeTajika } from './tajika.js'; import { computeNakshatraAdvanced, computeCharaDasha, computeKarakamsha } from './jyotish-advanced.js'; import { computeValidation, computeAudit, computeActionableContext } from './jyotish-export-modules.js'; -import { initTooltip, bindTerms } from './glossary.js'; +import { initTooltip, bindTerms, setGlossaryTerminologyMode } from './glossary.js'; import { initAIChat, aiChatSetChartData } from './ai-chat.js'; import { initAuth } from './auth.js'; import { initSubscription } from './subscription.js'; import { renderRectificationTab, initRectification } 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'; +import { initChartImport } from './import-chart.js'; +import { buildMEVGAudit, renderMEVGAudit } from './mevg-audit.js'; // 合并所有 Yoga 定义 const ALL_YOGA_DEFS = [...YOGA_DEFINITIONS, ...YOGA_EXTENDED_A, ...YOGA_EXTENDED_B]; +const D9_MALEFICS = ['Sun', 'Mars', 'Saturn', 'Rahu', 'Ketu']; +const D9_GREEN_HOUSES = [1, 3, 4, 5, 7, 9, 10]; +const D9_HARD_HOUSES = [6, 8, 12]; +const NODE_DIGNITY = { + Rahu: { exaltation: { sign: 'Taurus' }, debilitation: { sign: 'Scorpio' }, ownSigns: [] }, + Ketu: { exaltation: { sign: 'Scorpio' }, debilitation: { sign: 'Taurus' }, ownSigns: [] }, +}; +const PANCHANGA_ACTIVITIES = [ + ['all', '全部活动'], + ['marriage', '婚礼'], + ['business', '开业/签约'], + ['travel', '出行'], + ['medical', '医疗'], + ['education', '学习/入学'], +]; +const PANCHANGA_CONDITIONS = [ + ['all', '全部条件'], + ['has_vrata', 'Vrata/节日标签'], + ['festival_candidate', '节日候选'], + ['spiritual_practice', '修行/祈福'], + ['auspicious_activity', '适合所选活动'], + ['avoid_new_start', '不宜新开始'], + ['good_choghadiya', '有吉利 Choghadiya'], +]; +const PANCHANGA_CONDITION_GUIDE = { + all: '显示范围内所有日期。', + has_vrata: '当天存在 vrata、lunar observance 或节日候选标签。', + festival_candidate: '节日名称仍需 lunar masa 或传统规则确认,当前只作为候选窗口。', + spiritual_practice: '适合斋戒、祈福、修行或月相观察的保守标签。', + auspicious_activity: '所选活动或当日综合质量给出推荐倾向。', + avoid_new_start: '含不宜新开始、活动避开或质量偏弱的提醒。', + good_choghadiya: '当天存在吉利 Choghadiya 子时段,可作为日内择时入口。', +}; +const PANCHANGA_CONDITION_MODES = [ + ['all', '满足全部'], + ['any', '满足任一'], +]; +const DEMO_BIRTH = { + year: 1990, + month: 1, + day: 1, + hour: 12, + minute: 0, + lat: 39.9, + lon: 116.4, + tz: 8, + city: '北京', +}; +const CASE_GROUP_PRESETS = ['全部分组', '家庭', '伴侣', '客户', '研究', '朋友', '未分组']; +const CASE_RELATION_PRESETS = [ + ['all', '全部关系'], + ['self', '本人'], + ['partner', '伴侣/婚恋'], + ['family', '家庭'], + ['client', '客户'], + ['research', '研究'], + ['friend', '朋友'], + ['unknown', '未标注'], +]; const $ = id => document.getElementById(id); +const PRODUCT_GAP_DOC = '/Users/wuyongnaren/Documents/印度占星/docs/research/product_gap_matrix_2026_06_22.md'; +const CHART_LIBRARY_KEY = 'jyotish_chart_library'; +const SYNASTRY_PAIR_LIBRARY_KEY = 'jyotish_synastry_pair_library'; +const PRASHNA_CASE_LIBRARY_KEY = 'jyotish_prashna_case_library'; +const CALCULATION_SETTINGS_KEY = 'jyotish_calculation_settings'; +const TERMINOLOGY_MODE_KEY = 'jyotish_terminology_mode'; +const TRUST_CENTER_STORAGE_KEYS = [ + CHART_LIBRARY_KEY, + SYNASTRY_PAIR_LIBRARY_KEY, + PRASHNA_CASE_LIBRARY_KEY, + CALCULATION_SETTINGS_KEY, + TERMINOLOGY_MODE_KEY, + 'jyotish_ai_messages', + 'jyotish_user_profile', + 'jyotish_subscription_state', +]; +const DEFAULT_CALCULATION_SETTINGS = { + ayanamsa: 'lahiri', + nodeMode: 'mean', + houseSystem: 'whole_sign', + sunrisePolicy: 'auto_solar', + geocoderPolicy: 'local_city_db', + ephemerisBackend: 'swisseph_local', + terminologyMode: 'balanced', + yogaVariant: 'bvr_structured', + jaiminiKarakaVariant: 'seven_and_eight', + kpSignificatorVariant: 'abcd_weighted', + ashtakavargaVariant: 'bphs_pav_sodhita', + shadbalaVariant: 'relative_internal', + dashaReference: 'birth_moon', +}; +const TERMINOLOGY_MODE_OPTIONS = { + balanced: { + label: '平衡模式', + shortLabel: 'Balanced', + note: '同时显示中文、Sanskrit、English 和短解释。', + }, + beginner: { + label: '入门模式', + shortLabel: 'Beginner', + note: '优先现代生活语言,减少缩写负担。', + }, + professional: { + label: '专业模式', + shortLabel: 'Pro', + note: '保留中文、英文和 Sanskrit 对照。', + }, +}; +const CALCULATION_SETTING_OPTIONS = { + ayanamsa: [ + ['lahiri', 'Lahiri / Chitrapaksha'], + ['raman', 'Raman(后续统一引擎切换)'], + ['kp', 'KP / Krishnamurti(后续统一引擎切换)'], + ], + nodeMode: [ + ['mean', 'Mean node / 平均罗喉'], + ['true', 'True node / 真罗喉(后续统一引擎切换)'], + ], + houseSystem: [ + ['whole_sign', 'Whole Sign / Rashi'], + ['sripati', 'Sripati Bhava Chalit(专项接口)'], + ['equal', 'Equal House(专项接口)'], + ['placidus', 'Placidus(专项接口)'], + ], + sunrisePolicy: [ + ['auto_solar', '按地点计算日出/日落'], + ['manual_time', '使用面板手填日出/日落'], + ], + geocoderPolicy: [ + ['local_city_db', '本地城市库'], + ['manual_coordinates', '手动经纬度优先'], + ], + ephemerisBackend: [ + ['swisseph_local', 'Swiss Ephemeris / 本地 API 主路径'], + ['swisseph_wasm', 'SwissEph WASM / 浏览器降级'], + ['xalen_spike', 'xalen-ephemeris Apache-2.0 可行性记录'], + ], + terminologyMode: [ + ['balanced', '平衡:术语 + 短解释'], + ['beginner', '初学:现代生活语言优先'], + ['professional', '专业:Sanskrit / English / 技法名优先'], + ], + yogaVariant: [ + ['bvr_structured', 'B.V. Raman/PyJHora 对齐规则(当前)'], + ['conservative_classic', '保守经典规则(后续切换)'], + ['functional_plus_classic', '功能性 + 经典 Yoga(后续切换)'], + ], + jaiminiKarakaVariant: [ + ['seven_and_eight', '7/8 Karaka 双口径展示(当前)'], + ['seven_karaka', '7 Karaka 固定口径(后续切换)'], + ['eight_karaka_rahu', '8 Karaka 含 Rahu(后续切换)'], + ], + kpSignificatorVariant: [ + ['abcd_weighted', 'ABCD Significator 加权(当前)'], + ['traditional_sub_lord', '传统 Star/Sub/Sub-Sub(后续切换)'], + ['question_domain_focus', '按问题域重点宫位(后续切换)'], + ], + ashtakavargaVariant: [ + ['bphs_pav_sodhita', 'BPHS BAV/SAV + PAV/Sodhita(当前)'], + ['sav_bav_only', '仅 SAV/BAV 快速层(后续切换)'], + ['transit_av_weighted', 'Transit AV 加权(后续切换)'], + ], + shadbalaVariant: [ + ['relative_internal', '内部相对强弱(当前)'], + ['absolute_benchmark', '外部绝对值校准(后续切换)'], + ['ishta_kashta_weighted', 'Ishta/Kashta 加权(后续切换)'], + ], + dashaReference: [ + ['birth_moon', '出生月亮星宿起算(当前)'], + ['query_date_snapshot', '按查询日期快照(后续切换)'], + ['multi_system_convergence', '多 Dasha 共振(后续切换)'], + ], +}; // 全局状态 let chartData = null; let _lastChartSummary = null; +let _lastSynastryPairRecord = null; +let _lastPrashnaCaseRecord = null; +let _caseWorkspaceSelection = new Set(); +let _caseWorkspacePreview = null; +let _exportInProgress = false; // ============================================================================ // Tab 切换 @@ -81,7 +261,7 @@ document.addEventListener('DOMContentLoaded', () => { if (!tabs) return; tabs.addEventListener('click', e => { const btn = e.target.closest('.tab-btn'); - if (!btn) return; + if (!btn || !btn.dataset.tab) return; tabs.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); @@ -116,6 +296,13 @@ document.addEventListener('DOMContentLoaded', () => { }); }); +function switchToTab(tabName) { + const tabs = $('section-tabs'); + if (!tabs || !tabName) return; + const btn = [...tabs.querySelectorAll('.tab-btn')].find(item => item.dataset.tab === tabName); + if (btn) btn.click(); +} + // ============================================================================ // 城市搜索 // ============================================================================ @@ -124,6 +311,8 @@ function setupCitySearch() { const cityInput = $('birth-city'), suggestions = $('city-suggestions'); const latInput = $('birth-lat'), lonInput = $('birth-lon'), tzSelect = $('birth-tz'); cityInput.addEventListener('input', () => { + latInput.value = ''; + lonInput.value = ''; clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { const q = cityInput.value.trim(); @@ -131,8 +320,11 @@ function setupCitySearch() { const results = searchCities(q); if (results.length === 0) { suggestions.classList.add('hidden'); return; } suggestions.innerHTML = results.map(c => { + const lat = safeNumber(c.lat); + const lon = safeNumber(c.lon); + const tz = safeNumber(c.tz); const label = c.en ? `${c.name} (${c.en})` : c.name; - return `
${label} ${c.lat.toFixed(1)}°, ${c.lon.toFixed(1)}°
`; + return `
${escapeHtml(label)} ${lat.toFixed(1)}°, ${lon.toFixed(1)}°
`; }).join(''); suggestions.classList.remove('hidden'); suggestions.querySelectorAll('.suggestion-item').forEach(el => { @@ -150,6 +342,62 @@ function setupCitySearch() { }); } +function normalizeCityText(value) { + return String(value || '').trim().toLowerCase().replace(/\s+/g, ' '); +} + +function applyCityToBirthForm(city) { + if (!city) return false; + const lat = safeNumber(city.lat); + const lon = safeNumber(city.lon); + const tz = safeNumber(city.tz); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; + const label = city.en ? `${city.name} (${city.en})` : city.name; + $('birth-city').value = city.name || city.en || label; + $('birth-lat').value = lat; + $('birth-lon').value = lon; + if (Number.isFinite(tz)) $('birth-tz').value = String(tz); + const suggestions = $('city-suggestions'); + if (suggestions) suggestions.classList.add('hidden'); + return true; +} + +function resolveTypedBirthCity() { + const lat = parseFloat($('birth-lat').value); + const lon = parseFloat($('birth-lon').value); + if (Number.isFinite(lat) && Number.isFinite(lon)) return true; + const q = $('birth-city').value.trim(); + if (!q) return false; + const results = searchCities(q); + if (!results.length) return false; + const target = normalizeCityText(q); + const exact = results.find(city => { + const names = [city.name, city.en, city.en ? `${city.name} (${city.en})` : city.name]; + return names.some(name => normalizeCityText(name) === target); + }); + return applyCityToBirthForm(exact || (results.length === 1 ? results[0] : null)); +} + +function getBrowserTimezoneOffset() { + const tz = -new Date().getTimezoneOffset() / 60; + return Number.isFinite(tz) ? tz : 0; +} + +function resolveTimezoneValue(value) { + const raw = String(value ?? '').trim(); + if (!raw || raw === 'auto') return getBrowserTimezoneOffset(); + const utc = raw.match(/^UTC\s*([+-]?\d{1,2})(?::?(\d{2}))?/i); + if (utc) { + const hours = Number(utc[1]); + const minutes = Number(utc[2] || 0); + if (Number.isFinite(hours) && Number.isFinite(minutes)) { + return hours + (hours >= 0 ? minutes / 60 : -minutes / 60); + } + } + const tz = Number(raw); + return Number.isFinite(tz) ? tz : getBrowserTimezoneOffset(); +} + // ============================================================================ // 日期下拉框初始化 // ============================================================================ @@ -200,6 +448,45 @@ function initDateSelects() { // ============================================================================ // 表单提交 // ============================================================================ +async function computeChartForBirth(birth) { + const { year, month, day, hour, minute, lat, lon, tz } = birth; + const settings = readCalculationSettings(); + const payload = applyCalculationSettingsToPayload({ year, month, day, hour, minute, lat, lon, tz }); + const apiChart = await window.JyotishAPI?.computeWithPython(payload); + if (apiChart?.success) { + console.log('[Jyotish] ✅ 本地 API 服务 —', apiChart.dasha_count, 'Dasha'); + return attachCalculationSettings(apiChart, settings); + } + await initEngine(); + const fallbackChart = await computeChart({ year, month, day, hour, minute, lat, lon, tz }); + fallbackChart._fallback = true; + attachCalculationSettings(fallbackChart, settings); + console.log('[Jyotish] ⚠️ JS引擎计算完成'); + return fallbackChart; +} + +function buildChartComputeRecoveryMessage(error) { + const message = error?.message || error || '本地 API 未连接'; + return `计算失败:${message}。请到 Trust Center 运行健康检查;如本地 API 未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。`; +} + +function buildInteractiveAPIRecoveryMessage(label, error) { + const message = error?.message || error || '本地 API 未连接'; + return `${label}:${message}。请到 Trust Center 运行健康检查;如本地 API 未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。`; +} + +function renderInlineAPIError(className, label, error) { + return `

${escapeHtml(buildInteractiveAPIRecoveryMessage(label, error))}

`; +} + +function setChartComputeStatus(message = '', tone = 'warn') { + const status = $('chart-compute-status'); + if (!status) return; + status.textContent = message; + status.dataset.tone = tone; + status.classList.toggle('hidden', !message); +} + function setupForm() { const form = $('birth-form'), btn = $('btn-calculate'); const btnText = btn.querySelector('.btn-text'), btnLoading = btn.querySelector('.btn-loading'); @@ -209,26 +496,20 @@ function setupForm() { const month = parseInt($('birth-month').value); const day = parseInt($('birth-day').value); const timeVal = $('birth-time').value; + resolveTypedBirthCity(); const lat = parseFloat($('birth-lat').value), lon = parseFloat($('birth-lon').value); - const tz = parseFloat($('birth-tz').value); + const tz = resolveTimezoneValue($('birth-tz').value); if (!year || !month || !day || !timeVal) { alert(t('alert.date')); return; } if (isNaN(lat) || isNaN(lon)) { alert(t('alert.city')); return; } const [hour, minute] = timeVal.split(':').map(Number); btnText.classList.add('hidden'); btnLoading.classList.remove('hidden'); btn.disabled = true; + setChartComputeStatus('正在计算星盘...', 'warn'); try { - // v6.9.4: 计算层 — 优先Python API, 回退JS引擎 - chartData = await window.JyotishAPI?.computeWithPython({ year, month, day, hour, minute, lat, lon, tz }); - if (!chartData || !chartData.success) { - await initEngine(); - chartData = await computeChart({ year, month, day, hour, minute, lat, lon, tz }); - chartData._fallback = true; - console.log('[Jyotish] ⚠️ JS引擎计算完成'); - } else { - console.log('[Jyotish] ✅ Python API —', chartData.dasha_count, 'Dasha'); - } + // v6.9.4: 计算层 — 优先本地 API 服务, 回退JS引擎 + chartData = await computeChartForBirth({ year, month, day, hour, minute, lat, lon, tz }); } catch (e) { console.error('[Jyotish] 计算失败:', e); - alert('计算失败: ' + e.message); + setChartComputeStatus(buildChartComputeRecoveryMessage(e), 'error'); btnText.classList.remove('hidden'); btnLoading.classList.add('hidden'); btn.disabled = false; return; } @@ -239,15 +520,145 @@ function setupForm() { aiChatSetChartData(chartData); // v6.9.4: 触发 AI 解读 triggerAIReading(chartData); + setChartComputeStatus(chartData?._fallback ? '已使用浏览器本地引擎生成星盘;如需完整本地 API 服务能力,请到 Trust Center 运行健康检查。' : '星盘已生成。', 'ok'); } catch (err) { console.error('[Jyotish] Error:', err); - alert(t('alert.error') + err.message); + setChartComputeStatus(buildChartComputeRecoveryMessage(err), 'error'); } finally { btnText.classList.remove('hidden'); btnLoading.classList.add('hidden'); btn.disabled = false; } }); } +function fillBirthFormFromData(birth) { + const yearEl = $('birth-year'); + const monthEl = $('birth-month'); + const dayEl = $('birth-day'); + const cityEl = $('birth-city'); + yearEl.value = String(birth.year); + monthEl.value = String(birth.month); + monthEl.dispatchEvent(new Event('change')); + dayEl.value = String(birth.day); + $('birth-time').value = `${String(birth.hour).padStart(2, '0')}:${String(birth.minute).padStart(2, '0')}`; + $('birth-lat').value = birth.lat; + $('birth-lon').value = birth.lon; + $('birth-tz').value = String(birth.tz); + if (cityEl && birth.city) cityEl.value = birth.city; +} + +function setFirstUseStatus(message, tone = 'neutral') { + const status = $('first-use-status'); + if (!status) return; + status.textContent = message || ''; + status.dataset.tone = tone; +} + +function fillDemoBirth() { + fillBirthFormFromData(DEMO_BIRTH); + setFirstUseStatus('示例盘已填入。确认信息后点击“生成星盘”进入完整解盘。', 'ok'); + $('btn-calculate')?.focus(); +} + +async function runFirstUseHealthCheck() { + setFirstUseStatus('正在检查本地 API 与技法目录...', 'pending'); + try { + const apiHealth = await window.JyotishAPI?.getAPIHealth?.(); + const audit = await window.JyotishAPI?.getCapabilityAudit?.(); + const apiOnline = Boolean(apiHealth?.success || apiHealth?.status === 'ok' || apiHealth?.ok); + const techniqueCount = audit?.registry?.technique_count || audit?.registry_count || audit?.technique_count || 0; + const endpointCount = audit?.surfaces?.api_endpoint_count || audit?.api_endpoint_count || 0; + window.__jyotishRuntimeHealth = { + status: apiOnline ? 'ok' : 'warn', + checkedAt: new Date().toISOString(), + apiOnline, + apiBase: apiHealth?.base, + version: apiHealth?.version, + latencyMs: apiHealth?.latencyMs, + techniqueCount, + endpointCount, + apiHealth, + audit, + details: [ + ['PWA 安装壳', getPWAStatus().label, getPWAStatus().note], + ['本地 API 服务', apiHealth?.base || (apiOnline ? 'online' : '未连接'), apiOnline ? `health ok · v${apiHealth?.version || '-'} · ${apiHealth?.latencyMs ?? '-'}ms` : '本地 API 未通过 health 检查'], + ['Technique catalog', `${techniqueCount || 0} techniques`, `${endpointCount || 0} API endpoints 可被前端发现`], + ], + }; + if (apiOnline) { + setFirstUseStatus(`本地 API 可用;技法目录 ${techniqueCount || 0} 项、API ${endpointCount || 0} 个端点已返回。可以继续生成星盘。`, 'ok'); + return; + } + setFirstUseStatus('本地 API 未连接;仍可先用浏览器 fallback 生成基础星盘,后端报告/PDF/高级技法需启动本地 API 服务。', 'warn'); + } catch (error) { + window.__jyotishRuntimeHealth = { + status: 'warn', + checkedAt: new Date().toISOString(), + apiOnline: false, + error: error?.message || String(error), + details: [ + ['PWA 安装壳', getPWAStatus().label, getPWAStatus().note], + ['本地 API 服务', '未连接', error?.message || '请确认本地 API 服务正在 127.0.0.1:5200 运行'], + ['启动路径', '普通用户启动路径', '按 README 先启动网页服务,再启动本地 API 服务。'], + ], + }; + setFirstUseStatus('本地 API 未连接;请按普通用户启动路径启动网页服务和本地 API 服务后再检查。', 'warn'); + } +} + +function focusFirstUseImport() { + const importPanel = $('chart-import-panel'); + const importText = $('chart-import-text'); + if (importPanel) importPanel.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (importText) importText.focus(); + setFirstUseStatus('已定位到星盘导入区。粘贴出生资料或上传文本层 PDF 后点击“识别信息”。', 'neutral'); +} + +function setupFirstUsePanel() { + const panel = $('first-use-panel'); + if (!panel || panel.dataset.bound) return; + panel.dataset.bound = 'true'; + $('first-use-demo')?.addEventListener('click', fillDemoBirth); + $('first-use-health')?.addEventListener('click', runFirstUseHealthCheck); + $('first-use-import')?.addEventListener('click', focusFirstUseImport); +} + +function getAscendantLord(ascendant = {}) { + return ascendant.lord || SIGN_LORDS[ascendant.sign] || ''; +} + +function normalizePlanetRecord(planet = {}) { + return planet || {}; +} + +function formatMoonNakshatra(moonP) { + const moon = normalizePlanetRecord(moonP); + const nakshatra = moon.nakshatra || moon.nakshatra_name || ''; + const pada = moon.nakshatra_pada || moon.pada || ''; + if (!nakshatra) return ''; + return `${nakshatra}${pada ? ` Pada ${pada}` : ''}`; +} + +async function applyRectifiedBirth(birth) { + if (!birth) return; + try { + fillBirthFormFromData(birth); + chartData = await computeChartForBirth(birth); + window.__jyotishBirth = birth; + renderAll(); + showPage('chart'); + aiChatSetChartData(chartData); + triggerAIReading(chartData); + const rectPanel = $('rect-panel'); + const rectOverlay = $('rect-overlay'); + if (rectPanel) rectPanel.classList.add('hidden'); + if (rectOverlay) rectOverlay.classList.add('hidden'); + document.body.style.overflow = ''; + } catch (error) { + console.error('[Rect] apply corrected birth failed:', error); + setChartComputeStatus(buildInteractiveAPIRecoveryMessage('校正时间应用暂不可用', error), 'error'); + } +} + // ============================================================================ // 全部渲染 // ============================================================================ @@ -257,7 +668,9 @@ function renderAll() { if (!birth_info.date) birth_info.date = '2000-01-01'; if (!birth_info.time) birth_info.time = '12:00'; if (!birth_info.tz) birth_info.tz = 'UTC+8'; - const moonP = planets.Moon; + const moonP = normalizePlanetRecord(planets.Moon); + const ascendantLord = ascendant.lord || getAscendantLord(ascendant); + const moonNakshatra = formatMoonNakshatra(moonP); // 高级计算 computeCombustion(planets); @@ -272,9 +685,9 @@ function renderAll() { // Banner $('asc-sign').textContent = `${ascendant.sign} · ${signName(ascendant.sign)}`; - $('asc-degree').textContent = `${((ascendant.degree_in_sign ?? ascendant.degree) ?? 0).toFixed(2)}° | ${t('asc.lord')}: ${planetName(ascendant.lord)}`; - if (moonP) $('asc-nakshatra').textContent = `${t('asc.nakshatra')}: ${moonP.nakshatra} Pada ${moonP.nakshatra_pada}`; - $('asc-lord-badge').textContent = `${t('asc.lord')}: ${planetName(ascendant.lord)} (${ascendant.lord})`; + $('asc-degree').textContent = `${((ascendant.degree_in_sign ?? ascendant.degree) ?? 0).toFixed(2)}° | ${t('asc.lord')}: ${planetName(ascendantLord) || ascendantLord || '-'}`; + $('asc-nakshatra').textContent = moonNakshatra ? `${t('asc.nakshatra')}: ${moonNakshatra}` : ''; + $('asc-lord-badge').textContent = ascendantLord ? `${t('asc.lord')}: ${planetName(ascendantLord) || ascendantLord} (${ascendantLord})` : `${t('asc.lord')}: -`; // 生时校正引导卡片:显示/绑定 const rectPrompt = $('rect-prompt'); @@ -339,6 +752,7 @@ function renderAll() { // 分盘 Tab renderVargas(allV, planets, ascendant); + renderD9MarriageReport(allV, planets, ascendant, karaka); renderArudha(arudha); // Ashtakavarga Tab @@ -348,6 +762,7 @@ function renderAll() { renderShadbala(sb); // Dasha Tab + renderDashaSystemOverview(chartData); if (dashaData) renderDasha3Level(dashaData); // Transit Tab(异步) @@ -360,8 +775,10 @@ function renderAll() { const frequency = computeFrequency(planets); const triangle = computeTriangle(planets); const ramanGrades = computeRamanHouseScore(planets, ascendant.sign); + const argala = computeArgala(buildPlanetSignIndex(planets), SIGNS.indexOf(ascendant.sign)); renderFunctionalNature(ascendant.sign); renderPACDARES(pacdares); + renderArgala(argala); renderRamanGrades(ramanGrades); renderHouseInfluence(houseInfluence); renderVargottama(vargottama); @@ -387,74 +804,4982 @@ function renderAll() { renderPlanetStates(activity, pAge, alertness, mood); renderAVPinda(pinda); renderExtraDasas(extraDasas); + const validation = computeValidation(planets, ascendant, av, dashaData); + const audit = computeAudit(planets, ascendant, av, validation); + const actionableCtx = computeActionableContext(planets, ascendant); + const provenance = buildCalculationProvenance(chartData, ty, { + av, + shadbala: sb, + validation, + audit, + chartStyle: getChartStyle(), + }); + chartData._client_audit = { validation, audit, actionableContext: actionableCtx, provenance }; + renderSpecialLagnaReport(arudha, ascendant, birth_info, chartData.special_lagnas); + renderCompleteReadingTab({ ascendant, moonP, allYogas, dashaData, extraDasas, chartData, validation, audit }); + renderProvenancePanel({ + chartData, + panchanga: ty, + provenance, + validation, + audit, + allYogas, + dashaData, + extraDasas, + }); // 🔥 v6.7.2: API数据渲染 Recovery & KP tabs renderRemediesTab(chartData); + renderSynastryTab(chartData); + renderPrashnaTab(chartData); renderKPTab(chartData); // v6.9.0: 验证 & 过境 Tab renderVerifyTab(chartData); renderTransitCompareTab(chartData); + updateSaveChartButton(); + renderSavedChartPanel(); // 绑定术语 Tooltip(延迟确保所有异步渲染完成) setTimeout(() => bindTerms(document.querySelector('#page-chart')), 200); setTimeout(() => bindTerms(document.querySelector('#page-chart')), 800); } +// ============================================================================ +// 完整解盘 Tab:承接 Skill 全能力路线 +// ============================================================================ +function renderCompleteReadingTab({ ascendant, moonP, allYogas, dashaData, extraDasas, chartData, validation, audit }) { + const extraDashaCount = Object.keys(extraDasas || {}).length; + const apiDashaCount = chartData?._extended?.dasha_count || chartData?.available_dashas?.length || 0; + const dashaCount = Math.max(apiDashaCount, extraDashaCount, dashaData ? 1 : 0); + renderSkillCoverage($('skill-coverage-section'), { + ascendant: ascendant?.sign ? `${ascendant.sign} · ${signName(ascendant.sign)}` : '-', + moonNakshatra: formatMoonNakshatra(moonP) || '-', + yogaCount: allYogas?.length || 0, + dashaCount, + chartData, + birthInfo: chartData?.birth_info || {}, + }); + const mevg = buildMEVGAudit({ ascendant, moonP, allYogas, dashaData, chartData, validation, audit }); + renderMEVGAudit($('mevg-audit-section'), mevg); +} + +function readCalculationSettings() { + try { + const saved = JSON.parse(localStorage.getItem(CALCULATION_SETTINGS_KEY) || '{}'); + if (!saved.terminologyMode) saved.terminologyMode = localStorage.getItem(TERMINOLOGY_MODE_KEY); + return normalizeCalculationSettings(saved); + } catch { + return { ...DEFAULT_CALCULATION_SETTINGS }; + } +} + +function writeCalculationSettings(settings) { + const normalized = normalizeCalculationSettings(settings); + localStorage.setItem(CALCULATION_SETTINGS_KEY, JSON.stringify(normalized)); + localStorage.setItem(TERMINOLOGY_MODE_KEY, normalized.terminologyMode); + setGlossaryTerminologyMode(normalized.terminologyMode); + if (chartData) chartData._terminology_mode = normalized.terminologyMode; + return normalized; +} + +function normalizeCalculationSettings(settings = {}) { + const normalized = { ...DEFAULT_CALCULATION_SETTINGS }; + Object.keys(DEFAULT_CALCULATION_SETTINGS).forEach(key => { + const allowed = (CALCULATION_SETTING_OPTIONS[key] || []).map(([value]) => value); + if (allowed.includes(settings[key])) normalized[key] = settings[key]; + }); + return normalized; +} + +function getCalculationSettingLabel(key, value) { + const found = (CALCULATION_SETTING_OPTIONS[key] || []).find(([option]) => option === value); + return found ? found[1] : value || '-'; +} + +function applyCalculationSettingsToPayload(payload) { + const settings = readCalculationSettings(); + return { + ...payload, + ayanamsa: settings.ayanamsa, + node_mode: settings.nodeMode, + house_system: settings.houseSystem, + sunrise_policy: settings.sunrisePolicy, + geocoder_policy: settings.geocoderPolicy, + ephemeris_backend: settings.ephemerisBackend, + terminology_mode: settings.terminologyMode, + yoga_variant: settings.yogaVariant, + jaimini_karaka_variant: settings.jaiminiKarakaVariant, + kp_significator_variant: settings.kpSignificatorVariant, + ashtakavarga_variant: settings.ashtakavargaVariant, + shadbala_variant: settings.shadbalaVariant, + dasha_reference: settings.dashaReference, + }; +} + +function attachCalculationSettings(chart, settings = readCalculationSettings()) { + if (!chart) return chart; + chart._calculation_settings = normalizeCalculationSettings(settings); + chart._terminology_mode = readTerminologyMode(); + return chart; +} + +function buildCalculationProvenance(chartData, panchanga, context = {}) { + const birth = chartData?.birth_info || chartData?.birth || {}; + const settings = normalizeCalculationSettings(chartData?._calculation_settings || readCalculationSettings()); + const terminologyMode = readTerminologyMode(chartData?._terminology_mode); + const terminology = getTerminologyModeOption(terminologyMode); + const ayanamsaValue = birth.ayanamsa ?? chartData?.birth?.ayanamsa ?? chartData?.ayanamsa; + const ayanamsa = ayanamsaValue != null + ? `${getCalculationSettingLabel('ayanamsa', settings.ayanamsa)} (${Number(ayanamsaValue).toFixed(4)} deg)` + : getCalculationSettingLabel('ayanamsa', settings.ayanamsa); + const hasPython = chartData?.success && !chartData?._fallback; + return { + engine: hasPython ? `本地 API 服务 ${chartData?.version || ''}`.trim() : 'Browser SwissEph fallback', + ephemeris: hasPython ? 'Swiss Ephemeris via local API' : 'SwissEph WASM/browser', + ephemerisBackend: getCalculationSettingLabel('ephemerisBackend', settings.ephemerisBackend), + terminologyMode: getCalculationSettingLabel('terminologyMode', settings.terminologyMode), + ayanamsa, + nodeMode: `${getCalculationSettingLabel('nodeMode', settings.nodeMode)};Ketu derived 180 deg opposite`, + houseSystem: `${getCalculationSettingLabel('houseSystem', settings.houseSystem)};Bhava Chalit 可在专项接口复核`, + sunrisePolicy: getCalculationSettingLabel('sunrisePolicy', settings.sunrisePolicy), + geocoderPolicy: getCalculationSettingLabel('geocoderPolicy', settings.geocoderPolicy), + yogaVariant: getCalculationSettingLabel('yogaVariant', settings.yogaVariant), + jaiminiKarakaVariant: getCalculationSettingLabel('jaiminiKarakaVariant', settings.jaiminiKarakaVariant), + kpSignificatorVariant: getCalculationSettingLabel('kpSignificatorVariant', settings.kpSignificatorVariant), + ashtakavargaVariant: getCalculationSettingLabel('ashtakavargaVariant', settings.ashtakavargaVariant), + shadbalaVariant: getCalculationSettingLabel('shadbalaVariant', settings.shadbalaVariant), + dashaReference: getCalculationSettingLabel('dashaReference', settings.dashaReference), + terminologyMode, + terminologyLabel: terminology.label, + terminologyNote: terminology.note, + calculationSettings: settings, + ruleVariantStatus: 'Rule variants are saved/exported as interpretive policy; non-current variants are staged until each engine path supports live switching.', + calculationSettingsStatus: 'Settings are saved and exported; ayanamsa/node/rule/ephemeris switching beyond the current path is staged for unified engine support.', + chartStyle: context.chartStyle === 'north' ? 'North Indian' : 'South Indian', + timezone: birth.tz || 'UTC+8', + coordinates: `${safeNumber(birth.lat ?? 0).toFixed(4)}, ${safeNumber(birth.lon ?? 0).toFixed(4)}`, + julianDay: birth.julian_day || chartData?.birth?.julian_day || '', + panchangaSource: panchanga ? 'Birth-time Sun/Moon longitudes in current chart' : 'Unavailable', + exportStatus: 'HTML/JSON/SVG/PNG export available; backend PDF pipeline is queued in product gap matrix', + savedChartStatus: readSavedChartStatus(chartData), + }; +} + +function readSavedChartStatus(cd) { + const lib = readChartLibrary(); + const saved = Boolean(findChartLibraryEntry(lib, cd)); + return { + count: lib.length, + currentSaved: saved, + label: saved ? '当前星盘已在本地星盘库' : '当前星盘尚未保存到本地星盘库', + }; +} + +function readChartLibrary() { + try { + const lib = JSON.parse(localStorage.getItem(CHART_LIBRARY_KEY) || '[]'); + return Array.isArray(lib) ? lib : []; + } catch { + return []; + } +} + +function writeChartLibrary(lib) { + localStorage.setItem(CHART_LIBRARY_KEY, JSON.stringify(Array.isArray(lib) ? lib : [])); +} + +function readSynastryPairLibrary() { + try { + const lib = JSON.parse(localStorage.getItem(SYNASTRY_PAIR_LIBRARY_KEY) || '[]'); + return Array.isArray(lib) ? lib : []; + } catch { + return []; + } +} + +function writeSynastryPairLibrary(lib) { + localStorage.setItem(SYNASTRY_PAIR_LIBRARY_KEY, JSON.stringify(Array.isArray(lib) ? lib : [])); +} + +function readPrashnaCaseLibrary() { + try { + const lib = JSON.parse(localStorage.getItem(PRASHNA_CASE_LIBRARY_KEY) || '[]'); + return Array.isArray(lib) ? lib : []; + } catch { + return []; + } +} + +function writePrashnaCaseLibrary(lib) { + localStorage.setItem(PRASHNA_CASE_LIBRARY_KEY, JSON.stringify(Array.isArray(lib) ? lib : [])); +} + +function sortChartLibrary(lib) { + return [...(Array.isArray(lib) ? lib : [])].sort((a, b) => { + const aTime = Date.parse(a?.updatedAt || a?.savedAt || '') || 0; + const bTime = Date.parse(b?.updatedAt || b?.savedAt || '') || 0; + return bTime - aTime; + }); +} + +function sortCaseLibrary(lib) { + return [...(Array.isArray(lib) ? lib : [])].sort((a, b) => { + const aTime = Date.parse(a?.updatedAt || a?.generatedAt || a?.savedAt || '') || 0; + const bTime = Date.parse(b?.updatedAt || b?.generatedAt || b?.savedAt || '') || 0; + return bTime - aTime; + }); +} + +function buildWorkspaceChartId(cd) { + const birth = cd?.birth_info || {}; + if (!birth.date) return ''; + return [ + birth.date, + normalizeBirthIdPart(birth.time || '00:00'), + normalizeBirthIdPart(birth.lat), + normalizeBirthIdPart(birth.lon), + normalizeBirthIdPart(birth.tz), + ].join('_'); +} + +function buildLegacyWorkspaceChartId(cd) { + const birth = cd?.birth_info || {}; + if (!birth.date) return ''; + return [ + birth.date, + normalizeBirthIdPart(birth.lat), + normalizeBirthIdPart(birth.lon), + normalizeBirthIdPart(birth.tz), + ].join('_'); +} + +function normalizeBirthIdPart(value) { + return String(value ?? '').trim().replace(/\s+/g, ''); +} + +function findChartLibraryEntry(lib, cd) { + const id = buildWorkspaceChartId(cd); + const legacyId = buildLegacyWorkspaceChartId(cd); + return (Array.isArray(lib) ? lib : []).find(entry => entry?.id === id || entry?.id === legacyId) || null; +} + +function buildWorkspaceChartLabel(cd) { + const birth = cd?.birth_info || {}; + const asc = cd?.ascendant || {}; + const date = birth.date || '-'; + const time = birth.time || ''; + const ascLabel = asc.sign ? `${signName(asc.sign)} rising` : 'chart'; + return `${date} ${time} · ${ascLabel}`; +} + +function buildDefaultChartWorkspaceMeta(cd) { + const birth = cd?.birth_info || cd?.birth || {}; + const named = String(birth.name || birth.person_name || '').trim(); + return { + group: named ? '家庭' : '未分组', + relation: 'self', + tags: ['chart'], + }; +} + +function normalizeWorkspaceMeta(record, fallback = {}) { + const rawTags = Array.isArray(record?.tags) + ? record.tags + : String(record?.tags || '').split(',').map(tag => tag.trim()).filter(Boolean); + return { + group: record?.group || fallback.group || '未分组', + relation: record?.relation || fallback.relation || 'unknown', + tags: rawTags.length ? rawTags : (fallback.tags || []), + }; +} + +function getCaseDefaultMeta(kind) { + if (kind === 'pair') return { group: '伴侣', relation: 'partner', tags: ['synastry', 'relationship'] }; + if (kind === 'prashna') return { group: '研究', relation: 'research', tags: ['prashna'] }; + return { group: '未分组', relation: 'self', tags: ['chart'] }; +} + +function getCaseGroupLabel(record, kind) { + return normalizeWorkspaceMeta(record, getCaseDefaultMeta(kind)).group; +} + +function getCaseRelationValue(record, kind) { + return normalizeWorkspaceMeta(record, getCaseDefaultMeta(kind)).relation; +} + +function getCaseRelationLabel(record, kind) { + const value = getCaseRelationValue(record, kind); + return CASE_RELATION_PRESETS.find(([key]) => key === value)?.[1] || '未标注'; +} + +function renderCaseMetaLine(record, kind) { + const meta = normalizeWorkspaceMeta(record, getCaseDefaultMeta(kind)); + return ` +
+ ${escapeHtml(meta.group)} + ${escapeHtml(getCaseRelationLabel(meta, kind))} + ${meta.tags.slice(0, 3).map(tag => `${escapeHtml(tag)}`).join('')} +
+ `; +} + +function formatWorkspaceCaseTitle(record, kind) { + return `${record?.label || record?.id || '未命名'} · ${getCaseGroupLabel(record, kind)}`; +} + +function saveCurrentChartToLibrary() { + if (!chartData) return false; + const id = buildWorkspaceChartId(chartData); + if (!id) return false; + const now = new Date().toISOString(); + const lib = readChartLibrary(); + const existing = findChartLibraryEntry(lib, chartData); + if (existing) { + const meta = normalizeWorkspaceMeta(existing, buildDefaultChartWorkspaceMeta(chartData)); + existing.id = id; + existing.data = chartData; + existing.label = existing.label || buildWorkspaceChartLabel(chartData); + existing.group = meta.group; + existing.relation = meta.relation; + existing.tags = meta.tags; + existing.updatedAt = now; + } else { + const meta = buildDefaultChartWorkspaceMeta(chartData); + lib.push({ + id, + label: buildWorkspaceChartLabel(chartData), + data: chartData, + ...meta, + savedAt: now, + updatedAt: now, + }); + } + writeChartLibrary(sortChartLibrary(lib).slice(0, 24)); + updateSaveChartButton(); + renderSavedChartPanel(); + return true; +} + +function updateSaveChartButton() { + const btn = $('btn-save-chart'); + if (!btn) return; + if (!chartData) { + btn.disabled = true; + btn.textContent = '保存星盘'; + return; + } + const saved = readSavedChartStatus(chartData); + btn.disabled = false; + btn.textContent = saved.currentSaved ? '已保存' : '保存星盘'; + btn.classList.toggle('is-saved', Boolean(saved.currentSaved)); +} + +function setupSavedChartPanel() { + const saveBtn = $('btn-save-chart'); + if (saveBtn && !saveBtn.dataset.bound) { + saveBtn.dataset.bound = 'true'; + saveBtn.addEventListener('click', () => { + if (!saveCurrentChartToLibrary()) return; + saveBtn.textContent = '已保存'; + setTimeout(updateSaveChartButton, 1200); + if ($('provenance-panel')) renderAll(); + }); + } + renderSavedChartPanel(); +} + +function renderSavedChartPanel() { + const panel = $('saved-chart-panel'); + if (!panel) return; + const lib = sortChartLibrary(readChartLibrary()); + if (!lib.length) { + panel.innerHTML = ` +
+ 本地星盘库 + 先用示例盘、导入资料或手动输入生成星盘,保存后可一键打开。 +
+ `; + return; + } + panel.innerHTML = ` +
+ 本地星盘库 + ${escapeHtml(String(lib.length))} 个星盘 +
+
+ ${lib.slice(0, 5).map(entry => ` + + `).join('')} +
+ `; + panel.querySelectorAll('[data-open-saved-chart]').forEach(button => { + button.addEventListener('click', () => openSavedChartFromPanel(button.dataset.openSavedChart)); + }); +} + +function openSavedChartFromPanel(id) { + const entry = readChartLibrary().find(item => item?.id === id); + if (!entry?.data) return; + chartData = entry.data; + window.__jyotishBirth = normalizeSavedBirth(entry.data); + renderAll(); + showPage('chart'); + aiChatSetChartData(chartData); +} + +function normalizeSavedBirth(cd) { + const birth = cd?.birth_info || cd?.birth || {}; + const [year, month, day] = String(birth.date || '').split('-').map(Number); + const [hour, minute] = String(birth.time || '').split(':').map(Number); + return { + year: year || 2000, + month: month || 1, + day: day || 1, + hour: hour || 12, + minute: minute || 0, + lat: Number(birth.lat) || 0, + lon: Number(birth.lon) || 0, + tz: resolveTimezoneValue(birth.tz), + }; +} + +function renderProvenancePanel({ chartData, panchanga, provenance, validation, audit, allYogas, dashaData, extraDasas }) { + const container = $('provenance-panel'); + if (!container) return; + container._panchangaChartData = chartData; + const birth = chartData?.birth_info || {}; + const dashaCount = Math.max(Object.keys(extraDasas || {}).length, dashaData ? 1 : 0, chartData?.available_dashas?.length || 0); + const saved = provenance.savedChartStatus || {}; + const chartLibrary = readChartLibrary(); + const pairLibrary = sortCaseLibrary(readSynastryPairLibrary()); + const prashnaLibrary = sortCaseLibrary(readPrashnaCaseLibrary()); + const panchangaRows = buildPanchangaRows(panchanga); + const tithiLord = chartData?.tithi_lord_analysis || {}; + const auditRows = [ + ['核心能力', '65/65 registry 已产品化', '由 capability audit 与碎片审计持续守门'], + ['Yoga 数量', `${allYogas?.length || 0} 条`, '后续进入规则搜索/流派变体模式'], + ['Dasha 覆盖', `${dashaCount} 类/层级入口`, '后续强化事件时间线与候选窗口'], + ['校验状态', validation?.status || audit?.overall || '已生成', '导出 JSON 会携带 validation/audit 模块'], + ]; + const roadmap = [ + ['当前完成', 'PWA / Trust Center / 术语模式', '安装入口、本地数据边界、入门/专业/梵文术语偏好已进入产品面'], + ['下一步 P1', '桌面包装', 'PWA 作为当前交付;Pake 适合快速 URL 壳;Tauri 留给本地 API 服务 sidecar 与签名策略'], + ['下一步 P1', '星历抽象', '评估 SwissEph、VedAstro、Xalen 等底座替换边界,避免算法假切换'], + ['守门检查', 'Packaging preflight', '运行 scripts/desktop_packaging_preflight.py 检查 manifest、service worker、loopback API 与 Trust Center'], + ]; + + container.innerHTML = ` +
+
+
+ Calculation Provenance + ${escapeHtml(provenance.engine)} +
+
+ ${renderKV('出生时间', `${birth.date || '-'} ${birth.time || ''} ${birth.tz || ''}`)} + ${renderKV('坐标', provenance.coordinates)} + ${renderKV('Ayanamsa', provenance.ayanamsa)} + ${renderKV('星历', provenance.ephemeris)} + ${renderKV('星历底座', provenance.ephemerisBackend)} + ${renderKV('术语模式', provenance.terminologyMode)} + ${renderKV('宫位策略', provenance.houseSystem)} + ${renderKV('节点策略', provenance.nodeMode)} + ${renderKV('日出策略', provenance.sunrisePolicy)} + ${renderKV('地理策略', provenance.geocoderPolicy)} + ${renderKV('Yoga 口径', provenance.yogaVariant)} + ${renderKV('Jaimini 口径', provenance.jaiminiKarakaVariant)} + ${renderKV('KP 口径', provenance.kpSignificatorVariant)} + ${renderKV('AV 口径', provenance.ashtakavargaVariant)} + ${renderKV('Julian Day', provenance.julianDay || '-')} + ${renderKV('当前盘式', provenance.chartStyle)} +
+ ${renderCalculationSettingsPanel(provenance.calculationSettings)} +
+ +
+
+ Panchanga Preview + ${escapeHtml(panchanga?.tithi?.paksha || 'Birth-time')} +
+
+ ${panchangaRows.map(row => ` +
+ ${escapeHtml(row[0])} + ${escapeHtml(row[1])} + ${escapeHtml(row[2])} +
+ `).join('')} +
+ ${renderTithiLordInsight(tithiLord)} +
+ + + + + +
+
+ 条件组合 + +
+
+ ${PANCHANGA_CONDITIONS.filter(([value]) => value !== 'all').map(([value, label]) => ` + + `).join('')} +
+
+
+
+ + + + + +
+
使用 /api/panchanga_range 生成 Panchanga 日期范围、月历、活动筛选、Rahu Kala、Yamaganda、Gulika。
+
+ +
+
+ Workspace + ${escapeHtml(saved.count ?? 0)} saved +
+
+ ${escapeHtml(saved.label || '星盘库状态未知')} +

本地星盘库与 AI 面板共用同一份数据,可在主界面保存、打开、删除和导出案例。

+
+ ${renderChartWorkspaceList(chartLibrary)} + ${renderCaseWorkspaceSummary(pairLibrary, prashnaLibrary)} +
+ + + + + + + + + +
+
+
+ + ${renderTrustCenterPanel()} + +
+
+ Audit & Next Work + Product parity track +
+
+ + + ${auditRows.map(row => ``).join('')} + +
${escapeHtml(row[0])}${escapeHtml(row[1])}${escapeHtml(row[2])}
+
+
+ ${roadmap.map(item => ` +
+ ${escapeHtml(item[0])} + ${escapeHtml(item[1])} +

${escapeHtml(item[2])}

+
+ `).join('')} +
+
+ 产品差距矩阵路径 +
${escapeHtml(PRODUCT_GAP_DOC)}
+
+
+
+ `; + bindProvenanceActions(); +} + +function renderKV(label, value) { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(String(value || '-'))} +
+ `; +} + +function renderCalculationSettingsPanel(settings = readCalculationSettings()) { + const current = normalizeCalculationSettings(settings); + return ` +
+
+ Calculation Settings + 保存后下一次排盘生效,并随导出报告记录。 +
+
+ ${renderCalculationSelect('ayanamsa', 'Ayanamsa', current.ayanamsa)} + ${renderCalculationSelect('nodeMode', 'Node', current.nodeMode)} + ${renderCalculationSelect('houseSystem', 'House', current.houseSystem)} + ${renderCalculationSelect('sunrisePolicy', 'Sunrise', current.sunrisePolicy)} + ${renderCalculationSelect('geocoderPolicy', 'Geocoder', current.geocoderPolicy)} + ${renderCalculationSelect('ephemerisBackend', 'Ephemeris', current.ephemerisBackend)} + ${renderCalculationSelect('terminologyMode', 'Terminology', current.terminologyMode)} +
+ ${renderTerminologyModePreview(current.terminologyMode)} +
+
+ Rule Variants + 当前先记录解释口径,后续逐项接入实时切换。 +
+
+ ${renderCalculationSelect('yogaVariant', 'Yoga', current.yogaVariant)} + ${renderCalculationSelect('jaiminiKarakaVariant', 'Jaimini', current.jaiminiKarakaVariant)} + ${renderCalculationSelect('kpSignificatorVariant', 'KP', current.kpSignificatorVariant)} + ${renderCalculationSelect('ashtakavargaVariant', 'Ashtakavarga', current.ashtakavargaVariant)} + ${renderCalculationSelect('shadbalaVariant', 'Shadbala', current.shadbalaVariant)} + ${renderCalculationSelect('dashaReference', 'Dasha', current.dashaReference)} +
+
+
+ Lahiri / Mean Node / Whole Sign / Swiss Ephemeris 与当前 Rule Variants 是主路径;xalen-ephemeris 先作为 Apache-2.0 可行性记录,不改变当前核心黄经。 +
+ +
+ `; +} + +function renderTerminologyModePreview(mode) { + const copy = { + beginner: [ + '初学模式', + 'tooltip 会优先解释成日常生活语言,例如“第10宫=事业角色与公开责任”。', + ], + professional: [ + '专业模式', + 'tooltip 会保留 Sanskrit / English / 技法名,适合复盘 DK、UL、SAV、Shadbala 等证据。', + ], + balanced: [ + '平衡模式', + 'tooltip 同时显示中文、Sanskrit、English 和短解释,是默认阅读模式。', + ], + }; + const [title, note] = copy[mode] || copy.balanced; + return ` +
+ ${escapeHtml(title)} + ${escapeHtml(note)} +
+ `; +} + +function renderCalculationSelect(key, label, value) { + const options = CALCULATION_SETTING_OPTIONS[key] || []; + return ` + + `; +} + +function initPWAInstallability() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js') + .then(registration => { + window.__jyotishPWAStatus = { + serviceWorker: 'registered', + scope: registration.scope, + controlled: Boolean(navigator.serviceWorker.controller), + }; + updateTrustCenterPWAStatus(); + }) + .catch(error => { + window.__jyotishPWAStatus = { + serviceWorker: 'failed', + error: error?.message || 'registration failed', + }; + updateTrustCenterPWAStatus(); + }); + } else { + window.__jyotishPWAStatus = { serviceWorker: 'unsupported' }; + } + + window.addEventListener('beforeinstallprompt', event => { + event.preventDefault(); + window.__jyotishDeferredInstallPrompt = event; + window.__jyotishPWAStatus = { + ...(window.__jyotishPWAStatus || {}), + installPrompt: 'ready', + }; + updateTrustCenterPWAStatus(); + }); + window.addEventListener('appinstalled', () => { + window.__jyotishDeferredInstallPrompt = null; + window.__jyotishPWAStatus = { + ...(window.__jyotishPWAStatus || {}), + installed: true, + installPrompt: 'installed', + }; + updateTrustCenterPWAStatus(); + }); +} + +function renderTrustCenterPanel() { + const stats = getTrustCenterStats(); + const pwa = getPWAStatus(); + const terminology = getTerminologyModeOption(); + const runtime = getRuntimeHealthStatus(); + const statusMessage = getTrustCenterStatusMessage(pwa, runtime); + return ` +
+
+ Trust Center + Local-first +
+
+ ${renderTrustStatus('PWA', pwa.label, pwa.note)} + ${renderTrustStatus('本地星盘', `${stats.charts} charts`, '存储在浏览器 localStorage')} + ${renderTrustStatus('配对/问事', `${stats.pairs} pairs · ${stats.prashnas} prashna`, '可单独或整库导出')} + ${renderTrustStatus('术语', terminology.shortLabel, terminology.note)} + ${renderTrustStatus('API', '127.0.0.1:5200', '本地 API 服务;不需要外部账号')} + ${renderTrustStatus('运行体检', runtime.label, runtime.note)} +
+ ${renderRuntimeHealthPanel(runtime)} + ${renderTerminologyModePanel()} +
+

出生资料、保存星盘、配对记录、问事记录和计算设置默认保存在本机浏览器。导出报告会写入你主动下载的文件;PDF 后端工件仅由本地 API 生成。

+

术语模式只改变解释层和导出记录,不改变底层排盘;AI 聊天、订阅登录或外部接口只在你配置并主动使用对应功能时发生。

+
+
+ + + + +
+
${escapeHtml(statusMessage)}
+
+ `; +} + +function getTrustCenterStatusMessage(pwa, runtime = getRuntimeHealthStatus()) { + const health = window.__jyotishRuntimeHealth || {}; + if (health.status === 'checking') return '正在运行健康检查...'; + if (health.status === 'ok') return '健康检查通过:本地 API 服务、能力目录和 PWA 安装壳状态已记录。'; + if (health.status === 'warn') return `健康检查未通过:${health.error || runtime.note || '本地 API 或能力目录未通过健康检查。'}`; + return pwa.note; +} + +function renderTrustStatus(label, value, note) { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} + ${escapeHtml(note)} +
+ `; +} + +function getRuntimeHealthStatus() { + const health = window.__jyotishRuntimeHealth; + if (!health) { + return { + label: '未运行', + note: '点击运行健康检查,确认本地 API 服务、能力目录和 PWA 安装壳状态。', + tone: 'pending', + details: [], + }; + } + if (health.status === 'checking') { + return { + label: '检查中', + note: '正在探测 /api/health、/api/capability_audit 与 PWA 状态。', + tone: 'pending', + details: health.details || [], + }; + } + if (health.status === 'ok') { + return { + label: '可用', + note: `${health.apiBase || '本地 API'} · v${health.version || '-'} · ${health.latencyMs ?? '-'}ms`, + tone: 'ok', + details: health.details || [], + }; + } + return { + label: '需处理', + note: health.error || '本地 API 或能力目录未通过健康检查。', + tone: 'warn', + details: health.details || [], + }; +} + +function renderRuntimeHealthPanel(runtime = getRuntimeHealthStatus()) { + const details = runtime.details?.length ? runtime.details : [ + ['PWA 安装壳', getPWAStatus().label, getPWAStatus().note], + ['本地 API 服务', '待检查', '默认探测 http://127.0.0.1:5200/api/health'], + ['Desktop path', 'PWA now', 'Pake shell 短期可用;Tauri sidecar 留给一键启动本地 API 服务。'], + ]; + return ` +
+
+ Runtime Health + ${escapeHtml(runtime.note)} +
+
+ ${details.map(([label, value, note]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(String(value || '-'))} + ${escapeHtml(note || '')} +
+ `).join('')} +
+

Packaging preflight: python3 scripts/desktop_packaging_preflight.py。Pake 适合快速 URL 壳;Tauri shell with sidecar 适合后续一键启动本地 API 服务。

+
+ `; +} + +function getPWAStatus() { + const status = window.__jyotishPWAStatus || {}; + if (status.installed || status.installPrompt === 'installed') { + return { label: '已安装', note: '浏览器已把 Jyotish 加入应用环境。' }; + } + if (status.installPrompt === 'ready') { + return { label: '可安装', note: '可以通过按钮安装为桌面/移动应用。' }; + } + if (status.serviceWorker === 'registered') { + return { label: '离线壳已注册', note: '静态页面可缓存;高级计算仍需本地 API 服务在线。' }; + } + if (status.serviceWorker === 'failed') { + return { label: '注册失败', note: status.error || 'Service worker 注册失败。' }; + } + if (status.serviceWorker === 'unsupported') { + return { label: '不支持', note: '当前浏览器不支持 service worker。' }; + } + return { label: '检测中', note: '正在检查 manifest 与 service worker。' }; +} + +function updateTrustCenterPWAStatus() { + const host = $('trust-center-status'); + if (!host) return; + host.textContent = getPWAStatus().note; +} + +function getTrustCenterStats() { + return { + charts: readChartLibrary().length, + pairs: readSynastryPairLibrary().length, + prashnas: readPrashnaCaseLibrary().length, + keys: TRUST_CENTER_STORAGE_KEYS.filter(key => localStorage.getItem(key) !== null).length, + }; +} + +function exportTrustCenterLocalData() { + const payload = { + exportedAt: new Date().toISOString(), + terminologyMode: readTerminologyMode(), + storage: Object.fromEntries(TRUST_CENTER_STORAGE_KEYS.map(key => [key, readTrustStorageValue(key)])), + }; + downloadText(JSON.stringify(payload, null, 2), `jyotish-local-data-${toDateISO(new Date())}.json`, 'application/json;charset=utf-8'); + const status = $('trust-center-status'); + if (status) status.textContent = '已导出本地资料 JSON。'; +} + +function readTrustStorageValue(key) { + const raw = localStorage.getItem(key); + if (raw == null) return null; + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +function clearTrustCenterLocalData() { + if (!window.confirm('清空本地星盘、配对、问事、计算设置和相关本地状态?此操作不可撤销。')) return; + TRUST_CENTER_STORAGE_KEYS.forEach(key => localStorage.removeItem(key)); + _caseWorkspaceSelection.clear(); + _caseWorkspacePreview = null; + setGlossaryTerminologyMode(DEFAULT_CALCULATION_SETTINGS.terminologyMode); + if (chartData) chartData._terminology_mode = DEFAULT_CALCULATION_SETTINGS.terminologyMode; + const status = $('trust-center-status'); + if (status) status.textContent = '本地资料已清空。'; + renderAll(); + setupSavedChartPanel(); +} + +async function runTrustCenterHealthCheck() { + const status = $('trust-center-status'); + const checkedAt = new Date().toISOString(); + window.__jyotishRuntimeHealth = { + status: 'checking', + checkedAt, + details: [ + ['PWA 安装壳', getPWAStatus().label, getPWAStatus().note], + ['本地 API 服务', '检查中', '正在请求 /api/health'], + ['Technique catalog', '等待中', 'API 在线后检查能力目录'], + ], + }; + if (status) status.textContent = '正在运行健康检查...'; + renderAll(); + try { + const api = await window.JyotishAPI.getAPIHealth(); + const audit = await window.JyotishAPI.getCapabilityAudit(); + const surfaces = audit?.surfaces || {}; + const registry = audit?.registry || {}; + window.__jyotishRuntimeHealth = { + status: 'ok', + checkedAt, + apiBase: api.base, + version: api.version, + latencyMs: api.latencyMs, + details: [ + ['PWA 安装壳', getPWAStatus().label, getPWAStatus().note], + ['本地 API 服务', api.base || 'online', `health ok · v${api.version || '-'} · ${api.latencyMs}ms`], + ['Technique catalog', `${registry.technique_count || 0} techniques`, `${surfaces.api_endpoint_count || 0} API endpoints 可被前端发现`], + ['Desktop path', 'PWA now', 'Pake shell 可快速包 URL;Tauri sidecar 等 API 生命周期和签名策略确定后再落地。'], + ], + }; + if (status) status.textContent = '健康检查通过:本地 API 服务、能力目录和 PWA 安装壳状态已记录。'; + } catch (error) { + window.__jyotishRuntimeHealth = { + status: 'warn', + checkedAt, + error: error?.message || '运行健康检查失败', + details: [ + ['PWA 安装壳', getPWAStatus().label, getPWAStatus().note], + ['本地 API 服务', '未连接', error?.message || '请确认本地 API 服务正在 127.0.0.1:5200 运行'], + ['启动路径', '普通用户启动路径', '按 README 先启动网页服务,再启动本地 API 服务。'], + ['Packaging preflight', '待检查', '运行 python3 scripts/desktop_packaging_preflight.py 获取桌面包装前置结果。'], + ], + }; + if (status) status.textContent = `健康检查未通过:${window.__jyotishRuntimeHealth.error}`; + } + renderAll(); +} + +async function promptPWAInstall() { + const status = $('trust-center-status'); + const prompt = window.__jyotishDeferredInstallPrompt; + if (!prompt) { + if (status) status.textContent = getPWAStatus().note || '当前浏览器尚未提供安装提示,可使用浏览器菜单添加到主屏幕。'; + return; + } + prompt.prompt(); + const result = await prompt.userChoice.catch(() => null); + window.__jyotishDeferredInstallPrompt = null; + if (status) status.textContent = result?.outcome === 'accepted' ? '已接受安装。' : '已取消安装。'; +} + +function saveCalculationSettingsFromPanel(panel) { + const settings = {}; + panel.querySelectorAll('[data-setting-key]').forEach(input => { + settings[input.dataset.settingKey] = input.value; + }); + const normalized = writeCalculationSettings(settings); + if (chartData) attachCalculationSettings(chartData, normalized); + renderAll(); +} + +function readTerminologyMode(value = readCalculationSettings().terminologyMode) { + return Object.prototype.hasOwnProperty.call(TERMINOLOGY_MODE_OPTIONS, value) ? value : DEFAULT_CALCULATION_SETTINGS.terminologyMode; +} + +function writeTerminologyMode(mode) { + const normalized = readTerminologyMode(mode); + const settings = { ...readCalculationSettings(), terminologyMode: normalized }; + writeCalculationSettings(settings); + localStorage.setItem(TERMINOLOGY_MODE_KEY, normalized); + setGlossaryTerminologyMode(normalized); + if (chartData) chartData._terminology_mode = normalized; + return normalized; +} + +function getTerminologyModeOption(mode = readTerminologyMode()) { + const normalized = readTerminologyMode(mode); + return TERMINOLOGY_MODE_OPTIONS[normalized] || TERMINOLOGY_MODE_OPTIONS.beginner; +} + +function renderTerminologyModePanel() { + const current = readTerminologyMode(); + return ` +
+
+ Terminology Mode + ${escapeHtml(getTerminologyModeOption(current).note)} +
+
+ ${Object.entries(TERMINOLOGY_MODE_OPTIONS).map(([value, option]) => ` + + `).join('')} +
+ +
+ `; +} + +function saveTerminologyModeFromPanel(panel) { + const selected = panel.querySelector('input[name="terminology-mode"]:checked')?.value; + writeTerminologyMode(selected); + renderAll(); +} + +function buildPanchangaRows(panchanga) { + if (!panchanga) return [['状态', '不可用', '当前星盘缺少太阳/月亮经度']]; + return [ + ['Vara', panchanga.vara || '-', '星期/日主'], + ['Tithi', `${panchanga.tithi?.number || '-'} · ${panchanga.tithi?.name || '-'}`, panchanga.tithi?.paksha || ''], + ['Karana', panchanga.karana?.name || panchanga.karana || '-', '半 Tithi'], + ['Yoga', `${panchanga.yoga?.number || '-'} · ${panchanga.yoga?.name || '-'}`, '日月合经'], + ]; +} + +function renderTithiLordInsight(analysis) { + if (!analysis || !analysis.tithi_lord) return ''; + const doshaCount = Array.isArray(analysis.doshas) ? analysis.doshas.length : 0; + return ` +
+
+ Tithi Lord + ${escapeHtml(analysis.tithi_lord)} · ${escapeHtml(String(Math.round((analysis.tithi_score || 0) * 100)))}% +
+

${escapeHtml(analysis.relationship_style || analysis.emotional_pattern || '已接入 Tithi 主星分析。')}

+
+ ${escapeHtml(analysis.tithi_type_cn || analysis.tithi_type || 'Tithi')} + ${escapeHtml(analysis.lord_sign || '-')} H${escapeHtml(String(analysis.lord_house || '-'))} + ${doshaCount ? `${doshaCount} dosha` : 'no dosha'} +
+
+ `; +} + +function renderChartWorkspaceList(lib) { + if (!Array.isArray(lib) || !lib.length) { + return '
暂无保存星盘。保存当前盘后可从这里直接打开。
'; + } + return ` +
+ +
+ ${lib.slice(0, 4).map(entry => ` +
+ ${escapeHtml(entry.label || entry.id || '未命名星盘')} + ${renderCaseMetaLine(entry, 'chart')} + ${escapeHtml(formatSavedAt(entry.savedAt || entry.updatedAt))} +
+ `).join('')} +
+
+ `; +} + +function renderCaseWorkspaceSummary(pairs, prashnas) { + const filter = readCaseWorkspaceFilter(); + const chartLibrary = sortChartLibrary(readChartLibrary()); + const filteredCharts = filterCaseRecords(chartLibrary, filter, 'chart'); + const filteredPairs = filterCaseRecords(pairs || [], filter, 'pair'); + const filteredPrashnas = filterCaseRecords(prashnas || [], filter, 'prashna'); + pruneCaseWorkspaceSelection(chartLibrary, pairs || [], prashnas || []); + return ` +
+
+ ${renderCaseWorkspaceCounts(chartLibrary, pairs || [], prashnas || [], filteredCharts, filteredPairs, filteredPrashnas)} +
+
+ + + + +
+
+ + + + +
+
+ ${renderCaseWorkspaceRows(filteredCharts, filteredPairs, filteredPrashnas, filter)} +
+
+ ${renderWorkspaceCasePreview()} +
+
+ `; +} + +function renderCaseWorkspaceCounts(charts, pairs, prashnas, filteredCharts, filteredPairs, filteredPrashnas) { + return ` + 星盘 ${escapeHtml(String((charts || []).length))} + 配对 ${escapeHtml(String((pairs || []).length))} + 问事 ${escapeHtml(String((prashnas || []).length))} + 显示 ${escapeHtml(String((filteredCharts || []).length + (filteredPairs || []).length + (filteredPrashnas || []).length))} + 已选 ${escapeHtml(String(_caseWorkspaceSelection.size))} + `; +} + +function renderCaseWorkspaceRows(filteredCharts, filteredPairs, filteredPrashnas, filter) { + const chartRows = filteredCharts.slice(0, 4).map(record => ` +
+ ${renderCaseSelectControl('chart', record.id || '', '星盘')} +
+ ${escapeHtml(record.label || record.id || '未命名星盘')} + ${renderCaseMetaLine(record, 'chart')} + ${escapeHtml(formatSavedAt(record.updatedAt || record.savedAt))} +
+
+ + + +
+
+ `).join(''); + const pairRows = filteredPairs.slice(0, 4).map(record => ` +
+ ${renderCaseSelectControl('pair', record.id || '', '配对')} +
+ ${escapeHtml(record.label || record.id || '未命名配对')} + ${renderCaseMetaLine(record, 'pair')} + ${escapeHtml(record.score?.total ?? 0)} / ${escapeHtml(record.score?.max ?? 36)} · ${escapeHtml(record.verdict || '-')} +
+
+ + + + +
+
+ `).join(''); + const prashnaRows = filteredPrashnas.slice(0, 4).map(record => ` +
+ ${renderCaseSelectControl('prashna', record.id || '', '问事')} +
+ ${escapeHtml(record.label || record.id || '未命名问事')} + ${renderCaseMetaLine(record, 'prashna')} + ${escapeHtml(record.conclusion || '-')} · ${escapeHtml(formatSavedAt(record.updatedAt || record.generatedAt))} +
+
+ + + + +
+
+ `).join(''); + return ` + ${filter.type !== 'pair' && filter.type !== 'prashna' ? chartRows : ''} + ${filter.type !== 'prashna' ? pairRows : ''} + ${filter.type !== 'pair' ? prashnaRows : ''} + ${!chartRows && !pairRows && !prashnaRows ? '
暂无匹配案例。
' : ''} + `; +} + +function refreshCaseWorkspaceList() { + const countContainer = $('case-workspace-counts'); + const listContainer = $('workspace-case-list'); + if (!countContainer || !listContainer) return; + const charts = sortChartLibrary(readChartLibrary()); + const pairs = sortCaseLibrary(readSynastryPairLibrary()); + const prashnas = sortCaseLibrary(readPrashnaCaseLibrary()); + pruneCaseWorkspaceSelection(charts, pairs, prashnas); + const filter = readCaseWorkspaceFilter(); + const filteredCharts = filterCaseRecords(charts, filter, 'chart'); + const filteredPairs = filterCaseRecords(pairs, filter, 'pair'); + const filteredPrashnas = filterCaseRecords(prashnas, filter, 'prashna'); + countContainer.innerHTML = renderCaseWorkspaceCounts(charts, pairs, prashnas, filteredCharts, filteredPairs, filteredPrashnas); + listContainer.innerHTML = renderCaseWorkspaceRows(filteredCharts, filteredPairs, filteredPrashnas, filter); + renderWorkspaceCasePreviewPanel(); +} + +function readCaseWorkspaceFilter() { + return { + query: $('workspace-case-search')?.value?.trim() || '', + type: $('workspace-case-type')?.value || 'all', + group: $('workspace-case-group')?.value || '全部分组', + relation: $('workspace-case-relation')?.value || 'all', + }; +} + +function filterCaseRecords(records, filter, kind) { + if (filter.type !== 'all' && filter.type !== kind) return []; + let filtered = Array.isArray(records) ? records : []; + if (filter.group && filter.group !== '全部分组') { + filtered = filtered.filter(record => getCaseGroupLabel(record, kind) === filter.group); + } + if (filter.relation && filter.relation !== 'all') { + filtered = filtered.filter(record => getCaseRelationValue(record, kind) === filter.relation); + } + const query = normalizeCaseSearchText(filter.query); + if (!query) return filtered; + return filtered.filter(record => normalizeCaseSearchText([ + record?.label, + record?.group, + getCaseRelationLabel(record, kind), + ...(Array.isArray(record?.tags) ? record.tags : []), + record?.question_text, + record?.question_type, + record?.conclusion, + record?.verdict, + record?.self?.ascendant, + record?.partner?.ascendant, + record?.chartLabel, + record?.id, + ].filter(Boolean).join(' ')).includes(query)); +} + +function normalizeCaseSearchText(value) { + return String(value || '').trim().toLowerCase(); +} + +function buildCaseSelectionKey(kind, id) { + return `${kind}:${id}`; +} + +function renderCaseSelectControl(kind, id, label) { + const key = buildCaseSelectionKey(kind, id || ''); + return ` + + `; +} + +function pruneCaseWorkspaceSelection(charts, pairs, prashnas) { + const valid = new Set([ + ...(charts || []).filter(item => item?.id).map(item => buildCaseSelectionKey('chart', item.id)), + ...(pairs || []).filter(item => item?.id).map(item => buildCaseSelectionKey('pair', item.id)), + ...(prashnas || []).filter(item => item?.id).map(item => buildCaseSelectionKey('prashna', item.id)), + ]); + _caseWorkspaceSelection = new Set([..._caseWorkspaceSelection].filter(key => valid.has(key))); +} + +function getVisibleCaseSelectionKeys() { + return [...document.querySelectorAll('#workspace-case-list [data-case-key]')] + .map(input => input.dataset.caseKey) + .filter(Boolean); +} + +function resolveSelectedCaseRecords() { + const charts = readChartLibrary(); + const pairs = readSynastryPairLibrary(); + const prashnas = readPrashnaCaseLibrary(); + const selected = [..._caseWorkspaceSelection]; + return { + chartRecords: selected + .filter(key => key.startsWith('chart:')) + .map(key => charts.find(record => record?.id === key.slice(6))) + .filter(Boolean), + pairRecords: selected + .filter(key => key.startsWith('pair:')) + .map(key => pairs.find(record => record?.id === key.slice(5))) + .filter(Boolean), + prashnaRecords: selected + .filter(key => key.startsWith('prashna:')) + .map(key => prashnas.find(record => record?.id === key.slice(8))) + .filter(Boolean), + }; +} + +function previewWorkspaceCase(kind, id) { + const record = findWorkspaceCaseRecord(kind, id); + if (!record) return; + _caseWorkspacePreview = { kind, id }; + renderWorkspaceCasePreviewPanel(); +} + +function findWorkspaceCaseRecord(kind, id) { + if (!id) return null; + if (kind === 'pair') return readSynastryPairLibrary().find(record => record?.id === id) || null; + if (kind === 'prashna') return readPrashnaCaseLibrary().find(record => record?.id === id) || null; + return readChartLibrary().find(record => record?.id === id) || null; +} + +function renderWorkspaceCasePreviewPanel() { + const panel = $('workspace-case-preview'); + if (!panel) return; + panel.innerHTML = renderWorkspaceCasePreview(); +} + +function renderWorkspaceCasePreview() { + if (!_caseWorkspacePreview) { + return '
选择一条案例后,可在这里快速查看摘要。
'; + } + const { kind, id } = _caseWorkspacePreview; + const record = findWorkspaceCaseRecord(kind, id); + if (!record) { + _caseWorkspacePreview = null; + return '
所选案例已不存在。
'; + } + const meta = normalizeWorkspaceMeta(record, getCaseDefaultMeta(kind)); + const title = record.label || record.question_text || record.id || '未命名案例'; + return ` +
+
+ ${escapeHtml(getCaseKindLabel(kind))} + ${escapeHtml(title)} +
+ +
+ ${renderCaseMetaLine(record, kind)} +
+ ${renderWorkspaceCasePreviewCards(record, kind)} +
+ + `; +} + +function renderWorkspaceCasePreviewCards(record, kind) { + if (kind === 'pair') return renderSynastryCasePreviewCards(record); + if (kind === 'prashna') return renderPrashnaCasePreviewCards(record); + return renderChartCasePreviewCards(record); +} + +function renderChartCasePreviewCards(record) { + const data = record.data || {}; + const birth = data.birth_info || {}; + const asc = data.ascendant || {}; + const moon = data.planets?.Moon || {}; + return [ + ['出生', `${birth.date || '-'} ${birth.time || ''}`.trim() || '-'], + ['上升', asc.sign ? signName(asc.sign) : '-'], + ['月亮', moon.sign ? `${signName(moon.sign)} ${moon.nakshatra || ''}`.trim() : '-'], + ['坐标', birth.lat != null && birth.lon != null ? `${safeNumber(birth.lat).toFixed(3)}, ${safeNumber(birth.lon).toFixed(3)}` : '-'], + ].map(renderCasePreviewCard).join(''); +} + +function renderSynastryCasePreviewCards(record) { + const score = record.score || {}; + const deep = record.deep || {}; + return [ + ['合盘分', `${score.total ?? 0} / ${score.max ?? 36}${score.percentage != null ? ` · ${score.percentage}%` : ''}`], + ['判断', record.verdict || '-'], + ['对方', record.partner?.moon || record.partner?.ascendant || '-'], + ['节奏', deep.dasha?.note || deep.dasha?.label || '-'], + ].map(renderCasePreviewCard).join(''); +} + +function renderPrashnaCasePreviewCards(record) { + return [ + ['问题', record.question_text || record.question_type || '-'], + ['结论', record.conclusion || '-'], + ['置信度', record.confidence || '-'], + ['下一步', record.next_action || '-'], + ].map(renderCasePreviewCard).join(''); +} + +function renderCasePreviewCard([label, value]) { + return `
${escapeHtml(label)}${escapeHtml(value || '-')}
`; +} + +function getCaseKindLabel(kind) { + if (kind === 'pair') return '配对'; + if (kind === 'prashna') return '问事'; + return '星盘'; +} + +function editWorkspaceCaseMetadata(kind, id) { + if (!id) return; + const store = getWorkspaceCaseStore(kind); + const records = store.read(); + const record = records.find(item => item?.id === id); + if (!record) return; + const fallback = getCaseDefaultMeta(kind); + const currentMeta = normalizeWorkspaceMeta(record, fallback); + const label = window.prompt('案例标题', record.label || record.id || ''); + if (label === null) return; + const group = window.prompt(`分组(${CASE_GROUP_PRESETS.slice(1).join(' / ')})`, currentMeta.group || fallback.group); + if (group === null) return; + const relation = window.prompt(`关系类型(${CASE_RELATION_PRESETS.filter(([key]) => key !== 'all').map(([key]) => key).join(' / ')})`, currentMeta.relation || fallback.relation); + if (relation === null) return; + const tags = window.prompt('标签(逗号分隔)', currentMeta.tags.join(', ')); + if (tags === null) return; + const updated = applyWorkspaceCaseMetadata(record, { label, group, relation, tags }, fallback); + store.write(records.map(item => item?.id === id ? updated : item)); + renderSynastryPairWorkspace(); + renderPrashnaCaseWorkspace(); + refreshCaseWorkspaceList(); +} + +function getWorkspaceCaseStore(kind) { + if (kind === 'pair') { + return { read: readSynastryPairLibrary, write: value => writeSynastryPairLibrary(sortCaseLibrary(value).slice(0, 80)) }; + } + if (kind === 'prashna') { + return { read: readPrashnaCaseLibrary, write: value => writePrashnaCaseLibrary(sortCaseLibrary(value).slice(0, 80)) }; + } + return { read: readChartLibrary, write: value => writeChartLibrary(sortChartLibrary(value).slice(0, 24)) }; +} + +function applyWorkspaceCaseMetadata(record, input, fallback = {}) { + const tags = String(input.tags || '') + .split(',') + .map(tag => tag.trim()) + .filter(Boolean); + const relationKeys = new Set(CASE_RELATION_PRESETS.map(([key]) => key)); + const relation = relationKeys.has(input.relation) && input.relation !== 'all' + ? input.relation + : (fallback.relation || 'unknown'); + return { + ...record, + label: String(input.label || record.label || record.id || '').trim() || record.label || record.id, + group: String(input.group || '').trim() || fallback.group || '未分组', + relation, + tags: tags.length ? tags : (fallback.tags || []), + updatedAt: new Date().toISOString(), + }; +} + +function formatSavedAt(value) { + if (!value) return '未记录保存时间'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +} + +function bindProvenanceActions() { + const panel = $('provenance-panel'); + if (!panel || panel.dataset.bound === 'true') return; + panel.dataset.bound = 'true'; + panel.addEventListener('click', event => { + const btn = event.target.closest('.provenance-action, .mini-action'); + if (!btn) return; + if (btn.dataset.action === 'export-json') { + const item = document.querySelector('.export-item[data-format="json"]'); + if (item) item.click(); + } + if (btn.dataset.action === 'export-html') { + const item = document.querySelector('.export-item[data-format="html"]'); + if (item) item.click(); + } + if (btn.dataset.action === 'save-calculation-settings') { + saveCalculationSettingsFromPanel(panel); + } + if (btn.dataset.action === 'save-terminology-mode') { + saveTerminologyModeFromPanel(panel); + } + if (btn.dataset.action === 'panchanga-week') { + setPanchangaRangePreset('week'); + renderPanchangaRange(panel); + } + if (btn.dataset.action === 'panchanga-month') { + setPanchangaRangePreset('month'); + renderPanchangaRange(panel); + } + if (btn.dataset.action === 'panchanga-range') { + renderPanchangaRange(panel); + } + if (btn.dataset.action === 'panchanga-csv') { + exportPanchangaRangeCSV(panel); + } + if (btn.dataset.action === 'panchanga-ics') { + exportPanchangaRangeICS(panel); + } + if (btn.dataset.action === 'workspace-save-current') { + saveCurrentChartToWorkspace(panel); + } + if (btn.dataset.action === 'workspace-open-selected') { + openSelectedWorkspaceChart(panel); + } + if (btn.dataset.action === 'workspace-delete-selected') { + deleteSelectedWorkspaceChart(); + } + if (btn.dataset.action === 'trust-run-health') { + runTrustCenterHealthCheck(); + } + if (btn.dataset.action === 'pwa-install') { + promptPWAInstall(); + } + if (btn.dataset.action === 'trust-export-local') { + exportTrustCenterLocalData(); + } + if (btn.dataset.action === 'trust-clear-local') { + clearTrustCenterLocalData(); + } + if (btn.dataset.action === 'workspace-export-selected') { + exportSelectedWorkspaceChart(); + } + if (btn.dataset.action === 'workspace-export-cases') { + exportWorkspaceCaseLibrary(); + } + if (btn.dataset.action === 'workspace-select-visible') { + selectVisibleWorkspaceCases(); + } + if (btn.dataset.action === 'workspace-clear-selection') { + _caseWorkspaceSelection.clear(); + refreshCaseWorkspaceList(); + } + if (btn.dataset.action === 'workspace-export-selected-cases') { + exportSelectedWorkspaceCases(); + } + if (btn.dataset.action === 'workspace-delete-selected-cases') { + deleteSelectedWorkspaceCases(); + } + if (btn.dataset.action === 'workspace-edit-case') { + editWorkspaceCaseMetadata(btn.dataset.caseKind || 'chart', btn.dataset.caseId || ''); + } + if (btn.dataset.action === 'workspace-preview-case') { + previewWorkspaceCase(btn.dataset.caseKind || 'chart', btn.dataset.caseId || ''); + } + if (btn.dataset.action === 'workspace-clear-preview') { + _caseWorkspacePreview = null; + renderWorkspaceCasePreviewPanel(); + } + if (btn.dataset.action === 'workspace-open-pair') { + openSavedSynastryPair(btn.dataset.caseId || ''); + } + if (btn.dataset.action === 'workspace-open-chart') { + openSavedChartFromPanel(btn.dataset.caseId || ''); + } + if (btn.dataset.action === 'workspace-delete-pair') { + deleteSavedSynastryPair(btn.dataset.caseId || ''); + } + if (btn.dataset.action === 'workspace-open-prashna') { + openSavedPrashnaCase(btn.dataset.caseId || ''); + } + if (btn.dataset.action === 'workspace-delete-prashna') { + deleteSavedPrashnaCase(btn.dataset.caseId || ''); + } + if (btn.dataset.action === 'open-ai-library') { + const fab = document.querySelector('.ai-fab'); + if (fab) fab.click(); + } + }); + panel.addEventListener('change', event => { + const input = event.target.closest('#workspace-case-import-file'); + if (input) { + importWorkspaceCaseLibrary(input.files?.[0]); + input.value = ''; + return; + } + const caseToggle = event.target.closest('[data-action="workspace-toggle-case"]'); + if (caseToggle) { + if (caseToggle.checked) { + _caseWorkspaceSelection.add(caseToggle.dataset.caseKey || ''); + } else { + _caseWorkspaceSelection.delete(caseToggle.dataset.caseKey || ''); + } + _caseWorkspaceSelection.delete(''); + refreshCaseWorkspaceList(); + return; + } + if (event.target.matches('input[name="terminology-mode"]')) { + panel.querySelectorAll('.terminology-mode-option').forEach(option => option.classList.remove('active')); + event.target.closest('.terminology-mode-option')?.classList.add('active'); + setGlossaryTerminologyMode(readTerminologyMode(event.target.value)); + return; + } + if (event.target.closest('#workspace-case-type')) { + refreshCaseWorkspaceList(); + } + if (event.target.closest('#workspace-case-group')) { + refreshCaseWorkspaceList(); + } + if (event.target.closest('#workspace-case-relation')) { + refreshCaseWorkspaceList(); + } + }); + panel.addEventListener('input', event => { + if (event.target.closest('#workspace-case-search')) { + refreshCaseWorkspaceList(); + } + }); +} + +function getSelectedWorkspaceEntry() { + const select = $('workspace-chart-select'); + const id = select?.value; + if (!id) return null; + return sortChartLibrary(readChartLibrary()).find(entry => entry?.id === id) || null; +} + +function saveCurrentChartToWorkspace(panel) { + const source = panel?._panchangaChartData || chartData; + if (!source) return; + const id = buildWorkspaceChartId(source); + if (!id) return; + const lib = readChartLibrary(); + const existing = findChartLibraryEntry(lib, source); + if (existing) { + const meta = normalizeWorkspaceMeta(existing, buildDefaultChartWorkspaceMeta(source)); + existing.id = id; + existing.data = source; + existing.label = existing.label || buildWorkspaceChartLabel(source); + existing.group = meta.group; + existing.relation = meta.relation; + existing.tags = meta.tags; + existing.updatedAt = new Date().toISOString(); + } else { + const meta = buildDefaultChartWorkspaceMeta(source); + lib.push({ + id, + label: buildWorkspaceChartLabel(source), + data: source, + ...meta, + savedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + writeChartLibrary(sortChartLibrary(lib).slice(0, 24)); + renderAll(); +} + +function openSelectedWorkspaceChart(panel) { + const entry = getSelectedWorkspaceEntry(); + if (!entry?.data) return; + chartData = entry.data; + panel._panchangaChartData = chartData; + renderAll(); + showPage('chart'); + aiChatSetChartData(chartData); +} + +function deleteSelectedWorkspaceChart() { + const entry = getSelectedWorkspaceEntry(); + if (!entry) return; + if (!window.confirm(`删除「${entry.label || entry.id}」?`)) return; + writeChartLibrary(readChartLibrary().filter(item => item?.id !== entry.id)); + renderAll(); +} + +function exportSelectedWorkspaceChart() { + const entry = getSelectedWorkspaceEntry(); + if (!entry) return; + downloadText(JSON.stringify(entry, null, 2), `jyotish-chart-${entry.id || 'saved'}.json`, 'application/json;charset=utf-8'); +} + +function exportWorkspaceCaseLibrary() { + const payload = { + exportedAt: new Date().toISOString(), + charts: sortChartLibrary(readChartLibrary()).map(({ data, ...entry }) => ({ + ...entry, + meta: normalizeWorkspaceMeta(entry, buildDefaultChartWorkspaceMeta(data)), + })), + synastry_pairs: sortCaseLibrary(readSynastryPairLibrary()), + prashna_cases: sortCaseLibrary(readPrashnaCaseLibrary()), + }; + downloadText(JSON.stringify(payload, null, 2), `jyotish-case-library-${toDateISO(new Date())}.json`, 'application/json;charset=utf-8'); +} + +function selectVisibleWorkspaceCases() { + getVisibleCaseSelectionKeys().forEach(key => _caseWorkspaceSelection.add(key)); + refreshCaseWorkspaceList(); +} + +function exportSelectedWorkspaceCases() { + const { chartRecords, pairRecords, prashnaRecords } = resolveSelectedCaseRecords(); + if (!chartRecords.length && !pairRecords.length && !prashnaRecords.length) return; + const payload = { + exportedAt: new Date().toISOString(), + charts: chartRecords.map(({ data, ...entry }) => entry), + synastry_pairs: pairRecords, + prashna_cases: prashnaRecords, + }; + downloadText(JSON.stringify(payload, null, 2), `jyotish-selected-cases-${toDateISO(new Date())}.json`, 'application/json;charset=utf-8'); +} + +function deleteSelectedWorkspaceCases() { + const { chartRecords, pairRecords, prashnaRecords } = resolveSelectedCaseRecords(); + const count = chartRecords.length + pairRecords.length + prashnaRecords.length; + if (!count) return; + if (!window.confirm(`删除已选 ${count} 条案例?此操作只会删除本地案例库记录。`)) return; + const chartIds = new Set(chartRecords.map(record => record.id)); + const pairIds = new Set(pairRecords.map(record => record.id)); + const prashnaIds = new Set(prashnaRecords.map(record => record.id)); + writeChartLibrary(readChartLibrary().filter(record => !chartIds.has(record?.id))); + writeSynastryPairLibrary(readSynastryPairLibrary().filter(record => !pairIds.has(record?.id))); + writePrashnaCaseLibrary(readPrashnaCaseLibrary().filter(record => !prashnaIds.has(record?.id))); + _caseWorkspaceSelection.clear(); + _caseWorkspacePreview = null; + renderSynastryPairWorkspace(); + renderPrashnaCaseWorkspace(); + renderAll(); +} + +async function importWorkspaceCaseLibrary(file) { + const status = $('workspace-case-import-status'); + if (!file) return; + if (!file.name.toLowerCase().endsWith('.json') && file.type !== 'application/json') { + if (status) status.textContent = '请选择 JSON 案例库文件。'; + return; + } + try { + const payload = JSON.parse(await readWorkspaceFileAsText(file)); + const imported = mergeWorkspaceCaseLibrary(payload); + if (status) { + status.textContent = `已导入:星盘 ${imported.charts}、配对 ${imported.pairs}、问事 ${imported.prashnas}。`; + } + renderAll(); + } catch (error) { + if (status) status.textContent = `导入失败:${error?.message || 'JSON 格式不正确'}`; + } +} + +function readWorkspaceFileAsText(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(reader.error || new Error('文件读取失败')); + reader.readAsText(file); + }); +} + +function mergeWorkspaceCaseLibrary(payload) { + const charts = Array.isArray(payload?.charts) ? payload.charts : []; + const pairs = Array.isArray(payload?.synastry_pairs) ? payload.synastry_pairs : []; + const prashnas = Array.isArray(payload?.prashna_cases) ? payload.prashna_cases : []; + if (!charts.length && !pairs.length && !prashnas.length) { + throw new Error('未找到 charts、synastry_pairs 或 prashna_cases。'); + } + const validCharts = charts.filter(item => item?.id); + const validPairs = pairs.filter(item => item?.id); + const validPrashnas = prashnas.filter(item => item?.id); + if (!validCharts.length && !validPairs.length && !validPrashnas.length) { + throw new Error('案例库记录缺少 id,无法导入。'); + } + const chartLib = mergeLibraryById(readChartLibrary(), validCharts, getCaseDefaultMeta('chart')); + const pairLib = mergeLibraryById(readSynastryPairLibrary(), validPairs, getCaseDefaultMeta('pair')); + const prashnaLib = mergeLibraryById(readPrashnaCaseLibrary(), validPrashnas, getCaseDefaultMeta('prashna')); + writeChartLibrary(sortChartLibrary(chartLib).slice(0, 24)); + writeSynastryPairLibrary(sortCaseLibrary(pairLib).slice(0, 80)); + writePrashnaCaseLibrary(sortCaseLibrary(prashnaLib).slice(0, 80)); + return { charts: validCharts.length, pairs: validPairs.length, prashnas: validPrashnas.length }; +} + +function mergeLibraryById(current, incoming, defaultMeta = {}) { + const byId = new Map((Array.isArray(current) ? current : []).filter(item => item?.id).map(item => [item.id, item])); + for (const item of incoming) { + const merged = { ...(byId.get(item.id) || {}), ...item, importedAt: new Date().toISOString() }; + const meta = normalizeWorkspaceMeta(merged, merged.meta || defaultMeta); + byId.set(item.id, { ...merged, ...meta }); + } + return [...byId.values()]; +} + +function openSavedSynastryPair(caseId) { + const record = readSynastryPairLibrary().find(item => item?.id === caseId); + if (!record) return; + _lastSynastryPairRecord = record; + const result = $('synastry-result'); + if (result) { + result.innerHTML = renderSavedSynastryPair(record); + bindTerms(result); + } + showPage('chart'); + switchToTab('synastry'); +} + +function deleteSavedSynastryPair(caseId) { + const record = readSynastryPairLibrary().find(item => item?.id === caseId); + if (!record) return; + if (!window.confirm(`删除配对「${record.label || record.id}」?`)) return; + writeSynastryPairLibrary(readSynastryPairLibrary().filter(item => item?.id !== caseId)); + if (_caseWorkspacePreview?.kind === 'pair' && _caseWorkspacePreview.id === caseId) _caseWorkspacePreview = null; + renderSynastryPairWorkspace(); + renderAll(); +} + +function openSavedPrashnaCase(caseId) { + const record = readPrashnaCaseLibrary().find(item => item?.id === caseId); + if (!record) return; + _lastPrashnaCaseRecord = record; + recordPrashnaWorkflow(record.data || {}, record.question_text || '', record.question_type || 'general'); + const result = $('prashna-result'); + if (result) { + result.innerHTML = renderPrashnaResult(record.data || {}, record.question_text || ''); + bindTerms(result); + } + showPage('chart'); + switchToTab('prashna'); +} + +function deleteSavedPrashnaCase(caseId) { + const record = readPrashnaCaseLibrary().find(item => item?.id === caseId); + if (!record) return; + if (!window.confirm(`删除问事「${record.question_text || record.label || record.id}」?`)) return; + writePrashnaCaseLibrary(readPrashnaCaseLibrary().filter(item => item?.id !== caseId)); + if (_caseWorkspacePreview?.kind === 'prashna' && _caseWorkspacePreview.id === caseId) _caseWorkspacePreview = null; + renderPrashnaCaseWorkspace(); + renderAll(); +} + +function setPanchangaRangePreset(mode) { + const startEl = $('panchanga-start'); + const endEl = $('panchanga-end'); + if (!startEl || !endEl) return; + const base = parseDateISO(startEl.value || toDateISO(new Date())); + if (mode === 'month') { + const first = new Date(base.getFullYear(), base.getMonth(), 1); + const last = new Date(base.getFullYear(), base.getMonth() + 1, 0); + startEl.value = toDateISO(first); + endEl.value = toDateISO(last); + return; + } + startEl.value = toDateISO(base); + endEl.value = toDateISO(addDays(base, 6)); +} + +async function renderPanchangaRange(panel) { + const output = $('panchanga-range-result'); + const source = panel?._panchangaChartData || chartData; + if (!output) return; + if (!source) { + output.innerHTML = '
请先生成星盘。
'; + return; + } + if (!window.JyotishAPI?.computePanchangaRange) { + output.innerHTML = '
当前 API bridge 未提供 /api/panchanga_range。
'; + return; + } + + output.innerHTML = '
正在生成 Panchanga 日历...
'; + try { + const payload = readPanchangaRangeInputs(); + const result = await window.JyotishAPI.computePanchangaRange(payload); + const report = result?.report || {}; + const allRows = normalizePanchangaRangeRows(report.days || []); + const selectedConditions = normalizePanchangaConditions(payload.conditions || payload.condition || []); + const conditionMode = normalizePanchangaConditionMode(payload.condition_mode || payload.conditionMode); + const rows = filterPanchangaRowsByCondition(allRows, selectedConditions, conditionMode); + const activityLabel = getPanchangaActivityLabel(payload.activity || 'all'); + const conditionLabel = getPanchangaConditionSelectionLabel(selectedConditions, conditionMode); + panel._lastPanchangaRange = { report, rows, allRows, activity: payload.activity || 'all', condition: selectedConditions[0] || 'all', conditions: selectedConditions, conditionMode }; + output.innerHTML = ` +
+ ${escapeHtml(report.start_date || payload.start_date)} → ${escapeHtml(report.end_date || payload.end_date)} + ${escapeHtml(String(rows.length))}/${escapeHtml(String(report.day_count || allRows.length))} 天 · ${escapeHtml(activityLabel)} · ${escapeHtml(conditionLabel)} · ${escapeHtml(report.calculation_policy?.sunrise_sunset || report.calculation_policy?.inauspicious_periods || 'daytime eight-segment rule')} + ${renderPanchangaLocationSummary(report.location, source)} +
+ ${renderPanchangaConditionGuide(selectedConditions, conditionMode, report.search_summary)} + ${renderPanchangaFestivalDetails(rows)} + ${renderPanchangaMonthGrid(report.month_grid || [])} +
+ + + + + + ${rows.length ? rows.map(row => ` + + + + + + + + + + + + + + + + + `).join('') : ''} + +
日期质量活动条件标签吉时窗口TithiNakshatraYoga结束时间日出/日落Rahu KalaYamagandaGulika
${escapeHtml(row.date)}${escapeHtml(row.quality)}${formatPercent(row.score)}${escapeHtml(row.activityVerdict)}${renderPanchangaConditionBadges(row.conditionTags)}${renderVrataTagBadges(row.vrataTags)}${escapeHtml(row.subDaySummary)}${escapeHtml(row.tithi)}${escapeHtml(row.nakshatra)}${escapeHtml(row.yoga)}${escapeHtml(row.endSummary)}${escapeHtml(row.sunrise)}-${escapeHtml(row.sunset)}${escapeHtml(row.rahuKala)}${escapeHtml(row.yamaganda)}${escapeHtml(row.gulika)}
当前范围内没有匹配条件的日期。
+
+ `; + } catch (error) { + output.innerHTML = `
Panchanga 日历生成失败:${escapeHtml(error?.message || 'unknown error')}
`; + } +} + +function readPanchangaRangeInputs() { + const today = new Date(); + const start = $('panchanga-start')?.value || toDateISO(today); + const end = $('panchanga-end')?.value || toDateISO(addDays(today, 6)); + const payload = { + start_date: start, + end_date: end, + sunrise: $('panchanga-sunrise')?.value || '06:00', + sunset: $('panchanga-sunset')?.value || '18:00', + hour_from_sunrise: 6, + }; + const activity = $('panchanga-activity')?.value || 'all'; + if (activity !== 'all') payload.activity = activity; + const conditions = getSelectedPanchangaConditions(); + payload.conditions = conditions; + payload.condition = conditions[0] || 'all'; + payload.condition_mode = $('panchanga-condition-mode')?.value || 'all'; + const birth = chartData?.birth_info || chartData?.birth || {}; + const lat = Number(birth.lat); + const lon = Number(birth.lon); + const tz = Number(birth.tz); + if (Number.isFinite(lat) && Number.isFinite(lon) && Number.isFinite(tz)) { + payload.lat = lat; + payload.lon = lon; + payload.tz = tz; + } + return payload; +} + +function normalizePanchangaRangeRows(days) { + return days.map(day => { + const p = day.panchanga || {}; + const summary = day.summary || {}; + const periods = day.inauspicious_periods || {}; + const solar = day.solar_times || {}; + const checks = day.activity_checks || {}; + const primaryCheck = Object.values(checks)[0] || null; + const endTimes = day.end_times || {}; + const vrataTags = Array.isArray(day.vrata_tags) ? day.vrata_tags : []; + const festivalDetails = Array.isArray(day.festival_details) ? day.festival_details : []; + const conditionTags = Array.isArray(day.condition_tags) ? day.condition_tags : []; + const choghadiya = day.choghadiya || {}; + const horaWindows = day.hora_windows || {}; + return { + date: day.query_date || '-', + quality: summary.overall_quality || p.overall_quality || '-', + score: summary.overall_score ?? p.overall_score, + tithi: p.tithi?.full_name || p.tithi?.name || '-', + nakshatra: p.nakshatra?.nakshatra || p.nakshatra?.name || '-', + yoga: p.yoga?.yoga || p.yoga?.name || '-', + vara: p.vara?.vara || p.vara?.name || '-', + sunrise: solar.sunrise || periods.sunrise || '-', + sunset: solar.sunset || periods.sunset || '-', + solarPolicy: solar.policy || '', + endTimes, + tithiEndsAt: endTimes.tithi?.ends_at || '', + nakshatraEndsAt: endTimes.nakshatra?.ends_at || '', + yogaEndsAt: endTimes.yoga?.ends_at || '', + endSummary: formatEndTimeSummary(endTimes), + vrataTags, + festivalDetails, + vrataLabels: vrataTags.map(tag => tag.label || tag.key).filter(Boolean).join(' / '), + conditionTags, + conditionLabels: conditionTags.map(tag => tag.label || tag.key).filter(Boolean).join(' / '), + subDaySummary: formatSubDaySummary(choghadiya, horaWindows), + choghadiya, + horaWindows, + activityVerdict: primaryCheck?.verdict || summarizeActivities(summary), + activityNotes: Array.isArray(primaryCheck?.notes) ? primaryCheck.notes.join('; ') : '', + rahuKala: formatWindow(periods.rahu_kala), + yamaganda: formatWindow(periods.yamaganda), + gulika: formatWindow(periods.gulika), + }; + }); +} + +function renderPanchangaMonthGrid(grid) { + if (!Array.isArray(grid) || !grid.length) return ''; + const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + return ` +
+ ${weekdays.map(day => `
${escapeHtml(day)}
`).join('')} + ${grid.flatMap(week => week.map(cell => { + if (!cell) return '
'; + const best = Array.isArray(cell.best_activities) ? cell.best_activities.slice(0, 2).map(getPanchangaActivityLabel).join(' / ') : ''; + const tagLabels = Array.isArray(cell.vrata_tags) ? cell.vrata_tags.slice(0, 2).map(tag => tag.label || tag.key).filter(Boolean) : []; + const bestChoghadiya = Array.isArray(cell.best_choghadiya) ? cell.best_choghadiya.slice(0, 2).map(item => `${item.name} ${item.start}`).join(' / ') : ''; + return ` +
+
+ ${escapeHtml(String(cell.day || ''))} + ${escapeHtml(formatPercent(cell.score))} +
+

${escapeHtml(cell.tithi || '-')}

+ ${escapeHtml(cell.nakshatra || '-')} + ${cell.tithi_ends_at ? `${escapeHtml(shortLocalDateTime(cell.tithi_ends_at))}` : ''} + ${tagLabels.length ? `
${tagLabels.map(label => `${escapeHtml(label)}`).join('')}
` : ''} + ${bestChoghadiya ? `${escapeHtml(bestChoghadiya)}` : ''} + ${best ? `${escapeHtml(best)}` : ''} +
+ `; + })).join('')} +
+ `; +} + +function getPanchangaActivityLabel(value) { + const found = PANCHANGA_ACTIVITIES.find(([key]) => key === value); + return found ? found[1] : value || '全部活动'; +} + +function getPanchangaConditionLabel(value) { + const found = PANCHANGA_CONDITIONS.find(([key]) => key === value); + return found ? found[1] : value || '全部条件'; +} + +function getSelectedPanchangaConditions() { + return Array.from(document.querySelectorAll('input[name="panchanga-condition-option"]:checked')) + .map(input => input.value) + .filter(Boolean); +} + +function normalizePanchangaConditions(conditions) { + const list = Array.isArray(conditions) ? conditions : [conditions]; + return [...new Set(list.filter(condition => condition && condition !== 'all'))]; +} + +function normalizePanchangaConditionMode(mode) { + return mode === 'any' ? 'any' : 'all'; +} + +function getPanchangaConditionModeLabel(mode) { + const normalized = normalizePanchangaConditionMode(mode); + const found = PANCHANGA_CONDITION_MODES.find(([key]) => key === normalized); + return found ? found[1] : '满足全部'; +} + +function getPanchangaConditionSelectionLabel(conditions, mode = 'all') { + const selected = normalizePanchangaConditions(conditions); + if (!selected.length) return getPanchangaConditionLabel('all'); + const joiner = normalizePanchangaConditionMode(mode) === 'any' ? ' / ' : ' + '; + return `${getPanchangaConditionModeLabel(mode)}:${selected.map(getPanchangaConditionLabel).join(joiner)}`; +} + +function renderPanchangaLocationSummary(location, sourceChart) { + const birth = sourceChart?.birth_info || sourceChart?.birth || {}; + if (location?.lat != null && location?.lon != null) { + const city = birth.city || birth.place || ''; + return `地点:${escapeHtml(city ? `${city} · ` : '')}${escapeHtml(Number(location.lat).toFixed(3))}, ${escapeHtml(Number(location.lon).toFixed(3))} · TZ ${escapeHtml(location.tz)}`; + } + return '地点:手动日出/日落;未使用出生地坐标。'; +} + +function renderPanchangaConditionGuide(conditions, mode = 'all', searchSummary = null) { + const selected = normalizePanchangaConditions(conditions); + const items = selected.length ? selected : ['all']; + const counts = searchSummary?.condition_counts || {}; + return ` +
+
+ ${escapeHtml(getPanchangaConditionModeLabel(mode))} + ${escapeHtml(selected.length ? `当前组合 ${selected.length} 个条件;同类 /panchanga/search 可按 tithi+nākṣatra+yoga+vara 或标签组合检索。` : '未选择条件,显示全部日期。')} +
+ ${items.map(key => ` +
+ ${escapeHtml(getPanchangaConditionLabel(key))}${counts[key] ? ` · ${escapeHtml(String(counts[key]))}` : ''} + ${escapeHtml(PANCHANGA_CONDITION_GUIDE[key] || '按当前 Panchanga 标签筛选。')} +
+ `).join('')} +
+ `; +} + +function renderPanchangaFestivalDetails(rows) { + const details = []; + rows.forEach(row => { + (row.festivalDetails || []).forEach(detail => { + details.push({ ...detail, date: row.date }); + }); + }); + if (!details.length) return ''; + return ` +
+ ${details.slice(0, 6).map(detail => ` +
+ ${escapeHtml(detail.label || detail.key || '-')} + ${escapeHtml(detail.date || '')} · ${escapeHtml(detail.type || 'observance')} +

${escapeHtml(detail.guidance || detail.confirmation_note || '')}

+ ${escapeHtml((detail.basis || []).join(' · '))} +
+ `).join('')} +
+ `; +} + +function filterPanchangaRowsByCondition(rows, condition, mode = 'all') { + const selected = normalizePanchangaConditions(condition); + if (!selected.length) return rows; + const matcher = normalizePanchangaConditionMode(mode) === 'any' + ? selected.some.bind(selected) + : selected.every.bind(selected); + return rows.filter(row => matcher(item => rowMatchesPanchangaCondition(row, item))); +} + +function rowMatchesPanchangaCondition(row, condition) { + const tags = Array.isArray(row?.conditionTags) ? row.conditionTags : []; + if (tags.some(tag => tag.key === condition)) return true; + if (condition === 'festival_candidate') { + return (row?.vrataTags || []).some(tag => tag.type === 'festival_candidate'); + } + if (condition === 'has_vrata') return Boolean((row?.vrataTags || []).length); + if (condition === 'auspicious_activity') return /推荐|大吉|吉|Excellent|Good/.test(String(row?.activityVerdict || '')); + if (condition === 'avoid_new_start') return /避开|不宜|Avoid|Poor/.test(String(row?.activityVerdict || '')); + if (condition === 'good_choghadiya') { + return ['day', 'night'].some(part => (row?.choghadiya?.[part] || []).some(item => item.quality === 'auspicious')); + } + if (condition === 'spiritual_practice') { + return (row?.vrataTags || []).some(tag => ['fasting', 'observance', 'lunar', 'vrata'].includes(tag.type)); + } + return false; +} + +function summarizeActivities(summary) { + const best = Array.isArray(summary?.best_activities) ? summary.best_activities : []; + const avoid = Array.isArray(summary?.avoid_activities) ? summary.avoid_activities : []; + if (avoid.length) return `避开 ${avoid.map(getPanchangaActivityLabel).slice(0, 2).join('/')}`; + if (best.length) return `推荐 ${best.map(getPanchangaActivityLabel).slice(0, 2).join('/')}`; + return '中性'; +} + +function getPanchangaActivityClass(verdict) { + const text = String(verdict || ''); + if (/避开|不宜|Avoid|Poor/.test(text)) return 'panchanga-activity-badge is-bad'; + if (/推荐|大吉|吉|Excellent|Good/.test(text)) return 'panchanga-activity-badge is-good'; + return 'panchanga-activity-badge'; +} + +function formatEndTimeSummary(endTimes) { + const parts = []; + if (endTimes?.tithi?.ends_at) parts.push(`T ${shortLocalDateTime(endTimes.tithi.ends_at)}`); + if (endTimes?.nakshatra?.ends_at) parts.push(`N ${shortLocalDateTime(endTimes.nakshatra.ends_at)}`); + if (endTimes?.yoga?.ends_at) parts.push(`Y ${shortLocalDateTime(endTimes.yoga.ends_at)}`); + return parts.join(' · ') || '-'; +} + +function shortLocalDateTime(value) { + const text = String(value || ''); + return text.length >= 16 ? text.slice(5, 16) : text || '-'; +} + +function renderVrataTagBadges(tags) { + if (!Array.isArray(tags) || !tags.length) return '-'; + return `
${tags.slice(0, 3).map(tag => `${escapeHtml(tag.label || tag.key || '-')}`).join('')}
`; +} + +function renderPanchangaConditionBadges(tags) { + if (!Array.isArray(tags) || !tags.length) return '-'; + return `
${tags.slice(0, 3).map(tag => `${escapeHtml(tag.label || tag.key || '-')}`).join('')}
`; +} + +function formatSubDaySummary(choghadiya, horaWindows) { + const good = [ + ...(Array.isArray(choghadiya?.day) ? choghadiya.day : []), + ...(Array.isArray(choghadiya?.night) ? choghadiya.night : []), + ].filter(item => item.quality === 'auspicious').slice(0, 2); + const hora = Array.isArray(horaWindows?.day) ? horaWindows.day[0] : null; + const goodText = good.map(item => `${item.name} ${item.start}-${item.end}`).join(' · '); + const horaText = hora ? `Hora ${hora.lord} ${hora.start}-${hora.end}` : ''; + return [goodText, horaText].filter(Boolean).join(' · ') || '-'; +} + +function formatWindow(period) { + if (!period) return '-'; + return `${period.start || '-'}-${period.end || '-'}`; +} + +function exportPanchangaRangeCSV(panel) { + const rows = panel?._lastPanchangaRange?.rows || []; + if (!rows.length) { + renderPanchangaRange(panel); + return; + } + const header = ['date', 'quality', 'score', 'activity_verdict', 'activity_notes', 'condition_tags', 'vrata_tags', 'sub_day_windows', 'tithi', 'tithi_ends_at', 'nakshatra', 'nakshatra_ends_at', 'yoga', 'yoga_ends_at', 'vara', 'sunrise', 'sunset', 'rahu_kala', 'yamaganda', 'gulika']; + const csvRows = [header, ...rows.map(row => [ + row.date, + row.quality, + formatPercent(row.score), + row.activityVerdict, + row.activityNotes, + row.conditionLabels, + row.vrataLabels, + row.subDaySummary, + row.tithi, + row.tithiEndsAt, + row.nakshatra, + row.nakshatraEndsAt, + row.yoga, + row.yogaEndsAt, + row.vara, + row.sunrise, + row.sunset, + row.rahuKala, + row.yamaganda, + row.gulika, + ])]; + const csv = csvRows.map(cols => cols.map(escapeCsv).join(',')).join('\n'); + downloadText(csv, `panchanga-${rows[0].date || 'range'}-${rows[rows.length - 1].date || 'range'}.csv`, 'text/csv;charset=utf-8'); +} + +function exportPanchangaRangeICS(panel) { + const rows = panel?._lastPanchangaRange?.rows || []; + if (!rows.length) { + renderPanchangaRange(panel); + return; + } + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Jyotish Web App//Panchanga//CN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + ]; + rows.forEach((row, index) => { + const uid = `panchanga-${row.date}-${index}@jyotish-web-app`; + const start = row.date.replace(/-/g, ''); + const end = toDateISO(addDays(parseDateISO(row.date), 1)).replace(/-/g, ''); + const summary = `Panchanga ${row.quality}`; + const description = [ + `Tithi: ${row.tithi}`, + `Nakshatra: ${row.nakshatra}`, + `Yoga: ${row.yoga}`, + `Vara: ${row.vara}`, + `Activity: ${row.activityVerdict}`, + `Condition tags: ${row.conditionLabels || '-'}`, + `Vrata tags: ${row.vrataLabels || '-'}`, + `Sub-day windows: ${row.subDaySummary}`, + `End times: ${row.endSummary}`, + `Sunrise/Sunset: ${row.sunrise}-${row.sunset}`, + `Rahu Kala: ${row.rahuKala}`, + `Yamaganda: ${row.yamaganda}`, + `Gulika: ${row.gulika}`, + ].join('\\n'); + lines.push( + 'BEGIN:VEVENT', + `UID:${escapeIcs(uid)}`, + `DTSTAMP:${formatIcsTimestamp(new Date())}`, + `DTSTART;VALUE=DATE:${start}`, + `DTEND;VALUE=DATE:${end}`, + `SUMMARY:${escapeIcs(summary)}`, + `DESCRIPTION:${escapeIcs(description)}`, + 'END:VEVENT', + ); + }); + lines.push('END:VCALENDAR'); + downloadText(lines.join('\r\n'), `panchanga-${rows[0].date || 'range'}-${rows[rows.length - 1].date || 'range'}.ics`, 'text/calendar;charset=utf-8'); +} + +function escapeIcs(value) { + return String(value ?? '') + .replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/,/g, '\\,') + .replace(/;/g, '\\;'); +} + +function formatIcsTimestamp(date) { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); +} + +function escapeCsv(value) { + const s = String(value ?? ''); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +function downloadText(content, filename, mimeType) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +function addDays(date, days) { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} + +function parseDateISO(value) { + const [year, month, day] = String(value).split('-').map(Number); + return new Date(year, (month || 1) - 1, day || 1); +} + +function toDateISO(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function formatPercent(value) { + const n = Number(value); + if (!Number.isFinite(n)) return '-'; + return `${Math.round(n * 100)}%`; +} + +function buildPlanetSignIndex(planets) { + const result = {}; + for (const [pn, p] of Object.entries(planets || {})) { + if (!p || p.error) continue; + const signIdx = SIGNS.indexOf(p.sign); + if (signIdx >= 0) result[pn] = signIdx; + } + return result; +} + +function renderSpecialLagnaReport(arudha, ascendant, birthInfo, specialLagnas) { + const container = $('special-lagna-section'); + if (!container) return; + if (!ascendant?.sign) { + container.innerHTML = ''; + return; + } + const special = buildSpecialLagnaReport(arudha, ascendant, birthInfo, specialLagnas); + const cards = special.cards.map(card => ` +
+ ${escapeHtml(card.code)} + ${escapeHtml(card.title)} +
${escapeHtml(card.sign)}
+

${escapeHtml(card.meaning)}

+
+ `).join(''); + container.innerHTML = ` +
+
+
+

特殊 Lagna 专题

+

把 AL/UL/A10 的外显影像与 HL/GL/VL 的时间敏感辅助点放在同一张卡片里,作为完整解盘的身份、婚姻、事业、财富与权力观察入口。

+
+ ${escapeHtml(special.ghatiLabel)} +
+ ${special.meta.length ? `
${special.meta.map(item => `${escapeHtml(item)}`).join('')}
` : ''} +
${cards}
+

${escapeHtml(special.note)}

+
+ `; +} + +function buildSpecialLagnaReport(arudha, ascendant, birthInfo, specialLagnas) { + const apiSpecial = normalizeSpecialLagnas(specialLagnas); + if (apiSpecial) { + const card = (code, title, sign, lord, meaning, kind = 'derived') => ({ + code, + title, + sign: sign ? `${signName(sign)} · 主星 ${planetName(lord || SIGN_LORDS[sign])}` : '暂缺', + meaning, + kind, + }); + return { + ghatiLabel: `Ghati ${apiSpecial.ghatis.toFixed(2)}`, + meta: [ + apiSpecial.precision === 'sunrise_correct' ? '日出校正' : '后端计算', + apiSpecial.sunriseLocal ? `日出 ${apiSpecial.sunriseLocal}` : '', + ].filter(Boolean), + note: apiSpecial.note || 'HL/GL 已按出生地日出起算;若出生时间不确定,请结合生时校正模块共同判断。', + cards: [ + card('AL', 'Arudha Lagna 社会形象', arudha?.AL?.sign, arudha?.AL?.lord, '别人最容易看到的外在形象、名声与社会投射。', 'core'), + card('UL', 'Upapada Lagna 婚姻外显', arudha?.UL?.sign, arudha?.UL?.lord, '伴侣关系在外界呈现出的样子,也用于观察婚姻持续性。', 'core'), + card('A10', 'Karma Pada 事业形象', arudha?.A10?.sign, arudha?.A10?.lord, '职业名声、公众认可方式,以及事业成果如何被看见。', 'core'), + card('HL', 'Hora Lagna 财富驱动', apiSpecial.HL?.sign, apiSpecial.HL?.lord, '财富获取方式、资源动机和物质层面的行动入口。'), + card('GL', 'Ghati Lagna 权力行动', apiSpecial.GL?.sign, apiSpecial.GL?.lord, '行动效率、权力表达、竞争与事件触发敏感点。'), + card('VL', 'Varnada Lagna 身份来源', apiSpecial.VL?.sign, apiSpecial.VL?.lord, '身份阶层、外在定位和人生舞台的来源感。'), + ], + }; + } + + const birth = window.__jyotishBirth || {}; + const time = birthInfo?.time || `${String(birth.hour ?? 12).padStart(2, '0')}:${String(birth.minute ?? 0).padStart(2, '0')}`; + const [hourRaw, minuteRaw] = String(time).split(':'); + const hour = Number.isFinite(Number(hourRaw)) ? Number(hourRaw) : safeNumber(birth.hour, 12); + const minute = Number.isFinite(Number(minuteRaw)) ? Number(minuteRaw) : safeNumber(birth.minute, 0); + const ascIdx = SIGNS.indexOf(ascendant.sign); + const ghatis = ((hour + minute / 60) / 24) * 60; + const ghatiFloor = Math.floor(ghatis); + const hlIdx = ghatiFloor % 2 ? ghatiFloor % 12 : (7 - ghatiFloor + 120) % 12; + const glIdx = ghatiFloor % 12; + const vlIdx = (ascIdx * 3) % 12; + const card = (code, title, sign, lord, meaning, kind = 'derived') => ({ + code, + title, + sign: sign ? `${signName(sign)} · 主星 ${planetName(lord || SIGN_LORDS[sign])}` : '暂缺', + meaning, + kind, + }); + return { + ghatiLabel: `Ghati ${ghatis.toFixed(2)}`, + meta: ['本地简化值'], + note: '边界:HL/GL/VL 当前使用本地简化算法,按午夜起算 ghati;传统精算应使用出生地日出时间。因此它们作为辅助观察,不用于单独定论。', + cards: [ + card('AL', 'Arudha Lagna 社会形象', arudha?.AL?.sign, arudha?.AL?.lord, '别人最容易看到的外在形象、名声与社会投射。', 'core'), + card('UL', 'Upapada Lagna 婚姻外显', arudha?.UL?.sign, arudha?.UL?.lord, '伴侣关系在外界呈现出的样子,也用于观察婚姻持续性。', 'core'), + card('A10', 'Karma Pada 事业形象', arudha?.A10?.sign, arudha?.A10?.lord, '职业名声、公众认可方式,以及事业成果如何被看见。', 'core'), + card('HL', 'Hora Lagna 财富驱动', SIGNS[hlIdx], SIGN_LORDS[SIGNS[hlIdx]], '财富获取方式、资源动机和物质层面的行动入口。'), + card('GL', 'Ghati Lagna 权力行动', SIGNS[glIdx], SIGN_LORDS[SIGNS[glIdx]], '行动效率、权力表达、竞争与事件触发敏感点。'), + card('VL', 'Varnada Lagna 身份来源', SIGNS[vlIdx], SIGN_LORDS[SIGNS[vlIdx]], '身份阶层、外在定位和人生舞台的来源感。'), + ], + }; +} + +function normalizeSpecialLagnas(specialLagnas) { + if (!specialLagnas || !specialLagnas.HL || !specialLagnas.GL || !specialLagnas.VL) return null; + const ghatis = safeNumber( + specialLagnas.ghatis_elapsed_from_sunrise ?? specialLagnas.HL?.ghatis_elapsed, + NaN, + ); + if (!Number.isFinite(ghatis)) return null; + return { + precision: specialLagnas.precision || specialLagnas.capability_status || '', + sunriseLocal: specialLagnas.sunrise_local_time || '', + ghatis, + note: specialLagnas.note || '', + HL: normalizeSpecialPoint(specialLagnas.HL), + GL: normalizeSpecialPoint(specialLagnas.GL), + VL: normalizeSpecialPoint(specialLagnas.VL), + }; +} + +function normalizeSpecialPoint(point) { + if (!point) return null; + const sign = point.sign || SIGNS[point.sign_idx]; + return { + sign, + lord: point.lord || SIGN_LORDS[sign], + degree: point.degree_in_sign ?? point.longitude, + }; +} + +// ============================================================================ +// D9 Navamsa 婚姻专题 +// ============================================================================ +function renderD9MarriageReport(allV, planets, ascendant, karaka) { + const container = $('d9-marriage-section'); + if (!container) return; + const report = buildD9MarriageReport(allV, planets, ascendant, karaka); + if (!report) { + container.innerHTML = ` +
+
D9 婚姻专题需要完整出生时间和行星度数。请先生成可计算分盘的星盘。
+
+ `; + return; + } + + const coreHtml = report.core.map(item => ` +
+ ${escapeHtml(item.label)} + ${escapeHtml(item.value)} +

${escapeHtml(item.note)}

+
+ `).join(''); + + const flagHtml = report.flags.map(flag => ` +
+
+ ${escapeHtml(flag.name)} + ${escapeHtml(flag.label)} +
+

${escapeHtml(flag.detail)}

+
+ `).join(''); + + const evidenceHtml = report.evidence.map(item => ` +
  • ${escapeHtml(item.label)}:${escapeHtml(item.value)}
  • + `).join(''); + + container.innerHTML = ` +
    +
    +
    +

    D9 Navamsa 婚姻专题

    +

    基于 D9 上升、Darakaraka、Venus、D9 七宫、Vargottama 与 Pushkara 的八步旗标。

    +
    +
    + ${escapeHtml(report.zone.name)} + ${escapeHtml(report.score)} / 8 +
    +
    +
    ${coreHtml}
    +
    ${flagHtml}
    +
    +
    + 关键证据 +
      ${evidenceHtml}
    +
    +

    边界:D9 需要准确出生时间,且应与 D1、Dasha、Transit、现实关系模式一起判断;这里展示的是婚恋专题信号,不替代个人选择或专业咨询。

    +
    +
    + `; +} + +function buildD9MarriageReport(allV, planets, ascendant, karaka) { + if (!allV?.D9?.planets || !planets || !ascendant?.sign) return null; + const ascSign = getD9AscSign(ascendant); + if (!ascSign) return null; + const ascIdx = SIGNS.indexOf(ascSign); + const d9Planets = buildD9PlanetPositions(allV.D9.planets, planets, ascIdx); + const d9AscLord = SIGN_LORDS[ascSign]; + const d9AscLordPos = d9Planets[d9AscLord]; + const dkPlanet = karaka?.karaka7?.DK?.planet || karaka?.karaka8?.DK?.planet; + const dkPos = dkPlanet ? d9Planets[dkPlanet] : null; + const venusPos = d9Planets.Venus; + const seventhSign = SIGNS[(ascIdx + 6) % 12]; + const seventhLord = SIGN_LORDS[seventhSign]; + const seventhLordPos = d9Planets[seventhLord]; + const seventhPlanets = Object.entries(d9Planets).filter(([, p]) => p.house === 7).map(([pn]) => pn); + const vargottama = Object.entries(d9Planets) + .filter(([pn, p]) => planets[pn]?.sign && planets[pn].sign === p.sign) + .map(([pn]) => pn); + const pushkara = Object.entries(planets) + .filter(([pn, p]) => d9Planets[pn] && isPushkaraNavamsa(p)) + .map(([pn]) => pn); + + const flags = [ + evaluateAscLordFlag(d9AscLord, d9AscLordPos), + evaluateDKFlag(dkPlanet, dkPos), + evaluatePlanetDignityFlag('Venus', venusPos, 'Venus在D9尊严'), + evaluateSeventhLordFlag(seventhLord, seventhLordPos), + evaluateDKVenusFlag(dkPlanet, dkPos, venusPos), + evaluateSeventhMaleficsFlag(seventhPlanets), + evaluateVargottamaFlag(vargottama, dkPlanet), + evaluatePushkaraFlag(pushkara, dkPlanet, seventhLord), + ]; + const score = flags.reduce((sum, flag) => sum + flag.score, 0); + const positiveCount = flags.filter(flag => flag.score > 0).length; + const zone = d9Zone(score); + + return { + score: `${score >= 0 ? '+' : ''}${score}`, + zone, + flags, + core: [ + { label: 'D9上升', value: signName(ascSign), note: `${planetName(d9AscLord)}主导婚姻成熟后的运作气质` }, + { label: 'DK配偶征象', value: dkPlanet ? `${planetName(dkPlanet)} · ${formatD9Pos(dkPos)}` : '未取得DK', note: 'Darakaraka 描述配偶/亲密关系触发点' }, + { label: 'Venus关系品质', value: venusPos ? formatD9Pos(venusPos) : '缺少Venus', note: 'Venus 代表关系审美、亲密体验与和合力' }, + { label: 'D9七宫', value: `${signName(seventhSign)} · 主星 ${planetName(seventhLord)}`, note: seventhPlanets.length ? `七宫行星:${seventhPlanets.map(planetName).join('、')}` : '七宫无行星,重点看七宫主' }, + ], + evidence: [ + { label: 'D9上升主星', value: `${planetName(d9AscLord)} ${formatD9Pos(d9AscLordPos)}` }, + { label: 'D9七宫主', value: `${planetName(seventhLord)} ${formatD9Pos(seventhLordPos)}` }, + { label: 'D9七宫行星', value: seventhPlanets.length ? seventhPlanets.map(planetName).join('、') : '无' }, + { label: 'Vargottama', value: vargottama.length ? vargottama.map(planetName).join('、') : '无' }, + { label: 'Pushkara Navamsa', value: pushkara.length ? pushkara.map(planetName).join('、') : '无' }, + { label: '旗标概览', value: `${positiveCount} 个绿旗,${flags.filter(flag => flag.score < 0).length} 个红旗` }, + ], + }; +} + +function buildD9PlanetPositions(d9Planets, natalPlanets, ascIdx) { + const result = {}; + for (const [pn, p] of Object.entries(d9Planets || {})) { + if (!p?.sign) continue; + const signIdx = SIGNS.indexOf(p.sign); + if (signIdx < 0) continue; + result[pn] = { + sign: p.sign, + house: ((signIdx - ascIdx + 12) % 12) + 1, + dignity: planetDignity(pn, p.sign), + natal: natalPlanets[pn] || {}, + }; + } + return result; +} + +function getD9AscSign(ascendant) { + const si = SIGNS.indexOf(ascendant?.sign); + if (si < 0) return null; + const raw = safeNumber(ascendant.degree_in_sign ?? ascendant.degree); + const degInSign = raw >= 30 ? ((raw % 30) + 30) % 30 : raw; + const seg = Math.min(8, Math.floor(degInSign / (30 / 9))); + const elementStart = [0, 9, 6, 3]; + return SIGNS[(elementStart[si % 4] + seg) % 12]; +} + +function planetDignity(planet, sign) { + const data = PLANET_DIGNITY[planet] || NODE_DIGNITY[planet]; + if (!data || !sign) return { key: 'neutral', label: '中性' }; + if (data.debilitation?.sign === sign) return { key: 'debilitated', label: '落陷' }; + if (data.exaltation?.sign === sign) return { key: 'exalted', label: '旺相' }; + if (data.ownSigns?.includes(sign)) return { key: 'own', label: '入庙' }; + if (data.friends?.some(friend => SIGN_LORDS[sign] === friend)) return { key: 'friend', label: '友星座' }; + if (data.enemies?.some(enemy => SIGN_LORDS[sign] === enemy)) return { key: 'enemy', label: '敌星座' }; + return { key: 'neutral', label: '中性' }; +} + +function d9Flag(level, name, detail) { + return { + level, + name, + detail, + score: level === 'green' ? 1 : level === 'red' ? -1 : 0, + label: level === 'green' ? '绿旗' : level === 'red' ? '红旗' : '黄旗', + }; +} + +function evaluateAscLordFlag(planet, pos) { + if (!planet || !pos) return d9Flag('yellow', 'D9上升主星状态', '缺少上升主星位置,暂按中性处理。'); + if (['exalted', 'own', 'friend'].includes(pos.dignity.key) || D9_GREEN_HOUSES.includes(pos.house)) { + return d9Flag('green', 'D9上升主星状态', `${planetName(planet)}在${formatD9Pos(pos)},婚姻成熟后的自我调适力较好。`); + } + if (pos.dignity.key === 'debilitated' || pos.dignity.key === 'enemy' || D9_HARD_HOUSES.includes(pos.house)) { + return d9Flag('red', 'D9上升主星状态', `${planetName(planet)}在${formatD9Pos(pos)},亲密关系中需要更有意识地修复自我防御。`); + } + return d9Flag('yellow', 'D9上升主星状态', `${planetName(planet)}在${formatD9Pos(pos)},信号中性,需结合D1与Dasha。`); +} + +function evaluateDKFlag(planet, pos) { + if (!planet || !pos) return d9Flag('yellow', 'DK在D9位置', '缺少DK位置,暂按中性处理。'); + if ([1, 3, 4, 5, 7, 9, 10].includes(pos.house)) { + return d9Flag('green', 'DK在D9位置', `${planetName(planet)}落${formatD9Pos(pos)},配偶征象容易进入关系主轴。`); + } + if ([8, 12].includes(pos.house)) { + return d9Flag('red', 'DK在D9位置', `${planetName(planet)}落${formatD9Pos(pos)},关系可能触发深层转化、距离感或隐性议题。`); + } + if ([6, 11].includes(pos.house)) { + return d9Flag('yellow', 'DK在D9位置', `${planetName(planet)}落${formatD9Pos(pos)},关系议题偏现实协作或期待管理。`); + } + return d9Flag('yellow', 'DK在D9位置', `${planetName(planet)}落${formatD9Pos(pos)},需与Venus和七宫主交叉判断。`); +} + +function evaluatePlanetDignityFlag(planet, pos, name) { + if (!pos) return d9Flag('yellow', name, `缺少${planetName(planet)}位置,暂按中性处理。`); + if (['exalted', 'own', 'friend'].includes(pos.dignity.key)) { + return d9Flag('green', name, `${planetName(planet)}在${formatD9Pos(pos)},关系品质有稳定支撑。`); + } + if (['debilitated', 'enemy'].includes(pos.dignity.key)) { + return d9Flag('red', name, `${planetName(planet)}在${formatD9Pos(pos)},亲密表达和价值交换需要主动经营。`); + } + return d9Flag('yellow', name, `${planetName(planet)}在${formatD9Pos(pos)},关系品质信号中性。`); +} + +function evaluateSeventhLordFlag(planet, pos) { + if (!planet || !pos) return d9Flag('yellow', 'D9七宫主位置', '缺少D9七宫主位置,暂按中性处理。'); + if (['exalted', 'own', 'friend'].includes(pos.dignity.key) || [1, 4, 5, 7, 9, 10].includes(pos.house)) { + return d9Flag('green', 'D9七宫主位置', `${planetName(planet)}在${formatD9Pos(pos)},婚姻运作模式较容易成形。`); + } + if (pos.dignity.key === 'debilitated' || [8, 12].includes(pos.house)) { + return d9Flag('red', 'D9七宫主位置', `${planetName(planet)}在${formatD9Pos(pos)},伴侣协作与长期承诺需要更清晰规则。`); + } + return d9Flag('yellow', 'D9七宫主位置', `${planetName(planet)}在${formatD9Pos(pos)},婚姻运作信号中等。`); +} + +function evaluateDKVenusFlag(dkPlanet, dkPos, venusPos) { + if (!dkPlanet || !dkPos || !venusPos) return d9Flag('yellow', 'DK-Venus关系', '缺少DK或Venus,暂按中性处理。'); + const distance = signDistance(dkPos.sign, venusPos.sign); + if (distance === 1 || distance === 5 || distance === 7 || distance === 9 || dkPos.sign === venusPos.sign) { + return d9Flag('green', 'DK-Venus关系', `DK ${planetName(dkPlanet)}与Venus形成同宫/吉性距离,配偶征象与关系品质较能互相接住。`); + } + if ([6, 8, 12].includes(distance)) { + return d9Flag('red', 'DK-Venus关系', `DK ${planetName(dkPlanet)}与Venus呈${distance}位关系,吸引力与相处方式可能需要磨合。`); + } + return d9Flag('yellow', 'DK-Venus关系', `DK ${planetName(dkPlanet)}与Venus无明显强连接,需看Dasha触发。`); +} + +function evaluateSeventhMaleficsFlag(seventhPlanets) { + const malefics = seventhPlanets.filter(pn => D9_MALEFICS.includes(pn)); + if (malefics.length === 0) return d9Flag('green', 'D9七宫凶星压力', 'D9七宫无自然凶星占据,关系场域压力较轻。'); + if (malefics.length === 1) return d9Flag('yellow', 'D9七宫凶星压力', `D9七宫有${planetName(malefics[0])},关系需要管理节奏和边界。`); + return d9Flag('red', 'D9七宫凶星压力', `D9七宫有${malefics.map(planetName).join('、')},冲突/压力信号较集中。`); +} + +function evaluateVargottamaFlag(vargottama, dkPlanet) { + if (dkPlanet && vargottama.includes(dkPlanet)) return d9Flag('green', 'Vargottama检查', `DK ${planetName(dkPlanet)}为Vargottama,配偶征象跨D1/D9一致。`); + if (vargottama.includes('Venus')) return d9Flag('green', 'Vargottama检查', 'Venus为Vargottama,关系品质主题较稳定。'); + if (vargottama.length) return d9Flag('yellow', 'Vargottama检查', `${vargottama.map(planetName).join('、')}为Vargottama,可作为辅助稳定信号。`); + return d9Flag('red', 'Vargottama检查', '未见Vargottama行星,D1/D9之间需要更多交叉验证。'); +} + +function evaluatePushkaraFlag(pushkara, dkPlanet, seventhLord) { + if ((dkPlanet && pushkara.includes(dkPlanet)) || pushkara.includes('Venus')) { + const hits = pushkara.filter(pn => pn === dkPlanet || pn === 'Venus').map(planetName).join('、'); + return d9Flag('green', 'Pushkara检查', `${hits}落Pushkara Navamsa,婚恋主题有额外缓冲与祝福信号。`); + } + if (seventhLord && pushkara.includes(seventhLord)) { + return d9Flag('yellow', 'Pushkara检查', `D9七宫主${planetName(seventhLord)}落Pushkara,可缓和婚姻运作压力。`); + } + return d9Flag('red', 'Pushkara检查', 'DK、Venus、D9七宫主未落Pushkara,需要依赖其他强项补足。'); +} + +function isPushkaraNavamsa(planet) { + const sign = planet?.sign; + const deg = safeNumber(planet?.degree_in_sign ?? planet?.degree, NaN); + if (!sign || !Number.isFinite(deg)) return false; + const si = SIGNS.indexOf(sign); + const element = si % 4; + if (element === 0) return deg >= 20 && deg < 23 + (20 / 60); + if (element === 1) return deg >= 13 + (20 / 60) && deg < 16 + (40 / 60); + if (element === 2) return deg >= 6 + (40 / 60) && deg < 10; + return deg >= 26 + (40 / 60) && deg < 30; +} + +function signDistance(fromSign, toSign) { + const from = SIGNS.indexOf(fromSign); + const to = SIGNS.indexOf(toSign); + if (from < 0 || to < 0) return 0; + return ((to - from + 12) % 12) + 1; +} + +function formatD9Pos(pos) { + if (!pos) return '-'; + return `${signName(pos.sign)} · ${houseLabel(pos.house)} · ${pos.dignity.label}`; +} + +function d9Zone(score) { + if (score >= 5) return { key: 'green', name: 'Green Zone' }; + if (score >= 3) return { key: 'yellow', name: 'Yellow Zone' }; + if (score >= 1) return { key: 'orange', name: 'Orange Zone' }; + return { key: 'red', name: 'Red Zone' }; +} + +// ============================================================================ +// Dasha 系统总览 +// ============================================================================ +function parseDashaDate(value) { + if (!value) return null; + const text = String(value).slice(0, 10); + const match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (match) return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function formatDashaDate(value) { + const date = parseDashaDate(value); + if (!date) return value || '-'; + return dashaDateToString(date); +} + +function dashaDateToString(date) { + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + return `${date.getFullYear()}-${mm}-${dd}`; +} + +function formatDashaLord(lord) { + const value = String(lord || '').trim(); + if (!value) return '-'; + const houseMatch = value.match(/^House\s+(\d+)\s+\(([^)]+)\)$/i); + if (houseMatch) return `${houseMatch[1]}宫 (${signName(houseMatch[2])})`; + if (PLANET_CN[value] || PLANET_SYMBOLS[value]) return planetName(value); + if (SIGNS.includes(value)) return signName(value); + return value; +} + +function formatDashaYears(value) { + if (value == null || value === '') return '-'; + const num = Number(value); + return Number.isFinite(num) ? `${num}年` : `${value}年`; +} + +function dashaPrecisionLabel(precision) { + if (precision === 'calculator') return '专用算法'; + if (precision === 'generic') return '通用序列'; + return 'API时间线'; +} + +function dashaPrecisionNote(precision) { + if (precision === 'generic') return '当前使用通用序列展开,适合趋势参考;古典细则仍需继续校准。'; + if (precision === 'calculator') return '当前系统已调用专用计算器,适合与本命、Transit、分盘交叉验证。'; + return '当前系统由本地 API 服务返回时间线。'; +} + +function addDashaDays(date, days) { + return new Date(date.getTime() + days * 86400000); +} + +function getDashaPeriodDays(period) { + const start = parseDashaDate(period.start); + const end = parseDashaDate(period.end); + if (start && end && end > start) return (end.getTime() - start.getTime()) / 86400000; + const years = Number(period.years); + return Number.isFinite(years) && years > 0 ? years * 365.25636 : 0; +} + +function extendDashaPeriodsForToday(rawPeriods) { + const base = (Array.isArray(rawPeriods) ? rawPeriods : []) + .filter(period => period && period.start && period.end) + .map(period => ({ ...period, cycle_index: period.cycle_index || 0 })); + if (!base.length) return Array.isArray(rawPeriods) ? rawPeriods : []; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const lastEnd = parseDashaDate(base[base.length - 1].end); + if (!lastEnd || today < lastEnd) return base; + + const extended = [...base]; + let cursor = lastEnd; + let cycleIndex = 1; + let includeOneAfterCurrent = false; + + while (cursor && cycleIndex <= 5) { + for (const period of base) { + const days = getDashaPeriodDays(period); + if (!days) return extended; + const start = cursor; + const end = addDashaDays(start, days); + extended.push({ + ...period, + start: dashaDateToString(start), + end: dashaDateToString(end), + cycle_index: cycleIndex, + repeated_cycle: true, + }); + cursor = end; + if (includeOneAfterCurrent) return extended; + if (today >= start && today < end) includeOneAfterCurrent = true; + } + cycleIndex += 1; + } + + return extended; +} + +function getDashaTimelineState(periods) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const enriched = periods.map((period, index) => ({ + ...period, + _index: index, + _startDate: parseDashaDate(period.start), + _endDate: parseDashaDate(period.end), + })); + const current = enriched.find(period => ( + period._startDate && period._endDate && today >= period._startDate && today < period._endDate + )); + const next = current + ? enriched.find(period => period._index > current._index && period._startDate) + : enriched.find(period => period._startDate && today < period._startDate); + let progress = 0; + if (current?._startDate && current?._endDate) { + const total = current._endDate.getTime() - current._startDate.getTime(); + const elapsed = today.getTime() - current._startDate.getTime(); + progress = total > 0 ? Math.max(0, Math.min(100, Math.round((elapsed / total) * 100))) : 0; + } + return { periods: enriched, current, next, progress }; +} + +function renderDashaSystemOverview(chartData) { + const container = $('dasha-system-section'); + if (!container) return; + const dashas = chartData?.available_dashas || chartData?._extended?.available_dashas || []; + if (!dashas.length) { + container.innerHTML = ` +
    +
    + 当前为本地 JS 计算模式 +

    已展示 Vimshottari 三级大运。启动本地 API 服务后可查看完整 Dasha 系统清单。

    +
    +
    + `; + return; + } + + const grouped = dashas.reduce((acc, d) => { + const type = d.type || 'other'; + if (!acc[type]) acc[type] = []; + acc[type].push(d); + return acc; + }, {}); + const typeLabels = { + nakshatra: 'Nakshatra', + rasi: 'Rashi', + conditional: '条件', + varga: 'Varga', + bhav: 'Bhava', + special: 'Special', + other: 'Other', + }; + const typeButtons = ` + + ` + Object.entries(grouped).map(([type, items]) => ` + + `).join(''); + const cards = dashas.map((d, index) => ` + + `).join(''); + container.innerHTML = ` +
    +
    +
    +

    多 Dasha 系统

    +

    当前 API 返回 ${dashas.length} 种 Dasha。选择任一系统即可查看主周期时间线、当前周期、下一周期与计算精度提示。

    +
    +
    ${dashas.length}
    +
    +
    ${typeButtons}
    +
    +
    ${cards}
    +
    +
    +
    + `; + bindDashaSystemOverview(dashas, typeLabels, chartData); +} + +function bindDashaSystemOverview(dashas, typeLabels, sourceChart) { + const detail = $('dasha-system-detail'); + const list = document.querySelector('.dasha-system-list'); + const filters = document.querySelectorAll('.dasha-type-btn'); + if (!detail || !list) return; + + let requestSeq = 0; + const birth = window.__jyotishBirth || {}; + + const renderTimeline = (result) => { + const rawPeriods = extendDashaPeriodsForToday(result?.periods); + const timeline = getDashaTimelineState(rawPeriods); + const periods = timeline.periods; + const precisionLabel = dashaPrecisionLabel(result?.precision); + const current = timeline.current; + const next = timeline.next; + const analysisHtml = renderVimshottariAnalysisBlock(result?.vimshottari_analysis); + const summaryHtml = ` +
    +
    +
    当前周期
    + ${escapeHtml(current ? formatDashaLord(current.lord) : '未落入已返回周期')} +

    ${escapeHtml(current ? `${formatDashaDate(current.start)} ~ ${formatDashaDate(current.end)} · ${formatDashaYears(current.years)}` : '当前日期不在该系统已返回的时间线内。')}

    + ${current ? `
    ` : ''} +
    +
    +
    下一周期
    + ${escapeHtml(next ? formatDashaLord(next.lord) : '暂无后续周期')} +

    ${escapeHtml(next ? `${formatDashaDate(next.start)} ~ ${formatDashaDate(next.end)} · ${formatDashaYears(next.years)}` : 'API 当前没有返回更靠后的周期。')}

    +
    +
    + `; + const periodRows = periods.map(period => ` +
    + ${escapeHtml(formatDashaLord(period.lord))}${current?._index === period._index ? '当前' : ''}${next?._index === period._index ? '下一段' : ''}${period.cycle_index ? `第${escapeHtml(period.cycle_index + 1)}轮` : ''} + ${escapeHtml(formatDashaDate(period.start))} ~ ${escapeHtml(formatDashaDate(period.end))} + ${escapeHtml(formatDashaYears(period.years))} +
    + `).join(''); + detail.innerHTML = ` +
    +
    ${escapeHtml(typeLabels[result?.type] || result?.type || 'Other')} · ${escapeHtml(precisionLabel)}
    + ${escapeHtml(result?.name || result?.key || 'Dasha')} +

    周期:${escapeHtml(result?.cycle_years ?? '-')} 年。${escapeHtml(dashaPrecisionNote(result?.precision))}

    + ${summaryHtml} + ${analysisHtml} +
    ${periodRows || '

    暂无可显示周期。

    '}
    +
    + `; + }; + + const showDetail = async (key) => { + const item = dashas.find(d => (d.key || d.name) === key) || dashas[0]; + if (!item) return; + const activeSeq = ++requestSeq; + detail.innerHTML = ` +
    +
    ${escapeHtml(typeLabels[item.type] || item.type || 'Other')}
    + ${escapeHtml(item.name || item.key || 'Dasha')} +

    正在从本地 API 服务读取该 Dasha 的时间线...

    +
    + `; + try { + const result = await window.JyotishAPI?.computeDashaSystem?.({ + ...birth, + dasha: item.key, + planets: sourceChart?.planets || {}, + ascendant: sourceChart?.ascendant || {}, + }); + if (!result || result.success === false) { + throw new Error(result?.error || 'Dasha API 未返回有效时间线'); + } + if (activeSeq !== requestSeq) return; + renderTimeline(result); + } catch (err) { + if (activeSeq !== requestSeq) return; + detail.innerHTML = ` +
    +
    ${escapeHtml(typeLabels[item.type] || item.type || 'Other')}
    + ${escapeHtml(item.name || item.key || 'Dasha')} +

    周期:${escapeHtml(item.years ?? '-')} 年。当前未能连接单系统时间线接口:${escapeHtml(err?.message || '本地 API 服务不可用')}。

    +
    + `; + } + }; + + list.querySelectorAll('.dasha-system-card').forEach(card => { + card.addEventListener('click', () => { + list.querySelectorAll('.dasha-system-card').forEach(c => c.classList.remove('selected')); + card.classList.add('selected'); + showDetail(card.dataset.dashaKey); + }); + }); + filters.forEach(btn => { + btn.addEventListener('click', () => { + filters.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const type = btn.dataset.dashaType; + let firstVisible = null; + list.querySelectorAll('.dasha-system-card').forEach(card => { + const visible = type === 'all' || card.dataset.dashaType === type; + card.hidden = !visible; + card.classList.remove('selected'); + if (visible && !firstVisible) firstVisible = card; + }); + if (firstVisible) { + firstVisible.classList.add('selected'); + showDetail(firstVisible.dataset.dashaKey); + } + }); + }); + const first = list.querySelector('.dasha-system-card'); + if (first) showDetail(first.dataset.dashaKey); +} + +function renderVimshottariAnalysisBlock(analysis) { + if (!analysis || !analysis.current) return ''; + const md = analysis.current.mahadasha || {}; + const ad = analysis.current.antardasha || {}; + const levels = analysis.five_levels || {}; + const levelLine = ['mahadasha', 'bhukti', 'pratyantar', 'sookshma', 'prana'] + .map(key => levels[key]?.lord) + .filter(Boolean) + .join(' / '); + const keywords = (analysis.current.keywords || []).slice(0, 4); + return ` +
    +
    + ${escapeHtml(analysis.nakshatra?.name || 'Vimshottari')} + ${escapeHtml(analysis.summary?.headline || `${md.lord || '-'} / ${ad.lord || '-'}`)} +
    +
    +
    Mahadasha${escapeHtml(formatDashaLord(md.lord || '-'))}

    ${escapeHtml(formatDashaDate(md.start))} ~ ${escapeHtml(formatDashaDate(md.end))}

    +
    Antardasha${escapeHtml(formatDashaLord(ad.lord || '-'))}

    剩余约 ${escapeHtml(analysis.current.remaining_days ?? '-')} 天

    +
    五级层级${escapeHtml(levelLine || '-')}

    ${escapeHtml(analysis.current.theme || '以运限作为时间主轴')}

    +
    +
    + ${keywords.map(item => `${escapeHtml(item)}`).join('')} + ${escapeHtml(analysis.source || 'dasha_analyzer.py')} +
    +
    + `; +} + // ============================================================================ // Remedies Tab 渲染 // ============================================================================ function renderRemediesTab(chartData) { const sec = document.getElementById('remedies-section'); if (!sec) return; - const ext = chartData._extended; - if (!ext?.remedies) { - sec.innerHTML = '

    补救建议将在API返回后展示

    '; + const remedies = chartData?.remedies || chartData?._extended?.remedies; + if (!remedies) { + sec.innerHTML = ` +
    + 补救建议暂不可用 +

    启动本地 API 服务并重新排盘后,可根据 Shadbala、当前 Dasha 与 Dosha 自动生成补救计划。

    +
    + `; return; } - const { recommendations, summary } = ext.remedies; - let html = `

    ${summary}

    `; - - // Gems - const gems = recommendations?.gems || []; - if (gems.length > 0) { - html += '

    💎 宝石建议

    '; - for (const g of gems.slice(0, 4)) { - html += `
    - ${g.planet}: ${g.gem}
    ${g.metal || ''} · ${g.finger || ''} · ${g.day || ''}
    `; - } - html += '
    '; + const recommendations = remedies.recommendations || {}; + const gems = recommendations.gems || []; + const mantras = recommendations.mantras || []; + const donations = recommendations.donations || []; + const fasting = recommendations.fasting || []; + const lifestyle = recommendations.lifestyle || []; + const doshaRemedies = recommendations.dosha_remedies || []; + const weakPlanets = remedies.weak_planets || []; + const moderatePlanets = remedies.moderate_planets || []; + const primaryMantra = mantras[0]; + const primaryDonation = donations[0]; + const primaryLifestyle = lifestyle[0]; + const totalActions = gems.length + mantras.length + donations.length + fasting.length + lifestyle.length + doshaRemedies.length; + + const renderActionCards = (items, mapper) => items.map(item => { + const mapped = mapper(item); + return ` +
    + ${escapeHtml(mapped.label)} + ${escapeHtml(mapped.title)} +

    ${escapeHtml(mapped.body)}

    + ${mapped.meta ? `${escapeHtml(mapped.meta)}` : ''} +
    + `; + }).join(''); + + const gemstoneCards = renderActionCards(gems.slice(0, 3), g => ({ + kind: 'gem', + label: '谨慎', + title: `${g.planet || ''} · ${g.gem || ''}`, + body: g.note || '宝石类补救需要结合完整星盘、体质和预算复核。', + meta: [g.metal, g.finger, g.day].filter(Boolean).join(' · '), + })); + const mantraCards = renderActionCards(mantras.slice(0, 4), m => ({ + kind: 'mantra', + label: '低风险', + title: m.mantra || m.planet || 'Mantra', + body: m.note || `每日念诵 ${m.repetitions || 27} 遍`, + meta: m.priority === 'dasha_lord' ? '当前 Dasha 主星优先' : `${m.repetitions || '-'} 遍`, + })); + const donationCards = renderActionCards(donations.slice(0, 3), d => ({ + kind: 'donation', + label: '行动', + title: d.planet || 'Dana 捐赠', + body: d.note || '选择能力范围内的小额、持续、真实善行。', + meta: Array.isArray(d.items) ? d.items.slice(0, 3).join(' · ') : '', + })); + const fastingCards = renderActionCards(fasting.slice(0, 2), f => ({ + kind: 'fasting', + label: '需评估', + title: `${f.planet || ''} 斋戒`, + body: f.note || '有低血糖、慢病、孕期或饮食限制者不要自行断食。', + meta: f.day || '', + })); + const doshaCards = renderActionCards(doshaRemedies.slice(0, 3), d => ({ + kind: 'dosha', + label: '专项', + title: d.dosha || 'Dosha', + body: d.note || '专项补救需结合完整星盘和现实背景判断。', + meta: Array.isArray(d.actions) ? d.actions.slice(0, 2).join(' · ') : '', + })); + + const firstWeek = [ + primaryMantra ? `每天固定一个安静时段,念诵 ${primaryMantra.mantra || primaryMantra.planet} ${primaryMantra.repetitions || 27} 遍。` : '每天记录一次情绪、睡眠与精力变化,先观察一周。', + primaryDonation ? `选择一天做一次小额捐赠或服务:${primaryDonation.note || primaryDonation.planet || '按能力行善'}。` : '做一件可验证的小善行,保持简单、可持续。', + primaryLifestyle ? primaryLifestyle.note : '避免一次性购买高价宝石,先执行低风险补救。', + ]; + const evidenceCards = (remedies.evidence_chain || []).slice(0, 6).map(item => ` +
    + ${escapeHtml(item.source || 'evidence')} + ${escapeHtml(item.planet || item.dosha || '规则命中')} +

    ${escapeHtml(item.reason || '补救建议的来源依据')}

    + ${item.value !== undefined ? `value ${escapeHtml(item.value)} / threshold ${escapeHtml(item.threshold || '-')}` : ''} +
    + `).join(''); + + sec.innerHTML = ` +
    +
    +
    +

    Upaya 补救计划

    +

    ${escapeHtml(remedies.summary || '根据当前星盘生成补救建议。')}

    +
    +
    + ${escapeHtml(totalActions)}条建议 + ${escapeHtml(weakPlanets.length)}极弱行星 + ${escapeHtml(moderatePlanets.length)}偏弱行星 +
    +
    + +
    + 使用边界 +

    补救建议只能作为传统文化与自我管理参考,不能替代医疗、法律、投资或心理咨询。宝石、斋戒、仪式类建议请先做现实风险评估。

    +
    + +
    +

    建议依据

    +
    ${evidenceCards || '

    暂无明确弱项证据,当前建议以低风险观察为主。

    '}
    +

    ${escapeHtml(remedies.next_action || '先执行低风险补救,再根据现实反馈复核。')}

    +
    + +
    +

    前 7 天执行计划

    +
      ${firstWeek.map(step => `
    1. ${escapeHtml(step)}
    2. `).join('')}
    +
    + +
    +

    低风险优先

    +
    ${mantraCards || '

    暂无咒语建议。

    '}${donationCards}${lifestyle.length ? renderActionCards(lifestyle.slice(0, 2), l => ({ kind: 'lifestyle', label: '生活', title: l.type || 'Lifestyle', body: l.note || '', meta: '' })) : ''}
    +
    + +
    +

    需要谨慎确认

    +
    ${gemstoneCards || '

    暂无宝石建议。

    '}${fastingCards}${doshaCards}
    +
    + +
    + JSON +
    ${escapeHtml(JSON.stringify(remedies, null, 2).slice(0, 6000))}
    +
    +
    + `; +} + +// ============================================================================ +// 合盘 Tab 渲染 +// ============================================================================ +function renderSynastryTab(chartData) { + const selfMoon = $('synastry-self-moon'); + const partnerMoon = $('synastry-partner-moon'); + const result = $('synastry-result'); + const runBtn = $('btn-run-synastry'); + const runFullBtn = $('btn-run-synastry-full'); + const runLibraryBtn = $('btn-run-synastry-library'); + if (!result) return; + + const moonLon = getChartMoonLongitude(chartData); + if (selfMoon && Number.isFinite(Number(moonLon))) { + selfMoon.value = Number(moonLon).toFixed(2); } - // Mantras - const mantras = recommendations?.mantras || []; - if (mantras.length > 0) { - html += '

    🕉️ 咒语建议

    '; - for (const m of mantras.slice(0, 5)) { - html += `
    - ${m.mantra} × ${m.repetitions} — ${m.note || ''}
    `; - } + renderSynastryPartnerLibrary(); + renderSynastryPairWorkspace(); + setupSynastryPairActions(); + setupSynastryPartnerCitySearch(); + + if (runLibraryBtn && !runLibraryBtn.dataset.bound) { + runLibraryBtn.dataset.bound = 'true'; + runLibraryBtn.addEventListener('click', async () => { + try { + const entry = getSelectedSynastryLibraryEntry(); + if (!entry?.data) { + result.innerHTML = '

    请先在本地星盘库中选择一个对方星盘。

    '; + return; + } + await runSynastryWithPartnerChart(getActiveChartData(), entry.data, result, { + partnerBirth: entry.data?.birth_info || {}, + partnerLabel: entry.label || entry.id, + loading: `正在使用「${entry.label || entry.id}」计算完整合盘...`, + }); + } catch (e) { + result.innerHTML = renderInlineAPIError('synastry-error', '合盘计算暂不可用', e); + } + }); } - // Donations - const donations = recommendations?.donations || []; - if (donations.length > 0) { - html += '

    🙏 捐赠建议

    '; - for (const d of donations.slice(0, 3)) { - html += `
    ${d.note || ''}
    `; - } + if (runFullBtn && !runFullBtn.dataset.bound) { + runFullBtn.dataset.bound = 'true'; + runFullBtn.addEventListener('click', async () => { + try { + const partnerBirth = readSynastryPartnerBirth(); + result.innerHTML = '

    正在为对方排盘并计算完整合盘...

    '; + const partnerChart = await computeChartForBirth(partnerBirth); + await runSynastryWithPartnerChart(getActiveChartData(), partnerChart, result, { partnerBirth }); + } catch (e) { + result.innerHTML = renderInlineAPIError('synastry-error', '合盘计算暂不可用', e); + } + }); } - // Dosha remedies - const dr = recommendations?.dosha_remedies || []; - if (dr.length > 0) { - html += '

    ⚠️ Dosha专项补救

    '; - for (const d of dr) { - html += `
    - ${d.dosha}: ${d.note || ''}
    `; - } + if (runBtn && partnerMoon && selfMoon && !runBtn.dataset.bound) { + runBtn.dataset.bound = 'true'; + runBtn.addEventListener('click', async () => { + const maleMoon = Number(selfMoon.value); + const femaleMoon = Number(partnerMoon.value); + if (!Number.isFinite(maleMoon) || !Number.isFinite(femaleMoon)) { + result.innerHTML = '

    请输入 0-360 之间的月亮黄经。

    '; + return; + } + if (maleMoon < 0 || maleMoon > 360 || femaleMoon < 0 || femaleMoon > 360) { + result.innerHTML = '

    月亮黄经必须在 0-360 度之间。

    '; + return; + } + result.innerHTML = '

    正在计算 Ashtakoot 合盘...

    '; + try { + const data = await window.JyotishAPI?.computeSynastry?.({ male_moon: maleMoon, female_moon: femaleMoon }); + if (!data) throw new Error('本地 API 未返回结果'); + recordSynastryWorkflow(data, null, { mode: 'moon_longitude_quick' }); + result.innerHTML = renderSynastryResult(data); + bindTerms(result); + } catch (e) { + result.innerHTML = renderInlineAPIError('synastry-error', '合盘计算暂不可用', e); + } + }); } +} - sec.innerHTML = html; +function getActiveChartData() { + return chartData; +} + +function renderSynastryPartnerLibrary() { + const select = $('synastry-partner-library'); + if (!select) return; + const previous = select.value; + const currentId = buildWorkspaceChartId(chartData); + const legacyCurrentId = buildLegacyWorkspaceChartId(chartData); + const lib = sortChartLibrary(readChartLibrary()).filter(entry => entry?.id && ![currentId, legacyCurrentId].includes(entry.id) && entry?.data); + select.innerHTML = lib.length + ? `${lib.map(entry => ``).join('')}` + : ''; + if (previous && lib.some(entry => entry.id === previous)) { + select.value = previous; + } +} + +function getSelectedSynastryLibraryEntry() { + const id = $('synastry-partner-library')?.value || ''; + if (!id) return null; + return readChartLibrary().find(entry => entry?.id === id) || null; +} + +async function runSynastryWithPartnerChart(selfChart, partnerChart, result, options = {}) { + const maleMoon = getChartMoonLongitude(selfChart); + if (!Number.isFinite(maleMoon)) { + result.innerHTML = '

    本人星盘缺少月亮黄经,请先重新排盘。

    '; + return; + } + const femaleMoon = getChartMoonLongitude(partnerChart); + if (!Number.isFinite(femaleMoon)) throw new Error('对方星盘缺少月亮黄经,无法计算 Ashtakoot'); + if (options.loading) { + result.innerHTML = `

    ${escapeHtml(options.loading)}

    `; + } + const data = await window.JyotishAPI?.computeSynastry?.({ male_moon: maleMoon, female_moon: femaleMoon }); + if (!data) throw new Error('本地 API 未返回结果'); + const deep = buildSynastryDeepContext(selfChart, partnerChart, options.partnerBirth || partnerChart?.birth_info || {}); + recordSynastryWorkflow(data, deep, { mode: options.partnerBirth ? 'full_birth_chart' : 'saved_partner_chart' }); + _lastSynastryPairRecord = buildSynastryPairRecord(selfChart, partnerChart, data, deep, options); + result.innerHTML = renderSynastryResult(data, deep); + renderSynastryPairWorkspace(); + bindTerms(result); +} + +function getChartMoonLongitude(chart) { + const moon = chart?.planets?.Moon || {}; + const value = moon.lon ?? moon.longitude ?? moon.degree; + const n = Number(value); + return Number.isFinite(n) ? ((n % 360) + 360) % 360 : NaN; +} + +function setupSynastryPartnerCitySearch() { + const cityInput = $('synastry-partner-city'); + const suggestions = $('synastry-city-suggestions'); + const latInput = $('synastry-partner-lat'); + const lonInput = $('synastry-partner-lon'); + const tzInput = $('synastry-partner-tz'); + if (!cityInput || !suggestions || !latInput || !lonInput || !tzInput || cityInput.dataset.bound) return; + cityInput.dataset.bound = 'true'; + let timer = null; + cityInput.addEventListener('input', () => { + latInput.value = ''; + lonInput.value = ''; + clearTimeout(timer); + timer = setTimeout(() => { + const q = cityInput.value.trim(); + if (q.length < 1) { suggestions.classList.add('hidden'); return; } + const results = searchCities(q); + if (!results.length) { suggestions.classList.add('hidden'); return; } + suggestions.innerHTML = results.map(c => { + const lat = safeNumber(c.lat); + const lon = safeNumber(c.lon); + const tz = safeNumber(c.tz); + const label = c.en ? `${c.name} (${c.en})` : c.name; + return `
    ${escapeHtml(label)} ${lat.toFixed(1)}°, ${lon.toFixed(1)}°
    `; + }).join(''); + suggestions.classList.remove('hidden'); + suggestions.querySelectorAll('.suggestion-item').forEach(el => { + el.addEventListener('click', () => { + cityInput.value = el.textContent.trim().split(/\s+\(/)[0]; + latInput.value = el.dataset.lat; + lonInput.value = el.dataset.lon; + tzInput.value = el.dataset.tz; + suggestions.classList.add('hidden'); + }); + }); + }, 200); + }); + document.addEventListener('click', e => { + if (!cityInput.contains(e.target) && !suggestions.contains(e.target)) suggestions.classList.add('hidden'); + }); +} + +function readSynastryPartnerBirth() { + const dateValue = $('synastry-partner-date')?.value || ''; + const timeValue = $('synastry-partner-time')?.value || ''; + const lat = Number($('synastry-partner-lat')?.value); + const lon = Number($('synastry-partner-lon')?.value); + const tz = Number($('synastry-partner-tz')?.value); + if (!dateValue || !timeValue) throw new Error('请填写对方出生日期和时间。'); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) throw new Error('请选择对方出生城市,或通过城市搜索填入经纬度。'); + if (!Number.isFinite(tz)) throw new Error('请填写对方出生地时区,例如印度为 5.5,中国为 8。'); + const [year, month, day] = dateValue.split('-').map(Number); + const [hour, minute] = timeValue.split(':').map(Number); + if (![year, month, day, hour, minute].every(Number.isFinite)) throw new Error('对方出生日期或时间格式不正确。'); + return { year, month, day, hour, minute, lat, lon, tz }; +} + +function buildSynastryDeepContext(selfChart, partnerChart, partnerBirth) { + return { + partnerBirth, + partnerSummary: buildSynastryChartSummary(partnerChart), + comparison: buildBiWheelComparisonData(selfChart, partnerChart), + spouseStatus: buildSynastrySpouseStatusContext(selfChart, partnerChart), + ulDkTiming: buildULDKTimingContext(selfChart, partnerChart), + selfD9: buildChartD9MarriageSnapshot(selfChart), + partnerD9: buildChartD9MarriageSnapshot(partnerChart), + kuja: buildKujaComparison(selfChart, partnerChart), + dasha: buildDashaSync(selfChart, partnerChart), + }; +} + +function buildSynastryChartSummary(chart) { + const asc = chart?.ascendant || {}; + const moon = chart?.planets?.Moon || {}; + const current = getCurrentDashaSnapshot(chart); + return { + ascendant: asc.sign ? signName(asc.sign) : '-', + moon: moon.sign ? `${signName(moon.sign)} ${formatDegreeInSign(moon.degree_in_sign ?? moon.degree)}` : '-', + nakshatra: moon.nakshatra ? `${moon.nakshatra}${moon.nakshatra_pada ? ` P${moon.nakshatra_pada}` : ''}` : '-', + dasha: current.label, + }; +} + +function buildSynastrySpouseStatusContext(selfChart, partnerChart) { + return { + self: buildSpouseStatusSnapshot(selfChart), + partner: buildSpouseStatusSnapshot(partnerChart), + }; +} + +function buildULDKTimingContext(selfChart, partnerChart) { + return { + self: buildULDKTimingSnapshot(selfChart), + partner: buildULDKTimingSnapshot(partnerChart), + }; +} + +function buildULDKTimingSnapshot(chart) { + if (!chart?.planets || !chart?.ascendant?.sign) return null; + const karaka = computeKaraka(chart.planets); + const arudha = computeArudha(chart.planets, chart.ascendant.sign); + const dk7 = normalizeDKPoint(karaka?.karaka7?.DK, chart); + const dk8 = normalizeDKPoint(karaka?.karaka8?.DK, chart); + const ul = normalizeULPoint(arudha?.UL); + const dasha = getCurrentDashaSnapshot(chart); + const seventhSign = SIGNS[(SIGNS.indexOf(chart.ascendant.sign) + 6 + 12) % 12] || ''; + const seventhLord = SIGN_LORDS[seventhSign] || ''; + const triggers = []; + const dashaLords = [dasha.maha, dasha.antar, dasha.pratyantar].filter(Boolean); + const addTrigger = text => { + if (text && !triggers.includes(text)) triggers.push(text); + }; + dashaLords.forEach(lord => { + if (lord === dk7?.planet) addTrigger(`${planetName(lord)} 运触发 7星制 DK`); + if (lord === dk8?.planet && lord !== dk7?.planet) addTrigger(`${planetName(lord)} 运触发 8星制 DK`); + if (lord === seventhLord) addTrigger(`${planetName(lord)} 运触发 7宫主`); + if (['Venus', 'Jupiter', 'Moon', 'Mars'].includes(lord)) addTrigger(`${planetName(lord)} 运触发关系自然征象星`); + if (lord === ul?.lord) addTrigger(`${planetName(lord)} 运触发 UL 主星`); + }); + const ulMatchesDK = Boolean(ul?.sign && [dk7?.sign, dk8?.sign].includes(ul.sign)); + if (ulMatchesDK) addTrigger(`UL 与 DK 同落 ${signName(ul.sign)},配偶征象更集中`); + const level = triggers.length >= 3 ? 'strong' : triggers.length >= 1 ? 'moderate' : 'low'; + const summary = level === 'strong' + ? 'DK/UL 与当前运限多点交叉' + : level === 'moderate' + ? 'DK/UL 或关系星有单点触发' + : '当前运限未明显触发 DK/UL'; + return { + dk7, + dk8, + ul, + dasha, + triggers, + level, + summary, + evidence: [ + dk7 ? `7星制 DK:${formatDKPoint(dk7)}` : '7星制 DK 待补充', + dk8 ? `8星制 DK:${formatDKPoint(dk8)}` : '8星制 DK 待补充', + ul ? `UL:${formatULPoint(ul)}` : 'UL 待补充', + `当前 Dasha:${dasha.label || '-'}`, + ], + }; +} + +function normalizeDKPoint(point, chart) { + if (!point?.planet) return null; + const planet = chart?.planets?.[point.planet] || {}; + return { + planet: point.planet, + sign: point.sign || planet.sign || '', + house: point.house || planet.house || '', + degree: point.degree, + }; +} + +function normalizeULPoint(point) { + if (!point?.sign) return null; + return { + sign: point.sign, + pada_house: point.pada_house, + lord: point.lord, + }; +} + +function buildSpouseStatusSnapshot(chart) { + const ascSign = chart?.ascendant?.sign || ''; + if (!ascSign || !chart?.planets) return null; + const seventhSign = SIGNS[(SIGNS.indexOf(ascSign) + 6 + 12) % 12] || ''; + const seventhLord = SIGN_LORDS[seventhSign] || ''; + const lagnaLord = SIGN_LORDS[ascSign] || ''; + const seventhLordPlanet = chart.planets[seventhLord] || {}; + const lagnaLordPlanet = chart.planets[lagnaLord] || {}; + const seventhOccupants = Object.entries(chart.planets) + .filter(([, planet]) => Number(planet?.house) === 7) + .map(([name]) => name); + const upachayaFromSeventh = [9, 12, 4, 5]; + const growthHouses = Object.entries(chart.planets) + .filter(([, planet]) => upachayaFromSeventh.includes(Number(planet?.house))) + .map(([name, planet]) => `${planetName(name)} H${planet.house}`); + const score = [ + ['exalted', 'own'].includes(seventhLordPlanet.dignity) ? 2 : ['friendly'].includes(seventhLordPlanet.dignity) ? 1 : 0, + [1, 4, 5, 7, 9, 10].includes(Number(seventhLordPlanet.house)) ? 1 : 0, + [3, 6, 10, 11].includes(Number(seventhLordPlanet.house)) ? 1 : 0, + growthHouses.length ? 1 : 0, + ].reduce((sum, value) => sum + value, 0); + return { + ascSign, + seventhSign, + seventhLord, + lagnaLord, + score, + level: score >= 4 ? 'strong' : score >= 2 ? 'moderate' : 'low', + verdict: score >= 4 ? '婚后成长/伴侣资源信号强' : score >= 2 ? '有婚后成长或伴侣资源信号' : '未见明显高地位配偶 Yoga', + evidence: [ + `7宫 ${signName(seventhSign)},7主 ${planetName(seventhLord)} 位于 H${seventhLordPlanet.house || '-'} ${seventhLordPlanet.dignity || 'unknown'}`, + `Lagna 主 ${planetName(lagnaLord)} 位于 H${lagnaLordPlanet.house || '-'} ${lagnaLordPlanet.dignity || 'unknown'}`, + seventhOccupants.length ? `7宫内行星:${seventhOccupants.map(planetName).join('、')}` : '7宫无主要行星占据', + growthHouses.length ? `从7宫起算成长宫被占据:${growthHouses.join('、')}` : '从7宫起算成长宫暂无明显占据', + ], + }; +} + +function buildChartD9MarriageSnapshot(chart) { + if (!chart?.planets || !chart?.ascendant) return null; + const allV = computeAllVargas(chart.planets); + const karaka = computeKaraka(chart.planets); + const report = buildD9MarriageReport(allV, chart.planets, chart.ascendant, karaka); + if (!report) return null; + return { + score: report.score, + zone: report.zone, + core: report.core, + positives: report.flags.filter(flag => flag.score > 0).slice(0, 3), + risks: report.flags.filter(flag => flag.score < 0).slice(0, 3), + }; +} + +function buildKujaComparison(selfChart, partnerChart) { + const self = analyzeKujaDosha(selfChart); + const partner = analyzeKujaDosha(partnerChart); + const diff = Math.abs(self.score - partner.score); + let verdict = 'balanced'; + let label = '火星煞压力相近'; + let note = '双方 Kuja/Manglik 压力相近,可继续综合看 D9 与 Dasha。'; + if (diff >= 3) { + verdict = 'hard'; + label = '火星煞不平衡'; + note = '双方火星煞压力差距较大,关系冲突、节奏和安全感议题需要额外复核。'; + } else if (diff >= 2) { + verdict = 'watch'; + label = '火星煞需观察'; + note = '双方火星煞压力有差异,建议结合现实互动和婚姻宫受克情况判断。'; + } + return { self, partner, diff, verdict, label, note }; +} + +function analyzeKujaDosha(chart) { + const planets = chart?.planets || {}; + const mars = planets.Mars || {}; + const moon = planets.Moon || {}; + const venus = planets.Venus || {}; + const lagnaHouse = Number(mars.house); + const moonHouse = houseFromSign(moon.sign, mars.sign); + const venusHouse = houseFromSign(venus.sign, mars.sign); + const refs = [ + { from: 'Lagna', house: lagnaHouse }, + { from: 'Moon', house: moonHouse }, + { from: 'Venus', house: venusHouse }, + ].filter(item => Number.isFinite(item.house)); + const doshaHouses = new Set([1, 2, 4, 7, 8, 12]); + const severeHouses = new Set([7, 8]); + const hits = refs.filter(item => doshaHouses.has(item.house)); + const mitigations = []; + if (['Aries', 'Scorpio', 'Capricorn'].includes(mars.sign)) mitigations.push('Mars 入庙/旺相缓解'); + if (planets.Jupiter?.house === mars.house) mitigations.push('Jupiter 同宫保护'); + let score = hits.reduce((sum, item) => sum + (severeHouses.has(item.house) ? 2 : 1), 0); + if (mitigations.length) score = Math.max(0, score - 1); + const level = score >= 4 ? 'high' : score >= 2 ? 'medium' : score >= 1 ? 'low' : 'none'; + const levelLabel = { high: '偏强', medium: '中等', low: '轻微', none: '未见明显' }[level]; + return { + score, + level, + levelLabel, + marsSign: mars.sign || '-', + marsHouse: Number.isFinite(lagnaHouse) ? lagnaHouse : '-', + hits, + mitigations, + }; +} + +function houseFromSign(fromSign, toSign) { + const fromIdx = SIGNS.indexOf(fromSign); + const toIdx = SIGNS.indexOf(toSign); + if (fromIdx < 0 || toIdx < 0) return NaN; + return ((toIdx - fromIdx + 12) % 12) + 1; +} + +function buildDashaSync(selfChart, partnerChart) { + const self = getCurrentDashaSnapshot(selfChart); + const partner = getCurrentDashaSnapshot(partnerChart); + const shared = [self.maha, self.antar, self.pratyantar].filter(Boolean) + .filter(lord => [partner.maha, partner.antar, partner.pratyantar].includes(lord)); + const relationshipLords = new Set(['Venus', 'Jupiter', 'Moon', 'Mars']); + const selfRelationship = [self.maha, self.antar].some(lord => relationshipLords.has(lord)); + const partnerRelationship = [partner.maha, partner.antar].some(lord => relationshipLords.has(lord)); + let level = 'neutral'; + let label = '节奏中性'; + let note = '当前大运/小运没有明显同步,适合继续看行运触发与现实互动。'; + if (shared.length) { + level = 'strong'; + label = 'Dasha 同步强'; + note = `双方当前运限共享 ${shared.map(planetName).join('、')},关系议题更容易被同一类事件激活。`; + } else if (selfRelationship && partnerRelationship) { + level = 'supportive'; + label = '关系主题同时被激活'; + note = '双方当前运限都触及 Venus/Jupiter/Moon/Mars,亲密关系议题的可见度较高。'; + } + return { self, partner, shared, level, label, note }; +} + +function getCurrentDashaSnapshot(chart) { + const moonLon = getChartMoonLongitude(chart); + const date = chart?.birth_info?.date; + if (!Number.isFinite(moonLon) || !date) return { label: '-', maha: '', antar: '', pratyantar: '' }; + const dasha = computeDashaWithPratyantar(moonLon, date, new Date().toISOString().split('T')[0]); + const current = dasha?.current || dasha?.current_dasha || {}; + const maha = current.maha_lord || current.maha || current.md || current.lord || ''; + const antar = current.antar_lord || current.antar || current.ad || ''; + const pratyantar = current.pratyantar_lord || current.pratyantar || current.pd || ''; + const label = [maha, antar, pratyantar].filter(Boolean).map(planetName).join(' / ') || '-'; + return { label, maha, antar, pratyantar }; +} + +function formatDegreeInSign(value) { + const n = Number(value); + return Number.isFinite(n) ? `${n.toFixed(2)}°` : ''; +} + +function buildBiWheelComparisonData(selfChart, partnerChart) { + const axes = [ + buildBiWheelAxisRow('上升轴', buildChartAxisPoint(selfChart), buildChartAxisPoint(partnerChart), '身体节奏、第一印象与共同生活入口'), + buildBiWheelAxisRow('月亮轴', buildSynastryPlanetPoint(selfChart, 'Moon'), buildSynastryPlanetPoint(partnerChart, 'Moon'), '情绪安全感、日常照料和亲密反应'), + buildBiWheelAxisRow('金星轴', buildSynastryPlanetPoint(selfChart, 'Venus'), buildSynastryPlanetPoint(partnerChart, 'Venus'), '吸引力、价值交换和关系愉悦感'), + buildBiWheelAxisRow('火星轴', buildSynastryPlanetPoint(selfChart, 'Mars'), buildSynastryPlanetPoint(partnerChart, 'Mars'), '行动节奏、冲突方式和欲望表达'), + ].filter(row => row.self?.sign || row.partner?.sign); + const planets = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu']; + const rows = planets.map(planet => { + const self = buildSynastryPlanetPoint(selfChart, planet); + const partner = buildSynastryPlanetPoint(partnerChart, planet); + if (!self.sign && !partner.sign) return null; + return { + planet, + label: planetName(planet), + self, + partner, + relation: buildSignRelationship(self.sign, partner.sign), + partnerOverlayHouse: overlayHouseFromChart(selfChart, partner.sign), + selfOverlayHouse: overlayHouseFromChart(partnerChart, self.sign), + theme: relationshipPlanetTheme(planet), + }; + }).filter(Boolean); + return { + axes, + rows, + compositeStyle: buildCompositeStyleMidpoints(selfChart, partnerChart), + }; +} + +function buildBiWheelAxisRow(label, self, partner, theme) { + return { + label, + self, + partner, + relation: buildSignRelationship(self?.sign, partner?.sign), + theme, + }; +} + +function buildChartAxisPoint(chart) { + const asc = chart?.ascendant || {}; + return { + sign: asc.sign || '', + degree: Number.isFinite(Number(asc.degree_in_sign ?? asc.degree)) ? Number(asc.degree_in_sign ?? asc.degree) : null, + house: 1, + label: asc.sign ? `${signName(asc.sign)} ${formatDegreeInSign(asc.degree_in_sign ?? asc.degree)}` : '-', + }; +} + +function buildSynastryPlanetPoint(chart, planet) { + const raw = chart?.planets?.[planet] || {}; + const lon = normalizeLongitude(raw.lon ?? raw.longitude ?? raw.absolute_degree ?? raw.degree); + const sign = raw.sign || signFromLongitude(lon) || ''; + const degree = Number.isFinite(Number(raw.degree_in_sign)) + ? Number(raw.degree_in_sign) + : Number.isFinite(lon) + ? lon % 30 + : Number.isFinite(Number(raw.degree)) + ? Number(raw.degree) + : null; + return { + planet, + sign, + degree, + lon: Number.isFinite(lon) ? lon : null, + house: Number.isFinite(Number(raw.house)) ? Number(raw.house) : null, + nakshatra: raw.nakshatra || '', + label: sign ? `${signName(sign)} ${formatDegreeInSign(degree)}` : '-', + }; +} + +function normalizeLongitude(value) { + const n = Number(value); + return Number.isFinite(n) ? ((n % 360) + 360) % 360 : NaN; +} + +function signFromLongitude(value) { + const n = Number(value); + if (!Number.isFinite(n)) return ''; + return SIGNS[Math.floor(normalizeLongitude(n) / 30) % 12] || ''; +} + +function buildSignRelationship(selfSign, partnerSign) { + const selfIdx = SIGNS.indexOf(selfSign); + const partnerIdx = SIGNS.indexOf(partnerSign); + if (selfIdx < 0 || partnerIdx < 0) { + return { houseDistance: '-', label: '待补充', tone: 'neutral' }; + } + const houseDistance = ((partnerIdx - selfIdx + 12) % 12) + 1; + const labels = { + 1: '同星座共振', + 2: '资源磨合', + 3: '沟通学习', + 4: '家庭/安全感张力', + 5: '三方支持', + 6: '调适与服务', + 7: '对宫吸引', + 8: '深层转化', + 9: '三方支持', + 10: '目标/责任张力', + 11: '社群与愿景支持', + 12: '隐性议题', + }; + const tone = [1, 5, 9, 11].includes(houseDistance) + ? 'support' + : [4, 7, 10].includes(houseDistance) + ? 'tension' + : [2, 6, 8, 12].includes(houseDistance) + ? 'work' + : 'neutral'; + return { houseDistance, label: labels[houseDistance] || '中性', tone }; +} + +function overlayHouseFromChart(chart, sign) { + const house = houseFromSign(chart?.ascendant?.sign, sign); + return Number.isFinite(house) ? house : null; +} + +function buildCompositeStyleMidpoints(selfChart, partnerChart) { + return ['Sun', 'Moon', 'Venus', 'Mars'].map(planet => { + const self = buildSynastryPlanetPoint(selfChart, planet); + const partner = buildSynastryPlanetPoint(partnerChart, planet); + if (!Number.isFinite(self.lon) || !Number.isFinite(partner.lon)) return null; + const lon = midpointLongitude(self.lon, partner.lon); + const sign = signFromLongitude(lon); + return { + planet, + label: planetName(planet), + sign, + degree: lon % 30, + note: `${planetName(planet)} midpoint 指向共同关系场的${relationshipPlanetTheme(planet)}`, + }; + }).filter(Boolean); +} + +function midpointLongitude(a, b) { + const start = normalizeLongitude(a); + const delta = ((normalizeLongitude(b) - start + 540) % 360) - 180; + return normalizeLongitude(start + delta / 2); +} + +function relationshipPlanetTheme(planet) { + return { + Sun: '身份认同与长期方向', + Moon: '情绪安全感', + Mars: '行动、冲突与欲望', + Mercury: '沟通和日常协商', + Jupiter: '信念、承诺和成长', + Venus: '吸引力、价值和愉悦', + Saturn: '责任、边界和时间压力', + Rahu: '新奇、执念和突破常规', + Ketu: '疏离、灵性和旧模式', + }[planet] || '关系互动'; +} + +function buildRelationshipReportTemplate(data = {}, deep = null) { + const total = Number(data.total_score ?? data.score?.total ?? 0); + const max = Number(data.max_score ?? data.score?.max ?? 36) || 36; + const percentage = max ? Number(((total / max) * 100).toFixed(1)) : 0; + const scores = data.scores || {}; + const kutaDetails = Object.entries(scores).map(([name, score]) => { + const maxScore = Number(kutaMax(name)); + const value = Number(score); + const ratio = Number.isFinite(value) && Number.isFinite(maxScore) && maxScore > 0 ? value / maxScore : 0; + return { name, score: Number.isFinite(value) ? value : score, max: Number.isFinite(maxScore) ? maxScore : '-', ratio }; + }); + const strongestKutas = kutaDetails.filter(item => item.ratio >= 0.75).sort((a, b) => b.ratio - a.ratio).slice(0, 3); + const weakKutas = kutaDetails.filter(item => item.ratio <= 0.35).sort((a, b) => a.ratio - b.ratio).slice(0, 3); + const d9Scores = [deep?.selfD9?.score, deep?.partnerD9?.score].map(Number).filter(Number.isFinite); + const d9Average = d9Scores.length ? Number((d9Scores.reduce((sum, value) => sum + value, 0) / d9Scores.length).toFixed(1)) : null; + const kuja = deep?.kuja || null; + const dasha = deep?.dasha || null; + const hasDeep = Boolean(deep?.selfD9 || deep?.partnerD9 || kuja || dasha); + const spouseStatus = deep?.spouseStatus || null; + const ulDkTiming = deep?.ulDkTiming || null; + const approvedFlag = data.is_match_approved ?? data.is_approved; + const status = percentage >= 78 && approvedFlag !== false + ? 'strong' + : percentage >= 58 && approvedFlag !== false + ? 'workable' + : 'needs_context'; + const statusLabel = { + strong: '高兼容,仍需完整复核', + workable: '可推进观察', + needs_context: '需谨慎综合判断', + }[status]; + const headline = status === 'strong' + ? 'Ashtakoot 基础匹配较强,适合进入 D9、Kuja 与现实互动复核。' + : status === 'workable' + ? '基础匹配有可用支撑,但关键风险位需要逐项确认。' + : '当前匹配不能只按总分推进,需要把红旗与时机证据放在前面。'; + const strengths = strongestKutas.map(item => `${item.name} ${item.score}/${item.max}:此项支持关系中的${relationshipKutaMeaning(item.name)}。`); + if (d9Average !== null && d9Average >= 3) strengths.push(`D9 平均 ${d9Average}/8:双方婚姻分盘至少有可用支撑。`); + if (dasha?.level === 'strong' || dasha?.level === 'supportive') strengths.push(dasha.note || dasha.label); + collectULDKTimingStrengths(ulDkTiming).forEach(item => strengths.push(item)); + collectSpouseStatusStrengths(spouseStatus).forEach(item => strengths.push(item)); + if (!strengths.length) strengths.push('尚未出现足够稳定的强项,建议先补完整出生盘与 D9 复核。'); + const risks = weakKutas.map(item => `${item.name} ${item.score}/${item.max}:需重点观察${relationshipKutaMeaning(item.name)}。`); + if (kuja?.verdict === 'hard' || kuja?.verdict === 'watch') risks.push(kuja.note || kuja.label); + if (d9Average !== null && d9Average < 2) risks.push(`D9 平均 ${d9Average}/8:婚姻分盘质量偏弱,不能只依赖合盘总分。`); + if ((data.exceptions || []).length) risks.push(`存在缓解条件:${(data.exceptions || []).join(';')}`); + collectULDKTimingRisks(ulDkTiming).forEach(item => risks.push(item)); + collectSpouseStatusRisks(spouseStatus).forEach(item => risks.push(item)); + if (!hasDeep) risks.push('当前只完成基础 Ashtakoot,缺少 D9、Kuja Dosha 与 Dasha 同步证据。'); + if (!risks.length) risks.push('未见明显红旗,但仍需结合双方完整星盘与现实互动。'); + const evidence = [ + { label: 'Ashtakoot', value: `${formatReportNumber(total)} / ${formatReportNumber(max)}`, note: `${percentage}% · ${data.assessment || statusLabel}` }, + { label: 'D9 质量', value: d9Average === null ? '待补充' : `${d9Average} / 8`, note: d9Scores.length ? '取双方 D9 婚姻快照平均值' : '需要双方准确出生时间' }, + { label: 'Kuja Dosha', value: kuja?.label || '待补充', note: kuja?.note || '完整出生盘后可判断冲突压力是否平衡' }, + { label: 'Dasha 时机', value: dasha?.label || '待补充', note: dasha?.note || '需要双方 Moon 与出生日期计算当前运限' }, + { label: 'UL/DK 时机', value: formatULDKTimingEvidence(ulDkTiming), note: 'Jaimini 配偶征象与当前运限触发的交叉检查' }, + { label: '配偶/婚后成长', value: formatSpouseStatusEvidence(spouseStatus), note: '来自 7宫/7主/成长宫的个人关系体质快照' }, + ]; + return { + status, + statusLabel, + headline, + percentage, + evidence, + strongestKutas, + weakKutas, + strengths: strengths.slice(0, 5), + risks: risks.slice(0, 5), + nextSteps: [ + '先确认双方出生时间精度,再复核 D9 与 7宫/7主。', + '把低分 Kuta 对应到现实互动议题,不用单一分数替代沟通。', + '若 Kuja 或 Dasha 出现压力,优先安排边界、节奏和冲突处理观察期。', + ], + boundaries: [ + '合盘报告只提供传统 Jyotish 证据,不替代个人选择。', + '总分通过不等于关系必然稳定;总分偏低也不等于关系不可经营。', + ], + }; +} + +function collectSpouseStatusStrengths(spouseStatus) { + return ['self', 'partner'].map(key => { + const item = spouseStatus?.[key]; + if (!item || item.level === 'low') return ''; + return `${key === 'self' ? '本人' : '对方'}:${item.verdict},可作为关系长期资源/成长证据。`; + }).filter(Boolean); +} + +function collectSpouseStatusRisks(spouseStatus) { + return ['self', 'partner'].map(key => { + const item = spouseStatus?.[key]; + if (!item || item.level !== 'low') return ''; + return `${key === 'self' ? '本人' : '对方'}:${item.verdict},需结合 D9 与现实承诺复核。`; + }).filter(Boolean); +} + +function collectULDKTimingStrengths(ulDkTiming) { + return ['self', 'partner'].map(key => { + const item = ulDkTiming?.[key]; + if (!item || item.level === 'low') return ''; + return `${key === 'self' ? '本人' : '对方'}:${item.summary},关系时机有可读触发。`; + }).filter(Boolean); +} + +function collectULDKTimingRisks(ulDkTiming) { + return ['self', 'partner'].map(key => { + const item = ulDkTiming?.[key]; + if (!item || item.level !== 'low') return ''; + return `${key === 'self' ? '本人' : '对方'}:${item.summary},当前运限未明显触发 DK/UL。`; + }).filter(Boolean); +} + +function formatSpouseStatusEvidence(spouseStatus) { + const self = spouseStatus?.self?.verdict || '待补充'; + const partner = spouseStatus?.partner?.verdict || '待补充'; + return `本人:${self} / 对方:${partner}`; +} + +function formatULDKTimingEvidence(ulDkTiming) { + const self = ulDkTiming?.self?.summary || '待补充'; + const partner = ulDkTiming?.partner?.summary || '待补充'; + return `本人:${self} / 对方:${partner}`; +} + +function relationshipKutaMeaning(name) { + return { + Varna: '精神追求与价值层级', + Vashya: '影响力与相处主导权', + Tara: '生活阶段与支持感', + Yoni: '性格/身体语言兼容', + GrahaMaitri: '思维方式与情感理解', + Gana: '气质类型和社交风格', + Bhakoot: '情绪频率与整体关系节奏', + Nadi: '体质、习惯和深层相似性', + }[name] || '关系互动'; +} + +function formatReportNumber(value) { + const n = Number(value); + if (!Number.isFinite(n)) return value ?? '-'; + return Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, ''); +} + +function renderSynastryResult(data, deep = null) { + const scores = data.scores || {}; + const male = data.male_details || data.male || {}; + const female = data.female_details || data.female || {}; + const approvedFlag = data.is_match_approved ?? data.is_approved; + const percentage = data.match_percentage ?? ((Number(data.total_score || 0) / Number(data.max_score || 36)) * 100).toFixed(1); + const relationshipReport = deep?.relationshipReport || data.relationshipReport || data.relationship_report || buildRelationshipReportTemplate(data, deep); + const rows = Object.entries(scores).map(([name, score]) => ` + ${escapeHtml(name)}${escapeHtml(score)} / ${escapeHtml(kutaMax(name))} + `).join(''); + const approved = approvedFlag ? '匹配通过' : '需要谨慎综合判断'; + const approvedClass = approvedFlag ? 'synastry-ok' : 'synastry-warn'; + const exceptionHtml = (data.exceptions || []).length + ? `
    缓解条件

    ${(data.exceptions || []).map(escapeHtml).join('
    ')}

    ` + : ''; + return ` +
    + ${escapeHtml(data.total_score ?? 0)} / ${escapeHtml(data.max_score ?? 36)} + ${escapeHtml(percentage)}% · ${approved} +
    +
    + ${renderPartnerDetails('本人', male)} + ${renderPartnerDetails('对方', female)} +
    +
    + + + ${rows} +
    Kuta得分
    +
    + ${exceptionHtml} + ${renderRelationshipReport(relationshipReport)} + ${deep ? renderSynastryDeepResult(deep) : ''} + ${deep ? renderSynastryPairActions() : ''} +

    合盘分数不能单独决定关系。正式关系判断还需结合双方完整星盘、7宫/7主、D9、Kuja Dosha、Dasha 同步、现实互动与自主选择。

    + `; +} + +function buildSynastryPairRecord(selfChart, partnerChart, data, deep, options = {}) { + const now = new Date().toISOString(); + const selfId = buildWorkspaceChartId(selfChart) || 'current-chart'; + const partnerId = buildWorkspaceChartId(partnerChart) || `partner-${now}`; + const total = Number(data.total_score ?? 0); + const max = Number(data.max_score ?? 36); + const partnerLabel = options.partnerLabel || buildWorkspaceChartLabel(partnerChart); + const relationshipReport = buildRelationshipReportTemplate(data, deep); + return { + id: `${selfId}__${partnerId}`, + label: `${buildWorkspaceChartLabel(selfChart)} ↔ ${partnerLabel}`, + group: '伴侣', + relation: 'partner', + tags: ['synastry', 'relationship', 'ashtakoot'], + generatedAt: now, + updatedAt: now, + selfChartId: selfId, + partnerChartId: partnerId, + self: buildSynastryChartSummary(selfChart), + partner: buildSynastryChartSummary(partnerChart), + score: { + total, + max, + percentage: max ? Number(((total / max) * 100).toFixed(1)) : 0, + }, + verdict: data.assessment || (data.is_approved ? '匹配通过' : '需要谨慎综合判断'), + scores: data.scores || {}, + exceptions: data.exceptions || [], + relationshipReport, + deep: deep ? normalizeSynastryDeepForExport({ ...deep, relationshipReport }) : null, + source: options.partnerLabel ? 'saved_partner_chart' : 'full_birth_chart', + data: { + total_score: data.total_score ?? 0, + max_score: data.max_score ?? 36, + match_percentage: data.match_percentage ?? (max ? Number(((total / max) * 100).toFixed(1)) : 0), + is_match_approved: data.is_match_approved ?? data.is_approved ?? false, + scores: data.scores || {}, + exceptions: data.exceptions || [], + male_details: data.male_details || data.male || {}, + female_details: data.female_details || data.female || {}, + assessment: data.assessment || '', + relationship_report: relationshipReport, + }, + }; +} + +function renderSynastryPairActions() { + return ` +
    + + + +
    + `; +} + +function setupSynastryPairActions() { + const result = $('synastry-result'); + if (result && result.dataset.pairActionsBound !== 'true') { + result.dataset.pairActionsBound = 'true'; + result.addEventListener('click', event => { + const btn = event.target.closest('[data-action="save-synastry-pair"], [data-action="export-synastry-pair"], [data-action="export-synastry-html"]'); + if (!btn) return; + if (btn.dataset.action === 'save-synastry-pair') { + saveCurrentSynastryPair(); + } + if (btn.dataset.action === 'export-synastry-pair') { + exportCurrentSynastryPair(); + } + if (btn.dataset.action === 'export-synastry-html') { + exportCurrentSynastryHTMLReport(); + } + }); + } + const workspace = $('synastry-pair-workspace'); + if (!workspace || workspace.dataset.pairWorkspaceBound === 'true') return; + workspace.dataset.pairWorkspaceBound = 'true'; + workspace.addEventListener('click', event => { + const btn = event.target.closest('[data-action="save-synastry-pair"], [data-action="export-synastry-pair"]'); + const actionBtn = btn || event.target.closest('.mini-action'); + if (!actionBtn) return; + if (actionBtn.dataset.action === 'workspace-open-pair') openSavedSynastryPair(actionBtn.dataset.caseId || ''); + if (actionBtn.dataset.action === 'workspace-delete-pair') deleteSavedSynastryPair(actionBtn.dataset.caseId || ''); + if (!btn) return; + if (btn.dataset.action === 'save-synastry-pair') { + saveCurrentSynastryPair(); + } + if (btn.dataset.action === 'export-synastry-pair') { + exportCurrentSynastryPair(); + } + }); +} + +function sortSynastryPairLibrary(lib) { + return [...(Array.isArray(lib) ? lib : [])].sort((a, b) => { + const aTime = Date.parse(a?.updatedAt || a?.generatedAt || '') || 0; + const bTime = Date.parse(b?.updatedAt || b?.generatedAt || '') || 0; + return bTime - aTime; + }); +} + +function saveCurrentSynastryPair() { + if (!_lastSynastryPairRecord) return; + const now = new Date().toISOString(); + const lib = readSynastryPairLibrary(); + const existing = lib.find(record => record?.id === _lastSynastryPairRecord.id); + if (existing) { + Object.assign(existing, _lastSynastryPairRecord, { updatedAt: now, generatedAt: existing.generatedAt || now }); + } else { + lib.push({ ..._lastSynastryPairRecord, generatedAt: now, updatedAt: now }); + } + writeSynastryPairLibrary(sortSynastryPairLibrary(lib).slice(0, 50)); + renderSynastryPairWorkspace(); +} + +function exportCurrentSynastryPair() { + if (!_lastSynastryPairRecord) return; + const safeId = String(_lastSynastryPairRecord.id || 'pair').replace(/[^a-z0-9_-]+/gi, '-'); + downloadText(JSON.stringify(_lastSynastryPairRecord, null, 2), `jyotish-synastry-${safeId}.json`, 'application/json;charset=utf-8'); +} + +async function exportCurrentSynastryHTMLReport() { + if (!chartData || !_lastSynastryPairRecord) return; + const workflows = ensureClientWorkflows(); + if (workflows) { + workflows.synastry = buildSynastryWorkflowFromPair(_lastSynastryPairRecord); + } + const { exportHTMLReport } = await loadExportModule(); + exportHTMLReport(chartData, buildExportExtras(chartData)); +} + +function buildSynastryWorkflowFromPair(record) { + const replayDeep = buildSynastryReplayDeep(record); + const report = record.relationshipReport || record.data?.relationship_report || record.deep?.relationshipReport || buildRelationshipReportTemplate(record.data || record, replayDeep); + return { + mode: record.source || 'saved_pair', + generatedAt: record.generatedAt || record.updatedAt || new Date().toISOString(), + total_score: record.score?.total ?? 0, + max_score: record.score?.max ?? 36, + match_percentage: record.score?.percentage ?? 0, + is_match_approved: String(record.verdict || '').includes('通过'), + scores: record.scores || {}, + exceptions: record.exceptions || [], + male_details: record.data?.male_details || record.data?.male || {}, + female_details: record.data?.female_details || record.data?.female || {}, + assessment: record.verdict || record.data?.assessment || '', + relationship_report: report, + deep: record.deep || null, + }; +} + +function renderSavedSynastryPair(record) { + const data = record?.data || { + total_score: record?.score?.total ?? 0, + max_score: record?.score?.max ?? 36, + match_percentage: record?.score?.percentage ?? 0, + scores: record?.scores || {}, + exceptions: record?.exceptions || [], + assessment: record?.verdict || '', + }; + return ` +
    + 已打开保存配对 + ${escapeHtml(record?.label || record?.id || '未命名配对')} · ${escapeHtml(formatSavedAt(record?.updatedAt || record?.generatedAt))} +
    + ${renderSynastryResult(data, buildSynastryReplayDeep(record))} + `; +} + +function buildSynastryReplayDeep(record) { + const deep = record?.deep || {}; + const report = deep.relationshipReport || record?.relationshipReport || record?.data?.relationship_report || null; + if (!deep.partnerSummary && !record?.partner) { + return report ? { relationshipReport: report } : null; + } + const dasha = deep.dasha ? { + level: deep.dasha.level || 'saved', + label: deep.dasha.label || 'Dasha 同步', + note: deep.dasha.note || '-', + self: typeof deep.dasha.self === 'object' ? deep.dasha.self : { label: deep.dasha.self || '-' }, + partner: typeof deep.dasha.partner === 'object' ? deep.dasha.partner : { label: deep.dasha.partner || '-' }, + } : null; + const kuja = deep.kuja ? { + verdict: deep.kuja.verdict || 'balanced', + label: deep.kuja.label || 'Kuja Dosha', + note: deep.kuja.note || '-', + self: typeof deep.kuja.self === 'object' ? deep.kuja.self : { levelLabel: deep.kuja.self || '-', marsHouse: '-', marsSign: '-' }, + partner: typeof deep.kuja.partner === 'object' ? deep.kuja.partner : { levelLabel: deep.kuja.partner || '-', marsHouse: '-', marsSign: '-' }, + } : null; + return { + partnerSummary: deep.partnerSummary || record.partner || {}, + dasha, + kuja, + comparison: normalizeReplayComparison(deep.comparison || record.comparison), + spouseStatus: normalizeReplaySpouseStatus(deep.spouseStatus || record.spouseStatus), + ulDkTiming: normalizeReplayULDKTiming(deep.ulDkTiming || record.ulDkTiming), + selfD9: normalizeReplayD9(deep.selfD9 || deep.d9?.self), + partnerD9: normalizeReplayD9(deep.partnerD9 || deep.d9?.partner), + relationshipReport: report, + }; +} + +function normalizeReplayComparison(comparison) { + if (!comparison) return null; + return { + axes: Array.isArray(comparison.axes) ? comparison.axes : [], + rows: Array.isArray(comparison.rows) ? comparison.rows : [], + compositeStyle: Array.isArray(comparison.compositeStyle) ? comparison.compositeStyle : [], + }; +} + +function normalizeReplaySpouseStatus(spouseStatus) { + if (!spouseStatus) return null; + const normalize = item => item ? { + ascSign: item.ascSign || '', + seventhSign: item.seventhSign || '', + seventhLord: item.seventhLord || '', + lagnaLord: item.lagnaLord || '', + score: item.score ?? 0, + level: item.level || 'low', + verdict: item.verdict || '未见明显高地位配偶 Yoga', + evidence: Array.isArray(item.evidence) ? item.evidence : [], + } : null; + return { + self: normalize(spouseStatus.self), + partner: normalize(spouseStatus.partner), + }; +} + +function normalizeReplayULDKTiming(ulDkTiming) { + if (!ulDkTiming) return null; + const normalize = item => item ? { + dk7: item.dk7 || null, + dk8: item.dk8 || null, + ul: item.ul || null, + dasha: item.dasha || null, + triggers: Array.isArray(item.triggers) ? item.triggers : [], + level: item.level || 'low', + summary: item.summary || 'UL/DK 时机待补充', + evidence: Array.isArray(item.evidence) ? item.evidence : [], + } : null; + return { + self: normalize(ulDkTiming.self), + partner: normalize(ulDkTiming.partner), + }; +} + +function normalizeReplayD9(snapshot) { + if (!snapshot) return null; + return { + score: snapshot.score ?? '-', + zone: typeof snapshot.zone === 'object' ? snapshot.zone : { name: snapshot.zone || '保存摘要' }, + core: snapshot.core || [], + positives: snapshot.positives || [], + risks: snapshot.risks || [], + }; +} + +function renderSynastryPairWorkspace() { + const container = $('synastry-pair-workspace'); + if (!container) return; + const lib = sortSynastryPairLibrary(readSynastryPairLibrary()); + if (!lib.length) { + container.innerHTML = '
    暂无保存配对记录。完成一次完整合盘后可保存当前结果。
    '; + return; + } + container.innerHTML = ` +
    + 保存配对记录 + ${lib.length} 组 +
    +
    + ${lib.slice(0, 3).map(record => ` +
    +
    + ${escapeHtml(record.label || record.id || '未命名配对')} + ${escapeHtml(record.score?.total ?? 0)} / ${escapeHtml(record.score?.max ?? 36)} · ${escapeHtml(record.verdict || '-')} + ${escapeHtml(formatSavedAt(record.updatedAt || record.generatedAt))} +
    +
    + + +
    +
    + `).join('')} +
    + `; +} + +function renderSynastryDeepResult(deep) { + const cards = [ + deep.partnerSummary ? renderSynastryPartnerSummary(deep.partnerSummary) : '', + deep.dasha ? renderSynastryDashaCard(deep.dasha) : '', + deep.kuja ? renderSynastryKujaCard(deep.kuja) : '', + ].filter(Boolean).join(''); + const d9Cards = [ + deep.selfD9 ? renderSynastryD9Card('本人 D9', deep.selfD9) : '', + deep.partnerD9 ? renderSynastryD9Card('对方 D9', deep.partnerD9) : '', + ].filter(Boolean).join(''); + return ` + ${renderBiWheelComparisonView(deep.comparison)} + ${renderULDKTimingComparison(deep.ulDkTiming)} + ${renderSpouseStatusComparison(deep.spouseStatus)} + ${cards ? `
    ${cards}
    ` : ''} + ${d9Cards ? `
    ${d9Cards}
    ` : ''} + `; +} + +function renderULDKTimingComparison(ulDkTiming) { + if (!ulDkTiming?.self && !ulDkTiming?.partner) return ''; + return ` +
    +
    + UL/DK 与关系时机 + Jaimini · Dasha trigger +
    +
    + ${renderULDKTimingCard('本人', ulDkTiming.self)} + ${renderULDKTimingCard('对方', ulDkTiming.partner)} +
    +
    + `; +} + +function renderULDKTimingCard(label, item) { + if (!item) { + return ` +
    + ${escapeHtml(label)} +

    缺少 Jaimini/运限数据,暂无法判断。

    +
    + `; + } + const triggers = (item.triggers || []).slice(0, 4); + return ` +
    + ${escapeHtml(label)} · ${escapeHtml(item.summary || 'UL/DK 时机待补充')} +

    DK7 ${escapeHtml(formatDKPoint(item.dk7))} · DK8 ${escapeHtml(formatDKPoint(item.dk8))}

    +

    UL ${escapeHtml(formatULPoint(item.ul))} · 当前 ${escapeHtml(item.dasha?.label || '-')}

    +
      + ${(triggers.length ? triggers : ['当前运限未明显触发 DK/UL,需要结合行运和现实进展。']).map(text => `
    • ${escapeHtml(text)}
    • `).join('')} +
    +
    + `; +} + +function formatDKPoint(point) { + if (!point) return '待补充'; + return `${planetName(point.planet || '')}${point.sign ? ` ${signName(point.sign)}` : ''}${point.house ? ` H${point.house}` : ''}`; +} + +function formatULPoint(point) { + if (!point) return '待补充'; + return `${signName(point.sign || '')}${point.pada_house ? ` H${point.pada_house}` : ''}${point.lord ? ` · 主 ${planetName(point.lord)}` : ''}`; +} + +function renderSpouseStatusComparison(spouseStatus) { + if (!spouseStatus?.self && !spouseStatus?.partner) return ''; + return ` +
    +
    + 配偶/婚后成长 Yoga + spouse_status_yoga.py +
    +
    + ${renderSpouseStatusCard('本人', spouseStatus.self)} + ${renderSpouseStatusCard('对方', spouseStatus.partner)} +
    +
    + `; +} + +function renderSpouseStatusCard(label, item) { + if (!item) { + return ` +
    + ${escapeHtml(label)} +

    缺少出生盘数据,暂无法判断。

    +
    + `; + } + return ` +
    + ${escapeHtml(label)} · ${escapeHtml(item.verdict || '-')} +

    7宫 ${escapeHtml(signName(item.seventhSign || ''))} · 7主 ${escapeHtml(planetName(item.seventhLord || ''))} · ${escapeHtml(item.score ?? 0)} / 5

    +
      + ${(item.evidence || []).slice(0, 4).map(text => `
    • ${escapeHtml(text)}
    • `).join('')} +
    +
    + `; +} + +function renderRelationshipReport(report = {}) { + const evidence = Array.isArray(report.evidence) ? report.evidence : []; + return ` +
    +
    +
    + 关系报告模板 +

    ${escapeHtml(report.headline || '合盘报告需要结合完整星盘、D9、Kuja 与 Dasha 复核。')}

    +
    + ${escapeHtml(report.statusLabel || '需谨慎综合判断')} +
    +
    + ${evidence.map(item => ` +
    + ${escapeHtml(item.label || '-')} + ${escapeHtml(item.value || '-')} + ${escapeHtml(item.note || '-')} +
    + `).join('')} +
    +
    + ${renderRelationshipReportList('支持证据', report.strengths)} + ${renderRelationshipReportList('需要观察', report.risks)} + ${renderRelationshipReportList('下一步', report.nextSteps)} +
    +
    + ${(report.boundaries || []).map(item => `${escapeHtml(item)}`).join('')} +
    +
    + `; +} + +function renderRelationshipReportList(label, items = []) { + const list = Array.isArray(items) && items.length ? items : ['暂无可展示条目。']; + return ` +
    + ${escapeHtml(label)} +
      + ${list.map(item => `
    • ${escapeHtml(item)}
    • `).join('')} +
    +
    + `; +} + +function renderBiWheelComparisonView(comparison) { + if (!comparison || (!comparison.axes?.length && !comparison.rows?.length)) return ''; + return ` +
    +
    + 双人比较视图 + bi-wheel / composite-style +
    +
    + ${(comparison.axes || []).map(row => ` +
    + ${escapeHtml(row.label || '-')} + ${escapeHtml(row.relation?.label || '-')} +

    本人 ${escapeHtml(row.self?.label || '-')} · 对方 ${escapeHtml(row.partner?.label || '-')}

    + ${escapeHtml(row.theme || '-')} +
    + `).join('')} +
    +
    + + + + ${(comparison.rows || []).map(row => ` + + + + + + + + `).join('')} + +
    行星本人对方互动Overlay
    ${escapeHtml(row.label || row.planet || '-')}${escapeHtml(row.self?.label || '-')}${escapeHtml(row.partner?.label || '-')}${escapeHtml(row.relation?.label || '-')}对方落本人${escapeHtml(row.partnerOverlayHouse || '-')}宫 / 本人落对方${escapeHtml(row.selfOverlayHouse || '-')}宫
    +
    + ${renderCompositeStyleStrip(comparison.compositeStyle)} +
    + `; +} + +function renderCompositeStyleStrip(items = []) { + if (!Array.isArray(items) || !items.length) return ''; + return ` +
    + ${items.map(item => ` +
    + ${escapeHtml(item.label || '-')} midpoint + ${escapeHtml(signName(item.sign || ''))} ${escapeHtml(formatDegreeInSign(item.degree))} + ${escapeHtml(item.note || '-')} +
    + `).join('')} +
    + `; +} + +function renderSynastryPartnerSummary(summary = {}) { + return ` +
    + 对方星盘摘要 +

    上升:${escapeHtml(summary.ascendant || '-')}

    +

    月亮:${escapeHtml(summary.moon || '-')} · ${escapeHtml(summary.nakshatra || '-')}

    +

    当前运限:${escapeHtml(summary.dasha || '-')}

    +
    + `; +} + +function renderSynastryDashaCard(dasha) { + return ` +
    + ${escapeHtml(dasha.label)} +

    本人:${escapeHtml(dasha.self.label)}

    +

    对方:${escapeHtml(dasha.partner.label)}

    + ${escapeHtml(dasha.note)} +
    + `; +} + +function renderSynastryKujaCard(kuja) { + return ` +
    + ${escapeHtml(kuja.label)} +

    本人:${escapeHtml(kuja.self.levelLabel)} · Mars ${escapeHtml(kuja.self.marsHouse)}宫 ${escapeHtml(signName(kuja.self.marsSign))}

    +

    对方:${escapeHtml(kuja.partner.levelLabel)} · Mars ${escapeHtml(kuja.partner.marsHouse)}宫 ${escapeHtml(signName(kuja.partner.marsSign))}

    + ${escapeHtml(kuja.note)} +
    + `; +} + +function renderSynastryD9Card(label, report) { + if (!report) { + return ` +
    + ${escapeHtml(label)} +

    D9 信息不足,请确认出生时间精度。

    +
    + `; + } + const core = (report.core || []).slice(0, 2).map(item => `

    ${escapeHtml(item.label)}:${escapeHtml(item.value)}

    `).join(''); + const risks = (report.risks || []).map(flag => escapeHtml(flag.name)).join('、') || '无明显红旗'; + const positives = (report.positives || []).map(flag => escapeHtml(flag.name)).join('、') || '暂无突出绿旗'; + return ` +
    +
    + ${escapeHtml(label)} + ${escapeHtml(report.zone.name)} · ${escapeHtml(report.score)} / 8 +
    + ${core} + 绿旗:${positives} + 风险:${risks} +
    + `; +} + +function renderPartnerDetails(label, details = {}) { + return ` +
    + ${escapeHtml(label)} + ${escapeHtml(details.moon_sign || '-')} · ${escapeHtml(details.nakshatra || '-')}${details.nakshatra_pada ? ` P${escapeHtml(details.nakshatra_pada)}` : ''} + Gana ${escapeHtml(details.gana || '-')} · Nadi ${escapeHtml(details.nadi || '-')} · Yoni ${escapeHtml(details.yoni || '-')} +
    + `; +} + +function kutaMax(name) { + return { + Varna: 1, + Vashya: 2, + Tara: 3, + Yoni: 4, + GrahaMaitri: 5, + Gana: 6, + Bhakoot: 7, + Nadi: 8, + }[name] ?? '-'; +} + +// ============================================================================ +// Prashna 问事 Tab 渲染 +// ============================================================================ +function renderPrashnaTab(chartData) { + const category = $('prashna-category'); + const question = $('prashna-question'); + const result = $('prashna-result'); + const runBtn = $('btn-run-prashna'); + if (!category || !question || !result || !runBtn) return; + renderPrashnaCaseWorkspace(); + setupPrashnaCaseActions(); + + if (!runBtn.dataset.bound) { + runBtn.dataset.bound = 'true'; + runBtn.addEventListener('click', async () => { + const questionText = question.value.trim(); + const questionType = category.value || 'general'; + if (questionText.length > 120) { + result.innerHTML = '

    问题请控制在 120 字以内。

    '; + return; + } + result.innerHTML = '

    正在铸造 Prashna 问事盘...

    '; + try { + const data = await window.JyotishAPI?.computePrashna?.({ + question: questionType, + question_text: questionText, + planets: chartData?.planets || {}, + asc_degree: chartData?.ascendant?.lon ?? chartData?.ascendant?.degree ?? 15.5, + horary_number: chartData?.kp_horary?.horary_number || '', + }); + if (!data) throw new Error('本地 API 未返回结果'); + recordPrashnaWorkflow(data, questionText, questionType); + _lastPrashnaCaseRecord = buildPrashnaCaseRecord(data, questionText, questionType); + result.innerHTML = renderPrashnaResult(data, questionText); + renderPrashnaCaseWorkspace(); + bindTerms(result); + } catch (e) { + result.innerHTML = renderInlineAPIError('prashna-error', '问事计算暂不可用', e); + } + }); + } +} + +function ensureClientWorkflows() { + const active = getActiveChartData(); + if (!active) return null; + if (!active._client_workflows || typeof active._client_workflows !== 'object') { + active._client_workflows = {}; + } + return active._client_workflows; +} + +function recordSynastryWorkflow(data, deep = null, options = {}) { + const workflows = ensureClientWorkflows(); + if (!workflows) return; + const relationshipReport = deep?.relationshipReport || data.relationship_report || buildRelationshipReportTemplate(data, deep); + workflows.synastry = { + mode: options.mode || 'synastry', + generatedAt: new Date().toISOString(), + total_score: data.total_score ?? 0, + max_score: data.max_score ?? 36, + match_percentage: data.match_percentage ?? ((Number(data.total_score || 0) / Number(data.max_score || 36)) * 100), + is_match_approved: data.is_match_approved ?? data.is_approved ?? false, + scores: data.scores || {}, + exceptions: data.exceptions || [], + male_details: data.male_details || data.male || {}, + female_details: data.female_details || data.female || {}, + relationship_report: relationshipReport, + deep: deep ? normalizeSynastryDeepForExport({ ...deep, relationshipReport }) : null, + }; +} + +function normalizeSynastryDeepForExport(deep) { + return { + partnerSummary: deep.partnerSummary || {}, + relationshipReport: deep.relationshipReport || null, + comparison: deep.comparison ? normalizeReplayComparison(deep.comparison) : null, + spouseStatus: deep.spouseStatus ? normalizeReplaySpouseStatus(deep.spouseStatus) : null, + dasha: deep.dasha ? { + label: deep.dasha.label, + note: deep.dasha.note, + self: deep.dasha.self?.label || '-', + partner: deep.dasha.partner?.label || '-', + } : null, + kuja: deep.kuja ? { + label: deep.kuja.label, + note: deep.kuja.note, + self: deep.kuja.self?.levelLabel || '-', + partner: deep.kuja.partner?.levelLabel || '-', + } : null, + d9: { + self: deep.selfD9 ? { score: deep.selfD9.score, zone: deep.selfD9.zone?.name || '' } : null, + partner: deep.partnerD9 ? { score: deep.partnerD9.score, zone: deep.partnerD9.zone?.name || '' } : null, + }, + }; +} + +function recordPrashnaWorkflow(data, questionText, questionType) { + const workflows = ensureClientWorkflows(); + if (!workflows) return; + workflows.prashna = { + generatedAt: new Date().toISOString(), + question_text: questionText || '', + question_type: questionType || 'general', + summary: data.summary || {}, + prashna_chart: data.prashna_chart || {}, + kp_horary: data.kp_horary || {}, + kp_answer_v2: data.kp_answer_v2 || data.kp_answer || {}, + kp_answer: data.kp_answer || {}, + arudha: data.arudha || {}, + nadi: data.nadi || {}, + timing: data.timing || {}, + sphutas: data.sphutas || {}, + life_sphutas: data.life_sphutas || {}, + sahams: data.sahams || {}, + lost_item: data.lost_item || {}, + kunda: data.kunda || {}, + }; +} + +function buildPrashnaCaseRecord(data, questionText, questionType) { + const now = new Date().toISOString(); + const summary = data.summary || {}; + const answer = data.kp_answer_v2 || data.kp_answer || {}; + const chart = data.prashna_chart || {}; + const conclusion = summary.conclusion || answer.kp_answer || 'MAYBE — 需要更多信息确认'; + const questionLabel = questionText || questionType || 'Prashna'; + return { + id: `${buildWorkspaceChartId(getActiveChartData()) || 'chart'}__prashna__${now.replace(/[:.]/g, '-')}`, + label: `${questionLabel.slice(0, 42)} · ${conclusion}`, + group: '研究', + relation: 'research', + tags: ['prashna', questionType || 'general'], + generatedAt: now, + updatedAt: now, + chartId: buildWorkspaceChartId(getActiveChartData()), + chartLabel: buildWorkspaceChartLabel(getActiveChartData()), + question_text: questionText || '', + question_type: questionType || 'general', + conclusion, + confidence: summary.confidence || answer.confidence || '中', + primary_house: answer.primary_house || summary.primary_house || '', + kp_horary: data.kp_horary || {}, + horary_number: data.kp_horary?.horary_number || '', + sub_lord: answer.kp_sub_lord || '', + asc: chart.asc_sign ? `${chart.asc_sign} ${chart.asc_degree ?? ''}`.trim() : '', + next_action: summary.next_action || data.timing?.recommendation || '', + data, + }; +} + +function renderPrashnaCaseActions() { + return ` +
    + + +
    + `; +} + +function setupPrashnaCaseActions() { + const result = $('prashna-result'); + if (result && result.dataset.caseActionsBound !== 'true') { + result.dataset.caseActionsBound = 'true'; + result.addEventListener('click', event => { + const btn = event.target.closest('[data-action="save-prashna-case"], [data-action="export-prashna-case"]'); + if (!btn) return; + if (btn.dataset.action === 'save-prashna-case') saveCurrentPrashnaCase(); + if (btn.dataset.action === 'export-prashna-case') exportCurrentPrashnaCase(); + }); + } + const workspace = $('prashna-case-workspace'); + if (!workspace || workspace.dataset.caseWorkspaceBound === 'true') return; + workspace.dataset.caseWorkspaceBound = 'true'; + workspace.addEventListener('click', event => { + const btn = event.target.closest('.mini-action'); + if (!btn) return; + if (btn.dataset.action === 'workspace-open-prashna') openSavedPrashnaCase(btn.dataset.caseId || ''); + if (btn.dataset.action === 'workspace-delete-prashna') deleteSavedPrashnaCase(btn.dataset.caseId || ''); + }); +} + +function saveCurrentPrashnaCase() { + if (!_lastPrashnaCaseRecord) return; + const now = new Date().toISOString(); + const lib = readPrashnaCaseLibrary(); + const existing = lib.find(record => record?.id === _lastPrashnaCaseRecord.id); + if (existing) { + Object.assign(existing, _lastPrashnaCaseRecord, { updatedAt: now, generatedAt: existing.generatedAt || now }); + } else { + lib.push({ ..._lastPrashnaCaseRecord, generatedAt: now, updatedAt: now }); + } + writePrashnaCaseLibrary(sortCaseLibrary(lib).slice(0, 80)); + renderPrashnaCaseWorkspace(); +} + +function exportCurrentPrashnaCase() { + if (!_lastPrashnaCaseRecord) return; + const safeId = String(_lastPrashnaCaseRecord.id || 'prashna').replace(/[^a-z0-9_-]+/gi, '-'); + downloadText(JSON.stringify(_lastPrashnaCaseRecord, null, 2), `jyotish-prashna-${safeId}.json`, 'application/json;charset=utf-8'); +} + +function renderPrashnaCaseWorkspace() { + const container = $('prashna-case-workspace'); + if (!container) return; + const lib = sortCaseLibrary(readPrashnaCaseLibrary()); + if (!lib.length) { + container.innerHTML = '
    暂无保存问事案例。完成一次问事分析后可保存当前结果。
    '; + return; + } + container.innerHTML = ` +
    + 保存问事案例 + ${lib.length} 条 +
    +
    + ${lib.slice(0, 4).map(record => ` +
    +
    + ${escapeHtml(record.question_text || record.question_type || '未命名问事')} + ${escapeHtml(record.conclusion || '-')} · ${escapeHtml(record.confidence || '-')} + ${escapeHtml(formatSavedAt(record.updatedAt || record.generatedAt))} +
    +
    + + +
    +
    + `).join('')} +
    + `; +} + +function renderPrashnaResult(data, questionText) { + const chart = data.prashna_chart || {}; + const answer = data.kp_answer_v2 || data.kp_answer || {}; + const oldAnswer = data.kp_answer || {}; + const arudha = data.arudha || {}; + const nadi = data.nadi || {}; + const timing = data.timing || {}; + const summary = data.summary || {}; + const answerText = summary.conclusion || answer.kp_answer || oldAnswer.kp_answer || 'MAYBE — 需要更多信息确认'; + const tone = answerText.includes('YES') ? 'prashna-yes' : answerText.includes('NO') ? 'prashna-no' : 'prashna-maybe'; + const planetRows = Object.entries(chart.planet_houses || {}).map(([planet, house]) => ` + ${escapeHtml(planet)}${escapeHtml(house)}宫 + `).join(''); + const factors = (timing.factors || []).slice(0, 5).map(item => `
  • ${escapeHtml(item)}
  • `).join(''); + return ` +
    + ${escapeHtml(answerText)} + 置信度:${escapeHtml(summary.confidence || answer.confidence || oldAnswer.confidence || '中')} +
    + ${questionText ? `

    ${escapeHtml(questionText)}

    ` : ''} +
    +
    问事上升${escapeHtml(chart.asc_sign || '-')} ${escapeHtml(chart.asc_degree ?? '-')}°
    +
    上升主星${escapeHtml(chart.prashna_lagna_lord || '-')}
    +
    问题宫${escapeHtml(answer.primary_house || oldAnswer.primary_house || summary.primary_house || '-')}宫
    +
    问题宫主${escapeHtml(answer.question_lord || oldAnswer.question_lord || summary.question_lord || '-')}
    +
    Sub Lord${escapeHtml(answer.kp_sub_lord || '-')}
    +
    Karaka${escapeHtml(answer.karaka || oldAnswer.karaka || '-')}
    +
    +
    +
    + KP 三层判定 +

    ${escapeHtml(answer.reason || oldAnswer.note || '基于问题宫、宫主与 KP Prashna 信号综合判断。')}

    + ${answer.timing_note ? `${escapeHtml(answer.timing_note)}` : ''} +
    +
    + Arudha 镜像 +

    问题宫 ${escapeHtml(arudha.question_house || '-')},宫主 ${escapeHtml(arudha.lord || '-')} 落 ${escapeHtml(arudha.lord_house || '-')}宫,镜像点在 ${escapeHtml(arudha.arudha_house || '-')}宫。

    + ${escapeHtml(arudha.note || 'Arudha 用于观察问题背后的真实关注点。')} +
    +
    + Nadi 侧写 +

    ${escapeHtml(nadi.moon_jupiter_relation || '-')}

    + ${escapeHtml(nadi.nadi_interpretation || '').replace(/\n/g, ' / ')} +
    +
    + 时机评分 +

    ${escapeHtml(timing.score ?? '-')} / 100 · ${escapeHtml(timing.rating || '-')}

    + ${factors ? `
      ${factors}
    ` : ''} +
    +
    + ${planetRows ? `
    ${planetRows}
    行星问事宫位
    ` : ''} + ${renderKPHoraryEvidence(data.kp_horary || {})} + ${renderPrashnaAdvancedEvidence(data)} + ${renderPrashnaCaseActions()} +
    ${escapeHtml(summary.next_action || timing.recommendation || '把这个结论与现实证据交叉验证后再行动。')}
    +

    Prashna 适合回答具体、单一、当下的问题。重大决定仍需结合出生盘、大运、过境和现实信息,不应只凭单次问事结论行动。

    + `; +} + +function renderKPHoraryEvidence(kpHorary = {}) { + const ruling = kpHorary.ruling_planets || {}; + const cusp = kpHorary.cuspal_sub_lord || {}; + const question = kpHorary.question_houses || {}; + const significators = kpHorary.house_significators || {}; + const judgement = kpHorary.judgement_matrix || []; + const sigRows = Object.entries(significators).map(([house, sig]) => ` + + H${escapeHtml(house)} + ${escapeHtml(formatKPList(sig.A))} + ${escapeHtml(formatKPList(sig.B))} + ${escapeHtml(formatKPList(sig.C))} + ${escapeHtml(formatKPList(sig.D))} + + `).join(''); + const judgementCards = judgement.map(row => ` +
    + H${escapeHtml(row.house)} · ${escapeHtml(row.role || '-')} + ${escapeHtml(row.signal || '-')} + score ${escapeHtml(row.score ?? '-')} +
    + `).join(''); + if (!kpHorary.method && !sigRows && !judgementCards) return ''; + return ` +
    + KP Horary:Ruling Planets / Sub Lord / Significators +
    +
    + Ruling Planets +

    Asc ${escapeHtml(ruling.ascendant_lord || '-')} · Moon Star ${escapeHtml(ruling.moon_star_lord || '-')}

    + Moon Sub ${escapeHtml(ruling.moon_sub_lord || '-')} · Horary ${escapeHtml(kpHorary.horary_number || '-')} +
    +
    + Cuspal Sub Lord +

    H${escapeHtml(cusp.house || question.primary || '-')} · ${escapeHtml(cusp.kp_lords?.sub_lord || '-')}

    + Star ${escapeHtml(cusp.kp_lords?.nakshatra_lord || '-')} · Sub-Sub ${escapeHtml(cusp.kp_lords?.sub_sub_lord || '-')} +
    +
    + Question Houses +

    Primary H${escapeHtml(question.primary || '-')} · ${escapeHtml(formatKPList(question.secondary))}

    + Karaka ${escapeHtml(question.karaka || '-')} +
    +
    + Judgement Matrix +
    ${judgementCards || '

    暂无 judgement_matrix。

    '}
    +
    +
    + ${sigRows ? `
    ${sigRows}
    ABCD
    ` : ''} +

    ${escapeHtml(kpHorary.next_action || '用 KP Horary 证据复核结论,不单独替代出生盘和现实信息。')}

    +
    + `; +} + +function renderPrashnaAdvancedEvidence(data = {}) { + const sphutas = data.sphutas || {}; + const life = data.life_sphutas || {}; + const sahams = data.sahams || {}; + const lost = data.lost_item || {}; + const kunda = data.kunda || {}; + const points = ['trisphuta', 'catusphuta', 'pancasphuta'] + .map(key => sphutas[key]) + .filter(Boolean) + .map(point => `
    ${escapeHtml(point.name)}${escapeHtml(point.sign)} · ${escapeHtml(point.house)}宫${escapeHtml(point.longitude)}°
    `) + .join(''); + const sahamCards = (sahams.points || []).slice(0, 6) + .map(point => `
    ${escapeHtml(point.note || point.name)}${escapeHtml(point.sign)} · ${escapeHtml(point.house)}宫${escapeHtml(point.name)} ${escapeHtml(point.longitude)}°
    `) + .join(''); + return ` +
    + 进阶证据:Sphuta / Saham / 失物 / Kunda +
    +
    + Sphuta 组合 +
    ${points || '

    暂无 Sphuta 数据。

    '}
    +
    +
    + Prana / Deha / Mrityu +

    ${escapeHtml(life.signal || '-')}

    + ${escapeHtml(life.note || '生命点仅作压力提示。')} +
    +
    + 失物线索 +

    ${escapeHtml(lost.summary || '-')}

    + ${escapeHtml(lost.likely_direction || '-')} +
    +
    + Kunda 验证 +

    ${escapeHtml(kunda.nakshatra || '-')} P${escapeHtml(kunda.pada || '-')} · ${escapeHtml(kunda.strength || '-')}

    + ${escapeHtml(kunda.note || '')} +
    +
    +
    ${sahamCards || '

    暂无 Saham 数据。

    '}
    +
    + `; } // ============================================================================ @@ -468,44 +5793,104 @@ function renderKPTab(chartData) { sec.innerHTML = '

    KP分析将在API返回后展示

    '; return; } - - let html = '
    '; - - // House significators - for (let h = 1; h <= 12; h++) { - const hd = ext.kp.houses[h]; - if (!hd) continue; - const sig = hd.significators || {}; - html += `
    - ${h}宫 (${hd.sign || ''})
    - A: ${(sig.A || []).join(', ') || '—'}
    - B: ${(sig.B || []).join(', ') || '—'}
    - C: ${(sig.C || []).join(', ') || '—'}
    - D: ${sig.D || '—'} -
    `; - } - html += '
    '; - // KP lords summary + const focus = window.__kpFocus || 'career'; + const domains = kpDomains(); + const domain = domains[focus] || domains.career; + const focusRows = domain.houses.map(h => renderKPHouseCard(h, ext.kp.houses[h], true)).join(''); + const otherRows = Array.from({ length: 12 }, (_, i) => i + 1) + .filter(h => !domain.houses.includes(h)) + .map(h => renderKPHouseCard(h, ext.kp.houses[h], false)) + .join(''); const planets = ext.kp.planets || {}; - if (Object.keys(planets).length > 0) { - html += '

    KP Sublord 概览

    '; - html += ''; - for (const [pn, pd] of Object.entries(planets).slice(0, 9)) { - const kl = pd.kp_lords || {}; - const sig = pd.significators || {}; - html += ` - - - - - - `; - } - html += '
    行星Sub LordSubSub LordAB
    ${pn}${kl.sub_lord || '—'}${kl.sub_sub_lord || '—'}${sig.A || '—'}${sig.B || '—'}
    '; - } + const planetRows = Object.entries(planets).slice(0, 9).map(([pn, pd]) => { + const kl = pd.kp_lords || {}; + const sig = pd.significators || {}; + return ` + + ${escapeHtml(planetName(pn))} + ${escapeHtml(kl.nakshatra || '-')} + ${escapeHtml(kl.sub_lord || '-')} + ${escapeHtml(kl.sub_sub_lord || '-')} + ${escapeHtml(formatKPList(sig.A))} + ${escapeHtml(formatKPList(sig.B))} + + `; + }).join(''); - sec.innerHTML = html; + sec.innerHTML = ` +
    +
    +
    +

    KP Sublord 事件判断

    +

    用 ABCD significator 看具体问题是否容易兑现:A/B 权重更高,C/D 作背景补充。

    +
    + +
    +
    + ${escapeHtml(domain.label)} +

    ${escapeHtml(domain.note)}

    + 重点宫位:${domain.houses.map(h => `${h}宫`).join(' / ')} +
    +
    ${focusRows}
    +
    + 查看全部 12 宫 KP Significator +
    ${otherRows}
    +
    +
    + + + ${planetRows} +
    行星NakshatraSubSub-SubAB
    +
    +

    KP 适合回答边界清楚的事件问题。先选问题域,再看对应宫位的强 significator;不要把全盘 KP 表当作泛性格解读。

    +
    + `; + + const select = document.getElementById('kp-focus-select'); + if (select && !select.dataset.bound) { + select.dataset.bound = 'true'; + select.addEventListener('change', () => { + window.__kpFocus = select.value; + renderKPTab(chartData); + }); + } +} + +function renderKPHouseCard(house, data = {}, focused = false) { + const sig = data.significators || {}; + const kp = data.kp_lords || {}; + return ` +
    +
    + ${escapeHtml(house)}宫 · ${escapeHtml(signName(data.sign || '-'))} + ${escapeHtml(planetName(kp.sub_lord || ''))} +
    +

    A ${escapeHtml(formatKPList(sig.A))}

    +

    B ${escapeHtml(formatKPList(sig.B))}

    + C ${escapeHtml(formatKPList(sig.C))} · D ${escapeHtml(formatKPList(sig.D))} +
    + `; +} + +function formatKPList(value) { + if (Array.isArray(value)) return value.length ? value.map(item => typeof item === 'number' ? `${item}宫` : planetName(item)).join('、') : '-'; + if (value === null || value === undefined || value === '') return '-'; + return typeof value === 'number' ? `${value}宫` : planetName(value); +} + +function kpDomains() { + return { + career: { label: '事业/工作', houses: [10, 6, 2, 11], note: '10宫看职位与社会结果,6宫看劳动/竞争,2与11宫看收入和兑现。' }, + relationship: { label: '关系/婚姻', houses: [7, 2, 5, 11], note: '7宫看伴侣与契约,2宫看家庭延续,5宫看恋爱,11宫看愿望兑现。' }, + finance: { label: '财务/收益', houses: [2, 11, 5, 9], note: '2宫现金与储蓄,11宫收益,5宫投机,9宫机会与贵人。' }, + health: { label: '健康/恢复', houses: [1, 6, 8, 12], note: '1宫身体状态,6宫疾病,8宫风险,12宫消耗和住院。' }, + property: { label: '房产/家宅', houses: [4, 2, 11, 12], note: '4宫房产家宅,2/11宫付款与收益,12宫支出和迁移成本。' }, + travel: { label: '旅行/迁移', houses: [3, 9, 12, 4], note: '3宫短途,9宫远行,12宫海外/离开,4宫原居所。' }, + general: { label: '一般问题', houses: [1, 10, 11, 12], note: '一般问题先看问事者、事件结果、愿望兑现与损耗。' }, + }; } // ============================================================================ @@ -515,9 +5900,9 @@ function renderVerifyTab(chartData) { const pastSec = document.getElementById('past-events-result'); if (pastSec && chartData._extended?.past_events) { const pe = chartData._extended.past_events; - pastSec.innerHTML = `

    ${pe.summary || '基于Dasha/Transit反推'}

    + pastSec.innerHTML = `

    ${escapeHtml(pe.summary || '基于Dasha/Transit反推')}

    ${(pe.events || []).map(e => `
    - ${e.period || ''}: ${e.description || ''} (${e.confidence || ''})
    `).join('')}`; + ${escapeHtml(e.period || '')}: ${escapeHtml(e.description || '')} (${escapeHtml(e.confidence || '')})
    `).join('')}`; } const caseSec = document.getElementById('case-match-result'); @@ -525,7 +5910,7 @@ function renderVerifyTab(chartData) { const cm = chartData._extended.case_matches; caseSec.innerHTML = `

    匹配到${cm.length || 0}个相似案例

    ${(cm || []).slice(0,5).map(c => `
    - ${c.name || ''}: ${c.match || ''} 吻合度:${c.accuracy || '?'}
    `).join('')}`; + ${escapeHtml(c.name || '')}: ${escapeHtml(c.match || '')} 吻合度:${escapeHtml(c.accuracy || '?')}
    `).join('')}`; } const fallSec = document.getElementById('fallacy-warn-result'); @@ -539,13 +5924,33 @@ function renderVerifyTab(chartData) { } if (warnings.length === 0) warnings.push({text: '未检测到常见误区', fix: ''}); fallSec.innerHTML = warnings.map(w => `
    - ⚠️ ${w.text}${w.fix ? ' → '+w.fix : ''}
    `).join(''); + ⚠️ ${escapeHtml(w.text)}${w.fix ? ' → '+escapeHtml(w.fix) : ''}
    `).join(''); } } // ============================================================================ // 🪐 过境对比 Tab渲染 (v6.9.0) // ============================================================================ +async function parseTransitCompareResponse(resp) { + const raw = await resp.text(); + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch (_error) { + return { error: raw.slice(0, 240) }; + } +} + +function buildTransitCompareRecoveryMessage(error) { + const message = error?.message || String(error || '本地 API 未连接'); + return `过境数据需本地 API 服务:${message}。请到 Trust Center 运行健康检查;如本地 API 未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。`; +} + +function buildTransitRenderRecoveryMessage(error) { + const message = error?.message || String(error || '本地计算异常'); + return `Transit 计算暂不可用:${message}。请到 Trust Center 运行健康检查;如本地 API 或高级计算服务未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。`; +} + function renderTransitCompareTab(chartData) { const transitBtn = document.getElementById('btn-run-transit'); if (!transitBtn) return; @@ -557,21 +5962,23 @@ function renderTransitCompareTab(chartData) { rd.innerHTML = '

    搜索过境触发点...

    '; try { const base = window.JyotishAPI?.apiBase || ''; - const key = window.JyotishAPI?.apiKey || ''; const resp = await fetch(`${base}/api/transit`, { - method: 'POST', headers: {'Content-Type': 'application/json', 'Authorization': `Bearer ${key}`}, + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({start, end, planets: Object.keys(chartData.planets || {}).slice(0, 9)}) }); - const data = await resp.json(); + const data = await parseTransitCompareResponse(resp); + if (!resp.ok || data.success === false) { + throw new Error(data.error || data.message || `Transit API HTTP ${resp.status}`); + } if (data.triggers?.length) { rd.innerHTML = `

    发现${data.triggers.length}个触发点

    ${data.triggers.slice(0, 20).map(t => `
    - ${t.planet || ''} → ${t.event || t.description} · ${t.start_date || t.date}
    `).join('')}`; + ${escapeHtml(t.planet || '')} → ${escapeHtml(t.event || t.description || '')} · ${escapeHtml(t.start_date || t.date || '')}
    `).join('')}`; } else { rd.innerHTML = '

    无显著过境触发点

    '; } } catch(e) { - rd.innerHTML = '

    过境数据需API服务器 (python3 scripts/jyotish_api_server.py)

    '; + rd.innerHTML = `

    ${escapeHtml(buildTransitCompareRecoveryMessage(e))}

    `; } }; } @@ -588,7 +5995,7 @@ async function triggerAIReading(chartData) { if (result.success) { if (summaryEl) { summaryEl.innerHTML = '
    ' - + result.reading.replace(/\n/g, '
    ') + '
    '; + + escapeHtml(result.reading).replace(/\n/g, '
    ') + ''; } console.log('[AI] ✅ 解读生成成功'); } @@ -633,7 +6040,7 @@ function buildChartSummary(ascendant, moonP, dashaData, karaka, allYogas) { .map(y => ({ name_cn: y.name_cn, negative: y.negative })); return { - moonNakshatra: moonP ? `${moonP.nakshatra} P${moonP.nakshatra_pada}` : null, + moonNakshatra: formatMoonNakshatra(moonP) || null, dasha, karaka: karakaSummary, topYogas, @@ -1017,7 +6424,7 @@ async function renderTransit(natalPlanets, ascSign, avData, ascDegree = 0) { } } catch (err) { console.error('[Transit] Error:', err); - $('transit-date').textContent = 'Transit 计算失败: ' + err.message; + $('transit-date').textContent = buildTransitRenderRecoveryMessage(err); } } @@ -1035,6 +6442,7 @@ function showPage(name) { } document.addEventListener('DOMContentLoaded', () => { + initPWAInstallability(); // 初始化 i18n initI18N(); // 语言变化时重新渲染 @@ -1043,17 +6451,24 @@ document.addEventListener('DOMContentLoaded', () => { }); initDateSelects(); + initChartImport(); setupCitySearch(); setupForm(); + setupFirstUsePanel(); + setupSavedChartPanel(); $('btn-back').addEventListener('click', () => showPage('input')); // 初始化术语 Tooltip initTooltip(); + setGlossaryTerminologyMode(readTerminologyMode()); // 初始化认证系统 initAuth(); // 初始化订阅系统 initSubscription(); // 初始化 AI 聊天 initAIChat(); + document.addEventListener('jyotish:apply-rectified-birth', event => { + applyRectifiedBirth(event.detail?.birth); + }); // Transit 更新按钮 const transitBtn = $('btn-transit-update'); if (transitBtn) { @@ -1075,10 +6490,80 @@ document.addEventListener('DOMContentLoaded', () => { // 导出辅助函数 // ============================================================================ function _getBirthNakIdx(planets) { - const moonLon = planets.Moon?.degree || 0; + const moonLon = planets.Moon?.lon ?? planets.Moon?.longitude ?? planets.Moon?.degree ?? 0; return Math.floor(moonLon / (360 / 27)) % 27; } +async function loadExportModule() { + return import('./export.js'); +} + +function buildExportExtras(sourceChart) { + const { planets, ascendant, birth_info } = sourceChart; + const vargas = computeAllVargas(planets); + const karaka = computeKaraka(planets); + const av = computeAshtakavarga(planets, ascendant.sign); + const sb = computeShadbala(planets, ascendant.sign, birth_info, vargas); + const moonLon = planets.Moon?.lon ?? planets.Moon?.longitude ?? planets.Moon?.degree ?? 0; + const dasha = computeDashaWithPratyantar(moonLon, birth_info.date, new Date().toISOString().split('T')[0]); + const baseYogas = detectYogas(planets, ascendant.sign); + const yogas = [...(baseYogas?.yogas || [])]; + try { yogas.push(...detectExtendedYogas(planets, ascendant.sign)); } catch(e) {} + const aspects = _buildAspectsModule(planets); + const panchanga = computeTithiYoga(planets, birth_info); + const nakshatraAdvanced = computeNakshatraAdvanced(planets, _getBirthNakIdx(planets)); + const charaDasha = computeCharaDasha(planets, ascendant.sign); + const karakamsha = computeKarakamsha(planets); + const ascIdx = SIGNS.indexOf(ascendant.sign); + const planetSignIdx = buildPlanetSignIndex(planets); + const argala = computeArgala(planetSignIdx, ascIdx); + const birthYear = parseInt((birth_info.date || '').split('-')[0]) || 1990; + const birthMonth = parseInt((birth_info.date || '').split('-')[1]) || 1; + const tajika = computeTajika(planets, ascendant.sign, birthYear, birthMonth); + const validation = computeValidation(planets, ascendant, av, dasha); + const audit = computeAudit(planets, ascendant, av, validation); + const actionableContext = computeActionableContext(planets, ascendant); + const jaimini = _buildJaiminiModule(karaka, charaDasha, karakamsha); + + return { + dasha, yogas, vargas, aspects, jaimini, + nakshatraAdvanced, argala, tajika, + shadbala: sb, ashtakavarga: _buildAVModule(av), + panchanga, validation, audit, actionableContext, + provenance: sourceChart._client_audit?.provenance, + workflows: buildWorkflowExportExtras(sourceChart), + }; +} + +function buildWorkflowExportExtras(sourceChart) { + const workflows = { ...(sourceChart?._client_workflows || {}) }; + if (sourceChart?._extended?.kp) { + workflows.kp = buildKPReportSummary(sourceChart._extended.kp, window.__kpFocus || 'career'); + } + workflows.case_library = { + synastry_pairs: sortCaseLibrary(readSynastryPairLibrary()).slice(0, 12), + prashna_cases: sortCaseLibrary(readPrashnaCaseLibrary()).slice(0, 12), + }; + return workflows; +} + +function buildKPReportSummary(kp, focus = 'career') { + const domains = kpDomains(); + const domain = domains[focus] || domains.career; + const houses = {}; + domain.houses.forEach(house => { + houses[house] = kp?.houses?.[house] || {}; + }); + return { + focus, + label: domain.label, + note: domain.note, + focus_houses: domain.houses, + houses, + planets: kp?.planets || {}, + }; +} + function _buildAspectsModule(planets) { const rawAspects = computeAspects(planets); const aspects = (rawAspects || []).map(a => ({ @@ -1156,6 +6641,65 @@ function _buildJaiminiModule(karaka, charaDasha, karakamsha) { // ============================================================================ // 导出功能 // ============================================================================ +function getExportFormatLabel(format) { + return { + json: 'JSON 数据', + html: 'HTML 报告', + pdf: 'PDF 报告', + svg: '星盘 SVG', + png: '星盘 PNG', + }[format] || '报告'; +} + +function setExportStatus(message, tone = 'working') { + const status = $('export-status'); + if (!status) return; + status.textContent = message || ''; + status.classList.toggle('hidden', !message); + status.classList.toggle('is-working', tone === 'working'); + status.classList.toggle('is-success', tone === 'success'); + status.classList.toggle('is-error', tone === 'error'); +} + +function getPDFExportRecoveryMessage(error = null) { + const reason = error?.message || 'PDF 渲染器不可用'; + return `PDF 渲染器不可用:${reason}。已改为导出 HTML 报告;也可先导出 HTML 报告并用浏览器打印为 PDF。请先到 Trust Center 运行健康检查。若本地 API 未连接,启动网页服务和本地 API 服务后重试 PDF。`; +} + +function getGenericExportRecoveryMessage(format, error = null) { + const label = getExportFormatLabel(format); + const reason = error?.message || '导出模块加载失败'; + return `导出失败:${label} 未完成。${reason}。请刷新页面后重试;若仍失败,先导出 JSON 保存数据,再检查浏览器下载权限。`; +} + +function buildExportRecoveryMessage(format, error) { + return format === 'pdf' + ? getPDFExportRecoveryMessage(error) + : getGenericExportRecoveryMessage(format, error); +} + +function clearExportStatusSoon(delay = 4200) { + window.setTimeout(() => { + if (!_exportInProgress) setExportStatus(''); + }, delay); +} + +function setExportBusy(isBusy, activeItem = null) { + _exportInProgress = Boolean(isBusy); + const btnExport = $('btn-export'); + const items = document.querySelectorAll('.export-item'); + if (btnExport) { + btnExport.disabled = _exportInProgress; + btnExport.classList.toggle('is-exporting', _exportInProgress); + btnExport.setAttribute('aria-busy', String(_exportInProgress)); + } + items.forEach(item => { + item.disabled = _exportInProgress; + item.classList.toggle('is-exporting', _exportInProgress && item === activeItem); + item.setAttribute('aria-busy', String(_exportInProgress && item === activeItem)); + }); +} + function setupExport() { const btnExport = $('btn-export'); const menu = $('export-menu'); @@ -1168,50 +6712,47 @@ function setupExport() { document.addEventListener('click', () => menu.classList.add('hidden')); menu.querySelectorAll('.export-item').forEach(item => { - item.addEventListener('click', () => { + item.addEventListener('click', async () => { menu.classList.add('hidden'); if (!chartData) { alert(t('alert.chart')); return; } + if (_exportInProgress) return; const format = item.dataset.format; - if (format === 'json') { - const { planets, ascendant, birth_info } = chartData; - // ── 收集全部13模块 ── - const vargas = computeAllVargas(planets); - const karaka = computeKaraka(planets); - const av = computeAshtakavarga(planets, ascendant.sign); - const sb = computeShadbala(planets, ascendant.sign, birth_info, vargas); - const dasha = computeDashaWithPratyantar(planets.Moon?.degree || 0, birth_info.date, new Date().toISOString().split('T')[0]); - const yogas = [...detectYogas(planets, ascendant.sign)]; - // Extended yogas - try { yogas.push(...detectExtendedYogas(planets, ascendant.sign)); } catch(e) {} - const aspects = _buildAspectsModule(planets); - const panchanga = computeTithiYoga(planets, birth_info); - const nakshatraAdvanced = computeNakshatraAdvanced(planets, _getBirthNakIdx(planets)); - const charaDasha = computeCharaDasha(planets, ascendant.sign); - const karakamsha = computeKarakamsha(planets); - const ascIdx = SIGNS.indexOf(ascendant.sign); - const planetSignIdx = {}; - for (const [pn, p] of Object.entries(planets)) { if (!p.error) planetSignIdx[pn] = Math.floor(p.degree / 30); } - const argala = computeArgala(planetSignIdx, ascIdx); - const birthYear = parseInt((birth_info.date || '').split('-')[0]) || 1990; - const birthMonth = parseInt((birth_info.date || '').split('-')[1]) || 1; - const tajika = computeTajika(planets, ascendant.sign, birthYear, birthMonth); - const validation = computeValidation(planets, ascendant, av, dasha); - const audit = computeAudit(planets, ascendant, av, validation); - const actionableCtx = computeActionableContext(planets, ascendant); - const jaimini = _buildJaiminiModule(karaka, charaDasha, karakamsha); - - exportJSON(chartData, { - dasha, yogas, vargas, aspects, jaimini, - nakshatraAdvanced, argala, tajika, - shadbala: sb, ashtakavarga: _buildAVModule(av), - panchanga, validation, audit, actionableContext, - }); - } else if (format === 'svg') { - const chartEl = $('rasi-chart'); - exportSVG(chartEl, `jyotish-${chartData.birth_info?.date || 'chart'}`); - } else if (format === 'png') { - const chartEl = $('rasi-chart'); - exportPNG(chartEl, `jyotish-${chartData.birth_info?.date || 'chart'}`); + const label = getExportFormatLabel(format); + try { + setExportBusy(true, item); + setExportStatus(`正在准备${label}...`, 'working'); + const exportModule = await loadExportModule(); + if (format === 'json' || format === 'html' || format === 'pdf') { + const extras = buildExportExtras(chartData); + if (format === 'json') { + exportModule.exportJSON(chartData, extras); + setExportStatus('JSON 数据已开始下载。', 'success'); + } else if (format === 'html') { + exportModule.exportHTMLReport(chartData, extras); + setExportStatus('HTML 报告已开始下载,可直接打开或打印。', 'success'); + } else { + const result = await exportModule.exportPDFReport(chartData, extras); + if (result?.fallback === 'html') { + const artifactStatus = exportModule.formatReportArtifactStatus?.(result); + setExportStatus(artifactStatus || getPDFExportRecoveryMessage(result?.error ? new Error(result.error) : null), 'error'); + } else { + setExportStatus(exportModule.formatReportArtifactStatus?.(result) || 'PDF 报告已生成并开始下载。', 'success'); + } + } + } else if (format === 'svg') { + const chartEl = $('rasi-chart'); + exportModule.exportSVG(chartEl, `jyotish-${chartData.birth_info?.date || 'chart'}`); + setExportStatus('星盘 SVG 已开始下载。', 'success'); + } else if (format === 'png') { + const chartEl = $('rasi-chart'); + await exportModule.exportPNG(chartEl, `jyotish-${chartData.birth_info?.date || 'chart'}`); + setExportStatus('星盘 PNG 已开始下载。', 'success'); + } + } catch (error) { + setExportStatus(buildExportRecoveryMessage(format, error), 'error'); + } finally { + setExportBusy(false); + clearExportStatusSoon(); } }); }); diff --git a/jyotish-app/mevg-audit.js b/jyotish-app/mevg-audit.js new file mode 100644 index 00000000..666bb29b --- /dev/null +++ b/jyotish-app/mevg-audit.js @@ -0,0 +1,224 @@ +import { PLANET_CN, SIGN_LORDS } from './jyotish-engine.js'; +import { escapeHtml, escapeAttr, safeNumber } from './security.js'; + +const SOURCE_TIERS = [ + ['classic', '经典文本/学院', 'BPHS、B.V. Raman、K.N. Rao、Sanjay Rath、ICAS/BVRI'], + ['expert', '知名占星师', 'Marc Boney、Hart de Fouw、传统师承文章或讲座'], + ['specialist', '专业占星网站', 'AstroSage、AstroVed、Cosmic Insights、Vedic Astrology Journal'], + ['case', '案例库', '本地真实案例库、名人验证案例、历史事件对照'], +]; + +const BOUNDARIES = [ + '每条预测必须先拆成“承诺 Promise”与“激活 Activation”,不能只看 Dasha 或 Transit 单点下结论。', + '没有外部来源支持的 Yoga、Dasha、Transit、补救措施,只能作为待验证假设。', + '出生时间不确定时,D9、D10、特殊 Lagna、Pratyantar 级别判断必须降级。', + '占星输出的是结构模式、主题强弱与时间窗口,不保证具体事件形式或结果。', +]; + +function planetName(name) { + return PLANET_CN[name] ? `${PLANET_CN[name]} ${name}` : name || '-'; +} + +function signName(sign) { + return sign ? `${sign}` : '-'; +} + +function currentDashaLords(dashaData) { + const md = dashaData?.current_dasha; + const ad = md?.antardasha?.find(item => item.is_current); + const pd = ad?.pratyantardasha?.find(item => item.is_current); + return [md?.lord, ad?.lord, pd?.lord].filter(Boolean); +} + +function buildClaims({ ascendant, moonP, allYogas, dashaData, chartData }) { + const claims = []; + if (ascendant?.sign) { + claims.push({ + area: '本命身份', + claim: `${signName(ascendant.sign)} 上升,命主星为 ${planetName(ascendant.lord || SIGN_LORDS[ascendant.sign])}`, + evidence: ['D1 上升点', '宫主星表'], + query: `"${ascendant.sign} ascendant lord ${ascendant.lord || SIGN_LORDS[ascendant.sign]}" Vedic astrology interpretation`, + status: 'internal', + }); + } + if (moonP?.nakshatra) { + claims.push({ + area: '月亮星宿', + claim: `Moon 位于 ${moonP.nakshatra} Pada ${moonP.nakshatra_pada || '-'},用于 Dasha 起点与心理主题`, + evidence: ['Moon 黄经', 'Nakshatra 切分'], + query: `"${moonP.nakshatra}" nakshatra pada ${moonP.nakshatra_pada || ''} Vedic astrology traits`, + status: 'internal', + }); + } + const topYogas = (allYogas || []).slice(0, 3); + topYogas.forEach(yoga => { + claims.push({ + area: 'Yoga', + claim: `${yoga.name_cn || yoga.name}:${yoga.combination || yoga.effects || '需要确认形成条件'}`, + evidence: ['Yoga 规则引擎', yoga.category || '未分类'], + query: `"${yoga.name}" Vedic astrology formation conditions effects case study`, + status: 'external_required', + }); + }); + currentDashaLords(dashaData).slice(0, 2).forEach((lord, index) => { + claims.push({ + area: index === 0 ? 'Mahadasha' : 'Antardasha', + claim: `当前 ${index === 0 ? '大运' : '小运'}主星 ${planetName(lord)} 需要结合其落宫、宫主与分盘确认兑现方式`, + evidence: ['Vimshottari 时间线', 'Moon Nakshatra 起算'], + query: `"${lord}" "${index === 0 ? 'Mahadasha' : 'Antardasha'}" effects Vedic astrology case study`, + status: 'external_required', + }); + }); + const special = chartData?.special_lagnas; + if (special?.precision) { + claims.push({ + area: '特殊 Lagna', + claim: `HL/GL/VL 精度:${special.precision};用于辅助财富、行动与身份定位`, + evidence: ['Python API', special.sunrise_local_time ? `日出 ${special.sunrise_local_time}` : '日出校正字段'], + query: `"Hora Lagna" "Ghati Lagna" interpretation Jaimini Vedic astrology`, + status: special.precision === 'sunrise_correct' ? 'internal' : 'limited', + }); + } + return claims; +} + +function buildInternalChecks({ validation, chartData, allYogas, dashaData }) { + const checks = []; + const validationPassed = safeNumber(validation?.passed, 0); + const validationTotal = safeNumber(validation?.total_checks || validation?.checked, 0); + checks.push({ + name: 'R1-R10 数据完整性', + status: validation?.valid ? 'pass' : 'warn', + detail: validationTotal ? `${validationPassed}/${validationTotal} 项通过` : '未取得完整校验表', + }); + checks.push({ + name: 'Rahu/Ketu 轴线', + status: (validation?.results || []).find(r => r.rule === 'R5')?.passed ? 'pass' : 'warn', + detail: (validation?.results || []).find(r => r.rule === 'R5')?.detail || '未找到 R5 结果', + }); + checks.push({ + name: 'Dasha 时间线', + status: dashaData?.current_dasha ? 'pass' : 'warn', + detail: dashaData?.current_dasha ? `当前 ${planetName(dashaData.current_dasha.lord)}` : '未取得当前大运', + }); + checks.push({ + name: 'Yoga 规则命中', + status: allYogas?.length ? 'pass' : 'warn', + detail: `${allYogas?.length || 0} 条规则命中;解释仍需 MEVG 外部来源`, + }); + checks.push({ + name: '特殊 Lagna 精度', + status: chartData?.special_lagnas?.precision === 'sunrise_correct' ? 'pass' : 'warn', + detail: chartData?.special_lagnas?.precision === 'sunrise_correct' ? '日出校正版' : '缺少日出校正或后端字段', + }); + return checks; +} + +function confidenceProfile({ validation, allYogas, dashaData, chartData }) { + let score = 52; + if (validation?.valid) score += 14; + if ((allYogas || []).length) score += 8; + if (dashaData?.current_dasha) score += 10; + if (chartData?.special_lagnas?.precision === 'sunrise_correct') score += 8; + const externalRequired = Math.min(18, Math.max(8, (allYogas || []).slice(0, 3).length * 5 + 8)); + score -= externalRequired; + const bounded = Math.max(25, Math.min(86, Math.round(score))); + const level = bounded >= 75 ? '中高' : bounded >= 58 ? '中' : '谨慎'; + return { + score: bounded, + level, + cap: '未执行实时外部检索时,预测/补救/具体事件判断最高只建议给到中等置信度。', + }; +} + +export function buildMEVGAudit(input) { + const claims = buildClaims(input); + const internalChecks = buildInternalChecks(input); + const profile = confidenceProfile(input); + return { + profile, + internalChecks, + claims, + sourceTiers: SOURCE_TIERS, + boundaries: BOUNDARIES, + stats: { + claims: claims.length, + externalRequired: claims.filter(c => c.status === 'external_required').length, + internalPassed: internalChecks.filter(c => c.status === 'pass').length, + internalTotal: internalChecks.length, + }, + }; +} + +export function renderMEVGAudit(container, audit) { + if (!container || !audit) return; + const statusLabel = { + internal: '内部已校验', + external_required: '需外部验证', + limited: '精度受限', + }; + const checkHtml = audit.internalChecks.map(check => ` +
    + ${escapeHtml(check.status === 'pass' ? '通过' : '注意')} + ${escapeHtml(check.name)} +

    ${escapeHtml(check.detail)}

    +
    + `).join(''); + const claimHtml = audit.claims.map(claim => ` +
    +
    + ${escapeHtml(claim.area)} + ${escapeHtml(statusLabel[claim.status] || claim.status)} +
    +

    ${escapeHtml(claim.claim)}

    +
    ${claim.evidence.map(item => `${escapeHtml(item)}`).join('')}
    + ${escapeHtml(claim.query)} +
    + `).join(''); + const sourceHtml = audit.sourceTiers.map(([, label, detail]) => ` +
    + ${escapeHtml(label)} + ${escapeHtml(detail)} +
    + `).join(''); + container.innerHTML = ` +
    +
    +
    +

    MEVG 外部验证门控

    +

    把当前星盘的主要判断拆成证据、外部检索项、置信度上限和边界声明。

    +
    +
    + ${escapeHtml(audit.profile.level)}置信 + ${escapeHtml(audit.profile.score)} +
    +
    + +
    +
    ${escapeHtml(audit.stats.claims)}待审计声明
    +
    ${escapeHtml(audit.stats.externalRequired)}需外部来源
    +
    ${escapeHtml(`${audit.stats.internalPassed}/${audit.stats.internalTotal}`)}内部校验
    +
    + +
    ${escapeHtml(audit.profile.cap)}
    +
    ${checkHtml}
    + +
    +

    声明与建议检索词

    +
    ${claimHtml}
    +
    + +
    +

    来源优先级

    +
    ${sourceHtml}
    +
    + +
    +

    预测边界

    + +
    +
    + `; +} diff --git a/jyotish-app/public/api-bridge.js b/jyotish-app/public/api-bridge.js index a905b1e0..a4ba110b 100644 --- a/jyotish-app/public/api-bridge.js +++ b/jyotish-app/public/api-bridge.js @@ -2,11 +2,92 @@ * API Bridge v4.0 * * 架构: - * 计算 → 纯本地 (SwissEph WASM 或 localhost:5200 Python API) - * AI解读 → copse.top (GPT API 中转) + * 计算 → 纯本地 (SwissEph WASM 或 localhost:5200 本地 API 服务) + * AI解读 → 登录后的服务端 /api/chat;浏览器不保存模型 API key */ -const AI_BASE = 'https://copse.top'; -const AI_KEY = window.YINDUZHANXING_AI_KEY || localStorage.getItem('YINDUZHANXING_AI_KEY') || ''; +let activeApiBase = ''; +const AI_DISABLED_MESSAGE = 'AI_BROWSER_KEY_DISABLED: AI 解读需通过服务端 /api/chat 或后端代理;不要把 OpenAI API key 放进浏览器。'; + +function getApiBases(preferActive = false) { + const configured = window.YINDUZHANXING_API_BASE || localStorage.getItem('YINDUZHANXING_API_BASE') || ''; + const bases = [ + preferActive ? activeApiBase : '', + configured, + 'http://localhost:5200', + 'http://127.0.0.1:5200', + 'http://localhost:5201', + 'http://127.0.0.1:5201', + ].filter(Boolean); + return [...new Set(bases.map(base => base.replace(/\/$/, '')))]; +} + +async function postJson(path, payload, { requireModernChart = false } = {}) { + let firstSuccessful = null; + let lastError = null; + let lastAttempt = null; + for (const base of getApiBases(true)) { + try { + const resp = await fetch(`${base}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await parseApiResponse(resp); + lastAttempt = `${base}${path}`; + if (!resp.ok) { + lastError = new Error(buildAPIRecoveryMessage(path, data?.error || data?.message || `API请求失败: ${path}`, lastAttempt)); + continue; + } + if (data?.success === false) { + lastError = new Error(buildAPIRecoveryMessage(path, data?.error || data?.message || `API返回失败: ${path}`, lastAttempt)); + continue; + } + if (requireModernChart && data?.success && data?.special_lagnas?.precision !== 'sunrise_correct') { + firstSuccessful ||= { base, data }; + continue; + } + activeApiBase = base; + return data; + } catch (error) { + lastAttempt = `${base}${path}`; + lastError = new Error(buildAPIRecoveryMessage(path, error, lastAttempt)); + } + } + if (firstSuccessful) { + activeApiBase = firstSuccessful.base; + return firstSuccessful.data; + } + throw lastError || new Error(buildAPIRecoveryMessage(path, '本地 API 未连接', lastAttempt)); +} + +async function parseApiResponse(resp) { + const raw = await resp.text(); + try { + return raw ? JSON.parse(raw) : {}; + } catch { + return { message: raw?.slice(0, 180) || '' }; + } +} + +function buildAPIRecoveryMessage(path, error, lastAttempt = '') { + const message = typeof error === 'string' ? error : (error?.message || '本地 API 未连接'); + return `${message}。请到 Trust Center 运行健康检查;如未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。${lastAttempt ? ` lastAttempt=${lastAttempt}` : ''}`; +} + +function normalizeChartData(data) { + if (data.birth && !data.birth_info) data.birth_info = data.birth; + if (data.ascendant?.degree != null && data.ascendant.degree_in_sign == null) { + data.ascendant.degree_in_sign = data.ascendant.degree; + } + if (data.planets) { + for (const [, v] of Object.entries(data.planets)) { + if (v?.degree != null && v.degree_in_sign == null) { + v.degree_in_sign = v.degree % 30; + } + } + } + return data; +} // ═══════════════════════════════════════════════════════════════ // 计算层 — 纯本地,不需要服务器 @@ -14,73 +95,211 @@ const AI_KEY = window.YINDUZHANXING_AI_KEY || localStorage.getItem('YINDUZHANXIN async function computeWithPython(birthData) { try { - const resp = await fetch('http://localhost:5200/api/chart', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(birthData), - }); - const data = await resp.json(); + const data = await postJson('/api/chart', birthData, { requireModernChart: true }); if (data.success) { - console.log('[Compute] ✅ Python API v' + data.version); - // Python API → JS引擎字段映射 - if (data.birth && !data.birth_info) data.birth_info = data.birth; - // ascendant.degree → ascendant.degree_in_sign - if (data.ascendant?.degree != null && data.ascendant.degree_in_sign == null) { - data.ascendant.degree_in_sign = data.ascendant.degree; - } - // planet degree → degree_in_sign - if (data.planets) { - for (const [k, v] of Object.entries(data.planets)) { - if (v?.degree != null && v.degree_in_sign == null) { - v.degree_in_sign = v.degree % 30; - } - } - } - return data; + console.log('[Compute] ✅ 本地 API 服务 v' + data.version + ' @ ' + activeApiBase); + return normalizeChartData(data); } } catch(e) { - console.log('[Compute] Python API 不可用, 回退JS引擎'); + console.log('[Compute] 本地 API 服务不可用, 回退JS引擎'); } return null; } +async function computeSynastry(payload) { + return postJson('/api/synastry', payload); +} + +async function computePrashna(payload) { + return postJson('/api/prashna', payload); +} + +async function computeKP(payload) { + return postJson('/api/kp', payload); +} + +async function computeDashaSystem(payload) { + return postJson('/api/dasha', payload); +} + +async function computeRemedies(payload) { + return postJson('/api/remedies', payload); +} + +async function computeSadeSati(payload) { + return postJson('/api/sade_sati', payload); +} + +async function computePanchaMahapurusha(payload) { + return postJson('/api/pancha_mahapurusha', payload); +} + +async function computeCareer(payload) { + return postJson('/api/career', payload); +} + +async function computeRelationship(payload) { + return postJson('/api/relationship', payload); +} + +async function importChart(payload) { + return postJson('/api/import_chart', payload); +} + +async function generateReportArtifact(payload) { + return postJson('/api/report_artifact', payload); +} + +async function computeThematicReport(payload) { + return postJson('/api/thematic_report', payload); +} + +async function getAPIHealth() { + let lastError = null; + for (const base of getApiBases(true)) { + try { + const startedAt = Date.now(); + const resp = await fetch(`${base}/api/health`); + const data = await parseApiResponse(resp); + if (!resp.ok || data?.status !== 'ok') { + lastError = new Error(buildAPIRecoveryMessage('/api/health', data?.error || data?.message || '本地 API 健康检查失败', `${base}/api/health`)); + continue; + } + activeApiBase = base; + return { + success: true, + base, + latencyMs: Date.now() - startedAt, + ...data, + }; + } catch (error) { + lastError = new Error(buildAPIRecoveryMessage('/api/health', error, `${base}/api/health`)); + } + } + throw lastError || new Error(buildAPIRecoveryMessage('/api/health', '本地 API 健康检查不可用')); +} + +async function getCapabilityAudit() { + let lastError = null; + for (const base of getApiBases(true)) { + try { + const resp = await fetch(`${base}/api/capability_audit`); + const data = await parseApiResponse(resp); + if (!resp.ok || data?.success === false) { + lastError = new Error(buildAPIRecoveryMessage('/api/capability_audit', data?.error || data?.message || '能力审计接口不可用', `${base}/api/capability_audit`)); + continue; + } + activeApiBase = base; + return data; + } catch (error) { + lastError = new Error(buildAPIRecoveryMessage('/api/capability_audit', error, `${base}/api/capability_audit`)); + } + } + throw lastError || new Error(buildAPIRecoveryMessage('/api/capability_audit', '能力审计接口不可用')); +} + +async function getTechniqueCatalog() { + let lastError = null; + for (const base of getApiBases(true)) { + try { + const resp = await fetch(`${base}/api/technique_catalog`); + const data = await parseApiResponse(resp); + if (!resp.ok || data?.success === false) { + lastError = new Error(buildAPIRecoveryMessage('/api/technique_catalog', data?.error || data?.message || '技法目录接口不可用', `${base}/api/technique_catalog`)); + continue; + } + activeApiBase = base; + return data; + } catch (error) { + lastError = new Error(buildAPIRecoveryMessage('/api/technique_catalog', error, `${base}/api/technique_catalog`)); + } + } + throw lastError || new Error(buildAPIRecoveryMessage('/api/technique_catalog', '技法目录接口不可用')); +} + +async function runTechniqueExample(payload) { + return postJson('/api/technique_example', payload); +} + +async function computeAnnual(payload) { + return postJson('/api/annual', payload); +} + +async function computeMuhurta(payload) { + return postJson('/api/muhurta', payload); +} + +async function computePanchangaRange(payload) { + return postJson('/api/panchanga_range', payload); +} + +async function computeBhavaChalit(payload) { + return postJson('/api/bhava_chalit', payload); +} + +async function computeSudarshana(payload) { + return postJson('/api/sudarshana', payload); +} + +async function computeNakshatraFull(payload) { + return postJson('/api/nakshatra_full', payload); +} + +async function computeVargaFull(payload) { + return postJson('/api/varga_full', payload); +} + +async function computeJaimini(payload) { + return postJson('/api/jaimini', payload); +} + +async function computeAshtakavarga(payload) { + return postJson('/api/ashtakavarga', payload); +} + +async function computeShadbala(payload) { + return postJson('/api/shadbala', payload); +} + +async function computeYogas(payload) { + return postJson('/api/yogas', payload); +} + +async function computeAspects(payload) { + return postJson('/api/aspects', payload); +} + +async function computeRectificationGate(payload) { + return postJson('/api/rectification_gate', payload); +} + +async function computeCaseValidation(payload) { + return postJson('/api/case_validation', payload); +} + +async function computeDivisionalYoga(payload) { + return postJson('/api/divisional_yoga', payload); +} + +async function computeKakshya(payload) { + return postJson('/api/kakshya', payload); +} + +async function computeBhavaBala(payload) { + return postJson('/api/bhava_bala', payload); +} + +async function computeTransitTriggers(payload) { + return postJson('/api/transit', payload); +} + // ═══════════════════════════════════════════════════════════════ -// AI 解读层 — 通过 copse.top 调用 GPT +// AI 解读层 — 浏览器端禁用密钥直连;由 ai-chat.js 走服务端 /api/chat // ═══════════════════════════════════════════════════════════════ async function aiReading(chartData, options = {}) { const { style = 'deep', focus = '全部' } = options; - - if (!AI_KEY) { - return { success: false, error: 'AI_KEY_NOT_CONFIGURED: set window.YINDUZHANXING_AI_KEY or localStorage.YINDUZHANXING_AI_KEY' }; - } - - const prompt = buildReadingPrompt(chartData, style, focus); - - try { - const resp = await fetch(AI_BASE + '/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + AI_KEY, - }, - body: JSON.stringify({ - model: 'gpt-4', - messages: [ - { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: prompt }, - ], - temperature: 0.7, - max_tokens: 2000, - }), - }); - const data = await resp.json(); - const text = data?.choices?.[0]?.message?.content || ''; - return { success: true, reading: text, style, focus }; - } catch(e) { - console.warn('[AI] 解读失败:', e.message); - return { success: false, error: e.message }; - } + return { success: false, error: AI_DISABLED_MESSAGE, style, focus, chartDataPresent: Boolean(chartData) }; } const SYSTEM_PROMPT = `你是印度占星(Jyotish/Vedic Astrology)专业解盘师。 @@ -147,8 +366,44 @@ async function aiQuickInsight(chartData) { // ═══════════════════════════════════════════════════════════════ window.JyotishAPI = { + get apiBase() { return activeApiBase || getApiBases()[0] || ''; }, + aiKeyPolicy: 'server_side_only', // 计算 computeWithPython, + computeSynastry, + computePrashna, + computeKP, + computeDashaSystem, + computeRemedies, + computeSadeSati, + computePanchaMahapurusha, + computeCareer, + computeRelationship, + importChart, + generateReportArtifact, + computeThematicReport, + getAPIHealth, + getCapabilityAudit, + getTechniqueCatalog, + runTechniqueExample, + computeAnnual, + computeMuhurta, + computePanchangaRange, + computeBhavaChalit, + computeSudarshana, + computeNakshatraFull, + computeVargaFull, + computeJaimini, + computeAshtakavarga, + computeShadbala, + computeYogas, + computeAspects, + computeRectificationGate, + computeCaseValidation, + computeDivisionalYoga, + computeKakshya, + computeBhavaBala, + computeTransitTriggers, // AI 解读 aiReading, aiFullReading, diff --git a/jyotish-app/public/manifest.webmanifest b/jyotish-app/public/manifest.webmanifest new file mode 100644 index 00000000..a4a27dcb --- /dev/null +++ b/jyotish-app/public/manifest.webmanifest @@ -0,0 +1,29 @@ +{ + "name": "Jyotish Vedic Astrology", + "short_name": "Jyotish", + "description": "Local-first Vedic astrology charting, Panchanga, Dasha, reports, and case workspace.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#0f172a", + "theme_color": "#2563eb", + "categories": ["productivity", "education", "utilities"], + "lang": "zh-CN", + "icons": [ + { + "src": "/pwa-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "新建星盘", + "short_name": "星盘", + "url": "/", + "description": "输入出生信息并生成印度占星星盘" + } + ] +} diff --git a/jyotish-app/public/pwa-icon.svg b/jyotish-app/public/pwa-icon.svg new file mode 100644 index 00000000..c4a1f0cf --- /dev/null +++ b/jyotish-app/public/pwa-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/jyotish-app/public/sw.js b/jyotish-app/public/sw.js new file mode 100644 index 00000000..e550a97b --- /dev/null +++ b/jyotish-app/public/sw.js @@ -0,0 +1,44 @@ +const CACHE_NAME = 'jyotish-shell-v1'; +const APP_SHELL = [ + '/', + '/index.html', + '/style.css', + '/main.js', + '/api-bridge.js', + '/manifest.webmanifest', + '/pwa-icon.svg' +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(APP_SHELL)) + .then(() => self.skipWaiting()) + .catch(() => undefined) + ); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys() + .then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', event => { + const request = event.request; + if (request.method !== 'GET') return; + const url = new URL(request.url); + if (url.pathname.startsWith('/api/')) return; + + event.respondWith( + fetch(request) + .then(response => { + const copy = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(request, copy)); + return response; + }) + .catch(() => caches.match(request).then(cached => cached || (request.mode === 'navigate' ? caches.match('/index.html') : undefined))) + ); +}); diff --git a/jyotish-app/rectification-engine.js b/jyotish-app/rectification-engine.js index 4bea1212..798c7cf8 100644 --- a/jyotish-app/rectification-engine.js +++ b/jyotish-app/rectification-engine.js @@ -34,6 +34,15 @@ export const EVENT_CATEGORIES = { fame: {cn:'名声/荣誉', en:'Fame/Honor', icon:'🌟',planets:['Sun','Jupiter'], houses:[1,10,9],varga:'D10'}, }; +export const EVENT_COLLECTION_GUIDE = [ + { key: 'relationship', cn: '感情/婚姻/分离', en: 'relationship, marriage, separation', categories: ['marriage', 'divorce', 'relationship'] }, + { key: 'career', cn: '入职/升职/创业/换工作', en: 'job start, promotion, business, job change', categories: ['career', 'job_change', 'business', 'fame'] }, + { key: 'education', cn: '升学/考试/毕业', en: 'education, exam, graduation', categories: ['education'] }, + { key: 'family', cn: '生育/父母/家庭房产', en: 'childbirth, parents, home, property', categories: ['child', 'property'] }, + { key: 'stress', cn: '健康/事故/诉讼/破财', en: 'health, accident, legal dispute, loss', categories: ['health', 'legal', 'finance_neg', 'loss'] }, + { key: 'mobility', cn: '搬家/远行/移民', en: 'relocation, travel, migration', categories: ['travel'] }, +]; + // ——— 工具函数 ——— export function getHouseLord(ai, h) { return SIGN_LORDS[SIGNS[(ai + h - 1) % 12]]; } @@ -128,6 +137,92 @@ function scoreNak(baseDeg, offDeg) { return 0; } +function eventCategoryGroup(category) { + return EVENT_COLLECTION_GUIDE.find(group => group.categories.includes(category))?.key || category; +} + +function summarizeEventCoverage(events) { + const valid = (events || []).filter(evt => evt?.date && EVENT_CATEGORIES[evt.category]); + const groups = [...new Set(valid.map(evt => eventCategoryGroup(evt.category)))]; + const categories = [...new Set(valid.map(evt => evt.category))]; + const datedYears = valid.map(evt => Number(String(evt.date).slice(0, 4))).filter(Number.isFinite); + const yearSpan = datedYears.length ? Math.max(...datedYears) - Math.min(...datedYears) : 0; + const idealCount = valid.length >= 8; + const enoughCount = valid.length >= 5; + const enoughGroups = groups.length >= 3; + const enoughSpan = yearSpan >= 7 || valid.length >= 8; + const qualityScore = Math.round( + Math.min(valid.length / 8, 1) * 40 + + Math.min(groups.length / 4, 1) * 30 + + Math.min(yearSpan / 12, 1) * 20 + + Math.min(categories.length / 5, 1) * 10 + ); + const missing = EVENT_COLLECTION_GUIDE + .filter(group => !groups.includes(group.key)) + .slice(0, 3) + .map(group => group.cn); + return { + event_count: valid.length, + category_count: categories.length, + group_count: groups.length, + year_span: yearSpan, + quality_score: qualityScore, + quality_level: idealCount && enoughGroups && enoughSpan ? 'strong' : enoughCount && enoughGroups ? 'usable' : 'thin', + missing_groups: missing, + }; +} + +function countMatchedEvents(result) { + return (result?.eventScores || []).filter(es => (es.dashaScore || 0) > 0 || (es.vargaScore || 0) > 0).length; +} + +function summarizeCandidateEvidence(result, events) { + const eventCount = Math.max((events || []).length, 1); + const matchedEvents = countMatchedEvents(result); + const strongEvents = (result?.eventScores || []).filter(es => (es.dashaScore || 0) >= 4 || ((es.dashaScore || 0) > 0 && (es.vargaScore || 0) > 0)).length; + const sensitiveVargas = (result?.vargaChanges || []) + .filter(v => ['D9', 'D10', 'D24', 'D30', 'D60'].includes(v.varga)) + .map(v => v.varga); + const changedPlanets = (result?.houseChanges || []).map(c => c.planet); + return { + matched_events: matchedEvents, + strong_events: strongEvents, + match_rate: Math.round((matchedEvents / eventCount) * 100), + sensitive_vargas: [...new Set(sensitiveVargas)], + changed_planets: [...new Set(changedPlanets)], + }; +} + +function buildRectificationAudit(best, results, events) { + const second = results[1] || null; + const coverage = summarizeEventCoverage(events); + const evidence = summarizeCandidateEvidence(best, events); + const cluster = results.filter(r => Math.abs((r.totalScore || 0) - (best?.totalScore || 0)) <= 3).slice(0, 8); + const clusterOffsets = cluster.map(r => r.offsetMin); + const clusterSpan = clusterOffsets.length + ? { min: Math.min(...clusterOffsets), max: Math.max(...clusterOffsets), count: clusterOffsets.length } + : { min: 0, max: 0, count: 0 }; + const gap = second ? Math.max(0, (best.totalScore || 0) - (second.totalScore || 0)) : best?.totalScore || 0; + const exactTie = second ? (best.totalScore || 0) === (second.totalScore || 0) : false; + const shouldStayNearBaseline = exactTie || clusterSpan.count >= 4 || (best?.totalScore || 0) < 45; + const warnings = []; + if (coverage.event_count < 5) warnings.push('事件数量不足,建议至少补到5个,理想为8-15个。'); + if (coverage.group_count < 3) warnings.push('事件类型过于集中,容易把结果锁到单一主题。'); + if (coverage.year_span < 7 && coverage.event_count < 8) warnings.push('事件年份跨度偏短,建议补童年/青年/近年事件。'); + if (exactTie) warnings.push('第一名与第二名同分,不应把单一候选时间视为确定校正。'); + if (best?.vargaChanges?.some(v => v.varga === 'D9')) warnings.push('推荐时间会改变D9上升,婚姻与内在成熟判断需二次确认。'); + return { + coverage, + evidence, + runner_up: second ? { offsetMin: second.offsetMin, time: second.time, totalScore: second.totalScore } : null, + score_gap: gap, + exact_tie: exactTie, + top_cluster: clusterSpan, + should_stay_near_baseline: shouldStayNearBaseline, + warnings, + }; +} + // ——— 主函数 ——— export async function runRectification(birth, events, options={}) { const {rangeMin=15,stepMin=1,onProgress=null}=options; @@ -179,11 +274,12 @@ export async function runRectification(birth, events, options={}) { }catch(e){console.warn('[Rect]',off,e);} if(onProgress)onProgress(i+1,offsets.length); } - results.sort((a,b)=>b.totalScore-a.totalScore); + results.sort((a,b)=>(b.totalScore-a.totalScore)||Math.abs(a.offsetMin)-Math.abs(b.offsetMin)||a.offsetMin-b.offsetMin); if(results.length>0){ results[0].isBest=true; if(results.findIndex(r=>r.offsetMin===0)>0) results[0].correctionEffective=true; } + const audit = results.length ? buildRectificationAudit(results[0], results, events) : null; return { birth,events,options:{rangeMin,stepMin,totalCandidates:offsets.length}, baseChartInfo:{ @@ -192,23 +288,27 @@ export async function runRectification(birth, events, options={}) { vargaLagnas:baseVL,moonNak:getNakPada(baseMoon), }, results,bestMatch:results[0]||null, - confidence:calcConf(results,events.length), + confidence:calcConf(results,events.length,audit), + audit, }; } -function calcConf(results, evtCount) { +function calcConf(results, evtCount, audit=null) { if(!results.length) return {level:'不确定',score:0,bestPct:0,gapPct:0,description:'',recommendation:[]}; const best=results[0],sec=results[1]; const gap=sec?best.totalScore-sec.totalScore:best.totalScore; const gapPct=sec&&sec.totalScore>0?Math.round(gap/sec.totalScore*100):100; let level,score; - if(best.totalScore>=65&&gapPct>=15){level='高';score=3;} - else if(best.totalScore>=45&&gapPct>=8){level='中';score=2;} + const coverageOk = !audit || audit.coverage.quality_level !== 'thin'; + if(best.totalScore>=65&&gapPct>=15&&coverageOk&&!audit?.exact_tie){level='高';score=3;} + else if(best.totalScore>=45&&gapPct>=8&&!audit?.exact_tie){level='中';score=2;} else if(best.totalScore>=25){level='低';score=1;} else{level='不确定';score=0;} const recs=[`建议出生时间校正 ${best.offsetMin>=0?'+':''}${best.offsetMin} 分钟`]; if(best.vargaChanges?.some(v=>v.varga==='D9')) recs.push('⚠️ 此校正会改变Navamsa(D9)上升,影响重大,建议专业验证'); - if(evtCount<3) recs.push('建议提供至少5个以上生命事件以提高准确性'); + if(audit?.should_stay_near_baseline) recs.push('候选时间仍有聚集或同分现象,建议先保留原始时间为主、把推荐时间作为验证候选。'); + if(evtCount<5) recs.push('建议提供至少5个以上生命事件以提高准确性'); + if(audit?.coverage?.missing_groups?.length) recs.push(`可优先补充:${audit.coverage.missing_groups.join('、')}。`); if(level==='高') recs.push('多个事件一致指向此时间,可信度较高'); else if(level==='低'||level==='不确定') recs.push('事件对齐度较低,可能需要更多事件数据或尝试更大时间范围'); return {level,score,bestPct:best.totalScore,gapPct,description:`最佳匹配评分 ${best.totalScore}%,领先第二名 ${gapPct}%`,recommendation:recs}; diff --git a/jyotish-app/rectification.js b/jyotish-app/rectification.js index 99e78702..da23f337 100644 --- a/jyotish-app/rectification.js +++ b/jyotish-app/rectification.js @@ -4,10 +4,11 @@ */ import { SIGNS, PLANET_CN, SIGN_LORDS } from './jyotish-engine.js'; import { - EVENT_CATEGORIES, VARGA_SENSITIVITY, runRectification, + EVENT_CATEGORIES, EVENT_COLLECTION_GUIDE, VARGA_SENSITIVITY, runRectification, getHouseLord, fmtTime, dateToJD } from './rectification-engine.js'; import { t, getLang, signName, planetName } from './i18n.js'; +import { escapeHtml, escapeAttr } from './security.js'; function fmtOffset(m) { return m === 0 ? t('rect.baseline') : `${m > 0 ? '+' : ''}${m}min`; } @@ -53,6 +54,9 @@ export function renderRectificationTab(container) {

    ${t('rect.events')} 0 ${t('rect.event.count')}

    ${t('rect.events.hint')}

    +
    ${EVENT_COLLECTION_GUIDE.map(group => + `${lang === 'en' ? escapeHtml(group.en) : escapeHtml(group.cn)}` + ).join('')}
    @@ -78,6 +82,12 @@ export function renderRectificationTab(container) { bindEvents(container); } +function pctStyle(value) { + const num = Number(value); + const pct = Number.isFinite(num) ? Math.max(0, Math.min(100, Math.round(num))) : 0; + return `${pct}%`; +} + function bindEvents(container) { const q = s => container.querySelector(s); q('#rect-add-btn').addEventListener('click', () => { @@ -100,10 +110,10 @@ function renderEventList(container) { const cat = EVENT_CATEGORIES[evt.category]; return `
    ${cat.icon} - ${evt.date} - ${lang === 'en' ? cat.en : cat.cn} (${cat.varga}) - ${evt.desc} -
    `; + ${escapeHtml(evt.date)} + ${escapeHtml(lang === 'en' ? cat.en : cat.cn)} (${escapeHtml(cat.varga)}) + ${escapeHtml(evt.desc)} +
    `; }).join(''); listEl.querySelectorAll('.rect-evt-del').forEach(b => { b.addEventListener('click', () => { rectEvents.splice(+b.dataset.idx, 1); renderEventList(container); }); @@ -144,7 +154,10 @@ function renderResults(container, result) { const el = container.querySelector('#rect-results'); el.classList.remove('hidden'); const lang = getLang(); const { bestMatch: bm, confidence: conf, baseChartInfo: base, results: all } = result; + const audit = result.audit || {}; const confClr = { '高': '#22c55e', '中': '#f59e0b', '低': '#ef4444', '不确定': '#9ca3af' }; + const correctedBirth = buildCorrectedBirth(result.birth, bm.offsetMin); + const reportText = buildRectificationReportText(result, correctedBirth); // 分盘变化 const vcHtml = bm.vargaChanges.length > 0 @@ -157,33 +170,47 @@ function renderResults(container, result) { const s = bm.scores[k]; return `
    ${t('rect.scoring.'+k)} (${weights[k]}) -
    - ${s.pct}%
    `; +
    + ${escapeHtml(s.pct)}%
    `; }).join(''); + const auditCards = renderAuditCards(audit, conf, lang); + const warningHtml = (audit.warnings || []).length + ? `
    ${audit.warnings.map(w => `

    ${escapeHtml(w)}

    `).join('')}
    ` + : ''; + // 事件详情 const evtRows = bm.eventScores.map(es => { const cat = EVENT_CATEGORIES[es.event.category]; const rel = getRelevanceText(es.dasha, es.event.category, bm.ascSign); return ` - ${cat.icon} ${lang==='en'?cat.en:cat.cn} (${cat.varga}) - ${es.event.date} - ${es.dasha ? planetName(es.dasha.mahadasha) : '—'} - ${es.dasha?.antardasha ? planetName(es.dasha.antardasha) : '—'} - ${es.dashaScore.toFixed(1)} - ${es.vargaScore.toFixed(1)} - ${rel}`; + ${cat.icon} ${escapeHtml(lang==='en'?cat.en:cat.cn)} (${escapeHtml(cat.varga)}) + ${escapeHtml(es.event.date)} + ${escapeHtml(es.dasha ? planetName(es.dasha.mahadasha) : '—')} + ${escapeHtml(es.dasha?.antardasha ? planetName(es.dasha.antardasha) : '—')} + ${escapeHtml(es.dashaScore.toFixed(1))} + ${escapeHtml(es.vargaScore.toFixed(1))} + ${escapeHtml(rel)}`; }).join(''); el.innerHTML = `

    ${t('rect.result')}

    -
    ${t('rect.original.time')}${base.time}
    -
    ${t('rect.rec.time')}${bm.time} (${fmtOffset(bm.offsetMin)})
    +
    ${t('rect.original.time')}${escapeHtml(base.time)}
    +
    ${t('rect.rec.time')}${escapeHtml(bm.time)} (${escapeHtml(fmtOffset(bm.offsetMin))})
    ${t('rect.confidence')}${conf.level} (${conf.bestPct}%)
    -
    ${conf.recommendation.map(r => `

    ${r}

    `).join('')}
    +
    ${conf.recommendation.map(r => `

    ${escapeHtml(r)}

    `).join('')}
    + ${warningHtml} +
    + + +
    +
    +
    +

    证据闭环

    +
    ${auditCards}

    ${t('rect.scoring.title')}

    @@ -203,11 +230,11 @@ function renderResults(container, result) { const d9=r.vargaLagnas?.D9?.sign||r.ascSign, d10=r.vargaLagnas?.D10?.sign||r.ascSign; const d9chg=r.vargaChanges?.some(v=>v.varga==='D9')?' ⚠️':''; const d10chg=r.vargaChanges?.some(v=>v.varga==='D10')?' ⚠️':''; - return ` - ${i+1}${r.time}${fmtOffset(r.offsetMin)} - ${signName(r.ascSign)}${signName(d9)}${d9chg}${signName(d10)}${d10chg} - ${r.totalScore}% -
    ${r.totalScore}%
    `; + return ` + ${i+1}${escapeHtml(r.time)}${escapeHtml(fmtOffset(r.offsetMin))} + ${escapeHtml(signName(r.ascSign))}${escapeHtml(signName(d9))}${d9chg}${escapeHtml(signName(d10))}${d10chg} + ${escapeHtml(r.totalScore)}% +
    ${escapeHtml(r.totalScore)}%
    `; }).join('')}
    @@ -219,6 +246,21 @@ function renderResults(container, result) { `; + el.querySelector('#rect-apply-time')?.addEventListener('click', () => { + document.dispatchEvent(new CustomEvent('jyotish:apply-rectified-birth', { + detail: { birth: correctedBirth, rectification: result }, + })); + }); + el.querySelector('#rect-copy-report')?.addEventListener('click', async () => { + await copyRectificationReport(reportText); + const btn = el.querySelector('#rect-copy-report'); + if (btn) { + const oldText = btn.textContent; + btn.textContent = '已复制'; + setTimeout(() => { btn.textContent = oldText; }, 1200); + } + }); + el.querySelectorAll('.rect-top-results tr[data-offset]').forEach(tr => { tr.style.cursor = 'pointer'; tr.addEventListener('click', () => { @@ -229,6 +271,84 @@ function renderResults(container, result) { }); } +function buildCorrectedBirth(birth, offsetMin) { + const date = new Date(birth.year, birth.month - 1, birth.day, birth.hour, birth.minute || 0, 0); + date.setMinutes(date.getMinutes() + offsetMin); + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + hour: date.getHours(), + minute: date.getMinutes(), + lat: birth.lat, + lon: birth.lon, + tz: birth.tz, + }; +} + +function buildRectificationReportText(result, correctedBirth) { + const bm = result.bestMatch || {}; + const conf = result.confidence || {}; + const audit = result.audit || {}; + const lines = [ + 'Janma Samaya Shuddhi 生时校正摘要', + `原始时间:${result.baseChartInfo?.time || '-'}`, + `推荐时间:${fmtTime(correctedBirth.hour, correctedBirth.minute)} (${fmtOffset(bm.offsetMin || 0)})`, + `推荐日期:${correctedBirth.year}-${String(correctedBirth.month).padStart(2, '0')}-${String(correctedBirth.day).padStart(2, '0')}`, + `置信度:${conf.level || '不确定'} / ${conf.bestPct || 0}%`, + `事件覆盖:${audit.coverage?.event_count || 0} 个事件,${audit.coverage?.group_count || 0} 类主题,跨度 ${audit.coverage?.year_span || 0} 年`, + `候选差距:领先 ${audit.score_gap ?? 0} 分;同分簇 ${audit.top_cluster?.count || 0} 个`, + `使用边界:${conf.recommendation?.join(';') || '建议补充事件后复核。'}`, + ]; + if (audit.warnings?.length) { + lines.push(`警告:${audit.warnings.join(';')}`); + } + return lines.join('\n'); +} + +async function copyRectificationReport(text) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); +} + +function renderAuditCards(audit, conf, lang) { + const coverage = audit.coverage || {}; + const evidence = audit.evidence || {}; + const cluster = audit.top_cluster || {}; + const missing = coverage.missing_groups?.length ? coverage.missing_groups.join('、') : '暂不需要'; + const runner = audit.runner_up ? `${audit.runner_up.time} (${fmtOffset(audit.runner_up.offsetMin)}, ${audit.runner_up.totalScore}%)` : '无'; + const confidenceMeaning = { + '高': '可把推荐时间作为主候选,但仍建议保留原始记录。', + '中': '可用于D1/D9/D10观察,高敏感分盘需谨慎。', + '低': '只能作为探索候选,不建议覆盖出生证明/家人记录。', + '不确定': '证据不足,应继续收集事件。', + }[conf?.level] || '证据不足,应继续收集事件。'; + const cards = [ + ['事件覆盖', `${coverage.event_count || 0}个事件 · ${coverage.group_count || 0}类主题`, `质量 ${coverage.quality_score || 0}%;年份跨度 ${coverage.year_span || 0} 年。`], + ['命中证据', `${evidence.matched_events || 0}个匹配 · ${evidence.match_rate || 0}%`, `强证据 ${evidence.strong_events || 0} 个;敏感分盘 ${evidence.sensitive_vargas?.join('/') || '无变化'}。`], + ['候选差距', `领先 ${audit.score_gap ?? 0} 分`, `第二名:${runner};同分簇 ${cluster.count || 0} 个,范围 ${fmtOffset(cluster.min || 0)} 到 ${fmtOffset(cluster.max || 0)}。`], + ['使用边界', conf?.level || '不确定', confidenceMeaning], + ['补充建议', missing, '优先补充不同年龄段、不同主题、日期明确的事件。'], + ]; + return cards.map(([label, value, note]) => ` +
    + ${escapeHtml(label)} + ${escapeHtml(value)} +

    ${escapeHtml(note)}

    +
    + `).join(''); +} + function getRelevanceText(di, category, ascSign) { if (!di) return '—'; const cat = EVENT_CATEGORIES[category]; if (!cat) return '—'; @@ -245,17 +365,17 @@ function showOffsetDetail(container, r, base) { let det = container.querySelector('.rect-offset-detail'); if (!det) { det = document.createElement('div'); det.className = 'rect-offset-detail card'; container.appendChild(det); } const vlHtml = Object.entries(r.vargaLagnas || {}).map(([k, v]) => - `${k}: ${v ? signName(v.sign) : '—'}` + `${escapeHtml(k)}: ${escapeHtml(v ? signName(v.sign) : '—')}` ).join(''); const hcHtml = r.houseChanges.length > 0 - ? r.houseChanges.map(c => `${planetName(c.planet)}: H${c.from}→H${c.to}`).join('') - : t('rect.no.change'); + ? r.houseChanges.map(c => `${escapeHtml(planetName(c.planet))}: H${escapeHtml(c.from)}→H${escapeHtml(c.to)}`).join('') + : escapeHtml(t('rect.no.change')); det.innerHTML = ` -

    ${t('rect.offset.detail').replace('{0}',fmtOffset(r.offsetMin)).replace('{1}',r.time)}

    +

    ${escapeHtml(t('rect.offset.detail').replace('{0}',fmtOffset(r.offsetMin)).replace('{1}',r.time))}

    -
    ${t('rect.asc.label')}${signName(r.ascSign)} ${r.ascDeg?.toFixed(2)}°
    -
    ${t('rect.moon.nak')}${r.moonNak?.nakName||'—'} Pada ${r.moonNak?.pada||'?'}
    -
    ${t('rect.total.score')}${r.totalScore}%
    +
    ${t('rect.asc.label')}${escapeHtml(signName(r.ascSign))} ${escapeHtml(r.ascDeg?.toFixed(2) || '-')}°
    +
    ${t('rect.moon.nak')}${escapeHtml(r.moonNak?.nakName||'—')} Pada ${escapeHtml(r.moonNak?.pada||'?')}
    +
    ${t('rect.total.score')}${escapeHtml(r.totalScore)}%
    ${t('rect.varga.changes')}
    ${vlHtml}
    diff --git a/jyotish-app/renderers.js b/jyotish-app/renderers.js index 6da23784..4c65f02a 100644 --- a/jyotish-app/renderers.js +++ b/jyotish-app/renderers.js @@ -7,6 +7,7 @@ import { VARGA_DEFS } from './jyotish-advanced.js'; import { renderSouthIndianChart } from './chart-renderer.js'; import { t, signName, planetName, statusName, houseLabel, yearsLabel } from './i18n.js'; import { DASHA_THEMES, NAKSHATRA_DATA, PLANET_DIGNITY, ASCENDANT_TABLE } from './interpretation.js'; +import { escapeHtml, escapeAttr, safeNumber } from './security.js'; const $ = id => document.getElementById(id); // ========== Varga 分盘星座计算(与 jyotish-advanced.js 同逻辑) ========== @@ -43,11 +44,11 @@ export function renderTithiInfo(ty) { el.innerHTML = `

    ${t('panch.title')}

    -
    Vara 星期
    ${ty.vara || '-'}
    -
    Tithi 月相
    ${ty.tithi.name}
    -
    Paksha 半月
    ${ty.tithi.paksha}
    -
    Karana 半月相
    ${ty.karana?.name || '-'}
    -
    Yoga 日月瑜伽
    ${ty.yoga.name}
    +
    Vara 星期
    ${escapeHtml(ty.vara || '-')}
    +
    Tithi 月相
    ${escapeHtml(ty.tithi.name)}
    +
    Paksha 半月
    ${escapeHtml(ty.tithi.paksha)}
    +
    Karana 半月相
    ${escapeHtml(ty.karana?.name || '-')}
    +
    Yoga 日月瑜伽
    ${escapeHtml(ty.yoga.name)}
    `; } @@ -57,7 +58,7 @@ export function renderArudhaQuick(arudha) { const el = $('arudha-quick-section'); if (!arudha || Object.keys(arudha).length === 0) { el.innerHTML = ''; return; } const items = Object.entries(arudha).map(([k, v]) => - `
    ${k}${v.label ? ' ' + v.label : ''}
    ${signName(v.sign)} ${houseLabel(v.pada_house)}
    ` + `
    ${escapeHtml(k)}${v.label ? ' ' + escapeHtml(v.label) : ''}
    ${escapeHtml(signName(v.sign))} ${escapeHtml(houseLabel(v.pada_house))}
    ` ).join(''); el.innerHTML = `

    ${t('arudha.overview')}

    ${items}
    `; } @@ -73,9 +74,9 @@ function renderKarakaGrid(container, data) { container.innerHTML = Object.entries(data).map(([k, v]) => { const isDK = k === 'DK'; return `
    -
    ${labels[k] || k}
    -
    ${PLANET_SYMBOLS[v.planet] || ''} ${planetName(v.planet)}
    -
    ${v.degree.toFixed(2)}° · ${signName(v.sign)} H${v.house}
    +
    ${escapeHtml(labels[k] || k)}
    +
    ${escapeHtml(PLANET_SYMBOLS[v.planet] || '')} ${escapeHtml(planetName(v.planet))}
    +
    ${safeNumber(v.degree).toFixed(2)}° · ${escapeHtml(signName(v.sign))} H${escapeHtml(v.house)}
    `; }).join(''); } @@ -86,7 +87,7 @@ export function renderVargas(allV, planets, ascendant) { const order = ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu','Ketu']; // 渲染选择器 selector.innerHTML = VARGA_DEFS.map(v => - `` + `` ).join(''); selector.querySelectorAll('.varga-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -105,7 +106,8 @@ function renderSingleVarga(vargaId, planets, ascendant) { // 计算分盘上升 const ascSi = SIGNS.indexOf(ascendant.sign); - const ascDegInSign = ascendant.degree - ascSi * 30; + const ascDegRaw = safeNumber(ascendant.degree_in_sign ?? ascendant.degree); + const ascDegInSign = ascDegRaw >= 30 ? ((ascDegRaw % 30) + 30) % 30 : ascDegRaw; const vargaAscIdx = d === 1 ? ascSi : vargaSignIdx(ascDegInSign, ascSi, d); const vargaAscSign = SIGNS[vargaAscIdx]; @@ -136,7 +138,7 @@ function renderSingleVarga(vargaId, planets, ascendant) { const order = ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu','Ketu']; tbody.innerHTML = order.filter(pn => vPlanets[pn]).map(pn => { const vp = vPlanets[pn]; - return `${PLANET_SYMBOLS[pn]||''}${planetName(pn)}${signName(vp.sign)}H${vp.house}`; + return `${escapeHtml(PLANET_SYMBOLS[pn]||'')}${escapeHtml(planetName(pn))}${escapeHtml(signName(vp.sign))}H${escapeHtml(vp.house)}`; }).join(''); } @@ -145,7 +147,7 @@ export function renderArudha(arudha) { const el = $('arudha-section'); if (!arudha || Object.keys(arudha).length === 0) { el.innerHTML = ''; return; } const items = Object.entries(arudha).map(([k, v]) => - `
    ${k}${v.label ? ' · ' + v.label : ''}
    ${signName(v.sign)}
    ${houseLabel(v.pada_house)} · ${t('house.lord')}: ${planetName(v.lord)}
    ` + `
    ${escapeHtml(k)}${v.label ? ' · ' + escapeHtml(v.label) : ''}
    ${escapeHtml(signName(v.sign))}
    ${escapeHtml(houseLabel(v.pada_house))} · ${t('house.lord')}: ${escapeHtml(planetName(v.lord))}
    ` ).join(''); el.innerHTML = `
    ${t('arudha.title')}
    ${items}
    `; } @@ -165,14 +167,14 @@ export function renderAshtakavarga(av, ascSign) { for (let h = 1; h <= 12; h++) { const sh = av.sav_by_house[h]; if (!sh) continue; - const score = sh.score; - const pct = Math.round(score / 56 * 100); + const score = safeNumber(sh.score); + const pct = Math.max(0, Math.min(100, Math.round(score / 56 * 100))); const bavRow = pOrder.map(pn => { const has = av.bav_by_house[h]?.[pn] ? 'contrib' : ''; - return ``; + return ``; }).join(''); details.innerHTML += `
    -
    ${houseLabel(h)} · ${sh.sign}
    +
    ${escapeHtml(houseLabel(h))} · ${escapeHtml(sh.sign)}
    ${score}
    ${bavRow}
    @@ -194,7 +196,7 @@ function renderSAVChart(av, ascSign) { const [r, c] = layout[i]; const signIdx = i; // South Indian: Pisces=0, Aries=1, ... const house = ((signIdx - ai + 12) % 12) + 1; - const score = av.sav_by_house[house]?.score || 0; + const score = safeNumber(av.sav_by_house[house]?.score); const color = score >= 30 ? 'rgba(22,163,74,0.15)' : score >= 25 ? 'rgba(109,40,217,0.08)' : score < 20 ? 'rgba(220,38,38,0.1)' : '#fff'; const x = c * cell, y = r * cell; svg += ``; @@ -212,19 +214,19 @@ export function renderShadbala(sb) { tbody.innerHTML = ''; for (const pn of ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn']) { const s = sb[pn]; if (!s) continue; - const pct = s.percentage; + const pct = safeNumber(s.percentage); const barClass = pct >= 100 ? 'strong' : pct >= 75 ? 'medium' : 'weak'; tbody.innerHTML += ` - ${PLANET_SYMBOLS[pn]||''}${planetName(pn)} - ${s.total} R - ${s.required} R + ${escapeHtml(PLANET_SYMBOLS[pn]||'')}${escapeHtml(planetName(pn))} + ${escapeHtml(s.total)} R + ${escapeHtml(s.required)} R
    ${pct}%
    - ${s.status} + ${escapeHtml(s.status)} `; } } @@ -240,12 +242,12 @@ export function renderDasha3Level(data) { const curSub = cd.antardasha?.find(a => a.is_current); const curPraty = curSub?.pratyantardasha?.find(p => p.is_current); const theme = DASHA_THEMES[cd.lord]; - const themeHtml = theme ? `
    ${theme.theme}
    ✦ ${theme.positive}⚠ ${theme.negative}
    ` : ''; + const themeHtml = theme ? `
    ${escapeHtml(theme.theme)}
    ✦ ${escapeHtml(theme.positive)}⚠ ${escapeHtml(theme.negative)}
    ` : ''; currentEl.innerHTML = ` -
    ${t('dasha.maha')}
    ${planetName(cd.lord)}
    -
    ${t('dasha.antar')}
    ${curSub ? planetName(curSub.lord) : '-'}
    -
    ${t('dasha.praty')}
    ${curPraty ? planetName(curPraty.lord) : '-'}
    -
    ${cd.start} ~ ${cd.end}
    +
    ${t('dasha.maha')}
    ${escapeHtml(planetName(cd.lord))}
    +
    ${t('dasha.antar')}
    ${curSub ? escapeHtml(planetName(curSub.lord)) : '-'}
    +
    ${t('dasha.praty')}
    ${curPraty ? escapeHtml(planetName(curPraty.lord)) : '-'}
    +
    ${escapeHtml(cd.start)} ~ ${escapeHtml(cd.end)}
    ${themeHtml} `; } @@ -254,7 +256,7 @@ export function renderDasha3Level(data) { const isCur = data.current_dasha && data.current_dasha.lord === d.lord; const bar = document.createElement('div'); bar.className = `dasha-bar${isCur ? ' active' : ''}`; - bar.innerHTML = `
    ${planetName(d.lord)}
    ${yearsLabel(d.years)}
    ${d.start.slice(2)}
    `; + bar.innerHTML = `
    ${escapeHtml(planetName(d.lord))}
    ${escapeHtml(yearsLabel(d.years))}
    ${escapeHtml(String(d.start || '').slice(2))}
    `; bar.addEventListener('click', () => { container.querySelectorAll('.dasha-bar').forEach(b => b.classList.remove('selected')); bar.classList.add('selected'); @@ -274,7 +276,7 @@ function renderAntardashaPanel(subs) { for (const ad of subs) { const div = document.createElement('div'); div.className = `antardasha-item${ad.is_current ? ' current' : ''}`; - div.innerHTML = `
    ${planetName(ad.lord)}${ad.is_current ? ' ◀' : ''}
    ${ad.start.slice(5)}~${ad.end.slice(5)}
    `; + div.innerHTML = `
    ${escapeHtml(planetName(ad.lord))}${ad.is_current ? ' ◀' : ''}
    ${escapeHtml(String(ad.start || '').slice(5))}~${escapeHtml(String(ad.end || '').slice(5))}
    `; div.addEventListener('click', () => { panel.querySelectorAll('.antardasha-item').forEach(i => i.classList.remove('selected')); div.classList.add('selected'); @@ -293,8 +295,8 @@ function renderPratyantardasha(pratyList) { const grid = panel.querySelector('.praty-grid'); for (const p of pratyList) { grid.innerHTML += `
    -
    ${planetName(p.lord)}${p.is_current ? ' ◀' : ''}
    -
    ${p.start.slice(5)}~${p.end.slice(5)}
    +
    ${escapeHtml(planetName(p.lord))}${p.is_current ? ' ◀' : ''}
    +
    ${escapeHtml(String(p.start || '').slice(5))}~${escapeHtml(String(p.end || '').slice(5))}
    `; } } @@ -318,13 +320,13 @@ export function updatePlanetsTable(planets, d9Varga) { d9sign = signName(d9Varga.planets[pn].sign) || '-'; } tbody.innerHTML += ` - ${PLANET_SYMBOLS[pn]||''}${pn}${retro}${combust} - ${signName(p.sign)} - ${p.degree_in_sign.toFixed(2)}° - H${p.house} + ${escapeHtml(PLANET_SYMBOLS[pn]||'')}${escapeHtml(pn)}${retro}${combust} + ${escapeHtml(signName(p.sign))} + ${safeNumber(p.degree_in_sign).toFixed(2)}° + H${escapeHtml(p.house)} ${statusName(p.status)} - ${p.nakshatra} P${p.nakshatra_pada} - ${d9sign} + ${escapeHtml(p.nakshatra)} P${escapeHtml(p.nakshatra_pada)} + ${escapeHtml(d9sign)} `; } } @@ -334,9 +336,9 @@ export function updatePlanetsTable(planets, d9Varga) { export function renderBhavaBala(bb) { const el = $('bhava-bala-section'); if (!el || !bb) return; const rows = bb.houses.map(h => { - const bar = Math.min(100, h.in_rupas / 12 * 100); - return `${h.house}${signName(h.sign)}${planetName(h.lord)||h.lord} - ${h.in_rupas}${h.lord_bala}${h.dig_bala}${h.drig_bala} + const bar = Math.min(100, safeNumber(h.in_rupas) / 12 * 100); + return `${escapeHtml(h.house)}${escapeHtml(signName(h.sign))}${escapeHtml(planetName(h.lord)||h.lord)} + ${escapeHtml(h.in_rupas)}${escapeHtml(h.lord_bala)}${escapeHtml(h.dig_bala)}${escapeHtml(h.drig_bala)}
    `; }).join(''); el.innerHTML = `

    ${t('ext.bhava')}

    @@ -350,11 +352,11 @@ export function renderVimsopakaVaiseshikamsa(vim, vais) { const PL = ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu','Ketu']; const rows = PL.map(pn => { const d = vim.dasa[pn], s = vim.shodasa[pn], v = vais?.[pn]; - return `${planetName(pn)} - ${d?d.score:'-'}${d?d.pct+'%':'-'} - ${v?v.dasa.name:'-'}${v?v.dasa.level:'-'} - ${s?s.score:'-'}${s?s.pct+'%':'-'} - ${v?v.shodasa.name:'-'}${v?v.shodasa.level:'-'}`; + return `${escapeHtml(planetName(pn))} + ${escapeHtml(d?d.score:'-')}${escapeHtml(d?d.pct+'%':'-')} + ${escapeHtml(v?v.dasa.name:'-')}${escapeHtml(v?v.dasa.level:'-')} + ${escapeHtml(s?s.score:'-')}${escapeHtml(s?s.pct+'%':'-')} + ${escapeHtml(v?v.shodasa.name:'-')}${escapeHtml(v?v.shodasa.level:'-')}`; }).join(''); el.innerHTML = `

    ${t('ext.vim')}

    @@ -367,9 +369,9 @@ export function renderPlanetStates(activity, age, alertness, mood) { const PL = ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu','Ketu']; const rows = PL.map(pn => { const a=activity[pn], ag=age?.[pn], al=alertness?.[pn], m=mood?.[pn]; - return ` - - `; + return ` + + `; }).join(''); el.innerHTML = `

    ${t('ext.states')}

    ${t('col.planet')}
    ${planetName(pn)}${a?.activity||'-'}${ag?.age||'-'}${al?.alertness||'-'}${m?.moods?.join(', ')||'-'}
    ${escapeHtml(planetName(pn))}${escapeHtml(a?.activity||'-')}${escapeHtml(ag?.age||'-')}${escapeHtml(al?.alertness||'-')}${escapeHtml(m?.moods?.join(', ')||'-')}
    ${rows}
    ${t('col.planet')}${t('col.activity')}${t('col.age')}${t('col.alertness')}${t('col.mood')}
    `; @@ -380,8 +382,8 @@ export function renderAVPinda(pinda) { const PL = ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Lagna']; const rows = PL.map(pn => { const p = pinda[pn]; - return `${pn==='Lagna'?'Lagna':planetName(pn)} - ${p?p.sodhya_pinda:'-'}${p?p.rasi_pinda:'-'}${p?p.graha_pinda:'-'}`; + return `${escapeHtml(pn==='Lagna'?'Lagna':planetName(pn))} + ${escapeHtml(p?p.sodhya_pinda:'-')}${escapeHtml(p?p.rasi_pinda:'-')}${escapeHtml(p?p.graha_pinda:'-')}`; }).join(''); el.innerHTML = `

    ${t('ext.pinda')}

    ${rows}
    ${t('col.planet')}Sodhya PindaRasi PindaGraha Pinda
    `; @@ -394,13 +396,13 @@ export function renderExtraDasas(dasas) { for (const [key, dasa] of systems) { if (!dasa) continue; const cur = dasa.current; - html += `
    ${dasa.system}${cur?' — 当前: '+(cur.lord_cn||cur.lord)+' ('+cur.start+' ~ '+cur.end+')':''}
    `; + html += `
    ${escapeHtml(dasa.system)}${cur?' — 当前: '+escapeHtml(cur.lord_cn||cur.lord)+' ('+escapeHtml(cur.start)+' ~ '+escapeHtml(cur.end)+')':''}
    `; // Antardasha if (cur?.antardasha) { html += '
    '; for (const s of cur.antardasha) { const cls = s.is_current ? 'style="font-weight:bold;color:#222;"' : 'style="color:#888;"'; - html += `${s.lord_cn||s.lord} ${s.start}~${s.end} `; + html += `${escapeHtml(s.lord_cn||s.lord)} ${escapeHtml(s.start)}~${escapeHtml(s.end)} `; } html += '
    '; } @@ -408,7 +410,7 @@ export function renderExtraDasas(dasas) { html += '
    '; for (const d of dasa.timeline) { const cls = d.is_current ? 'dasha-active' : ''; - html += `${d.lord_cn||d.lord}`; + html += `${escapeHtml(d.lord_cn||d.lord)}`; } html += '
    '; } diff --git a/jyotish-app/security.js b/jyotish-app/security.js new file mode 100644 index 00000000..4e9360e1 --- /dev/null +++ b/jyotish-app/security.js @@ -0,0 +1,17 @@ +export function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, '`'); +} + +export function safeNumber(value, fallback = 0) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; +} diff --git a/jyotish-app/skill-map.js b/jyotish-app/skill-map.js new file mode 100644 index 00000000..5fd568bf --- /dev/null +++ b/jyotish-app/skill-map.js @@ -0,0 +1,2122 @@ +import { escapeAttr, escapeHtml } from './security.js'; + +const STAGES = [ + ['入口路由', '出生信息 / PDF星盘 / 生时不明三路径'], + ['静态十步', '宫位、Yoga、Argala、逆行燃烧、Nakshatra、Shadbala、AV、Ketu、分盘'], + ['动态七步', 'Dasha、收敛、Transit、Double Transit、Jaimini、KP、Varshaphala'], + ['应期输出', '时间窗口、行动类型、置信度、案例检索'], + ['补救措施', '宝石、咒语、捐赠、斋戒、Dosha专项'], + ['现代措辞', '把传统术语映射为普通用户能理解的生活语言'], + ['验证门控', 'Technique Audit、预测边界、外部验证状态'], + ['报告交付', '结构化摘要、专题章节、PDF/打印导出'], +]; + +const MODULES = [ + { id: 'd1', name: 'D1基础星盘', tab: '本命盘', status: 'app', detail: '行星位置、宫位、尊严、星宿、上升信息' }, + { id: 'lagna', name: '特殊Lagna', tab: '完整解盘', status: 'app', detail: 'AL/UL/A10 与日出校正版 HL/GL/VL 已在完整解盘展示' }, + { id: 'karaka', name: 'Jaimini Karaka', tab: 'Karaka', status: 'app', detail: '7/8 Karaka 已展示,并已声明两套体系差异与固定口径要求' }, + { id: 'shadbala', name: 'Shadbala六维力量', tab: 'Shadbala', status: 'app', detail: '相对强弱已展示,外部绝对值校准仍需标注边界' }, + { id: 'avastha', name: 'Avastha状态', tab: '扩展分析', status: 'partial', detail: '行星年龄/警觉/情绪状态已部分展示' }, + { id: 'bhava', name: 'Bhava Bala', tab: '扩展分析', status: 'app', detail: '宫位力量、宫主、Drig/Dig 维度' }, + { id: 'vimsopaka', name: 'Vimsopaka', tab: '扩展分析', status: 'app', detail: 'Dasa/Shodasa 分盘综合评分' }, + { id: 'varga', name: '分盘与频率', tab: '分盘 / 综合深度', status: 'app', detail: 'D9、D10、Vargottama、频率与三角验证' }, + { id: 'ashtakavarga', name: 'Ashtakavarga', tab: 'Ashtakavarga', status: 'app', detail: 'SAV/BAV、PAV、Sodhita 与 Yoga Pinda 贡献追溯' }, + { id: 'dasha', name: '多Dasha收敛', tab: 'Dasha', status: 'app', detail: 'Vimshottari 三级、35类Dasha总览、单系统时间线、当前/下一周期已在用户端展示' }, + { id: 'navamsa', name: 'Navamsa婚姻', tab: '分盘 / 完整解盘', status: 'app', detail: 'D9婚姻专题报告、八步旗标、Vargottama/Pushkara 与边界说明已展示' }, + { id: 'synthesis', name: '综合结论', tab: '完整解盘', status: 'app', detail: '把所有模块汇总为用户可读报告与审计表' }, +]; + +const TECHNIQUES = [ + ['D1 / Rashi', '已接入', '本命盘、行星表、宫位分析'], + ['D9 / D10 / 分盘', '已接入', '分盘 Tab、三角验证、Vargottama'], + ['D9婚姻八步旗标', '已接入', 'D9上升、DK、Venus、D9七宫、Vargottama、Pushkara专题报告'], + ['特殊Lagna / AL / UL / HL / GL', '已接入', '完整解盘已展示 AL/UL/A10/HL/GL/VL;本地 API 服务可返回日出校正版 HL/GL/VL,离线时保留本地简化兜底'], + ['Vimshottari Dasha', '已接入', 'Mahadasha / Antardasha / Pratyantar'], + ['35种Dasha', '已接入', '用户端已接入 Dasha 系统总览、分类筛选、单系统主周期时间线、当前/下一周期与精度说明'], + ['Jaimini Karaka / Arudha', '已接入', 'Karaka、Arudha概览,A10/UL需专题化'], + ['Argala', '已接入', '综合深度 Tab 已展示 Argala/Virodhargala 支持、阻挡和净分'], + ['Shadbala', '已接入', '六维力量表;保留绝对值校准边界'], + ['Ashtakavarga', '已接入', 'SAV/BAV、Transit AV'], + ['KP / Sub-lord', '已接入', 'KP页已支持问题域筛选、重点宫位 ABCD significator、高权重证据解释、Sublord 表格与使用边界'], + ['Synastry / Ashtakoot', '已接入', '合盘页已支持对方完整出生资料排盘、36分Ashtakoot、D9专题、Kuja Dosha平衡与Dasha同步;保留月亮黄经快算'], + ['Prashna', '已接入', '问事页已展示 Prashna Lagna、KP三层判定、Arudha镜像、Nadi侧写、时机评分与下一步行动;Sphuta/Sahams/失物仍保留为进阶路线'], + ['Remedies', '已接入', 'API自动返回补救建议;用户端已加入免责声明、低风险优先与7日执行计划'], + ['生时矫正', '已接入', '事件输入、候选时间评分、证据闭环、摘要复制与推荐时间重新排盘已闭环'], + ['MEVG外部验证', '已接入', '完整解盘已展示内部校验、外部检索项、置信度上限与预测边界'], + ['PDF星盘输入', '已接入', '用户端已支持纯文本/文本层PDF上传、抽取、质量门控与自动填表'], +]; + +let capabilityAuditPromise = null; + +const TECHNIQUE_API_ENDPOINTS = { + chart: '/api/chart', + kp: '/api/kp', + prashna: '/api/prashna', + synastry: '/api/synastry', + ashtakoot: '/api/synastry', + dasha: '/api/dasha', + remedies: '/api/remedies', + sade_sati: '/api/sade_sati', + pancha_mahapurusha: '/api/pancha_mahapurusha', + career: '/api/career', + relationship: '/api/relationship', + 'full-reading': '/api/chart', + tajika: '/api/annual', + 'solar-return': '/api/annual', + muhurta: '/api/muhurta', + 'bhava-chalit': '/api/bhava_chalit', + sudarshana: '/api/sudarshana', + 'nakshatra-full': '/api/nakshatra_full', + 'varga-full': '/api/varga_full', + jaimini: '/api/jaimini', + ashtakavarga: '/api/ashtakavarga', + shadbala: '/api/shadbala', + yoga: '/api/yogas', + yogas: '/api/yogas', + aspects: '/api/aspects', + rectification: '/api/rectification_gate', + 'case-validation': '/api/case_validation', + 'divisional-yoga': '/api/divisional_yoga', + 'deep-varga-avastha': '/api/deep_varga_avastha', + kakshya: '/api/kakshya', + 'bhava-bala': '/api/bhava_bala', + 'transit-trigger': '/api/transit', + 'thematic-report': '/api/thematic_report', + 'report-artifact': '/api/report_artifact', +}; + +const TECHNIQUE_COMMAND_ACTIONS = { + 'varga-full': 'varga', + 'solar-return': 'annual', + tajika: 'annual', + varshaphala: 'annual', + muhurta: 'muhurta', + muhurtha: 'muhurta', + 'bhava-chalit': 'bhava', + sudarshana: 'sudarshana', + 'nakshatra-full': 'nakshatra', + jaimini: 'jaimini', + ashtakavarga: 'ashtakavarga', + shadbala: 'shadbala', + yoga: 'yogas', + yogas: 'yogas', + aspects: 'aspects', + rectification: 'rectification', + 'case-validation': 'caseValidation', + 'divisional-yoga': 'divisionalYoga', + 'deep-varga-avastha': 'deepVargaAvastha', + kakshya: 'kakshya', + 'bhava-bala': 'bhavaBala', + 'transit-trigger': 'transitTrigger', + transit: 'transitTrigger', + 'thematic-report': 'thematicReport', + career: 'career', + relationship: 'relationship', + kp: 'kpQuick', + prashna: 'prashnaQuick', + synastry: 'synastryQuick', + ashtakoot: 'synastryQuick', + dasha: 'dasha', + remedies: 'remedies', + sade_sati: 'sadeSati', + 'pancha_mahapurusha': 'panchaMahapurusha', +}; + +const TECHNIQUE_ID_ACTIONS = { + vimshottari_dasha: 'dasha', + remedies: 'remedies', + sade_sati: 'sadeSati', + pancha_mahapurusha: 'panchaMahapurusha', + kp_system: 'kpQuick', + prashna: 'prashnaQuick', + prashna_integration: 'prashnaQuick', + synastry_16factor: 'synastryQuick', + full_reading_strict: 'thematicReport', +}; + +const TECHNIQUE_EXPLORER_PRIORITY = [ + 'varga', + 'annual', + 'muhurta', + 'bhava', + 'sudarshana', + 'nakshatra', + 'jaimini', + 'ashtakavarga', + 'shadbala', + 'yogas', + 'aspects', + 'dasha', + 'remedies', + 'sadeSati', + 'panchaMahapurusha', + 'rectification', + 'caseValidation', + 'divisionalYoga', + 'kakshya', + 'bhavaBala', + 'transitTrigger', + 'thematicReport', + 'career', + 'relationship', + 'kpQuick', + 'prashnaQuick', + 'synastryQuick', +]; + +function statusClass(status) { + if (status === 'app' || status === '已接入') return 'skill-status-ready'; + if (status === 'partial' || status === '部分接入') return 'skill-status-partial'; + return 'skill-status-pending'; +} + +function statusText(status) { + return { + app: '已在用户端承载', + partial: '部分承载', + engine: '引擎/Skill已覆盖', + }[status] || status; +} + +export function renderSkillCoverage(container, context = {}) { + if (!container) return; + const yogaCount = context.yogaCount ?? 0; + const dashaCount = context.dashaCount ?? 1; + const ready = MODULES.filter(m => m.status === 'app').length; + const partial = MODULES.filter(m => m.status === 'partial').length; + const engine = MODULES.filter(m => m.status === 'engine').length; + + const stageHtml = STAGES.map(([name, detail], index) => ` +
    + ${index + 1} +
    ${escapeHtml(name)}

    ${escapeHtml(detail)}

    +
    + `).join(''); + + const moduleHtml = MODULES.map(m => ` +
    +
    + ${escapeHtml(m.name)} + ${escapeHtml(statusText(m.status))} +
    +

    ${escapeHtml(m.detail)}

    + ${escapeHtml(m.tab)} +
    + `).join(''); + + const techniqueRows = TECHNIQUES.map(([name, status, note]) => ` + + ${escapeHtml(name)} + ${escapeHtml(status)} + ${escapeHtml(note)} + + `).join(''); + + container.innerHTML = ` +
    +
    +
    +

    专业完整解盘

    +

    当前报告把核心星盘、分盘、Dasha、Yoga、力量评估、过境、补救与验证边界放在同一条解盘链路中,便于从总览继续进入各专题。

    +
    +
    + ${ready}已承载 + ${partial}部分 + ${engine}待接入 +
    +
    + +
    +
    ${escapeHtml(context.ascendant || '-')}上升
    +
    ${escapeHtml(context.moonNakshatra || '-')}月亮星宿
    +
    ${yogaCount}Yoga识别
    +
    ${dashaCount}Dasha层级
    +
    + +
    +

    完整工作流

    +
    ${stageHtml}
    +
    + +
    +

    12模块深度报告

    +
    ${moduleHtml}
    +
    + +
    +

    Technique Audit Table

    +
    + + + ${techniqueRows} +
    技法用户端状态说明
    +
    +
    +
    + `; + renderTechniqueWorkbench(container, context); + hydrateCapabilityAudit(container, context); +} + +function renderTechniqueWorkbench(container, context = {}) { + const host = container.querySelector('.skill-dashboard'); + if (!host || host.querySelector('.technique-workbench')) return; + const chartData = context.chartData || {}; + const planets = chartData.planets || {}; + const hasChart = Object.keys(planets).length > 0; + const actions = [ + ['varga', 'D81/D108/D144', '精微分盘'], + ['annual', 'Varshaphala', '年运/Tajika'], + ['muhurta', 'Muhurta', '今日择日'], + ['bhava', 'Bhava Chalit', '宫位漂移'], + ['sudarshana', 'Sudarshana', '三重Lagna'], + ['nakshatra', 'Nakshatra+', '星宿深层'], + ['jaimini', 'Jaimini', 'Karaka/Arudha'], + ['ashtakavarga', 'Ashtakavarga', 'SAV/BAV'], + ['shadbala', 'Shadbala', '六重力量'], + ['yogas', 'Yogas', '格局检测'], + ['aspects', 'Aspects', '精确相位'], + ['rectification', '生时门控', '精度/分盘'], + ['caseValidation', '案例验证', 'MEVG证据'], + ['divisionalYoga', '分盘Yoga', 'D9/D10/D12'], + ['deepVargaAvastha', '深层状态', 'Sayanadi/Shayanadi · D24/D30/D60'], + ['kakshya', 'Kakshya', '度数触发'], + ['bhavaBala', 'Bhava Bala', '宫位力量'], + ['transitTrigger', '过境触发', '时间窗口'], + ['thematicReport', '主题报告', '叙事/裁决'], + ['career', '事业引擎', 'D10/10宫'], + ['relationship', '感情引擎', '7宫/D9'], + ['kpQuick', 'KP快读', 'Sublord'], + ['prashnaQuick', '问事快读', '当下问题'], + ['synastryQuick', '合盘快读', 'Ashtakoot'], + ]; + const buttons = actions.map(([id, label, note], index) => ` + + `).join(''); + const section = document.createElement('section'); + section.className = 'skill-section technique-workbench'; + section.innerHTML = ` +
    +

    高级技法工作台

    + ${hasChart ? '本地 API 服务' : '等待星盘'} +
    +
    ${buttons}
    +
    +

    ${hasChart ? '选择一个高级技法读取后端完整计算结果。' : '完成排盘后显示高级技法入口。'}

    +
    + `; + host.appendChild(section); + if (hasChart) bindTechniqueWorkbench(section, context); +} + +function bindTechniqueWorkbench(section, context) { + const resultHost = section.querySelector('.technique-result'); + const buttons = [...section.querySelectorAll('[data-action]')]; + for (const button of buttons) { + button.addEventListener('click', async () => { + if (!window.JyotishAPI) { + resultHost.innerHTML = renderWorkbenchError('本地 API 服务未连接'); + return; + } + const action = button.dataset.action; + buttons.forEach(btn => btn.classList.toggle('active', btn === button)); + button.classList.add('loading'); + resultHost.innerHTML = `

    正在计算 ${escapeHtml(button.querySelector('strong')?.textContent || action)}...

    `; + try { + const data = await runTechniqueAction(action, context); + resultHost.innerHTML = renderTechniqueResult(action, data); + } catch (error) { + resultHost.innerHTML = renderWorkbenchError(error?.message || '高级技法计算失败'); + } finally { + button.classList.remove('loading'); + } + }); + } +} + +async function runTechniqueAction(action, context) { + const chartData = context.chartData || {}; + const base = { + planets: chartData.planets || {}, + ascendant: chartData.ascendant || {}, + }; + if (action === 'varga') { + return window.JyotishAPI.computeVargaFull({ + ...base, + divisions: ['D81', 'D108', 'D144'], + }); + } + if (action === 'annual') { + return window.JyotishAPI.computeAnnual({ + ...base, + ...buildBirthPayload(context), + target_year: new Date().getFullYear(), + }); + } + if (action === 'muhurta') { + return window.JyotishAPI.computeMuhurta({ + ...base, + date: new Date().toISOString().slice(0, 10), + }); + } + if (action === 'bhava') { + return window.JyotishAPI.computeBhavaChalit({ + ...base, + ...buildBirthPayload(context), + mode: 'compare', + house_system: resolveBhavaHouseSystem(context), + }); + } + if (action === 'sudarshana') { + return window.JyotishAPI.computeSudarshana(base); + } + if (action === 'nakshatra') { + return window.JyotishAPI.computeNakshatraFull({ + ...base, + age: estimateAge(context), + }); + } + if (action === 'jaimini') { + return window.JyotishAPI.computeJaimini({ + ...base, + ...buildBirthPayload(context), + mode: 'all', + }); + } + if (action === 'ashtakavarga') { + return window.JyotishAPI.computeAshtakavarga(base); + } + if (action === 'shadbala') { + return window.JyotishAPI.computeShadbala({ + ...base, + ...buildBirthPayload(context), + }); + } + if (action === 'yogas') { + return window.JyotishAPI.computeYogas(base); + } + if (action === 'aspects') { + return window.JyotishAPI.computeAspects(base); + } + if (action === 'dasha') { + return window.JyotishAPI.computeDashaSystem({ + ...base, + ...buildBirthPayload(context), + dasha: 'vimshottari', + }); + } + if (action === 'remedies') { + return window.JyotishAPI.computeRemedies({ + ...base, + dasha: chartData.dasha || {}, + sade_sati: chartData.sade_sati || {}, + }); + } + if (action === 'sadeSati') { + return window.JyotishAPI.computeSadeSati({ + moon_degree: chartData.planets?.Moon?.lon ?? chartData.planets?.Moon?.degree ?? 0, + asc_degree: chartData.ascendant?.lon ?? chartData.ascendant?.degree ?? 0, + saturn_degree: chartData.transit?.Saturn?.lon ?? chartData.planets?.Saturn?.lon ?? chartData.planets?.Saturn?.degree ?? 0, + }); + } + if (action === 'panchaMahapurusha') { + return window.JyotishAPI.computePanchaMahapurusha({ + planets: chartData.planets || {}, + sun_degree: chartData.planets?.Sun?.lon ?? chartData.planets?.Sun?.degree, + }); + } + if (action === 'rectification') { + return window.JyotishAPI.computeRectificationGate({ + ...base, + declared_accuracy: 'minute', + time_source: 'family_clear', + }); + } + if (action === 'caseValidation') { + return window.JyotishAPI.computeCaseValidation({ + ...base, + current_md: chartData?.dasha?.current_md || chartData?.dasha?.current_dasha?.lord || '', + predicted_events: inferValidationEvents(chartData), + transit_desc: inferTransitDescription(chartData), + }); + } + if (action === 'divisionalYoga') { + return window.JyotishAPI.computeDivisionalYoga({ + ...base, + divisions: ['D9', 'D10', 'D12'], + }); + } + if (action === 'deepVargaAvastha') { + return callTechniqueEndpoint('/api/deep_varga_avastha', base); + } + if (action === 'kakshya') { + return window.JyotishAPI.computeKakshya(base); + } + if (action === 'bhavaBala') { + return window.JyotishAPI.computeBhavaBala(base); + } + if (action === 'transitTrigger') { + return window.JyotishAPI.computeTransitTriggers({ + natal_planets: chartData.planets || {}, + ascendant: chartData.ascendant || {}, + start: new Date().toISOString().slice(0, 10), + end: new Date(Date.now() + 90 * 86400000).toISOString().slice(0, 10), + planets_to_check: ['Saturn', 'Jupiter', 'Rahu', 'Ketu'], + }); + } + if (action === 'thematicReport') { + return window.JyotishAPI.computeThematicReport({ + chart_data: chartData, + themes: ['marriage', 'career', 'wealth', 'health', 'spirituality'], + }); + } + if (action === 'career') { + return window.JyotishAPI.computeCareer({ + planets: chartData.planets || {}, + asc_sign: chartData.ascendant?.sign || 'Aries', + }); + } + if (action === 'relationship') { + return window.JyotishAPI.computeRelationship({ + planets: chartData.planets || {}, + asc_sign: chartData.ascendant?.sign || 'Aries', + }); + } + if (action === 'kpQuick') { + return window.JyotishAPI.computeKP({ + planets: chartData.planets || {}, + asc_sign_idx: chartData.ascendant?.sign_idx ?? signIndex(chartData.ascendant?.sign), + }); + } + if (action === 'prashnaQuick') { + return window.JyotishAPI.computePrashna({ + planets: chartData.planets || {}, + question: 'general', + }); + } + if (action === 'synastryQuick') { + const moon = chartData.planets?.Moon?.lon ?? chartData.planets?.Moon?.degree ?? 0; + return window.JyotishAPI.computeSynastry({ + male_moon: moon, + female_moon: (Number(moon) + 120) % 360, + }); + } + throw new Error(`未知技法: ${action}`); +} + +function signIndex(sign) { + const signs = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces']; + const index = signs.indexOf(sign); + return index >= 0 ? index : 0; +} + +function inferValidationEvents(chartData = {}) { + const dasha = chartData.dasha || {}; + const events = []; + const current = dasha.current_md || dasha.current_dasha?.lord; + if (current === 'Jupiter') events.push('事业巅峰', '教育/法律成就', '全球影响力'); + if (current === 'Saturn') events.push('结构化成', '长期社会地位', '压力'); + if (current === 'Mars') events.push('行动力爆发', '竞争成就', '冲突'); + if (current === 'Venus') events.push('艺术创作', '关系发展', '美学/奢侈品'); + if (!events.length) events.push('事业巅峰', '关系发展'); + return events; +} + +function inferTransitDescription(chartData = {}) { + const asc = chartData.ascendant?.sign || ''; + if (asc) return `Double Jupiter Saturn activation around ${asc} ascendant`; + return 'Double Jupiter Saturn'; +} + +function buildBirthPayload(context) { + const birth = window.__jyotishBirth || {}; + const birthInfo = context.birthInfo || context.chartData?.birth_info || {}; + const dateParts = String(birthInfo.date || '').split('-').map(Number); + const timeParts = String(birthInfo.time || '').split(':').map(Number); + return { + year: pickNumber(birth.year, dateParts[0], 1990), + month: pickNumber(birth.month, dateParts[1], 1), + day: pickNumber(birth.day, dateParts[2], 1), + hour: pickNumber(birth.hour, timeParts[0], 12), + minute: pickNumber(birth.minute, timeParts[1], 0), + lat: pickNumber(birth.lat, birthInfo.lat, 0), + lon: pickNumber(birth.lon, birthInfo.lon, 0), + tz: pickNumber(birth.tz, parseTimezone(birthInfo.tz), 0), + }; +} + +function pickNumber(...values) { + for (const value of values) { + if (value == null || value === '') continue; + const number = Number(value); + if (Number.isFinite(number)) return number; + } + const fallback = Number(values[values.length - 1]); + return Number.isFinite(fallback) ? fallback : 0; +} + +function parseTimezone(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + const match = String(value || '').match(/UTC\s*([+-]\d+(?:\.\d+)?)(?::(\d+))?/i); + if (!match) return 0; + const hours = Number(match[1]); + const minutes = Number(match[2] || 0) / 60; + const sign = match[1].trim().startsWith('-') ? -1 : 1; + return hours + sign * minutes; +} + +function estimateAge(context) { + const birth = window.__jyotishBirth || {}; + const birthInfo = context.birthInfo || context.chartData?.birth_info || {}; + const birthYear = Number(birth.year || String(birthInfo.date || '').slice(0, 4)); + if (!Number.isFinite(birthYear) || birthYear < 1800) return undefined; + return Math.max(0, new Date().getFullYear() - birthYear); +} + +function renderTechniqueResult(action, data) { + if (action === 'varga') return renderVargaTechniqueResult(data); + if (action === 'annual') return renderAnnualTechniqueResult(data); + if (action === 'muhurta') return renderMuhurtaTechniqueResult(data); + if (action === 'ashtakavarga') return renderAshtakavargaTechniqueResult(data); + if (action === 'shadbala') return renderShadbalaTechniqueResult(data); + if (action === 'yogas') return renderYogaTechniqueResult(data); + if (action === 'dasha') return renderDashaTechniqueResult(data); + if (action === 'remedies') return renderRemediesExplorerResult(data); + if (action === 'sadeSati') return renderDomainEngineResult('Sade Sati 土星周期', data, '只把 Sade Sati 作为土星主题窗口,仍需结合本命承诺与 Dasha。'); + if (action === 'panchaMahapurusha') return renderDomainEngineResult('Pancha Mahapurusha 五王瑜伽', data, '确认行星尊严、宫位和实际强度后再写成优势主题。'); + if (action === 'rectification') return renderRectificationGateResult(data); + if (action === 'caseValidation') return renderCaseValidationResult(data); + if (action === 'divisionalYoga') return renderDivisionalYogaResult(data); + if (action === 'deepVargaAvastha') return renderDeepVargaAvasthaResult(data); + if (action === 'kakshya') return renderKakshyaResult(data); + if (action === 'bhava') return renderBhavaChalitResult(data); + if (action === 'bhavaBala') return renderBhavaBalaResult(data); + if (action === 'transitTrigger') return renderTransitTriggerResult(data); + if (action === 'thematicReport') return renderThematicReportResult(data); + if (action === 'career') return renderDomainEngineResult('事业引擎', data, '优先把强项领域和当前 Dasha/Transit 对齐,避免只按10宫单点下结论。'); + if (action === 'relationship') return renderDomainEngineResult('感情引擎', data, '把7宫、Venus、DK、UL、D9 与双方现实关系状态一起判断。'); + if (action === 'kpQuick') return renderKPQuickResult(data); + if (action === 'prashnaQuick') return renderPrashnaQuickResult(data); + if (action === 'synastryQuick') return renderSynastryQuickResult(data); + const payload = data?.report || data?.result || data || {}; + const rows = Object.entries(payload).slice(0, 8).map(([key, value]) => ` +
    + ${escapeHtml(formatKey(key))} + ${escapeHtml(formatMiniValue(value))} +
    + `).join(''); + return ` +
    + ${escapeHtml(resultTitle(action))} + ${escapeHtml(data?.mode || data?.endpoint || 'complete')} +
    +
    ${rows || '

    后端已返回结果。

    '}
    +
    JSON
    ${escapeHtml(JSON.stringify(payload, null, 2).slice(0, 6000))}
    + `; +} + +function renderVargaTechniqueResult(data) { + const charts = Object.entries(data?.result || {}); + const cards = charts.map(([key, chart]) => { + const asc = chart?.ascendant || {}; + const moon = chart?.planets?.Moon || {}; + return ` +
    + ${escapeHtml(key.replace(/^D(\d+)_/, 'D$1 · '))} + ${escapeHtml(asc.sign || '-')} ${escapeHtml(formatDegree(asc.degree))} + Moon: ${escapeHtml(moon.sign || '-')} ${escapeHtml(formatDegree(moon.degree))} · H${escapeHtml(moon.house || '-')} +
    + `; + }).join(''); + return ` +
    + D81/D108/D144 精微分盘 + ${escapeHtml(data?.source || 'varga')} +
    +
    ${cards}
    +
    JSON
    ${escapeHtml(JSON.stringify(data?.result || {}, null, 2).slice(0, 6000))}
    + `; +} + +function renderWorkbenchError(message) { + return ` +
    + 计算失败 +

    ${escapeHtml(message)}

    + 先到 Trust Center 运行健康检查;若本地 API 未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。 +
    + `; +} + +function resultTitle(action) { + return { + annual: 'Varshaphala / Tajika', + muhurta: 'Muhurta', + bhava: 'Bhava Chalit', + sudarshana: 'Sudarshana Chakra', + nakshatra: 'Nakshatra 深层报告', + jaimini: 'Jaimini 系统', + ashtakavarga: 'Ashtakavarga', + shadbala: 'Shadbala 六重力量', + yogas: 'Yoga 格局检测', + aspects: '精确相位', + rectification: '生时精度门控', + caseValidation: '案例验证 / MEVG', + divisionalYoga: '分盘 Yoga', + kakshya: 'Kakshya 度数触发', + bhavaBala: 'Bhava Bala 宫位力量', + transitTrigger: 'Transit Trigger', + thematicReport: '主题化报告', + career: '事业专题', + relationship: '感情专题', + kpQuick: 'KP Sublord', + prashnaQuick: 'Prashna 问事', + synastryQuick: 'Ashtakoot 合盘', + }[action] || action; +} + +function renderAnnualTechniqueResult(data) { + const report = data?.report || {}; + const muntha = report.muntha || {}; + const yearLord = report.year_lord || report.varshesha || {}; + const tajikaYogas = report.tajika_yogas || {}; + const tajikaStrength = report.tajika_strength || {}; + const strengthSummary = tajikaStrength.summary || {}; + const strongestPlanet = strengthSummary.strongest_planets?.[0]; + const weakestPlanet = strengthSummary.weakest_planets?.[0]; + const yogaCount = Array.isArray(tajikaYogas) ? tajikaYogas.length : (tajikaYogas.yogas?.length || tajikaYogas.count || 0); + const cards = [ + ['年度主题', annualTheme(report, muntha), 'Muntha / Solar Return'], + ['Muntha', formatMuntha(muntha), muntha.formula || '年度上升点每年前进一宫'], + ['年度主星', yearLord.year_lord || yearLord.lord || yearLord.planet || '-', yearLord.year_theme || yearLord.reason || '用于判断年度主导行星'], + ['Tajika Yoga', `${yogaCount} 项`, '作为年度事件触发与强弱判断的证据层'], + ['年度强度', formatTajikaStrengthPlanet(strongestPlanet), 'Harsha Bala / Panchavargiya Bala 最强行星'], + ['年度风险', formatTajikaStrengthPlanet(weakestPlanet), '综合强度较低,需用 Dasha/Transit 复核'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + return ` +
    + Varshaphala / Tajika 年运 + ${escapeHtml(report.target_year || report.report_year || data?.endpoint || 'annual')} +
    +
    ${cards}
    + ${renderTajikaStrengthCards(tajikaStrength)} +
    下一步:结合当前 Dasha 与 Transit,只把年度盘作为“今年主题和触发方向”,不要单独当作事件承诺。
    +
    JSON
    ${escapeHtml(JSON.stringify(report, null, 2).slice(0, 6000))}
    + `; +} + +function renderTajikaStrengthCards(tajikaStrength = {}) { + if (!tajikaStrength?.method || tajikaStrength.error) { + return ''; + } + const summary = tajikaStrength.summary || {}; + const strongest = summary.strongest_planets || []; + const weakest = summary.weakest_planets || []; + const combinedStrength = tajikaStrength.combined_strength || {}; + const rows = Object.entries(combinedStrength) + .sort(([, a], [, b]) => (b?.score || 0) - (a?.score || 0)) + .slice(0, 7) + .map(([planet, item]) => { + const components = item.components || {}; + return ` +
    + ${escapeHtml(planet)} + ${escapeHtml(item.grade || '-')} · ${escapeHtml(item.score ?? '-')} / ${escapeHtml(item.max_score ?? '-')} + Harsha Bala ${escapeHtml(components.harsha_bala ?? '-')} · Panchavargiya Bala ${escapeHtml(components.panchavargiya_bala ?? '-')} +
    + `; + }).join(''); + const evidence = [ + strongest[0] ? `最强:${formatTajikaStrengthPlanet(strongest[0])}` : null, + weakest[0] ? `需复核:${formatTajikaStrengthPlanet(weakest[0])}` : null, + summary.headline, + ].filter(Boolean); + return ` +
    ${evidence.map(item => `${escapeHtml(item)}`).join('')}
    +
    + Harsha Bala / Panchavargiya Bala +
    ${rows || '
    暂无年度强度数据
    '}
    +
    + `; +} + +function formatTajikaStrengthPlanet(item) { + if (!item) return '待计算'; + const score = item.score == null ? '-' : item.score; + const maxScore = item.max_score == null ? '-' : item.max_score; + return `${item.planet || '-'} · ${item.grade || '-'} · ${score}/${maxScore}`; +} + +function renderMuhurtaTechniqueResult(data) { + const report = data?.report || {}; + const rangeSearch = data?.range_search || report.range_search || {}; + const summary = report.summary || {}; + const panchanga = report.panchanga || {}; + const best = summary.best_activities || []; + const avoid = summary.avoid_activities || []; + const cards = [ + ['整体质量', `${summary.overall_quality || '-'} · ${formatPercent(summary.overall_score)}`, `${summary.auspicious_elements ?? 0}/${panchanga.total_elements || 6} 个 Panchanga 要素偏吉`], + ['适合活动', best.length ? best.join(' / ') : '暂无明显优先项', '按 Tithi、Nakshatra、Vara 等规则筛选'], + ['谨慎活动', avoid.length ? avoid.join(' / ') : '暂无硬性避开项', '若有 Dosha 或不吉 Yoga,应降级处理'], + ['Abhijit', report.abhijit_muhurta?.time_range || '约 48 分钟窗口', report.abhijit_muhurta?.warning || '仍需结合当地日出日落精算'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + const evidence = [ + panchanga.tithi?.full_name || panchanga.tithi?.name, + panchanga.nakshatra?.nakshatra, + panchanga.yoga?.yoga, + panchanga.vara?.vara, + ].filter(Boolean); + return ` +
    + Muhurta 择日 + ${escapeHtml(report.query_date || data?.endpoint || 'today')} +
    +
    ${cards}
    +
    ${evidence.map(item => `${escapeHtml(item)}`).join('')}
    + ${renderMuhurtaRangeSolver(rangeSearch)} +
    下一步:若用于婚礼、手术、签约等高风险活动,继续输入具体活动和当地日出后的小时,避免只看“今日整体”。
    +
    JSON
    ${escapeHtml(JSON.stringify(report, null, 2).slice(0, 6000))}
    + `; +} + +function renderMuhurtaRangeSolver(rangeSearch = {}) { + if (rangeSearch.mode !== 'muhurta_date_range_solver') return ''; + const bestWindows = rangeSearch.best_windows || []; + const rejectedDates = rangeSearch.rejected_dates || []; + const candidates = bestWindows.slice(0, 5).map(item => { + const windows = (item.recommended_windows || []).slice(0, 3) + .map(win => `${win.name || win.type} ${win.start || ''}-${win.end || ''}`) + .join(' / '); + const panchanga = item.evidence?.panchanga || {}; + return ` +
    + ${escapeHtml(item.date || '-')} · ${escapeHtml(item.quality || '-')} · ${escapeHtml(item.score ?? '-')} + ${escapeHtml(item.activity_verdict || '活动结论待确认')} + ${escapeHtml(windows || '暂无干净窗口')} · ${escapeHtml(panchanga.tithi || '-')} · ${escapeHtml(panchanga.nakshatra || '-')} +
    + `; + }).join(''); + const rejected = rejectedDates.slice(0, 4) + .map(item => `${item.date}: ${item.reason}`) + .join(';'); + return ` +
    + 范围择日 · 择日候选 +
    + ${escapeHtml(rangeSearch.activity_label || rangeSearch.activity || '-')} + ${escapeHtml(rangeSearch.date_range?.start || '-')} - ${escapeHtml(rangeSearch.date_range?.end || '-')} + ${escapeHtml(rangeSearch.candidate_count ?? 0)} / ${escapeHtml(rangeSearch.scanned_days ?? 0)} 个候选 +
    +
    ${candidates || '
    暂无可推荐候选
    '}
    + ${rejected ? `已过滤:${escapeHtml(rejected)}` : ''} +
    + `; +} + +function renderAshtakavargaTechniqueResult(data) { + const summary = data?.summary || {}; + const pavSummary = data?.pav_summary || summary.pav_summary || {}; + const sodhitaSummary = data?.sodhita_summary || summary.sodhita_summary || {}; + const yogaPindaSummary = data?.yoga_pinda_summary || summary.yoga_pinda_summary || {}; + const strongest = summary.strongest_houses || []; + const weakest = summary.weakest_houses || []; + const topPav = pavSummary.top_planets || []; + const topSodhita = sodhitaSummary.top_signs || []; + const topYogaPinda = yogaPindaSummary.top_planets || []; + const cards = [ + ['SAV结论', summary.headline || '-', `总分 ${summary.sav_total ?? '-'} · 校验 ${summary.sav_valid === false ? '需复核' : '通过'}`], + ['最强宫位', strongest.map(item => `H${item.house}:${item.sign} ${item.score}`).join(' / ') || '-', '优先承载事件和结果的领域'], + ['谨慎宫位', weakest.map(item => `H${item.house}:${item.sign} ${item.score}`).join(' / ') || '-', '需要Dasha/Transit确认后再判断'], + ['Sodhita', sodhitaSummary.headline || '-', topSodhita.map(item => `${item.sign}:${item.score}`).join(' / ') || '净化分未返回'], + ['Yoga Pinda', yogaPindaSummary.headline || '-', topYogaPinda.map(item => `${item.planet}:${item.yoga_pinda}`).join(' / ') || 'Pinda未返回'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + const evidence = [ + pavSummary.validation_passed === false ? 'PAV校验需复核' : 'PAV校验通过', + yogaPindaSummary.validation_passed === false ? 'Yoga Pinda需复核' : 'Yoga Pinda校验通过', + ...topPav.map(item => `${item.planet}:${item.total}`), + `扣减:${sodhitaSummary.reduction_total ?? 0}`, + ].filter(Boolean); + return ` +
    + Ashtakavarga / PAV / Sodhita / Yoga Pinda + ${escapeHtml(data?.endpoint || 'ashtakavarga')} +
    +
    ${cards}
    +
    ${evidence.map(item => `${escapeHtml(item)}`).join('')}
    +
    ${escapeHtml(summary.next_action || '先看SAV领域强弱,再用PAV、Sodhita和Yoga Pinda追溯证据。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderShadbalaTechniqueResult(data) { + const result = data?.result || {}; + const planets = result.planets || {}; + const advanced = data?.advanced_layer || {}; + const strongest = result.strongest || result.ranking?.[0] || '-'; + const weakest = result.weakest || result.ranking?.[result.ranking.length - 1] || '-'; + const yuddha = advanced.active_yuddha || {}; + const topKala = advanced.top_kala_support || []; + const drik = advanced.sputa_drik_bala || {}; + const cards = [ + ['最强/最弱', `${strongest} / ${weakest}`, `排名 ${formatMiniValue(result.ranking || [])}`], + ['主算法', result.method || 'Shadbala六重力量', '保留原总分和排名,不被增强层覆盖'], + ['Kala增强', topKala.map(item => `${item.planet}:${item.virupas}`).join(' / ') || '无额外时间力量', 'Varsha/Maasa/Dina/Hora 子项证据'], + ['Yuddha/Sputa', Object.keys(yuddha).length ? formatMiniValue(yuddha) : strongestScore(drik), '交战力量与连续相位力量作为复核证据'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + const evidence = [ + ...(data?.rule_variants?.selected || []), + ...Object.entries(planets).slice(0, 4).map(([planet, item]) => `${planet}:${item.strength_level || item.total_rupas}`), + ].filter(Boolean); + return ` +
    + Shadbala 六重力量 + ${escapeHtml(advanced.source ? 'advanced layer' : data?.endpoint || 'shadbala')} +
    +
    ${cards}
    +
    ${evidence.map(item => `${escapeHtml(item)}`).join('')}
    +
    ${escapeHtml(advanced.next_action || data?.rule_variants?.boundary || '先看主排名,再用高级层解释冲突。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderYogaTechniqueResult(data) { + const result = data?.result || data || {}; + const summary = result.summary || {}; + const curse = result.curse_yogas || {}; + const curses = curse.curses_detected || []; + const firstCurse = curses[0] || {}; + const selectedVariants = data?.rule_variants?.selected || result.rule_variants?.selected || ['extended_algorithm', 'json_rule_engine', 'curse_conjunctions']; + const cards = [ + ['扩展算法', `${summary.extended_count ?? (result.extended_yogas || []).length ?? 0} 项`, 'scripts/yoga_expansion.py'], + ['JSON规则', `${summary.rule_engine_count ?? (result.rule_engine_yogas || []).length ?? 0} 项`, 'references/yoga_rules.json'], + ['凶星合相', `${summary.curse_count ?? curses.length} 项 · ${curse.overall_risk || 'low'}`, firstCurse.name_cn || firstCurse.name || '未发现高风险合相'], + ['规则口径', formatMiniValue(selectedVariants), data?.rule_variants?.boundary || result.rule_variants?.boundary || '多套规则并行展示'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + const evidence = curses.slice(0, 5).map(item => `${item.name_cn || item.name}:${item.severity_label || item.severity}`); + return ` +
    + Yoga 格局检测 + ${escapeHtml(data?.ascendant || data?.endpoint || 'yogas')} +
    +
    ${cards}
    +
    ${evidence.map(item => `${escapeHtml(item)}`).join('')}
    +
    ${escapeHtml(curse.narrative ? '凶星合相仅作为风险提示层,必须与 Dasha、Transit、现实事件共同验证。' : '继续用 Dasha/Transit 验证 Yoga 是否有时间窗口。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(result, null, 2).slice(0, 6000))}
    + `; +} + +function renderDashaTechniqueResult(data) { + const periods = data?.periods || []; + const current = periods[0] || {}; + const next = periods[1] || {}; + const analysis = data?.vimshottari_analysis || {}; + const active = analysis.current || {}; + const md = active.mahadasha || {}; + const ad = active.antardasha || {}; + const levels = analysis.five_levels || {}; + const cards = [ + ['系统', data?.name || data?.key || 'Dasha', `${data?.precision || '-'} · ${data?.cycle_years || '-'}年循环`], + ['当前MD/AD', md.lord ? `${md.lord} / ${ad.lord || '-'}` : (current.lord || current.name || '-'), md.start ? `${md.start} → ${md.end}` : `${current.start || current.start_date || '-'} → ${current.end || current.end_date || '-'}`], + ['五级层级', renderDashaLevelLine(levels), active.remaining_days != null ? `当前小运剩余约 ${active.remaining_days} 天` : `${next.lord || next.name || '-'} 下一周期`], + ['返回周期', `${periods.length}段`, analysis.source || '用于时间线,不单独代表事件承诺'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + const evidence = [ + analysis.nakshatra?.name ? `Moon:${analysis.nakshatra.name} P${analysis.nakshatra.pada}` : '', + ...(active.keywords || []).slice(0, 4), + ...(data?.fragment_sources || []), + ].filter(Boolean); + return ` +
    + Dasha API Explorer + ${escapeHtml(data?.key || 'vimshottari')} +
    +
    ${cards}
    +
    ${evidence.map(item => `${escapeHtml(item)}`).join('')}
    +
    ${escapeHtml(analysis.summary?.next_action || '下一步:把 Dasha 只作为时间主轴,再用本命承诺、Transit 与案例验证收敛。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderDashaLevelLine(levels = {}) { + const ordered = ['mahadasha', 'bhukti', 'pratyantar', 'sookshma', 'prana']; + const labels = ordered.map(key => levels[key]?.lord).filter(Boolean); + return labels.length ? labels.join(' / ') : '-'; +} + +function renderRemediesExplorerResult(data) { + const source = data?.remedies || data?.result || data || {}; + const gemstone = source.gemstone || source.gemstones || {}; + const mantra = source.mantra || source.mantras || {}; + const actions = source.actions || source.behavioral_remedies || source.low_risk_actions || []; + const evidence = source.evidence_chain || source.evidence || []; + const cards = [ + ['原则', source.priority || source.policy || '低风险优先', '补救建议不能替代医疗、法律、投资或心理咨询'], + ['宝石', formatMiniValue(gemstone), '高成本/身体接触类建议必须二次确认'], + ['Mantra', formatMiniValue(mantra), '优先选择低风险、可停止的练习'], + ['执行项', formatMiniValue(actions), `${Array.isArray(evidence) ? evidence.length : 0} 条证据链`], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + return ` +
    + Remedies API Explorer + ${escapeHtml(data?.endpoint || 'remedies')} +
    +
    ${cards}
    +
    ${escapeHtml(source.next_action || '先执行低风险行动,观察7天后再升级。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(source, null, 2).slice(0, 6000))}
    + `; +} + +function renderThematicReportResult(data) { + const themes = data?.themes || {}; + const entries = Object.entries(themes); + const workflow = data?.workflow_orchestration || {}; + const evidenceSource = data?.evidence_source || {}; + const moduleStatus = evidenceSource.module_status || {}; + const fragmentSources = data?.fragment_sources || ['report_orchestrator.py', 'reading_orchestrator.py', 'orchestrator_bridge.py']; + const fullReadingBadge = evidenceSource.full_reading_used + ? `full-reading:${evidenceSource.full_reading_module_count || 0} modules` + : ''; + const themeLabels = { + marriage: '婚姻', + career: '事业', + wealth: '财富', + health: '健康', + spirituality: '灵性', + }; + const cards = entries.map(([key, report]) => { + const timing = report?.timing || {}; + const evidence = Array.isArray(report?.evidence) ? report.evidence : []; + const recommendations = Array.isArray(report?.recommendations) ? report.recommendations : []; + return ` +
    +
    + ${escapeHtml(themeLabels[key] || key)} + ${escapeHtml(report?.strength || '-')} +
    +

    ${escapeHtml(report?.summary || '-')}

    + ${escapeHtml(timing.dasha_period ? `${timing.dasha_period} · ${timing.start_year}-${timing.end_year}` : '未返回明确时间锚点')} +
    + ${evidence.slice(0, 3).map(item => `${escapeHtml(item.technique || item.chart || 'evidence')}`).join('')} +
    +
      + ${recommendations.slice(0, 2).map(item => `
    • ${escapeHtml(item)}
    • `).join('')} +
    +
    + `; + }).join(''); + return ` +
    + 主题化报告 / 冲突裁决 + ${escapeHtml(data?.mode || data?.endpoint || 'thematic')} +
    +
    ${[ + evidenceSource.source, + fullReadingBadge, + evidenceSource.sample_fallback ? 'sample fallback' : 'real evidence path', + evidenceSource.warning_count ? `${evidenceSource.warning_count} warnings` : '', + ...Object.entries(moduleStatus).slice(0, 6).map(([key, value]) => `${key}:${value}`), + ].filter(Boolean).map(item => `${escapeHtml(item)}`).join('')}
    +
    ${cards || '

    后端已返回主题化报告。

    '}
    +
    ${[ + ...fragmentSources, + workflow.bridge?.class, + workflow.stage, + ].filter(Boolean).map(item => `${escapeHtml(item)}`).join('')}
    +
    ${escapeHtml(data?.boundary || '主题报告负责组织证据与叙事,仍需 Dasha、Transit、案例验证共同收敛。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderRectificationGateResult(data) { + const summary = data?.summary || {}; + const lagna = data?.lagna_boundary || {}; + const asc = data?.ascendant || {}; + const cards = [ + ['结论', summary.headline || '-', lagna.note || `${asc.sign || '-'} ${formatDegree(asc.degree_in_sign)}`], + ['有效精度', data?.effective_accuracy || '-', `声明:${data?.declared_accuracy || '-'};来源:${data?.time_source || '-'}`], + ['可用分盘', (summary.enabled || []).join(' / ') || '-', '这些分盘可正常进入解释层'], + ['需谨慎/禁用', [...(summary.warned || []), ...(summary.disabled || [])].join(' / ') || '无', summary.confidence_floor || '按出生时间可信度降级'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + const events = summary.recommended_events || []; + return ` +
    + 生时精度门控 + ${lagna.is_sensitive ? 'sensitive' : 'stable'} +
    +
    ${cards}
    +
    ${events.map(item => `${escapeHtml(formatEventType(item))}`).join('')}
    +
    ${escapeHtml(summary.next_action || '补充事件后再做高敏分盘判断。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderCaseValidationResult(data) { + const summary = data?.summary || {}; + const result = data?.result || {}; + const gate = data?.mevg_gate || {}; + const fragmentSources = data?.fragment_sources || ['case_validator.py', 'mevg_automation.py']; + const cards = [ + ['验证结论', summary.headline || '-', `${summary.overall_confidence ?? 0}% overall confidence`], + ['通过/待证', `${summary.validated_count ?? 0} / ${summary.unvalidated_count ?? 0}`, summary.case_base || result.case_base || '本地案例库'], + ['MEVG门控', gate.gate_status || summary.gate_status || '-', `失败率 ${gate.fail_rate ?? 0} · 阈值 ${gate.threshold ?? '-'}`], + ['下一步', summary.next_action || '-', '未验证项不得写成确定预测'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + const validations = result.validations || []; + const evidence = [ + ...validations.slice(0, 5).map(item => `${item.type || 'rule'}:${item.validated ? 'ok' : 'wait'}`), + ...fragmentSources, + ]; + return ` +
    + 案例验证 / MEVG + ${escapeHtml(data?.endpoint || 'case')} +
    +
    ${cards}
    +
    ${evidence.map(item => `${escapeHtml(item)}`).join('')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderDivisionalYogaResult(data) { + const summary = data?.summary || {}; + const result = data?.result || {}; + const divisionCards = Object.entries(result).map(([division, item]) => { + const first = (item.yogas || [])[0]; + const note = first ? `${first.name || '-'} · ${first.description || ''}` : '暂无强 Yoga 命中'; + return renderInsightCard(division, `${item.yoga_count || 0} 项`, note); + }).join(''); + return ` +
    + 分盘 Yoga 检测 + ${escapeHtml(data?.ascendant || 'varga')} +
    +
    + ${renderInsightCard('结论', summary.headline || '-', `${summary.total_yogas ?? 0} 个 D9/D10/D12 命中`)} + ${divisionCards} +
    +
    ${escapeHtml(summary.next_action || '分盘 Yoga 只作为对应领域的辅助证据。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderDeepVargaAvasthaResult(data) { + const report = data?.report || data || {}; + const summary = report.summary || {}; + const avasthaSummary = report.avastha_summary || {}; + const deepVargaTemplates = report.deep_varga_templates || {}; + const dominantStates = avasthaSummary.dominant_states || []; + const priorityCards = summary.priority_cards || []; + const cards = [ + ['结论', summary.headline || '-', 'Sayanadi/Shayanadi 与 D24/D30/D60 综合'], + ['主导状态', dominantStates.map(item => `${item.state}:${item.count}`).join(' / ') || '-', 'dominant_states'], + ['弱状态行星', (avasthaSummary.weak_planets || []).map(item => `${item.planet}:${item.state}`).join(' / ') || '暂无', 'Avastha 低活跃或 Bala 偏弱'], + ['深分盘', Object.keys(deepVargaTemplates).join(' / ') || 'D24/D30/D60', 'D24/D30/D60 deep_varga_templates'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + const templateRows = Object.entries(deepVargaTemplates).map(([division, item]) => { + const templateCards = item.template_cards || []; + const riskFlags = item.risk_flags || []; + const firstCards = templateCards.slice(0, 3).map(card => `${card.planet} H${card.house} ${card.sign}`).join(' / '); + return ` +
    + ${escapeHtml(division)} · ${escapeHtml(item.title || item.theme || '-')} + ${escapeHtml(firstCards || '暂无关键行星')} + ${escapeHtml(riskFlags.length)} 个 risk_flags · ${escapeHtml(item.next_action || '')} +
    + `; + }).join(''); + const priority = priorityCards.map(card => `${escapeHtml(card.title || '-')}:${escapeHtml(card.value || '-')}`).join(''); + return ` +
    + Sayanadi/Shayanadi · D24/D30/D60 + ${escapeHtml(data?.endpoint || report.method || 'deep_varga_avastha')} +
    +
    ${cards}
    +
    ${priority || '深层状态解释层'}
    +
    + D24/D30/D60 模板 +
    ${templateRows || '
    暂无深分盘模板数据
    '}
    +
    +
    ${escapeHtml(summary.next_action || '深分盘和 Avastha 只作为侧证,需要与 D1、Dasha、Transit 同读。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(report, null, 2).slice(0, 6000))}
    + `; +} + +function renderKakshyaResult(data) { + const summary = data?.summary || {}; + const strongest = summary.strongest || []; + const weakest = summary.weakest || []; + const cards = [ + ['结论', summary.headline || '-', `平均强度 ${summary.average_strength ?? '-'}`], + ['最强触发', strongest.map(item => `${item.planet}:${item.kakshya_lord}`).join(' / ') || '-', '这些行星所在度数区间支持度较高'], + ['需谨慎', weakest.map(item => `${item.planet}:${item.kakshya_lord}`).join(' / ') || '-', '行运触发时不宜放大单点结论'], + ['区间单位', `${data?.result?.kakshya_span || 3.75}°`, '每个星座分为 8 个 Kakshya 区间'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + return ` +
    + Kakshya 度数触发 + ${escapeHtml(data?.ascendant || 'ashtakavarga')} +
    +
    ${cards}
    +
    ${escapeHtml(summary.next_action || '只在已有主题承诺时用 Kakshya 细化触发窗口。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderBhavaChalitResult(data) { + const result = data?.result || {}; + const summary = result.summary || {}; + const selected = data?.selected_house_system || result.selected_house_system || result.house_system || '-'; + const requested = data?.requested_house_system || result.requested_house_system || selected; + const available = data?.available_house_systems || result.available_house_systems || []; + const systemLabels = { + sripati: 'Sripati', + placidus: 'Placidus', + equal: 'Equal House', + whole_sign: 'Whole Sign', + porphyry: 'Porphyry', + koch: 'Koch', + }; + const shifts = result.shifts || []; + const firstHouse = result.boundaries?.houses?.[0]; + const cards = [ + ['宫位制', systemLabels[selected] || selected, requested === selected ? '使用用户选择的 houseSystem' : `请求 ${requested},实际 ${selected}`], + ['可选系统', available.map(item => systemLabels[item] || item).join(' / ') || '-', 'Sripati 与 Placidus 可从 Calculation Settings 切换'], + ['迁移行星', `${summary.shifted_count ?? result.shifted_count ?? shifts.length} / ${summary.total_planets ?? Object.keys(result.rashi_chart || {}).length}`, shifts.slice(0, 3).map(item => `${item.planet}:H${item.rashi_house}->H${item.bhava_house}`).join(' · ') || '暂无迁移'], + ['第一宫边界', firstHouse ? `${firstHouse.sandhi_start_lon}° → ${firstHouse.sandhi_end_lon}°` : '-', result.calculation_note || data?.calculation_note || 'Bhava Madhya / Sandhi 边界'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + return ` +
    + Bhava Chalit 宫位漂移 + ${escapeHtml(systemLabels[selected] || selected)} +
    +
    ${cards}
    +
    ${escapeHtml(result.fallback_reason || data?.fallback_reason || '用 Bhava Chalit 复核行星是否跨越宫位边界,再与 Rashi chart 一起解释。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderBhavaBalaResult(data) { + const summary = data?.summary || {}; + const strongest = summary.strongest || []; + const weakest = summary.weakest || []; + const cards = [ + ['结论', summary.headline || '-', data?.ascendant || 'Ascendant'], + ['最强宫位', strongest.map(item => `H${item.house}:${item.level}`).join(' / ') || '-', '优先作为人生主题与承载领域'], + ['最弱宫位', weakest.map(item => `H${item.house}:${item.level}`).join(' / ') || '-', '需要 Dasha/Transit 确认后再定风险'], + ['方法', data?.result?.method || 'Bhava Bala', 'Adhipathi + Dig + Drik 三元力量'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + return ` +
    + Bhava Bala 宫位力量 + ${escapeHtml(data?.ascendant || 'bhava')} +
    +
    ${cards}
    +
    ${escapeHtml(summary.next_action || '把宫位力量作为领域权重,不单独代表事件结果。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderTransitTriggerResult(data) { + const summary = data?.summary || {}; + const triggers = summary.top_triggers || []; + const cards = [ + ['结论', summary.headline || '-', `${summary.total_triggers ?? 0} 个触发点`], + ['区间', `${summary.period?.start || '-'} → ${summary.period?.end || '-'}`, '默认扫描未来90天慢行星触发'], + ['首要触发', triggers[0] ? `${triggers[0].planet || '-'} → ${triggers[0].sensitive_point || '-'} ${triggers[0].start_date || ''}` : '暂无', '仅代表时间窗口'], + ['证据类型', triggers[0]?.source || data?.result?.summary || '-', '优先 Swiss Ephemeris,缺失时回退均速模型'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + return ` +
    + 过境精确触发 + ${escapeHtml(data?.endpoint || 'transit')} +
    +
    ${cards}
    +
    ${escapeHtml(summary.next_action || '过境只用于细化时间窗口。')}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderDomainEngineResult(title, data, nextAction) { + const cards = summarizeDomainObject(data).slice(0, 4).map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + return ` +
    + ${escapeHtml(title)} + ${escapeHtml(data?.endpoint || 'analysis')} +
    +
    ${cards || renderInsightCard('结果', '已返回', '展开 JSON 查看完整结构')}
    +
    ${escapeHtml(nextAction)}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function summarizeDomainObject(data) { + const source = data?.summary && typeof data.summary === 'object' ? data.summary : data; + const rows = []; + for (const [key, value] of Object.entries(source || {})) { + if (rows.length >= 4) break; + if (value == null || typeof value === 'function') continue; + rows.push([formatKey(key), formatMiniValue(value), Array.isArray(value) ? `${value.length} 项证据` : '结构化分析字段']); + } + return rows; +} + +function renderKPQuickResult(data) { + const houses = data?.houses || {}; + const planets = data?.planets || {}; + const firstHouse = houses[1] || Object.values(houses)[0] || {}; + const firstPlanet = Object.entries(planets)[0] || []; + const kp = firstPlanet[1]?.kp_lords || {}; + const cards = [ + ['结论', Object.keys(houses).length ? 'KP 宫位 Significator 已返回' : '等待 KP 数据', 'ABCD Significator 用于事件兑现判断'], + ['1宫证据', firstHouse.sign || '-', `A/B/C/D: ${formatMiniValue(firstHouse.significators || {})}`], + ['首颗行星', firstPlanet[0] || '-', `${kp.sub_lord || '-'} / ${kp.sub_sub_lord || '-'}`], + ['下一步', '用具体问题限定宫位', 'KP 不适合无问题泛读'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + return ` +
    KP Sublord 快读kp
    +
    ${cards}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderPrashnaQuickResult(data) { + const chart = data?.prashna_chart || {}; + const answer = data?.kp_answer || {}; + const cards = [ + ['答案', answer.kp_answer || 'MAYBE', `置信度 ${answer.confidence || '-'}`], + ['问题宫', `${answer.primary_house || '-'}宫`, `宫主 ${answer.question_lord || '-'}`], + ['问事上升', `${chart.asc_sign || '-'} ${formatDegree(chart.asc_degree)}`, `Lagna lord ${chart.prashna_lagna_lord || '-'}`], + ['边界', '一次只问一个具体问题', '重大决定需结合出生盘'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + return ` +
    Prashna 问事快读horary
    +
    ${cards}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function renderSynastryQuickResult(data) { + const percentage = data?.match_percentage ?? ((Number(data?.total_score || 0) / Number(data?.max_score || 36)) * 100).toFixed(1); + const scores = data?.scores || {}; + const cards = [ + ['总分', `${data?.total_score ?? 0}/${data?.max_score ?? 36}`, `${percentage}%`], + ['结论', data?.is_match_approved ? '匹配通过' : '需谨慎综合判断', '月亮星宿体系结果'], + ['强项', strongestScore(scores), '从 Kuta 分项中读取'], + ['边界', '还需完整双方星盘', '7宫/D9/Kuja/Dasha 同步不可省略'], + ].map(([label, value, note]) => renderInsightCard(label, value, note)).join(''); + return ` +
    Ashtakoot 合盘快读synastry
    +
    ${cards}
    +
    JSON
    ${escapeHtml(JSON.stringify(data, null, 2).slice(0, 6000))}
    + `; +} + +function strongestScore(scores = {}) { + const entries = Object.entries(scores); + if (!entries.length) return '-'; + const [name, score] = entries.sort((a, b) => Number(b[1]) - Number(a[1]))[0]; + return `${name}: ${score}`; +} + +function renderInsightCard(label, value, note) { + return ` +
    + ${escapeHtml(label)} + ${escapeHtml(value)} +

    ${escapeHtml(note || '')}

    +
    + `; +} + +function annualTheme(report, muntha) { + const note = report.note || muntha.interpretation || ''; + if (note) return String(note).slice(0, 90); + const year = report.target_year || report.report_year || new Date().getFullYear(); + return `${year} 年的太阳返照主题`; +} + +function formatMuntha(muntha = {}) { + const sign = muntha.muntha_sign || muntha.sign || '-'; + const lord = muntha.muntha_lord || muntha.lord || ''; + const house = muntha.house ? ` H${muntha.house}` : ''; + return `${sign}${house}${lord ? ` · ${lord}` : ''}`; +} + +function formatPercent(value) { + const number = Number(value); + if (!Number.isFinite(number)) return '-'; + return number <= 1 ? `${Math.round(number * 100)}%` : `${Math.round(number)}%`; +} + +function formatEventType(value) { + return { + marriage: '婚姻', + child_birth: '子女', + career_start: '入职', + career_change: '事业转折', + promotion: '晋升', + relocation: '迁移', + education_end: '学业完成', + father_death: '父亲事件', + mother_death: '母亲事件', + accident: '事故', + health_crisis: '健康危机', + windfall: '意外收入', + financial_loss: '财务损失', + spiritual_awakening: '灵性转折', + }[value] || value; +} + +function formatKey(key) { + return String(key).replace(/_/g, ' '); +} + +function formatMiniValue(value) { + if (value == null || value === '') return '-'; + if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(3); + if (typeof value === 'string' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) { + if (value.length === 0) return '0项'; + if (value.every(item => ['string', 'number', 'boolean'].includes(typeof item))) return value.slice(0, 4).join(' / '); + return `${value.length}项`; + } + if (typeof value === 'object') { + if (value.sign) return `${value.sign} ${formatDegree(value.degree ?? value.degree_in_sign)}`; + if (value.name) return value.name; + if (value.summary) return value.summary; + return Object.keys(value).slice(0, 5).join(' / '); + } + return String(value); +} + +function formatDegree(value) { + const number = Number(value); + if (!Number.isFinite(number)) return ''; + return `${number.toFixed(2)}°`; +} + +function hydrateCapabilityAudit(container, context = {}) { + if (!window.JyotishAPI?.getCapabilityAudit && !window.JyotishAPI?.getTechniqueCatalog) { + renderCapabilityFallback(container, '本地 API 服务未启动,当前显示前端静态能力兜底。'); + return; + } + const host = container.querySelector('.skill-dashboard'); + if (!host || host.querySelector('.capability-audit-panel')) return; + const placeholder = document.createElement('section'); + placeholder.className = 'skill-section capability-audit-panel capability-loading'; + placeholder.innerHTML = '

    地毯式能力审计

    正在读取 technique registry、引擎子命令、API、前端入口与本地开源源码碎片...

    '; + host.appendChild(placeholder); + capabilityAuditPromise ||= getTechniqueCatalogOrAudit(); + capabilityAuditPromise + .then(audit => { + if (audit?.api_docs) window.__jyotishTechniqueCatalog = audit; + placeholder.classList.remove('capability-loading'); + placeholder.innerHTML = renderCapabilityAudit(audit, context); + bindTechniqueDirectory(placeholder, audit, context); + }) + .catch(error => { + capabilityAuditPromise = null; + placeholder.classList.remove('capability-loading'); + placeholder.innerHTML = renderCapabilityFallbackHtml(error?.message || '能力审计接口不可用'); + }); +} + +async function getTechniqueCatalogOrAudit() { + if (window.JyotishAPI?.getTechniqueCatalog) { + try { + return await window.JyotishAPI.getTechniqueCatalog(); + } catch (error) { + if (!window.JyotishAPI?.getCapabilityAudit) throw error; + } + } + return window.JyotishAPI.getCapabilityAudit(); +} + +function renderCapabilityFallback(container, message) { + const host = container.querySelector('.skill-dashboard'); + if (!host || host.querySelector('.capability-audit-panel')) return; + const section = document.createElement('section'); + section.className = 'skill-section capability-audit-panel'; + section.innerHTML = renderCapabilityFallbackHtml(message); + host.appendChild(section); +} + +function renderCapabilityFallbackHtml(message) { + return ` +

    地毯式能力审计

    +
    + 未取得后端审计结果 +

    ${escapeHtml(message)}

    + 按 README 的普通用户启动路径启动网页服务和本地 API 服务,然后重新排盘。 +
    + `; +} + +function renderCapabilityAudit(audit, context = {}) { + const registry = audit?.registry || {}; + const surfaces = audit?.surfaces || {}; + const local = audit?.local_open_source || {}; + const research = audit?.external_research || []; + const gaps = audit?.priority_gaps || []; + const productization = audit?.productization || {}; + const uxProductization = audit?.ux_productization || {}; + const productSummary = productization.summary || {}; + const productQueue = productization.next_queue || []; + const uxSummary = uxProductization.summary || {}; + const uxQueue = uxProductization.next_queue || []; + const directSources = (local.sources || []).filter(s => s.reuse === 'direct'); + const cautionSources = (local.sources || []).filter(s => s.reuse !== 'direct'); + const statusBadges = Object.entries(registry.status_counts || {}).map(([status, count]) => ( + `${escapeHtml(count)}${escapeHtml(status)}` + )).join(''); + const commandTags = (surfaces.engine_not_api || []).slice(0, 18).map(command => ( + `${escapeHtml(command)}` + )).join(''); + const sourceCards = (local.sources || []).map(source => renderSourceCard(source)).join(''); + const researchRows = research.map(item => ` + + ${escapeHtml(item.name)} + ${escapeHtml(item.license)} + ${escapeHtml(reuseLabel(item.reuse))} + ${escapeHtml((item.best_for || []).join(' / '))} + + `).join(''); + const gapCards = gaps.map(gap => ` +
    + ${escapeHtml(gap.kind || 'gap')} + ${escapeHtml(gap.command || gap.topic || '待补能力')} +

    ${escapeHtml(gap.reason || '')}

    + ${(gap.related_techniques || []).length ? `${gap.related_techniques.map(escapeHtml).join(' · ')}` : ''} +
    + `).join(''); + const productQueueCards = productQueue.slice(0, 8).map(row => ` +
    + ${escapeHtml(productLevelLabel(row.level))} + ${escapeHtml(row.name)} +

    ${escapeHtml(row.next_action || row.reason || '')}

    + ${(row.commands || []).length ? `${row.commands.slice(0, 4).map(escapeHtml).join(' · ')}` : ''} +
    + `).join(''); + const uxQueueCards = uxQueue.slice(0, 8).map(row => ` +
    + ${escapeHtml(uxLevelLabel(row.ux_level))} · ${escapeHtml(row.ux_score ?? 0)}/6 + ${escapeHtml(row.name)} +

    ${escapeHtml(row.ux_next_action || '')}

    + ${(row.missing_ux || []).length ? `缺:${row.missing_ux.slice(0, 4).map(uxCriterionLabel).map(escapeHtml).join(' / ')}` : ''} +
    + `).join(''); + + return ` +

    地毯式能力审计

    +
    +
    ${escapeHtml(registry.technique_count ?? 0)}注册技法
    +
    ${escapeHtml(surfaces.engine_command_count ?? 0)}引擎子命令
    +
    ${escapeHtml(surfaces.api_endpoint_count ?? 0)}Web API
    +
    ${escapeHtml(surfaces.app_tab_count ?? 0)}前端入口
    +
    ${escapeHtml(local.source_count ?? 0)}本地开源碎片
    +
    +
    ${statusBadges}
    + +
    +
    Skill 产品化覆盖
    +
    +
    ${escapeHtml(productSummary.productized ?? 0)}已产品化
    +
    ${escapeHtml(productSummary.api_backed ?? 0)}API 已承载
    +
    ${escapeHtml(productSummary.engine_or_full_reading ?? 0)}引擎/完整解盘
    +
    ${escapeHtml(productSummary.registry_only ?? 0)}待确认
    +
    +
    ${productQueueCards || '

    当前没有自动识别出的下一步队列。

    '}
    +

    这里衡量用户能否在页面中真正用到该 skill,而不只是代码里存在算法。

    +
    + +
    +
    UX 验收
    +
    +
    ${escapeHtml(uxSummary.excellent ?? 0)}优秀
    +
    ${escapeHtml(uxSummary.usable ?? 0)}可用
    +
    ${escapeHtml(uxSummary.thin ?? 0)}偏薄
    +
    ${escapeHtml(uxSummary.not_user_ready ?? 0)}未就绪
    +
    +
    ${uxQueueCards || '

    当前没有 UX 队列。

    '}
    +

    UX 分数按入口、可读结论、证据链、下一步建议、JSON隐藏、移动端可读六项评估。

    +
    + + ${renderTechniqueDirectory(audit, context)} + +
    +
    +
    引擎已有但 API/应用未完整暴露
    +
    ${commandTags || '暂无明显缺口'}
    +
    +
    +
    开源复用库存
    +

    可直接复用:${escapeHtml(directSources.length)} 个;需许可证谨慎或人工确认:${escapeHtml(cautionSources.length)} 个。

    +
    ${sourceCards}
    +
    +
    + +
    +
    优先补洞项
    +
    ${gapCards || '

    未发现高优先级暴露缺口。

    '}
    +
    + +
    +
    全网对标与许可证边界
    +
    + + + ${researchRows} +
    项目许可证复用策略最适合对标
    +
    +

    AGPL/GPL 项目只用于 benchmark 和算法口径校准;MIT/Apache 项目优先直接复用,避免重复造轮子。

    +
    + `; +} + +function renderSourceCard(source) { + return ` +
    +
    + ${escapeHtml(source.name)} + ${escapeHtml(source.license || 'unknown')} · ${escapeHtml(reuseLabel(source.reuse))} +
    +

    ${escapeHtml((source.modules || []).slice(0, 8).join(' / ') || '未自动识别模块')}

    + ${escapeHtml(source.file_count || 0)} files · ${escapeHtml(source.path || '')} +
    + `; +} + +function renderTechniqueDirectory(audit, context = {}) { + const rows = buildTechniqueDirectoryRows(audit); + const hasChart = Boolean(Object.keys(context?.chartData?.planets || {}).length); + const domains = uniqueSorted(rows.flatMap(row => row.domains)); + const statuses = uniqueSorted(rows.map(row => row.status).filter(Boolean)); + const surfaceOptions = [ + ['all', '全部承载'], + ['productized', '已产品化'], + ['api_backed', 'API 已承载'], + ['engine_or_full_reading', '引擎/完整解盘'], + ['registry_only', '待确认'], + ]; + const domainOptions = ['', ...domains.map(domain => ( + `` + ))].join(''); + const statusOptions = ['', ...statuses.map(status => ( + `` + ))].join(''); + const surfaceOptionHtml = surfaceOptions.map(([value, label]) => ( + `` + )).join(''); + + return ` +
    +
    +
    +
    技法与 API 目录
    +

    按关键词、领域、注册状态和承载层筛选,直接看到每项能力是否已经进入用户端,并可用当前星盘试算白名单 API。

    +
    + ${escapeHtml(rows.length)} 项 +
    +
    + + + + +
    +
    ${escapeHtml(rows.length)} 项匹配
    +
    + ${renderTechniqueDirectoryRows(rows, { hasChart })} +
    +
    + `; +} + +function buildTechniqueDirectoryRows(audit) { + if (Array.isArray(audit?.techniques) && audit.techniques.some(row => row.api_endpoints || row.example_endpoint)) { + return audit.techniques.map(row => { + const commands = uniqueSorted(row.commands || []); + const endpoints = uniqueSorted(row.api_endpoints || []); + const explorerAction = resolveTechniqueExplorerAction(row.id, commands, row.example_endpoint); + const domains = uniqueSorted(row.domains || []); + const visibleMarkers = uniqueSorted(row.visible_markers || []); + const outputPaths = uniqueSorted(row.output_paths || []); + const searchText = [ + row.id, + row.name, + row.audit_label, + row.status, + row.level, + row.reason, + row.next_action, + row.method_docs?.summary, + row.method_docs?.boundary, + row.method_docs?.primary_endpoint, + row.ux_level, + ...domains, + ...commands, + ...endpoints, + ...outputPaths, + ...visibleMarkers, + ].filter(Boolean).join(' ').toLowerCase(); + return { + id: row.id, + name: row.name || row.id, + auditLabel: row.audit_label || row.name || row.id, + status: row.status || 'unknown', + domains, + commands, + apiCommands: uniqueSorted(row.api_commands || []), + endpoints, + outputPaths, + visibleMarkers, + explorerAction, + exampleEndpoint: row.example_endpoint || endpointForAction(explorerAction), + level: row.level || 'registry_only', + reason: row.reason || '', + nextAction: row.next_action || '保持监控。', + methodDocs: row.method_docs || {}, + uxScore: row.ux_score ?? 0, + uxLevel: row.ux_level || 'not_user_ready', + missingUx: row.missing_ux || [], + searchText, + }; + }).sort(sortTechniqueRows); + } + const techniques = audit?.techniques || []; + const productRows = new Map((audit?.productization?.rows || []).map(row => [row.id, row])); + const uxRows = new Map((audit?.ux_productization?.rows || []).map(row => [row.id, row])); + const availableEndpoints = new Set(audit?.surfaces?.api_endpoints || []); + return techniques.map(technique => { + const product = productRows.get(technique.id) || {}; + const ux = uxRows.get(technique.id) || {}; + const commands = uniqueSorted([...(product.commands || []), ...(technique.commands || [])]); + const apiCommands = uniqueSorted(product.api_commands || []); + const endpoints = uniqueSorted([ + ...apiCommands.map(command => TECHNIQUE_API_ENDPOINTS[command]).filter(Boolean), + ...commands.map(command => TECHNIQUE_API_ENDPOINTS[command]).filter(endpoint => endpoint && availableEndpoints.has(endpoint)), + ]); + const explorerAction = resolveTechniqueExplorerAction(technique.id, commands); + const domains = uniqueSorted([...(product.domains || []), ...(technique.domains || [])]); + const outputPaths = uniqueSorted([...(product.output_paths || []), ...(technique.output_paths || [])]); + const visibleMarkers = uniqueSorted(product.visible_markers || []); + const searchText = [ + technique.id, + technique.name, + technique.audit_label, + technique.status, + technique.limitation, + technique.missing_impact, + product.level, + product.reason, + product.next_action, + product.method_docs?.summary, + product.method_docs?.boundary, + ux.ux_level, + ux.ux_next_action, + ...domains, + ...commands, + ...apiCommands, + ...endpoints, + ...outputPaths, + ...visibleMarkers, + ].filter(Boolean).join(' ').toLowerCase(); + + return { + id: technique.id, + name: technique.name || product.name || technique.id, + auditLabel: technique.audit_label || technique.name || technique.id, + status: technique.status || product.status || 'unknown', + domains, + commands, + apiCommands, + endpoints, + outputPaths, + visibleMarkers, + explorerAction, + exampleEndpoint: endpointForAction(explorerAction), + level: product.level || 'registry_only', + reason: product.reason || technique.limitation || '', + nextAction: product.next_action || ux.ux_next_action || technique.missing_impact || '保持监控。', + methodDocs: product.method_docs || {}, + uxScore: ux.ux_score ?? 0, + uxLevel: ux.ux_level || 'not_user_ready', + missingUx: ux.missing_ux || [], + searchText, + }; + }).sort(sortTechniqueRows); +} + +function sortTechniqueRows(a, b) { + const levelOrder = { registry_only: 0, engine_or_full_reading: 1, api_backed: 2, productized: 3 }; + return (levelOrder[a.level] ?? 0) - (levelOrder[b.level] ?? 0) + || String(a.name).localeCompare(String(b.name), 'zh-Hans-CN'); +} + +function renderTechniqueDirectoryRows(rows, options = {}) { + if (!rows.length) { + return '

    没有匹配的技法,换一个关键词或筛选条件。

    '; + } + const hasChart = Boolean(options.hasChart); + return rows.map(row => { + const domainTags = row.domains.slice(0, 4).map(domain => `${escapeHtml(domain)}`).join(''); + const commandTags = row.commands.slice(0, 4).map(command => `${escapeHtml(command)}`).join(''); + const endpointTags = row.endpoints.slice(0, 3).map(endpoint => `${escapeHtml(endpoint)}`).join(''); + const markerText = row.visibleMarkers.length ? row.visibleMarkers.slice(0, 3).join(' / ') : '未识别独立入口'; + const missingText = row.missingUx.length + ? `缺:${row.missingUx.slice(0, 3).map(uxCriterionLabel).join(' / ')}` + : 'UX 验收完整'; + const methodSummary = row.methodDocs?.summary || row.reason || '已进入能力注册表。'; + const methodBoundary = row.methodDocs?.boundary || row.nextAction; + const apiDocKey = row.methodDocs?.api_doc_key || row.exampleEndpoint; + const explorerButton = row.explorerAction + ? `` + : '完整解盘承载'; + return ` +
    +
    +
    + ${escapeHtml(row.name)} + ${escapeHtml(row.id)} +
    + ${escapeHtml(productLevelLabel(row.level))} +
    +
    + ${escapeHtml(row.status)} + ${escapeHtml(uxLevelLabel(row.uxLevel))} · ${escapeHtml(row.uxScore)}/6 + ${escapeHtml(markerText)} +
    +

    ${escapeHtml(methodSummary)}

    + ${methodBoundary ? `${escapeHtml(methodBoundary)}` : ''} +
    ${domainTags || '未标领域'}
    +
    ${commandTags || '无命令'}
    +
    ${endpointTags || '待 API 暴露'}
    + ${apiDocKey ? `
    API docs${escapeHtml(apiDocKey)}
    ` : ''} + ${escapeHtml(missingText)} + ${escapeHtml(row.nextAction)} +
    + ${explorerButton} + ${row.explorerAction ? `${escapeHtml(resultTitle(row.explorerAction))}` : 'full-reading'} +
    +
    +
    + `; + }).join(''); +} + +function bindTechniqueDirectory(root, audit, context = {}) { + const directory = root?.querySelector('[data-technique-directory]'); + if (!directory) return; + const rows = buildTechniqueDirectoryRows(audit); + const hasChart = Boolean(Object.keys(context?.chartData?.planets || {}).length); + const resultHost = directory.querySelector('[data-technique-directory-results]'); + const summary = directory.querySelector('[data-technique-directory-summary]'); + const filters = [...directory.querySelectorAll('[data-technique-filter]')]; + const render = () => { + const criteria = readTechniqueDirectoryFilters(directory); + const filtered = filterTechniqueDirectoryRows(rows, criteria); + if (summary) summary.textContent = `${filtered.length} / ${rows.length} 项匹配`; + if (resultHost) resultHost.innerHTML = renderTechniqueDirectoryRows(filtered, { hasChart }); + bindTechniqueExplorerActions(resultHost, context); + }; + filters.forEach(control => { + control.addEventListener(control.tagName === 'INPUT' ? 'input' : 'change', render); + }); + bindTechniqueExplorerActions(resultHost, context); +} + +function readTechniqueDirectoryFilters(directory) { + return { + search: normalizeDirectoryText(directory.querySelector('[data-technique-filter="search"]')?.value), + domain: directory.querySelector('[data-technique-filter="domain"]')?.value || 'all', + status: directory.querySelector('[data-technique-filter="status"]')?.value || 'all', + surface: directory.querySelector('[data-technique-filter="surface"]')?.value || 'all', + }; +} + +function filterTechniqueDirectoryRows(rows, filters = {}) { + const search = normalizeDirectoryText(filters.search); + return rows.filter(row => { + if (search && !row.searchText.includes(search)) return false; + if (filters.domain && filters.domain !== 'all' && !row.domains.includes(filters.domain)) return false; + if (filters.status && filters.status !== 'all' && row.status !== filters.status) return false; + if (filters.surface && filters.surface !== 'all' && row.level !== filters.surface) return false; + return true; + }); +} + +function bindTechniqueExplorerActions(host, context = {}) { + if (!host) return; + const buttons = [...host.querySelectorAll('[data-technique-run]')]; + for (const button of buttons) { + button.addEventListener('click', async () => { + const action = button.dataset.techniqueRun; + const card = button.closest('.technique-directory-card'); + const resultHost = card?.querySelector('[data-technique-result]'); + if (!resultHost || !action) return; + if (!window.JyotishAPI) { + resultHost.innerHTML = renderWorkbenchError('本地 API 服务未连接'); + return; + } + button.disabled = true; + button.classList.add('loading'); + resultHost.innerHTML = renderTechniqueExplorerLoading(action, context); + try { + const data = await runTechniqueExplorerAction(action, context, button.dataset.techniqueEndpoint || ''); + resultHost.innerHTML = renderTechniqueExplorerResult(action, data); + } catch (error) { + resultHost.innerHTML = renderWorkbenchError(error?.message || 'API Explorer 试算失败'); + } finally { + button.disabled = false; + button.classList.remove('loading'); + } + }); + } +} + +async function runTechniqueExplorerAction(action, context = {}, endpoint = '') { + const payload = buildTechniqueExplorerPayload(action, context); + const targetEndpoint = endpoint || endpointForAction(action); + if (window.JyotishAPI?.runTechniqueExample && targetEndpoint) { + return window.JyotishAPI.runTechniqueExample({ endpoint: targetEndpoint, payload }); + } + return runTechniqueAction(action, context); +} + +function renderTechniqueExplorerResult(action, data) { + if (data?.endpoint === 'technique_example') { + const apiDoc = getTechniqueApiDoc(data.target_endpoint); + return ` +
    + 样例 API + ${escapeHtml(data.target_endpoint || '')} +
    + ${apiDoc ? renderTechniqueApiDocs(apiDoc) : ''} + ${renderTechniqueResult(action, data.result || data)} +
    Sample payload
    ${escapeHtml(JSON.stringify(data.sample_payload || {}, null, 2).slice(0, 3000))}
    + `; + } + return renderTechniqueResult(action, data); +} + +function renderTechniqueExplorerLoading(action, context = {}) { + const payload = buildTechniqueExplorerPayload(action, context); + return ` +
    + 正在试算 ${escapeHtml(resultTitle(action))}... +
    请求预览
    ${escapeHtml(JSON.stringify(payload, null, 2).slice(0, 1200))}
    +
    + `; +} + +function buildTechniqueExplorerPayload(action, context = {}) { + const chartData = context.chartData || {}; + const base = { + planets: chartData.planets || {}, + ascendant: chartData.ascendant || {}, + }; + if (action === 'annual') return { ...base, ...buildBirthPayload(context), target_year: new Date().getFullYear() }; + if (action === 'muhurta') return { date: new Date().toISOString().slice(0, 10), activity: 'business', hour_from_sunrise: 6.0 }; + if (action === 'bhava') return { ...base, ...buildBirthPayload(context), mode: 'compare', house_system: resolveBhavaHouseSystem(context) }; + if (action === 'nakshatra') return { ...base, age: estimateAge(context) }; + if (action === 'jaimini') return { ...base, ...buildBirthPayload(context), mode: 'all' }; + if (action === 'shadbala') return { ...base, ...buildBirthPayload(context) }; + if (action === 'yogas') return { ...base, current_dasha: chartData.dasha?.current_md || chartData.dasha_lord || '' }; + if (action === 'dasha') return { ...base, ...buildBirthPayload(context), dasha: 'vimshottari' }; + if (action === 'remedies') return { shadbala: chartData.shadbala || {}, doshas: chartData.doshas || [], dasha_lord: chartData.dasha?.current_md || '' }; + if (action === 'sadeSati') return { moon_degree: chartData.planets?.Moon?.lon ?? 0, asc_degree: chartData.ascendant?.lon ?? 0, saturn_degree: chartData.planets?.Saturn?.lon ?? 0 }; + if (action === 'panchaMahapurusha') return { planets: chartData.planets || {}, sun_degree: chartData.planets?.Sun?.lon ?? chartData.planets?.Sun?.degree }; + if (action === 'rectification') return { ...base, declared_accuracy: 'minute', time_source: 'family_clear' }; + if (action === 'caseValidation') return { ...base, current_md: chartData?.dasha?.current_md || '', predicted_events: inferValidationEvents(chartData), transit_desc: inferTransitDescription(chartData) }; + if (action === 'divisionalYoga') return { ...base, divisions: ['D9', 'D10', 'D12'] }; + if (action === 'deepVargaAvastha') return base; + if (action === 'transitTrigger') return { natal_planets: chartData.planets || {}, ascendant: chartData.ascendant || {}, start: new Date().toISOString().slice(0, 10), end: new Date(Date.now() + 90 * 86400000).toISOString().slice(0, 10), planets_to_check: ['Saturn', 'Jupiter', 'Rahu', 'Ketu'] }; + if (action === 'thematicReport') return { chart_data: chartData, theme: 'marriage' }; + if (action === 'career') return { planets: chartData.planets || {}, asc_sign: chartData.ascendant?.sign || 'Aries' }; + if (action === 'relationship') return { planets: chartData.planets || {}, asc_sign: chartData.ascendant?.sign || 'Aries', dasha_info: { maha_dasha: chartData.dasha?.current_md || 'Venus' } }; + if (action === 'kpQuick') return { planets: chartData.planets || {}, asc_sign_idx: chartData.ascendant?.sign_idx ?? signIndex(chartData.ascendant?.sign) }; + if (action === 'prashnaQuick') return { planets: chartData.planets || {}, question: 'general' }; + if (action === 'synastryQuick') { + const moon = chartData.planets?.Moon?.lon ?? chartData.planets?.Moon?.degree ?? 0; + return { male_moon: moon, female_moon: (Number(moon) + 120) % 360 }; + } + if (action === 'varga') return { ...base, divisions: ['D9', 'D10'] }; + return base; +} + +function resolveBhavaHouseSystem(context = {}) { + const selected = context.calculationSettings?.houseSystem || context.chartData?.provenance?.calculationSettings?.houseSystem || context.chartData?.calculationSettings?.houseSystem; + const allowed = ['sripati', 'placidus', 'equal', 'whole_sign', 'porphyry', 'koch']; + return allowed.includes(selected) ? selected : 'sripati'; +} + +function buildExplorerPayloadPreview(action, context = {}) { + return buildTechniqueExplorerPayload(action, context); +} + +function resolveTechniqueExplorerAction(id, commands = [], endpoint = '') { + if (TECHNIQUE_ID_ACTIONS[id]) return TECHNIQUE_ID_ACTIONS[id]; + const endpointAction = actionForEndpoint(endpoint); + if (endpointAction) return endpointAction; + const available = commands + .map(command => TECHNIQUE_COMMAND_ACTIONS[command]) + .filter(Boolean); + if (!available.length) return ''; + return available.sort((a, b) => ( + TECHNIQUE_EXPLORER_PRIORITY.indexOf(a) - TECHNIQUE_EXPLORER_PRIORITY.indexOf(b) + ))[0]; +} + +function actionForEndpoint(endpoint) { + return { + '/api/varga_full': 'varga', + '/api/annual': 'annual', + '/api/muhurta': 'muhurta', + '/api/bhava_chalit': 'bhava', + '/api/sudarshana': 'sudarshana', + '/api/nakshatra_full': 'nakshatra', + '/api/jaimini': 'jaimini', + '/api/ashtakavarga': 'ashtakavarga', + '/api/shadbala': 'shadbala', + '/api/yogas': 'yogas', + '/api/aspects': 'aspects', + '/api/dasha': 'dasha', + '/api/remedies': 'remedies', + '/api/sade_sati': 'sadeSati', + '/api/pancha_mahapurusha': 'panchaMahapurusha', + '/api/rectification_gate': 'rectification', + '/api/case_validation': 'caseValidation', + '/api/divisional_yoga': 'divisionalYoga', + '/api/kakshya': 'kakshya', + '/api/bhava_bala': 'bhavaBala', + '/api/transit': 'transitTrigger', + '/api/thematic_report': 'thematicReport', + '/api/career': 'career', + '/api/relationship': 'relationship', + '/api/kp': 'kpQuick', + '/api/prashna': 'prashnaQuick', + '/api/synastry': 'synastryQuick', + }[endpoint] || ''; +} + +function endpointForAction(action) { + return { + varga: '/api/varga_full', + annual: '/api/annual', + muhurta: '/api/muhurta', + bhava: '/api/bhava_chalit', + sudarshana: '/api/sudarshana', + nakshatra: '/api/nakshatra_full', + jaimini: '/api/jaimini', + ashtakavarga: '/api/ashtakavarga', + shadbala: '/api/shadbala', + yogas: '/api/yogas', + aspects: '/api/aspects', + dasha: '/api/dasha', + remedies: '/api/remedies', + sadeSati: '/api/sade_sati', + panchaMahapurusha: '/api/pancha_mahapurusha', + rectification: '/api/rectification_gate', + caseValidation: '/api/case_validation', + divisionalYoga: '/api/divisional_yoga', + deepVargaAvastha: '/api/deep_varga_avastha', + kakshya: '/api/kakshya', + bhavaBala: '/api/bhava_bala', + transitTrigger: '/api/transit', + thematicReport: '/api/thematic_report', + career: '/api/career', + relationship: '/api/relationship', + kpQuick: '/api/kp', + prashnaQuick: '/api/prashna', + synastryQuick: '/api/synastry', + }[action] || ''; +} + +function getTechniqueApiDoc(endpoint) { + const catalog = window.__jyotishTechniqueCatalog || {}; + return catalog.api_docs?.[endpoint] || null; +} + +function renderTechniqueApiDocs(apiDoc) { + const openapi = apiDoc.openapi || {}; + return ` +
    + cURL / OpenAPI +
    +
    + cURL +
    ${escapeHtml(apiDoc.curl || '')}
    +
    +
    + OpenAPI +
    ${escapeHtml(JSON.stringify(openapi, null, 2).slice(0, 5000))}
    +
    +
    +

    ${escapeHtml(apiDoc.notes || '样例 payload 可复制到本地 API。')}

    +
    + `; +} + +function normalizeDirectoryText(value) { + return String(value || '').trim().toLowerCase(); +} + +function uniqueSorted(values) { + return [...new Set((values || []).filter(value => value !== null && value !== undefined && value !== ''))] + .sort((a, b) => String(a).localeCompare(String(b), 'zh-Hans-CN')); +} + +function reuseLabel(reuse) { + return { + direct: '可直接复用', + caution: '仅借鉴/需隔离', + unknown: '需确认许可', + benchmark_only: '仅benchmark', + review_before_copy: '复制前确认', + direct_or_port: '可复用/可移植', + }[reuse] || reuse || '未知'; +} + +function productLevelLabel(level) { + return { + productized: '已产品化', + api_backed: 'API承载', + engine_or_full_reading: '引擎/完整解盘', + registry_only: '待确认', + }[level] || level || '未知'; +} + +function uxLevelLabel(level) { + return { + excellent: '优秀', + usable: '可用', + thin: '偏薄', + not_user_ready: '未就绪', + }[level] || level || '未知'; +} + +function uxCriterionLabel(key) { + return { + clear_entry: '入口', + human_readable_conclusion: '结论', + evidence_chain: '证据链', + next_action: '下一步', + json_hidden: '隐藏JSON', + mobile_scannable: '移动端', + }[key] || key; +} diff --git a/jyotish-app/style.css b/jyotish-app/style.css index 714ea019..2da09cfe 100644 --- a/jyotish-app/style.css +++ b/jyotish-app/style.css @@ -75,6 +75,97 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .card-title { font-size: 18px; font-weight: 600; color: var(--text-heading); text-align: center; margin-bottom: 28px; letter-spacing: 0.5px; } /* === FORM === */ +.first-use-panel { + margin: -8px 0 20px; + padding: 14px; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.first-use-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: baseline; + margin-bottom: 10px; +} +.first-use-head strong { + color: var(--text-heading); + font-size: 14px; +} +.first-use-head span { + color: var(--text-secondary); + font-size: 12px; + text-align: right; +} +.first-use-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.first-use-step { + width: 100%; + min-height: 112px; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-body); + padding: 10px; + text-align: left; + font-family: var(--font-body); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 5px; + transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s; +} +.first-use-step:hover, +.first-use-step:focus-visible { + border-color: var(--purple); + box-shadow: 0 0 0 3px var(--purple-soft); + outline: none; + transform: translateY(-1px); +} +.first-use-kicker { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.first-use-step strong { + color: var(--text-heading); + font-size: 13px; +} +.first-use-step small { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} +.first-use-action { + font-weight: 600; +} +.first-use-status { + min-height: 18px; + margin-top: 10px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} +.first-use-status[data-tone="ok"] { color: var(--green); } +.first-use-status[data-tone="warn"] { color: var(--amber); } +.first-use-status[data-tone="pending"] { color: var(--purple); } +.chart-compute-status { + min-height: 18px; + margin-top: 10px; + padding: 10px 12px; + border-left: 3px solid var(--amber); + background: var(--bg-soft); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} +.chart-compute-status[data-tone="ok"] { border-left-color: var(--green); } +.chart-compute-status[data-tone="warn"] { border-left-color: var(--amber); } +.chart-compute-status[data-tone="error"] { border-left-color: var(--red); } .form-row { display: flex; gap: 16px; margin-bottom: 16px; } .form-group { flex: 1; display: flex; flex-direction: column; gap: 6px; } .form-group label { font-size: 13px; color: var(--text-secondary); font-weight: 500; } @@ -107,6 +198,162 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .suggestion-item:hover { background: var(--bg-surface); } .suggestion-item small { color: var(--text-muted); margin-left: 8px; } +.chart-import-panel { + margin-top: 8px; + padding: 14px; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.chart-import-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: baseline; + margin-bottom: 10px; +} +.chart-import-head strong { color: var(--text-heading); font-size: 14px; } +.chart-import-head span { color: var(--text-secondary); font-size: 12px; text-align: right; } +.chart-import-panel textarea { + width: 100%; + min-height: 96px; + resize: vertical; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-body); + padding: 10px 12px; + font-family: var(--font-body); + font-size: 13px; + line-height: 1.5; + outline: none; +} +.chart-import-panel textarea:focus { border-color: var(--purple); box-shadow: 0 0 0 3px var(--purple-soft); } +.chart-import-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} +.chart-import-actions button, +.chart-import-file { + min-height: 44px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-input); + background: var(--bg-input); + color: var(--text-heading); + padding: 8px 12px; + font-size: 13px; + font-family: var(--font-body); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.chart-import-actions button:hover, +.chart-import-file:hover { border-color: var(--text-muted); } +.chart-import-actions button:disabled { + cursor: not-allowed; + color: var(--text-muted); + background: var(--bg-card); +} +.chart-import-file input { display: none; } +.chart-import-result { margin-top: 10px; font-size: 13px; color: var(--text-secondary); } +.chart-import-result-card { + display: grid; + grid-template-columns: 76px 1fr; + gap: 12px; + align-items: center; +} +.chart-import-score { + border-radius: var(--radius-sm); + background: var(--bg-input); + border: 1px solid var(--border-light); + min-height: 68px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} +.chart-import-score strong { color: var(--text-heading); font-size: 22px; line-height: 1; } +.chart-import-score span { color: var(--text-muted); font-size: 11px; margin-top: 4px; } +.chart-import-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px 10px; +} +.chart-import-fields span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chart-import-ok { color: var(--green); margin-top: 8px; } +.chart-import-warning { color: var(--amber); margin-top: 8px; } + +.saved-chart-panel { + margin-top: 12px; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-white); + overflow: hidden; +} +.saved-chart-empty, +.saved-chart-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding: 12px 14px; +} +.saved-chart-empty strong, +.saved-chart-head strong { + color: var(--text-heading); + font-size: 14px; +} +.saved-chart-empty span, +.saved-chart-head span { + color: var(--text-muted); + font-size: 12px; + text-align: right; +} +.saved-chart-list { + display: grid; + gap: 0; + border-top: 1px solid var(--border-light); +} +.saved-chart-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 44px; + padding: 10px 14px; + border: 0; + border-bottom: 1px solid var(--border-light); + background: var(--bg-card); + color: var(--text-body); + text-align: left; + cursor: pointer; + font-family: var(--font-body); +} +.saved-chart-item:last-child { border-bottom: 0; } +.saved-chart-item:hover { + background: var(--bg-surface); +} +.saved-chart-item span { + min-width: 0; + color: var(--text-heading); + font-size: 13px; + font-weight: 600; + overflow-wrap: anywhere; +} +.saved-chart-item small { + color: var(--text-muted); + font-size: 11px; + white-space: nowrap; +} + /* === BUTTONS === */ .btn-primary { display: flex; align-items: center; justify-content: center; width: 100%; margin-top: 24px; @@ -136,6 +383,37 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: var(--transition); } .btn-export:hover { background: var(--bg-surface); border-color: var(--text-muted); } +.btn-export.is-exporting, +.btn-export:disabled { + cursor: wait; + color: var(--text-muted); + opacity: 0.78; +} +.btn-save-chart { + min-height: 36px; + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--border-card); + background: var(--bg-card); + color: var(--text-body); + font-size: 13px; + font-family: var(--font-body); + cursor: pointer; + transition: var(--transition); +} +.btn-save-chart:hover { + background: var(--bg-surface); + border-color: var(--text-muted); +} +.btn-save-chart.is-saved { + border-color: rgba(22, 163, 74, 0.35); + color: var(--green); +} +.btn-save-chart:disabled { + cursor: not-allowed; + color: var(--text-muted); + opacity: 0.72; +} .export-menu { position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--bg-white); border: 1px solid var(--border-card); @@ -148,7 +426,37 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t font-size: 13px; color: var(--text-body); cursor: pointer; transition: var(--transition); } .export-item:hover { background: var(--bg-surface); color: var(--text-heading); } +.export-item:disabled { + cursor: wait; + color: var(--text-muted); + background: var(--bg-surface); +} +.export-item.is-exporting::after { + content: '...'; + float: right; + color: var(--text-muted); +} .export-item + .export-item { border-top: 1px solid var(--border-light); } +.export-status { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 99; + min-width: 220px; + max-width: min(300px, 72vw); + padding: 9px 12px; + border: 1px solid var(--border-light); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + background: var(--bg-white); + box-shadow: 0 8px 20px rgba(30, 41, 59, 0.12); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} +.export-status.is-error { border-left-color: var(--red); } +.export-status.is-success { border-left-color: var(--green); } +.export-status.is-working { border-left-color: var(--amber); } .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } @@ -230,6 +538,2413 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .tab-panel { display: none; } .tab-panel.active { display: block; animation: fadeIn 0.3s ease; } +/* === COMPLETE READING / SKILL COVERAGE === */ +.skill-dashboard { + display: flex; + flex-direction: column; + gap: 18px; +} +.special-lagna-panel { + margin-bottom: 18px; + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-md); +} +.special-lagna-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 14px; +} +.special-lagna-head h4 { + color: var(--text-heading); + font-size: 16px; + margin-bottom: 5px; +} +.special-lagna-head p, +.special-lagna-note { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.7; +} +.special-lagna-head > span { + flex: 0 0 auto; + padding: 7px 10px; + border-radius: var(--radius-sm); + background: var(--bg-white); + border: 1px solid var(--border-light); + color: var(--text-heading); + font-size: 12px; + font-weight: 700; +} +.special-lagna-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: -4px 0 14px; +} +.special-lagna-meta span { + padding: 4px 8px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; +} +.special-lagna-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} +.special-lagna-card { + padding: 13px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-left: 4px solid var(--amber); + border-radius: var(--radius-sm); +} +.special-lagna-card.core { + border-left-color: var(--green); +} +.special-lagna-card span { + display: inline-block; + margin-bottom: 6px; + padding: 1px 7px; + border-radius: 999px; + background: var(--bg-white); + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.special-lagna-card strong { + display: block; + color: var(--text-heading); + font-size: 14px; + margin-bottom: 4px; +} +.special-lagna-card div { + color: var(--purple); + font-size: 12px; + font-weight: 700; + margin-bottom: 6px; +} +.special-lagna-card p { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} +.mevg-panel { + margin-bottom: 18px; + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-md); +} +.mevg-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 14px; +} +.mevg-head h4, +.mevg-section h4 { + color: var(--text-heading); + font-size: 16px; + margin: 0 0 6px; +} +.mevg-head p { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.7; +} +.mevg-score { + flex: 0 0 96px; + min-height: 76px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: var(--radius-sm); + border: 1px solid var(--border-light); + background: var(--bg-white); +} +.mevg-score span { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.mevg-score strong { + color: var(--text-heading); + font-size: 28px; + line-height: 1.1; +} +.mevg-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} +.mevg-summary > div { + padding: 12px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-white); +} +.mevg-summary strong { + display: block; + color: var(--text-heading); + font-size: 20px; +} +.mevg-summary span { + color: var(--text-muted); + font-size: 12px; +} +.mevg-cap { + margin-bottom: 12px; + padding: 10px 12px; + border-left: 4px solid var(--amber); + border-radius: var(--radius-sm); + background: rgba(217,119,6,0.08); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} +.mevg-check-grid, +.mevg-claims, +.mevg-sources { + display: grid; + gap: 10px; +} +.mevg-check-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + margin-bottom: 16px; +} +.mevg-check, +.mevg-claim, +.mevg-source { + min-width: 0; + padding: 12px; + border: 1px solid var(--border-light); + border-left: 4px solid var(--amber); + border-radius: var(--radius-sm); + background: var(--bg-white); +} +.mevg-check.mevg-pass, +.mevg-claim.mevg-claim-internal { + border-left-color: var(--green); +} +.mevg-claim.mevg-claim-limited { + border-left-color: var(--purple); +} +.mevg-check > span, +.mevg-claim-head span { + display: inline-flex; + min-height: 22px; + align-items: center; + padding: 2px 7px; + border-radius: 999px; + background: var(--bg-surface); + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.mevg-check strong, +.mevg-claim strong, +.mevg-source strong { + display: block; + color: var(--text-heading); + font-size: 13px; + margin: 7px 0 4px; +} +.mevg-check p, +.mevg-claim p, +.mevg-source span, +.mevg-boundaries { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} +.mevg-section { + margin-top: 16px; +} +.mevg-claim-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} +.mevg-claim-head strong { + margin: 0; +} +.mevg-evidence { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0; +} +.mevg-evidence span { + padding: 3px 7px; + border-radius: var(--radius-sm); + background: var(--bg-surface); + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.mevg-claim code { + display: block; + white-space: normal; + overflow-wrap: anywhere; + padding: 8px; + border-radius: var(--radius-sm); + background: var(--bg-surface); + color: var(--text-body); + font-size: 11px; + line-height: 1.5; +} +.mevg-sources { + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); +} +.mevg-boundaries { + padding-left: 18px; +} +.mevg-boundaries li { + margin: 5px 0; +} +.skill-hero { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; + padding: 20px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.skill-hero h4, +.skill-section h4 { + color: var(--text-heading); + font-size: 16px; + font-weight: 700; + margin: 0 0 8px; +} +.skill-hero p { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.7; + max-width: 680px; +} +.skill-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(72px, 1fr)); + gap: 8px; + min-width: 260px; +} +.skill-metrics span, +.skill-summary-grid > div { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 12px; +} +.skill-metrics b, +.skill-summary-grid strong { + color: var(--text-heading); + font-size: 20px; + line-height: 1.1; +} +.skill-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} +.skill-summary-grid strong { + font-size: 16px; + overflow-wrap: anywhere; +} +.skill-section { + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.skill-stage-grid, +.skill-module-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.skill-stage, +.skill-module { + display: flex; + gap: 10px; + min-width: 0; + padding: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.skill-stage-index { + flex: 0 0 24px; + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--accent); + color: var(--text-on-dark); + font-size: 12px; + font-weight: 700; +} +.skill-stage strong, +.skill-module strong { + color: var(--text-heading); + font-size: 13px; +} +.skill-stage p, +.skill-module p { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; + margin-top: 3px; +} +.skill-module { + display: block; + border-left-width: 4px; +} +.skill-module-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.skill-module-head span, +.skill-module small, +.skill-status { + font-size: 11px; + color: var(--text-muted); +} +.skill-module small { + display: inline-block; + margin-top: 8px; +} +.skill-module.skill-status-ready { border-left-color: var(--green); } +.skill-module.skill-status-partial { border-left-color: var(--amber); } +.skill-module.skill-status-pending { border-left-color: var(--purple); } +.skill-status { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 8px; + border-radius: 999px; + font-weight: 600; + white-space: nowrap; +} +.skill-status.skill-status-ready { + color: var(--green); + background: rgba(22,163,74,0.08); +} +.skill-status.skill-status-partial { + color: var(--amber); + background: rgba(217,119,6,0.1); +} +.skill-status.skill-status-pending { + color: var(--purple); + background: var(--purple-soft); +} +.skill-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.skill-audit-table { + width: 100%; + min-width: 680px; + border-collapse: collapse; + background: var(--bg-white); + border-radius: var(--radius-sm); + overflow: hidden; + font-size: 12px; +} +.skill-audit-table th, +.skill-audit-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border-light); + text-align: left; + vertical-align: top; +} +.skill-audit-table th { + color: var(--text-heading); + background: var(--bg-surface); + font-weight: 700; +} +.skill-audit-table td { + color: var(--text-secondary); +} +.skill-audit-table tr:last-child td { border-bottom: none; } + +.capability-audit-panel { + display: flex; + flex-direction: column; + gap: 14px; +} +.capability-audit-panel h5 { + color: var(--text-heading); + font-size: 13px; + font-weight: 700; + margin: 0 0 10px; +} +.capability-loading p, +.capability-error p, +.capability-box p, +.capability-note { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} +.capability-error { + padding: 14px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-left: 4px solid var(--amber); + border-radius: var(--radius-sm); +} +.capability-error strong { + color: var(--text-heading); +} +.capability-error small { + display: block; + margin-top: 8px; + color: var(--text-muted); + font-size: 11px; +} +.capability-metrics { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} +.capability-metrics > div, +.capability-box, +.capability-source-card, +.capability-gap-card { + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.capability-metrics > div { + display: flex; + flex-direction: column; + gap: 3px; + padding: 12px; + color: var(--text-muted); + font-size: 11px; +} +.capability-metrics strong { + color: var(--text-heading); + font-size: 22px; + line-height: 1; +} +.capability-status-row, +.capability-command-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.capability-status-row span, +.capability-command-tags span, +.reuse-pill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + border-radius: 999px; + background: var(--bg-white); + border: 1px solid var(--border-light); + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; +} +.capability-status-row b { + margin-right: 4px; + color: var(--text-heading); +} +.capability-split { + display: grid; + grid-template-columns: minmax(240px, 0.7fr) minmax(0, 1.3fr); + gap: 12px; +} +.capability-box { + padding: 14px; + min-width: 0; +} +.capability-source-grid, +.capability-gap-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.capability-source-card, +.capability-gap-card, +.product-queue-card { + min-width: 0; + padding: 12px; + border-left: 4px solid var(--border-light); +} +.productization-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} +.ux-productization-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} +.productization-metrics > div, +.product-queue-card, +.ux-productization-metrics > div, +.ux-queue-card { + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.productization-metrics > div, +.ux-productization-metrics > div { + min-height: 70px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 4px; + color: var(--text-muted); + font-size: 11px; +} +.productization-metrics strong, +.ux-productization-metrics strong { + color: var(--text-heading); + font-size: 22px; + line-height: 1; +} +.product-queue-grid, +.ux-queue-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.product-queue-card.product-api_backed { + border-left-color: var(--green); +} +.product-queue-card.product-engine_or_full_reading { + border-left-color: var(--amber); +} +.product-queue-card.product-registry_only { + border-left-color: var(--purple); +} +.ux-queue-card { + min-width: 0; + padding: 12px; + border-left: 4px solid var(--border-light); +} +.ux-queue-card.ux-not_user_ready { + border-left-color: #dc2626; +} +.ux-queue-card.ux-thin { + border-left-color: var(--amber); +} +.ux-queue-card.ux-usable { + border-left-color: var(--green); +} +.capability-source-card.reuse-direct, +.reuse-pill.reuse-direct, +.reuse-pill.reuse-direct_or_port { + border-left-color: var(--green); + color: var(--green); + background: rgba(22,163,74,0.08); +} +.capability-source-card.reuse-caution, +.reuse-pill.reuse-caution, +.reuse-pill.reuse-benchmark_only { + border-left-color: var(--amber); + color: var(--amber); + background: rgba(217,119,6,0.1); +} +.capability-source-card.reuse-unknown, +.reuse-pill.reuse-unknown, +.reuse-pill.reuse-review_before_copy { + border-left-color: var(--purple); + color: var(--purple); + background: var(--purple-soft); +} +.capability-source-card div { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} +.capability-source-card strong, +.capability-gap-card strong, +.product-queue-card strong, +.ux-queue-card strong { + display: block; + color: var(--text-heading); + font-size: 13px; +} +.capability-source-card span, +.capability-gap-card span, +.capability-source-card small, +.capability-gap-card small, +.product-queue-card span, +.product-queue-card small, +.ux-queue-card span, +.ux-queue-card small { + color: var(--text-muted); + font-size: 11px; +} +.capability-source-card p, +.capability-gap-card p, +.product-queue-card p, +.ux-queue-card p { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + margin: 8px 0; +} +.capability-gap-card.capability-high { + border-left-color: var(--amber); +} +.capability-gap-card.capability-medium { + border-left-color: var(--purple); +} +.capability-section { + display: flex; + flex-direction: column; + gap: 10px; +} +.capability-note { + margin-top: 8px; +} + +.technique-directory { + gap: 12px; +} +.technique-directory-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} +.technique-directory-head h5 { + margin-bottom: 4px; +} +.technique-directory-head p { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} +.technique-directory-head > span { + flex: 0 0 auto; + padding: 4px 9px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--text-heading); + font-size: 11px; + font-weight: 700; +} +.technique-directory-controls { + display: grid; + grid-template-columns: minmax(180px, 1.4fr) repeat(3, minmax(130px, 1fr)); + gap: 10px; +} +.technique-directory-controls label { + display: flex; + flex-direction: column; + gap: 5px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.technique-directory-controls input, +.technique-directory-controls select { + width: 100%; + min-height: 42px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-white); + color: var(--text-heading); + font: inherit; + font-size: 12px; + padding: 8px 10px; +} +.technique-directory-controls input:focus, +.technique-directory-controls select:focus { + outline: 2px solid rgba(59,130,246,0.18); + border-color: var(--accent); +} +.technique-directory-summary { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.technique-directory-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.technique-directory-card { + min-width: 0; + padding: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-left: 4px solid var(--border-light); + border-radius: var(--radius-sm); +} +.technique-directory-card.technique-productized { + border-left-color: var(--green); +} +.technique-directory-card.technique-api_backed { + border-left-color: var(--accent); +} +.technique-directory-card.technique-engine_or_full_reading { + border-left-color: var(--amber); +} +.technique-directory-card.technique-registry_only { + border-left-color: var(--purple); +} +.technique-directory-card-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} +.technique-directory-card-head strong { + display: block; + color: var(--text-heading); + font-size: 13px; + overflow-wrap: anywhere; +} +.technique-directory-card-head small, +.technique-directory-card > small { + color: var(--text-muted); + font-size: 11px; +} +.technique-directory-pill { + flex: 0 0 auto; + max-width: 116px; + padding: 3px 8px; + border-radius: 999px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + text-align: center; +} +.technique-directory-meta, +.technique-directory-tags, +.technique-directory-endpoints { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.technique-directory-meta span, +.technique-directory-tags span, +.technique-directory-endpoints code, +.technique-directory-endpoints em { + display: inline-flex; + align-items: center; + min-height: 23px; + padding: 2px 7px; + border-radius: 999px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + color: var(--text-secondary); + font-size: 11px; + font-style: normal; + font-family: inherit; + overflow-wrap: anywhere; +} +.technique-directory-endpoints code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--text-heading); +} +.technique-directory-card p { + margin: 9px 0 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} +.technique-directory-card b { + display: block; + margin-top: 8px; + color: var(--text-heading); + font-size: 12px; + line-height: 1.5; +} +.technique-explorer-actions, +.technique-explorer-call, +.technique-example-badge { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-light); +} +.technique-explorer-run { + min-height: 32px; + padding: 5px 12px; + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--text-on-dark); + font-size: 12px; + font-weight: 800; + cursor: pointer; +} +.technique-explorer-run:hover:not(:disabled) { + background: var(--accent-hover); +} +.technique-explorer-run:disabled { + cursor: not-allowed; + opacity: 0.55; +} +.technique-explorer-run.loading { + opacity: 0.75; +} +.technique-explorer-actions code, +.technique-explorer-call code, +.technique-explorer-call span, +.technique-example-badge span, +.technique-example-badge code { + color: var(--text-muted); + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow-wrap: anywhere; + text-align: right; +} +.technique-method-boundary { + display: block; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; +} +.technique-api-docs { + margin: 8px 0 10px; + padding: 8px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.technique-api-docs summary { + cursor: pointer; + color: var(--text-heading); + font-size: 12px; + font-weight: 800; +} +.technique-api-doc-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 8px; +} +.technique-api-doc-grid strong { + display: block; + color: var(--text-secondary); + font-size: 11px; + margin-bottom: 4px; +} +.technique-api-doc-grid pre { + max-height: 220px; + margin: 0; + padding: 8px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 11px; + line-height: 1.45; +} +.technique-api-docs p { + margin: 8px 0 0; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; +} +.technique-explorer-static { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.technique-explorer-result { + margin-top: 10px; +} +.technique-explorer-result .technique-result-head { + margin-bottom: 8px; +} +.technique-explorer-result .technique-insight-grid, +.technique-explorer-result .technique-result-grid, +.technique-explorer-result .technique-varga-grid { + grid-template-columns: 1fr; +} +.technique-explorer-result .technique-json pre, +.technique-explorer-loading pre { + max-height: 220px; + overflow: auto; + white-space: pre-wrap; +} +.technique-explorer-loading { + padding: 10px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.technique-explorer-loading strong { + display: block; + color: var(--text-heading); + font-size: 12px; + margin-bottom: 6px; +} +.technique-explorer-loading summary { + cursor: pointer; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.technique-example-badge { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + margin-bottom: 8px; + padding: 6px 8px; + background: var(--accent-soft); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 11px; + font-weight: 800; +} +.technique-example-badge code { + color: var(--text-heading); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow-wrap: anywhere; +} +.technique-directory-empty { + grid-column: 1 / -1; + margin: 0; + padding: 14px; + background: var(--bg-white); + border: 1px dashed var(--border-light); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 12px; +} + +/* === SYNASTRY === */ +.synastry-tool { + display: flex; + flex-direction: column; + gap: 16px; +} +.synastry-library-panel { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + gap: 12px; + align-items: end; + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.synastry-inputs { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 12px; + align-items: end; + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.synastry-full-inputs { + grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(120px, 1fr)) auto; +} +.synastry-quick { + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); + overflow: visible; +} +.synastry-quick summary { + cursor: pointer; + padding: 12px 14px; + color: var(--text-heading); + font-weight: 700; +} +.synastry-quick-inputs { + border: 0; + border-top: 1px solid var(--border-card); + border-radius: 0; + background: var(--bg-white); +} +.synastry-field-wide { + min-width: 0; +} +.synastry-run { + width: auto; + min-width: 120px; + margin-top: 0; + white-space: nowrap; +} +.synastry-result { + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 13px; +} +.synastry-score { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 14px; + border-radius: var(--radius-sm); + background: var(--bg-white); + border: 1px solid var(--border-light); + margin-bottom: 12px; +} +.synastry-score strong { + color: var(--text-heading); + font-size: 24px; + line-height: 1; +} +.synastry-score span { + font-weight: 600; +} +.synastry-score.synastry-ok { border-left: 4px solid var(--green); } +.synastry-score.synastry-warn { border-left: 4px solid var(--amber); } +.synastry-details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 12px; +} +.synastry-person { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.synastry-person strong { color: var(--text-heading); } +.synastry-person span { color: var(--text-body); } +.synastry-person small { color: var(--text-muted); } +.synastry-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.synastry-table { + width: 100%; + min-width: 420px; + border-collapse: collapse; + background: var(--bg-white); + border-radius: var(--radius-sm); + overflow: hidden; +} +.synastry-table th, +.synastry-table td { + text-align: left; + padding: 9px 12px; + border-bottom: 1px solid var(--border-light); +} +.synastry-table th { + color: var(--text-heading); + background: var(--bg-surface); +} +.synastry-note, +.synastry-boundary { + margin-top: 12px; + padding: 12px; + border-radius: var(--radius-sm); + background: var(--bg-white); + border: 1px solid var(--border-light); + line-height: 1.7; +} +.synastry-note strong { color: var(--text-heading); } +.synastry-pair-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +} +.synastry-pair-workspace, +.prashna-case-workspace { + padding: 14px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.synastry-pair-empty, +.prashna-case-empty { + color: var(--text-muted); + font-size: 13px; +} +.synastry-pair-head, +.synastry-pair-row, +.prashna-case-head, +.prashna-case-row { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.synastry-pair-head, +.prashna-case-head { + margin-bottom: 10px; + color: var(--text-heading); +} +.synastry-pair-head span, +.synastry-pair-row small, +.prashna-case-head span, +.prashna-case-row small { + color: var(--text-muted); + font-size: 12px; +} +.synastry-pair-list, +.prashna-case-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.synastry-pair-row, +.prashna-case-row { + padding: 10px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.synastry-pair-row strong, +.prashna-case-row strong { + color: var(--text-heading); + overflow-wrap: anywhere; +} +.synastry-pair-row span, +.prashna-case-row span { + color: var(--text-body); + white-space: nowrap; +} +.case-row-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} +.case-select-control { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--text-muted); + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} +.case-select-control input { + width: 16px; + height: 16px; + margin: 0; +} +.mini-action { + min-height: 28px; + padding: 5px 9px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); + color: var(--text-secondary); + font-size: 12px; + font-weight: 800; + cursor: pointer; +} +.mini-action:hover { + border-color: var(--accent); + color: var(--accent); +} +.mini-action.danger:hover { + border-color: rgba(220,38,38,0.35); + color: var(--red); +} +.case-replay-banner { + display: grid; + gap: 4px; + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.case-replay-banner strong { + color: var(--text-heading); +} +.case-replay-banner span { + color: var(--text-muted); + font-size: 12px; +} +.synastry-error { + color: var(--red); + background: rgba(220,38,38,0.06); + border: 1px solid rgba(220,38,38,0.15); + border-radius: var(--radius-sm); + padding: 12px; +} +.synastry-deep-grid, +.synastry-d9-compare { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} +.synastry-d9-compare { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.synastry-deep-card { + display: flex; + flex-direction: column; + gap: 7px; + padding: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + line-height: 1.55; +} +.synastry-deep-card strong { + color: var(--text-heading); +} +.synastry-deep-card p { + margin: 0; + color: var(--text-body); +} +.synastry-deep-card small { + color: var(--text-muted); +} +.relationship-report-template { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-white); +} +.relationship-report-strong { + border-left: 4px solid var(--green); +} +.relationship-report-workable { + border-left: 4px solid var(--blue); +} +.relationship-report-needs_context { + border-left: 4px solid var(--amber); +} +.relationship-report-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} +.relationship-report-head strong { + color: var(--text-heading); +} +.relationship-report-head p { + margin: 4px 0 0; + color: var(--text-body); + line-height: 1.55; +} +.relationship-report-status { + flex: 0 0 auto; + padding: 4px 8px; + border-radius: var(--radius-xs); + background: var(--bg-surface); + color: var(--text-heading); + font-size: 12px; + font-weight: 700; +} +.relationship-report-grid, +.relationship-report-sections { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} +.relationship-report-sections { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.relationship-evidence-card, +.relationship-report-section { + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); + min-width: 0; +} +.relationship-evidence-card span, +.relationship-evidence-card small { + display: block; + color: var(--text-muted); +} +.relationship-evidence-card strong { + display: block; + margin: 4px 0; + color: var(--text-heading); +} +.relationship-report-section strong { + color: var(--text-heading); +} +.relationship-report-list { + margin: 8px 0 0; + padding-left: 18px; + color: var(--text-body); + line-height: 1.55; +} +.relationship-report-boundary { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} +.relationship-report-boundary span { + padding: 4px 8px; + border-radius: var(--radius-xs); + background: rgba(148, 163, 184, 0.12); + color: var(--text-muted); + font-size: 12px; +} +.biwheel-comparison-view { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-white); +} +.biwheel-comparison-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.biwheel-comparison-head strong { + color: var(--text-heading); +} +.biwheel-comparison-head span { + color: var(--text-muted); + font-size: 12px; +} +.biwheel-axis-grid, +.composite-style-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} +.biwheel-axis-card, +.composite-style-strip div { + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.biwheel-axis-card span, +.biwheel-axis-card small, +.composite-style-strip span, +.composite-style-strip small { + display: block; + color: var(--text-muted); +} +.biwheel-axis-card strong, +.composite-style-strip strong { + display: block; + margin: 4px 0; + color: var(--text-heading); +} +.biwheel-axis-card p { + margin: 0 0 4px; + color: var(--text-body); +} +.biwheel-tone-support { + border-left: 4px solid var(--green); +} +.biwheel-tone-tension { + border-left: 4px solid var(--amber); +} +.biwheel-tone-work { + border-left: 4px solid var(--blue); +} +.biwheel-table-wrap { + margin-top: 10px; + overflow: auto; +} +.biwheel-comparison-table { + width: 100%; + border-collapse: collapse; + min-width: 720px; +} +.biwheel-comparison-table th, +.biwheel-comparison-table td { + padding: 8px; + border-bottom: 1px solid var(--border-light); + text-align: left; + vertical-align: top; +} +.biwheel-comparison-table th { + color: var(--text-muted); + font-size: 12px; +} +.spouse-status-comparison { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-white); +} +.spouse-status-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.spouse-status-head strong { + color: var(--text-heading); +} +.spouse-status-head span { + color: var(--text-muted); + font-size: 12px; +} +.spouse-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} +.spouse-status-card { + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.spouse-status-strong { + border-left: 4px solid var(--green); +} +.spouse-status-moderate { + border-left: 4px solid var(--blue); +} +.spouse-status-low { + border-left: 4px solid var(--amber); +} +.spouse-status-card strong { + color: var(--text-heading); +} +.spouse-status-card p { + margin: 6px 0; + color: var(--text-body); +} +.spouse-status-card ul { + margin: 0; + padding-left: 18px; + color: var(--text-muted); + line-height: 1.55; +} +.uldk-timing-comparison { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-white); +} +.uldk-timing-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.uldk-timing-head strong { + color: var(--text-heading); +} +.uldk-timing-head span { + color: var(--text-muted); + font-size: 12px; +} +.uldk-timing-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} +.uldk-timing-card { + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.uldk-timing-strong { + border-left: 4px solid var(--green); +} +.uldk-timing-moderate { + border-left: 4px solid var(--blue); +} +.uldk-timing-low { + border-left: 4px solid var(--amber); +} +.uldk-timing-card strong { + color: var(--text-heading); +} +.uldk-timing-card p { + margin: 6px 0; + color: var(--text-body); +} +.uldk-timing-card ul { + margin: 0; + padding-left: 18px; + color: var(--text-muted); + line-height: 1.55; +} +.synastry-person, +.synastry-deep-card, +.relationship-report-template, +.relationship-evidence-card, +.relationship-report-section, +.biwheel-comparison-view, +.biwheel-axis-card, +.composite-style-strip div, +.spouse-status-comparison, +.spouse-status-card, +.uldk-timing-comparison, +.uldk-timing-card, +.prashna-note, +.prashna-advanced-card, +.prashna-mini-grid div, +.prashna-saham-strip div, +.kp-house-card, +.kp-verdict, +.kp-boundary { + overflow-wrap: anywhere; +} +.synastry-kuja-hard, +.synastry-dasha-neutral { + border-left: 4px solid var(--amber); +} +.synastry-kuja-watch, +.synastry-dasha-supportive { + border-left: 4px solid var(--blue); +} +.synastry-kuja-balanced, +.synastry-dasha-strong { + border-left: 4px solid var(--green); +} +.synastry-d9-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.synastry-d9-head span { + color: var(--text-body); + font-weight: 700; +} + +/* === PRASHNA === */ +.prashna-tool { + display: flex; + flex-direction: column; + gap: 16px; +} +.prashna-inputs { + display: grid; + grid-template-columns: minmax(150px, 0.7fr) minmax(260px, 1.6fr) auto; + gap: 12px; + align-items: end; + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.prashna-run { + width: auto; + min-width: 120px; + margin-top: 0; + white-space: nowrap; +} +.prashna-result { + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 13px; +} +.prashna-answer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 14px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-left: 4px solid var(--amber); + border-radius: var(--radius-sm); + margin-bottom: 12px; +} +.prashna-answer strong { + color: var(--text-heading); + font-size: 16px; +} +.prashna-answer span { + font-weight: 600; +} +.prashna-answer.prashna-yes { border-left-color: var(--green); } +.prashna-answer.prashna-no { border-left-color: var(--red); } +.prashna-answer.prashna-maybe { border-left-color: var(--amber); } +.prashna-case-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +} +.prashna-question-readout, +.prashna-note, +.prashna-boundary { + margin-top: 12px; + padding: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + line-height: 1.7; +} +.prashna-note strong { color: var(--text-heading); } +.prashna-note small { + display: block; + margin-top: 6px; + color: var(--text-muted); +} +.prashna-note ul { + margin: 8px 0 0 16px; + padding: 0; + color: var(--text-body); +} +.prashna-insight-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.prashna-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.prashna-grid > div { + display: flex; + flex-direction: column; + gap: 3px; + padding: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.prashna-grid span { + color: var(--text-muted); + font-size: 11px; +} +.prashna-grid strong { + color: var(--text-heading); + font-size: 14px; +} +.prashna-table-wrap { + margin-top: 12px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.prashna-table { + width: 100%; + min-width: 360px; + border-collapse: collapse; + background: var(--bg-white); + border-radius: var(--radius-sm); + overflow: hidden; +} +.prashna-table th, +.prashna-table td { + text-align: left; + padding: 9px 12px; + border-bottom: 1px solid var(--border-light); +} +.prashna-table th { + color: var(--text-heading); + background: var(--bg-surface); +} +.prashna-next-action { + margin-top: 12px; + padding: 12px; + color: var(--text-heading); + background: rgba(37,99,235,0.08); + border: 1px solid rgba(37,99,235,0.16); + border-radius: var(--radius-sm); + line-height: 1.65; + font-weight: 700; +} +.prashna-advanced { + margin-top: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.prashna-advanced summary { + cursor: pointer; + padding: 12px; + color: var(--text-heading); + font-weight: 700; +} +.prashna-advanced-grid, +.prashna-saham-strip, +.prashna-mini-grid { + display: grid; + gap: 10px; +} +.prashna-advanced-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 0 12px 12px; +} +.prashna-saham-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 0 12px 12px; +} +.prashna-mini-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.prashna-advanced-card, +.prashna-mini-grid div, +.prashna-saham-strip div { + padding: 10px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + line-height: 1.55; +} +.prashna-advanced-card strong, +.prashna-mini-grid strong, +.prashna-saham-strip strong { + color: var(--text-heading); +} +.prashna-advanced-card p, +.prashna-mini-grid span, +.prashna-saham-strip span { + margin: 0; + color: var(--text-body); +} +.prashna-advanced-card small, +.prashna-mini-grid small, +.prashna-saham-strip small { + display: block; + color: var(--text-muted); +} +.prashna-error { + color: var(--red); + background: rgba(220,38,38,0.06); + border: 1px solid rgba(220,38,38,0.15); + border-radius: var(--radius-sm); + padding: 12px; +} + +/* === KP === */ +.kp-dashboard { + display: grid; + gap: 14px; +} +.kp-head { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: center; + padding: 14px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.kp-head h4 { + margin: 0 0 6px; + color: var(--text-heading); +} +.kp-head p { + margin: 0; + color: var(--text-body); + line-height: 1.6; +} +.kp-focus-select { + min-width: 150px; +} +.kp-verdict, +.kp-boundary { + padding: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + line-height: 1.7; +} +.kp-verdict strong, +.kp-house-card strong { + color: var(--text-heading); +} +.kp-verdict p, +.kp-boundary { + margin: 0; + color: var(--text-body); +} +.kp-verdict small { + display: block; + margin-top: 6px; + color: var(--text-muted); +} +.kp-house-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} +.kp-house-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.kp-house-focus { + border-left: 4px solid var(--blue); +} +.kp-house-title { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.kp-house-title span { + color: var(--text-muted); + font-weight: 700; +} +.kp-house-card p { + margin: 0; + color: var(--text-body); +} +.kp-house-card small { + color: var(--text-muted); +} +.kp-all-houses summary { + cursor: pointer; + padding: 10px 0; + color: var(--text-heading); + font-weight: 700; +} +.kp-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.kp-table { + width: 100%; + min-width: 680px; + border-collapse: collapse; + background: var(--bg-white); + border-radius: var(--radius-sm); + overflow: hidden; +} +.kp-table th, +.kp-table td { + text-align: left; + padding: 9px 12px; + border-bottom: 1px solid var(--border-light); +} +.kp-table th { + color: var(--text-heading); + background: var(--bg-surface); +} + +/* === REMEDIES === */ +.remedies-dashboard { + display: grid; + gap: 16px; +} +.remedies-hero { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.remedies-hero h4, +.remedies-section h4, +.remedies-plan h4 { + color: var(--text-heading); + font-size: 16px; + margin-bottom: 6px; +} +.remedies-hero p, +.remedies-boundary p, +.remedies-plan li, +.remedy-action-card p, +.remedies-empty p { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.7; +} +.remedies-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(72px, 1fr)); + gap: 8px; + min-width: 270px; +} +.remedies-metrics span { + padding: 10px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 11px; + text-align: center; +} +.remedies-metrics b { + display: block; + color: var(--text-heading); + font-size: 18px; +} +.remedies-boundary, +.remedies-plan, +.remedies-section, +.remedies-empty { + padding: 16px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.remedies-boundary { + border-left: 4px solid var(--amber); +} +.remedies-boundary strong { + display: block; + color: var(--text-heading); + margin-bottom: 4px; +} +.remedies-plan ol { + display: grid; + gap: 8px; + padding-left: 20px; +} +.remedies-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} +.remedies-evidence-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 10px; +} +.remedies-evidence-card { + min-height: 104px; + padding: 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.remedies-evidence-card span { + color: #0369a1; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} +.remedies-evidence-card strong { + display: block; + margin-top: 6px; + color: var(--text-heading); + font-size: 14px; +} +.remedies-evidence-card p, +.remedies-next-action { + margin-top: 6px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} +.remedies-evidence-card small { + display: block; + margin-top: 8px; + color: var(--text-muted); + font-size: 11px; +} +.remedy-action-card { + min-height: 132px; + padding: 13px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-left: 4px solid var(--accent); + border-radius: var(--radius-sm); +} +.remedy-action-card span { + display: inline-block; + margin-bottom: 8px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.remedy-action-card strong { + display: block; + color: var(--text-heading); + font-size: 14px; + margin-bottom: 6px; +} +.remedy-action-card small { + display: block; + color: var(--text-muted); + font-size: 11px; + margin-top: 8px; +} +.remedy-gem, +.remedy-fasting, +.remedy-dosha { + border-left-color: var(--amber); +} +.remedy-mantra, +.remedy-donation, +.remedy-lifestyle { + border-left-color: var(--green); +} +.remedies-empty strong { + display: block; + color: var(--text-heading); + margin-bottom: 4px; +} + +/* === DASHA SYSTEM OVERVIEW === */ +.dasha-system-panel { + margin-bottom: 18px; + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.dasha-system-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} +.dasha-system-head h4 { + color: var(--text-heading); + font-size: 16px; + margin-bottom: 4px; +} +.dasha-system-head p, +.dasha-system-empty p { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.7; +} +.dasha-system-count { + min-width: 48px; + min-height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--accent); + color: var(--text-on-dark); + border-radius: 50%; + font-weight: 700; + font-size: 18px; +} +.dasha-type-filter { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 12px; + -webkit-overflow-scrolling: touch; +} +.dasha-type-btn { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 36px; + padding: 7px 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + color: var(--text-secondary); + cursor: pointer; + font-family: var(--font-body); + font-size: 12px; +} +.dasha-type-btn span { + color: var(--text-muted); + font-size: 11px; +} +.dasha-type-btn.active { + color: var(--text-heading); + border-color: var(--accent); + background: var(--accent-soft); +} +.dasha-system-layout { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(240px, 0.8fr); + gap: 12px; +} +.dasha-system-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 8px; + max-height: 260px; + overflow: auto; + padding-right: 4px; +} +.dasha-system-card { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 72px; + text-align: left; + padding: 11px 12px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + cursor: pointer; + font-family: var(--font-body); +} +.dasha-system-card strong { + color: var(--text-heading); + font-size: 13px; +} +.dasha-system-card span { + color: var(--text-muted); + font-size: 11px; +} +.dasha-system-card.selected { + border-color: var(--accent); + background: var(--accent-soft); +} +.dasha-system-detail-card { + height: 100%; + min-height: 150px; + padding: 14px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.dasha-system-meta { + display: inline-block; + margin-bottom: 8px; + color: var(--purple); + font-size: 11px; + font-weight: 700; +} +.dasha-system-detail-card strong { + display: block; + color: var(--text-heading); + font-size: 16px; + margin-bottom: 8px; +} +.dasha-system-detail-card p { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.7; +} +.dasha-system-summary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; +} +.dasha-system-now, +.dasha-system-next { + min-width: 0; + padding: 10px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.dasha-system-now { + border-color: rgba(22, 163, 74, 0.35); + background: rgba(22, 163, 74, 0.06); +} +.dasha-system-now.muted, +.dasha-system-next.muted { + background: var(--bg-card); + border-color: var(--border-light); +} +.dasha-system-kicker { + margin-bottom: 4px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.dasha-system-summary strong { + margin-bottom: 3px; + font-size: 13px; +} +.dasha-system-summary p { + font-size: 11px; + line-height: 1.5; +} +.dasha-system-progress { + height: 6px; + margin-top: 8px; + overflow: hidden; + background: rgba(22, 163, 74, 0.16); + border-radius: 999px; +} +.dasha-system-progress i { + display: block; + height: 100%; + background: var(--green); + border-radius: inherit; +} +.dasha-analysis-block { + margin-top: 12px; + padding: 10px; + background: rgba(14,165,233,0.06); + border: 1px solid rgba(14,165,233,0.22); + border-radius: var(--radius-sm); +} +.dasha-analysis-head { + display: grid; + gap: 3px; + margin-bottom: 10px; +} +.dasha-analysis-head span, +.dasha-analysis-grid span { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.dasha-analysis-head strong { + margin: 0; + color: var(--text-heading); + font-size: 13px; +} +.dasha-analysis-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.dasha-analysis-grid div { + min-width: 0; + padding: 8px; + background: var(--bg-white); + border: 1px solid rgba(14,165,233,0.16); + border-radius: var(--radius-sm); +} +.dasha-analysis-grid strong { + margin: 3px 0; + font-size: 12px; + overflow-wrap: anywhere; +} +.dasha-analysis-grid p { + margin: 0; + font-size: 11px; + line-height: 1.45; +} +.dasha-analysis-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 9px; +} +.dasha-analysis-tags span { + padding: 3px 7px; + background: rgba(14,165,233,0.1); + border-radius: var(--radius-sm); + color: #0369a1; + font-size: 11px; + font-weight: 700; +} +.dasha-system-periods { + display: grid; + gap: 8px; + margin-top: 12px; + max-height: 260px; + overflow: auto; +} +.dasha-system-period { + display: grid; + grid-template-columns: minmax(70px, 0.7fr) minmax(120px, 1.4fr) auto; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.dasha-system-period.current { + border-color: rgba(22, 163, 74, 0.45); + background: rgba(22, 163, 74, 0.08); +} +.dasha-system-period.upcoming { + border-color: rgba(217, 119, 6, 0.42); + background: rgba(217, 119, 6, 0.06); +} +.dasha-system-period strong { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-heading); + font-size: 12px; +} +.dasha-system-period small { + padding: 2px 5px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: 999px; + color: var(--green); + font-size: 10px; + font-weight: 700; + line-height: 1; +} +.dasha-system-period span { + color: var(--text-secondary); + font-size: 11px; + margin: 0; +} +.dasha-system-period em { + color: var(--text-muted); + font-size: 11px; + font-style: normal; + text-align: right; +} +.dasha-system-empty { + padding: 14px; + background: var(--bg-white); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.dasha-system-empty strong { + display: block; + color: var(--text-heading); + margin-bottom: 4px; +} + /* === CHART LAYOUT === */ .chart-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 28px; } .chart-container, .planets-container { @@ -395,6 +3110,70 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .varga-btn:hover { border-color: var(--accent); color: var(--text-heading); } .varga-btn.active { background: var(--accent); color: var(--text-on-dark); border-color: var(--accent); } +/* === D9 MARRIAGE REPORT === */ +.d9-marriage-section { margin-top: 20px; } +.d9-panel { + background: var(--bg-card); border: 1px solid var(--border-card); + border-radius: var(--radius-md); padding: 20px; +} +.d9-panel-head { + display: flex; justify-content: space-between; align-items: flex-start; + gap: 18px; margin-bottom: 16px; +} +.d9-panel-head h4 { font-size: 16px; color: var(--text-heading); margin-bottom: 4px; } +.d9-panel-head p { font-size: 12px; color: var(--text-secondary); max-width: 680px; } +.d9-score { + min-width: 118px; padding: 10px 12px; border: 1px solid var(--border-light); + border-radius: var(--radius-sm); background: var(--bg-surface); text-align: center; +} +.d9-score span { display: block; font-size: 11px; color: var(--text-secondary); } +.d9-score strong { display: block; font-size: 20px; color: var(--text-heading); line-height: 1.2; } +.d9-score-green { border-color: rgba(22,163,74,0.28); background: rgba(22,163,74,0.08); } +.d9-score-yellow { border-color: rgba(217,119,6,0.28); background: rgba(217,119,6,0.08); } +.d9-score-orange { border-color: rgba(234,88,12,0.28); background: rgba(234,88,12,0.08); } +.d9-score-red { border-color: rgba(220,38,38,0.28); background: rgba(220,38,38,0.08); } +.d9-core-grid { + display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; margin-bottom: 14px; +} +.d9-core-card { + background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: var(--radius-sm); padding: 12px; +} +.d9-core-card span { display: block; font-size: 11px; color: var(--text-muted); margin-bottom: 4px; } +.d9-core-card strong { display: block; font-size: 14px; color: var(--text-heading); margin-bottom: 4px; } +.d9-core-card p { font-size: 11px; color: var(--text-secondary); line-height: 1.5; } +.d9-flags { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; margin-bottom: 14px; +} +.d9-flag { + background: var(--bg-surface); border: 1px solid var(--border-light); + border-left-width: 4px; border-radius: var(--radius-sm); padding: 12px; +} +.d9-flag-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 4px; } +.d9-flag-head strong { font-size: 13px; color: var(--text-heading); } +.d9-flag-head span { + flex: 0 0 auto; font-size: 11px; padding: 2px 7px; + border-radius: 999px; background: var(--bg-white); color: var(--text-secondary); +} +.d9-flag p { font-size: 12px; color: var(--text-secondary); line-height: 1.55; } +.d9-flag-green { border-left-color: var(--green); } +.d9-flag-yellow { border-left-color: var(--amber); } +.d9-flag-red { border-left-color: var(--red); } +.d9-evidence { + display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr); + gap: 14px; padding-top: 12px; border-top: 1px solid var(--border-light); +} +.d9-evidence strong { color: var(--text-heading); font-size: 13px; } +.d9-evidence ul { margin-top: 8px; padding-left: 18px; } +.d9-evidence li { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; } +.d9-evidence p { + font-size: 12px; color: var(--text-secondary); background: var(--bg-surface); + border: 1px solid var(--border-light); border-radius: var(--radius-sm); padding: 12px; +} +.d9-empty { color: var(--text-secondary); font-size: 13px; text-align: center; padding: 12px; } + /* === ARUDHA SECTION (in Vargas tab) === */ .arudha-section { margin-top: 20px; padding: 20px; @@ -490,7 +3269,7 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .antardasha-item .ad-planet { color: var(--text-heading); font-weight: 500; } .antardasha-item .ad-date { color: var(--text-muted); font-size: 10px; margin-top: 2px; } -.pratyantardasha-panel { +.pratyantardasha-panel { background: var(--bg-card); border: 1px solid var(--border-card); border-radius: var(--radius-md); padding: 16px; } @@ -653,6 +3432,110 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .pi-str { color: var(--text-muted); font-size: 11px; } .pi-desc { color: var(--text-secondary); font-size: 11px; } +/* Argala */ +.argala-dashboard { + display: grid; + gap: 12px; +} +.argala-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.argala-summary div, +.argala-note, +.argala-card, +.argala-empty { + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.argala-summary div { + padding: 12px; + text-align: center; +} +.argala-summary strong { + display: block; + color: var(--text-heading); + font-size: 22px; + line-height: 1.2; +} +.argala-summary span { + color: var(--text-muted); + font-size: 12px; +} +.argala-note { + padding: 13px 14px; + border-left: 4px solid var(--accent); +} +.argala-note strong { + display: block; + color: var(--text-heading); + margin-bottom: 4px; + font-size: 13px; +} +.argala-note p { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.7; +} +.argala-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 10px; +} +.argala-card { + padding: 13px; + border-left-width: 4px; +} +.argala-card-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + margin-bottom: 10px; +} +.argala-card-head strong { + color: var(--text-heading); + font-size: 14px; +} +.argala-card-head span { + flex: 0 0 auto; + padding: 2px 7px; + border-radius: 999px; + background: var(--bg-white); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; +} +.argala-card-body { + display: grid; + gap: 8px; +} +.argala-card-body div { + display: grid; + gap: 4px; +} +.argala-card-body b { + color: var(--text-heading); + font-size: 11px; +} +.argala-card-body span { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} +.argala-strong, +.argala-supported { border-left-color: var(--green); } +.argala-blocked { border-left-color: var(--red); } +.argala-neutral { border-left-color: var(--amber); } +.argala-empty { + padding: 16px; + color: var(--text-secondary); + text-align: center; + font-size: 13px; +} + /* House Influence */ .hi-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; @@ -1362,7 +4245,37 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t padding-top: calc(16px + env(safe-area-inset-top, 0px)); } .form-row { flex-direction: column; gap: 12px; } - .chart-layout, .dasha-detail-layout, .karaka-layout, .transit-layout { grid-template-columns: 1fr; } + .first-use-head { flex-direction: column; gap: 4px; } + .first-use-head span { text-align: left; } + .first-use-step { min-height: 92px; } + .chart-import-head { flex-direction: column; gap: 4px; } + .chart-import-head span { text-align: left; } + .chart-import-actions { display: grid; grid-template-columns: 1fr; } + .saved-chart-empty, + .saved-chart-head { flex-direction: column; align-items: flex-start; } + .saved-chart-empty span, + .saved-chart-head span { text-align: left; } + .saved-chart-item { grid-template-columns: 1fr; } + .saved-chart-item small { white-space: normal; } + .chart-import-result-card, + .chart-import-fields { grid-template-columns: 1fr; } + .chart-import-fields span { white-space: normal; } + .chart-layout, .dasha-detail-layout, .karaka-layout, .transit-layout { + grid-template-columns: minmax(0, 1fr); + width: 100%; + min-width: 0; + } + .chart-layout > *, + .tab-panel, + .chart-container, + .planets-container, + .chart-header, + .section-title, + .planets-table-wrap { + width: 100%; + max-width: 100%; + min-width: 0; + } .ascendant-banner { flex-direction: column; text-align: center; padding: 16px; } .card { padding: 20px 16px; } .house-grid, .av-details { grid-template-columns: 1fr; } @@ -1378,24 +4291,128 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .asc-sign { font-size: 22px; } .varga-selector { gap: 4px; } .varga-btn { padding: 8px 10px; font-size: 12px; min-height: 44px; } + .d9-panel-head { flex-direction: column; } + .d9-score { width: 100%; } + .d9-core-grid, .d9-flags, .d9-evidence { grid-template-columns: 1fr; } .pacdares-grid { grid-template-columns: 1fr; } .hi-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } .rg-grid { grid-template-columns: 1fr 1fr; } .freq-grid { grid-template-columns: 1fr 1fr; } .tri-table { font-size: 11px; } .tri-table th, .tri-table td { padding: 6px; } + .skill-hero { flex-direction: column; padding: 16px; } + .special-lagna-head { flex-direction: column; } + .special-lagna-head > span { width: 100%; text-align: center; } + .mevg-head { flex-direction: column; } + .mevg-score { width: 100%; flex-basis: auto; } + .mevg-summary { grid-template-columns: 1fr; } + .mevg-claim-head { flex-direction: column; gap: 6px; } + .skill-metrics { width: 100%; min-width: 0; } + .skill-summary-grid { grid-template-columns: 1fr 1fr; } + .skill-stage-grid, .skill-module-grid { grid-template-columns: 1fr; } + .skill-section { padding: 14px; } + .skill-module-head { align-items: flex-start; } + .capability-metrics { grid-template-columns: 1fr 1fr; } + .productization-metrics { grid-template-columns: 1fr 1fr; } + .ux-productization-metrics { grid-template-columns: 1fr 1fr; } + .technique-directory-head { flex-direction: column; } + .technique-directory-head > span { width: fit-content; } + .technique-directory-controls, + .technique-directory-grid { grid-template-columns: 1fr; } + .technique-directory-card-head { flex-direction: column; } + .technique-api-doc-grid { grid-template-columns: 1fr; } + .capability-split, + .capability-source-grid, + .capability-gap-grid, + .product-queue-grid, + .ux-queue-grid { grid-template-columns: 1fr; } + .capability-source-card div { flex-direction: column; gap: 4px; } + .capability-status-row span, + .capability-command-tags span, + .reuse-pill { white-space: normal; } + .synastry-inputs { grid-template-columns: 1fr; padding: 14px; } + .synastry-library-panel { grid-template-columns: 1fr; padding: 14px; } + .synastry-run { width: 100%; } + .synastry-score, + .synastry-details { grid-template-columns: 1fr; } + .synastry-score { flex-direction: column; align-items: flex-start; } + .synastry-pair-actions, + .synastry-pair-head, + .synastry-pair-row, + .prashna-case-actions, + .prashna-case-head, + .prashna-case-row { flex-direction: column; align-items: flex-start; } + .synastry-pair-row span, + .prashna-case-row span { white-space: normal; } + .workspace-case-row { grid-template-columns: 1fr; } + .case-row-actions { justify-content: flex-start; } + .case-workspace-controls { grid-template-columns: 1fr; } + .case-bulk-actions { width: 100%; } + .case-preview-head { flex-direction: column; } + .case-preview-grid { grid-template-columns: 1fr; } + .relationship-report-head { flex-direction: column; } + .relationship-report-status { align-self: flex-start; } + .relationship-report-grid, + .relationship-report-sections { grid-template-columns: 1fr; } + .biwheel-comparison-head { flex-direction: column; align-items: flex-start; } + .biwheel-axis-grid, + .composite-style-strip { grid-template-columns: 1fr; } + .spouse-status-head { flex-direction: column; align-items: flex-start; } + .spouse-status-grid { grid-template-columns: 1fr; } + .uldk-timing-head { flex-direction: column; align-items: flex-start; } + .uldk-timing-grid { grid-template-columns: 1fr; } + .synastry-deep-grid, + .synastry-d9-compare { grid-template-columns: 1fr; } + .synastry-d9-head { flex-direction: column; align-items: flex-start; } + .prashna-inputs { grid-template-columns: 1fr; padding: 14px; } + .prashna-run { width: 100%; } + .prashna-answer { flex-direction: column; align-items: flex-start; } + .prashna-grid { grid-template-columns: 1fr 1fr; } + .prashna-insight-grid, + .prashna-advanced-grid, + .prashna-mini-grid, + .prashna-saham-strip { grid-template-columns: 1fr; } + .kp-head { flex-direction: column; align-items: stretch; } + .kp-focus-select { width: 100%; min-height: 44px; } + .kp-house-grid { grid-template-columns: 1fr; } + .kp-house-title { flex-direction: column; align-items: flex-start; } + .remedies-hero { flex-direction: column; } + .remedies-metrics { width: 100%; min-width: 0; grid-template-columns: 1fr 1fr 1fr; } + .remedies-grid { grid-template-columns: 1fr; } + .dasha-system-head { flex-direction: column; } + .dasha-system-layout { grid-template-columns: 1fr; } + .dasha-system-list { max-height: none; } + .dasha-analysis-grid { grid-template-columns: 1fr; } + .dasha-system-period { grid-template-columns: 1fr; } + .dasha-system-period em { text-align: left; } /* AI 聊天浮动按钮 — 避开底部安全区 */ - .ai-panel { width: 100%; right: -100%; } + .ai-panel { + left: 100vw; + right: auto; + width: 100vw; + max-width: 100vw; + } + .ai-panel.open { + left: 0; + right: auto; + } .ai-fab { - bottom: calc(20px + env(safe-area-inset-bottom, 0px)); - right: 16px; + top: calc(72px + env(safe-area-inset-top, 0px)); + bottom: auto; + left: 16px; + right: auto; height: 44px; padding: 0 16px 0 12px; + z-index: 9000; + pointer-events: auto; } .ai-fab-tip { - bottom: calc(76px + env(safe-area-inset-bottom, 0px)); - right: 16px; + top: calc(124px + env(safe-area-inset-top, 0px)); + bottom: auto; + left: 16px; + right: auto; font-size: 12px; + z-index: 9001; } /* Auth 移动端 */ .header-right { flex-direction: column; align-items: flex-end; gap: 8px; } @@ -1404,6 +4421,7 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t /* 页面 header — 按钮满足 44pt 触摸目标 */ .btn-back { min-height: 44px; min-width: 44px; } .btn-export { min-height: 44px; padding: 8px 14px; } + .btn-save-chart { min-height: 44px; padding: 8px 14px; } /* 输入表单按钮 */ .generate-btn { min-height: 48px; font-size: 16px; } /* 行星表格 — 移动端可横向滚动,隐藏D9和星宿列节省空间 */ @@ -1416,11 +4434,27 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t /* === iOS 安全区域全局适配 === */ @supports (padding: env(safe-area-inset-bottom)) { /* FAB 按钮永远避开底部安全区 */ - .ai-fab { - bottom: calc(28px + env(safe-area-inset-bottom)); + @media (min-width: 769px) { + .ai-fab { + bottom: calc(28px + env(safe-area-inset-bottom)); + } + .ai-fab-tip { + bottom: calc(82px + env(safe-area-inset-bottom)); + } } - .ai-fab-tip { - bottom: calc(82px + env(safe-area-inset-bottom)); + @media (max-width: 768px) { + .ai-fab { + top: calc(72px + env(safe-area-inset-top)); + bottom: auto; + left: 16px; + right: auto; + } + .ai-fab-tip { + top: calc(124px + env(safe-area-inset-top)); + bottom: auto; + left: 16px; + right: auto; + } } } @@ -1533,6 +4567,20 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .rect-events { margin-bottom: 16px; } .rect-count { font-size: 12px; color: var(--text-secondary); font-weight: 400; } .rect-hint { font-size: 12px; color: var(--text-secondary); margin: 4px 0 12px; } +.rect-event-guide { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} +.rect-event-guide span { + padding: 4px 8px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: 999px; + color: var(--text-secondary); + font-size: 11px; +} .rect-add-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; } .rect-add-row .form-group { flex: 1; min-width: 120px; } .rect-add-row .btn-sm { padding: 8px 16px; font-size: 13px; white-space: nowrap; margin-bottom: 0; min-height: 38px; } @@ -1583,8 +4631,81 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .rect-value { font-size: 14px; font-weight: 500; } .rect-value.rect-best { color: var(--purple); font-weight: 700; } .rect-recommendations { margin-top: 12px; padding: 12px; background: rgba(109,40,217,0.04); border-radius: 8px; } -.rect-rec-line { font-size: 13px; margin-bottom: 4px; color: var(--text-primary); } +.rect-rec-line { font-size: 13px; margin-bottom: 4px; color: var(--text-body); } .rect-rec-line:last-child { margin-bottom: 0; } +.rect-result-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.rect-apply-btn, +.rect-secondary-btn { + min-height: 38px; + border-radius: var(--radius-sm); + padding: 8px 12px; + font-size: 13px; + font-family: var(--font-body); + cursor: pointer; +} +.rect-apply-btn { + background: var(--accent); + color: var(--text-on-dark); + border: 1px solid var(--accent); +} +.rect-secondary-btn { + background: var(--bg-white); + color: var(--text-heading); + border: 1px solid var(--border-input); +} +.rect-apply-btn:hover { background: var(--accent-hover); } +.rect-secondary-btn:hover { border-color: var(--text-muted); } +.rect-audit-warnings { + display: grid; + gap: 6px; + margin-top: 10px; +} +.rect-audit-warnings p { + padding: 8px 10px; + background: rgba(217,119,6,0.08); + border: 1px solid rgba(217,119,6,0.18); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 12px; +} +.rect-audit { + margin-bottom: 16px; +} +.rect-audit-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} +.rect-audit-card { + min-height: 112px; + padding: 12px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.rect-audit-card span { + display: block; + margin-bottom: 5px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; +} +.rect-audit-card strong { + display: block; + margin-bottom: 5px; + color: var(--text-heading); + font-size: 14px; +} +.rect-audit-card p { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} /* Top 10 表格 */ .rect-top-results { margin-bottom: 16px; } @@ -1666,6 +4787,9 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .rect-add-row .form-group { min-width: 100%; } .rect-add-row .btn-sm { width: 100%; } .rect-summary-grid { grid-template-columns: 1fr; } + .rect-result-actions { flex-direction: column; } + .rect-apply-btn, + .rect-secondary-btn { width: 100%; } .rect-score-label { min-width: 100px; font-size: 11px; } } @@ -1694,6 +4818,1241 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t } .rect-prompt-btn:hover { opacity: 0.85; } +/* === 高级技法工作台 === */ +.technique-workbench { + display: grid; + gap: 12px; +} +.technique-workbench-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.technique-workbench-head h4 { + margin: 0; +} +.technique-workbench-head span { + padding: 3px 8px; + border-radius: var(--radius-sm); + background: var(--bg-surface); + border: 1px solid var(--border-light); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} +.technique-action-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 8px; +} +.technique-action { + position: relative; + min-height: 74px; + padding: 10px 12px; + text-align: left; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color 0.2s, background 0.2s, box-shadow 0.2s; +} +.technique-action:hover, +.technique-action.active { + border-color: rgba(109,40,217,0.35); + background: rgba(109,40,217,0.04); + box-shadow: 0 2px 8px rgba(109,40,217,0.08); +} +.technique-action:disabled { + cursor: not-allowed; + opacity: 0.55; +} +.technique-action.loading { + border-color: rgba(14,165,233,0.35); +} +.technique-action strong, +.technique-action span { + display: block; + padding-right: 24px; +} +.technique-action strong { + color: var(--text-heading); + font-size: 13px; + line-height: 1.25; +} +.technique-action span { + margin-top: 4px; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.35; +} +.technique-action small { + position: absolute; + top: 10px; + right: 10px; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--bg-surface); + color: var(--text-muted); + font-size: 10px; + font-weight: 700; +} +.technique-result { + min-height: 86px; + padding: 12px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.technique-result > p { + margin: 0; + color: var(--text-secondary); + font-size: 13px; +} +.technique-result-head { + display: flex; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} +.technique-result-head strong { + color: var(--text-heading); + font-size: 14px; +} +.technique-result-head span { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} +.technique-result-grid, +.technique-varga-grid, +.technique-insight-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; +} +.technique-result-row, +.technique-varga-card, +.technique-insight-card { + min-height: 64px; + padding: 10px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.technique-result-row span, +.technique-varga-card span, +.technique-varga-card small, +.technique-insight-card span { + display: block; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.35; +} +.technique-result-row strong, +.technique-varga-card strong, +.technique-insight-card strong { + display: block; + margin-top: 4px; + color: var(--text-heading); + font-size: 13px; + line-height: 1.35; + overflow-wrap: anywhere; +} +.technique-insight-card p { + margin: 6px 0 0; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.45; +} +.technique-varga-card small { + margin-top: 6px; +} +.technique-evidence-strip { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 10px; +} +.technique-evidence-strip span { + padding: 4px 8px; + border-radius: var(--radius-sm); + background: rgba(14,165,233,0.08); + color: #0369a1; + font-size: 11px; + font-weight: 700; +} +.technique-next-action { + margin-top: 10px; + padding: 9px 10px; + border-radius: var(--radius-sm); + background: rgba(22,163,74,0.07); + color: var(--text-body); + font-size: 12px; + line-height: 1.5; +} +.thematic-report-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} +.thematic-report-card { + min-width: 0; + padding: 12px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.thematic-report-card-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.thematic-report-card-head strong { + color: var(--text-heading); + font-size: 13px; +} +.thematic-report-card-head span { + padding: 2px 7px; + border-radius: var(--radius-sm); + background: rgba(79,70,229,0.08); + color: #4338ca; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} +.thematic-report-card p { + margin: 8px 0 0; + color: var(--text-body); + font-size: 12px; + line-height: 1.5; +} +.thematic-report-card small { + display: block; + margin-top: 6px; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.4; +} +.thematic-report-evidence { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} +.thematic-report-evidence span { + padding: 3px 6px; + border-radius: var(--radius-sm); + background: rgba(14,165,233,0.08); + color: #0369a1; + font-size: 10px; + font-weight: 700; +} +.thematic-report-card ul { + margin: 8px 0 0; + padding-left: 18px; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.45; +} +.technique-json { + margin-top: 10px; +} +.technique-json summary { + cursor: pointer; + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; +} +.technique-json pre { + max-height: 320px; + overflow: auto; + margin: 8px 0 0; + padding: 10px; + background: #111827; + color: #e5e7eb; + border-radius: var(--radius-sm); + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; +} +.technique-error { + padding: 10px; + background: rgba(220,38,38,0.06); + border: 1px solid rgba(220,38,38,0.16); + border-radius: var(--radius-sm); +} +.technique-error strong { + display: block; + color: #b91c1c; + font-size: 13px; +} +.technique-error p { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 12px; +} +.technique-error small { + display: block; + margin-top: 6px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.45; +} + +/* === 参数 / Panchanga / 工作区 === */ +.provenance-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.provenance-card { + min-width: 0; + padding: 16px; + background: var(--bg-card); + border: 1px solid var(--border-card); + border-radius: var(--radius-sm); +} +.provenance-card-wide { + grid-column: 1 / -1; +} +.provenance-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 12px; +} +.provenance-head span { + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} +.provenance-head strong { + max-width: 60%; + color: var(--text-heading); + font-size: 13px; + line-height: 1.35; + text-align: right; + overflow-wrap: anywhere; +} +.provenance-kv-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.provenance-kv, +.provenance-list-row, +.roadmap-card { + min-height: 64px; + padding: 10px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.provenance-kv span, +.provenance-list-row span, +.roadmap-card span { + display: block; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.35; +} +.provenance-kv strong, +.provenance-list-row strong, +.roadmap-card strong { + display: block; + margin-top: 5px; + color: var(--text-heading); + font-size: 13px; + line-height: 1.35; + overflow-wrap: anywhere; +} +.provenance-list { + display: grid; + gap: 8px; +} +.provenance-list-row small, +.roadmap-card p, +.workspace-status p { + display: block; + margin-top: 5px; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.45; +} +.workspace-status { + padding: 12px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.tithi-lord-insight { + margin-top: 10px; + padding: 12px; + background: rgba(22,163,74,0.07); + border: 1px solid rgba(22,163,74,0.18); + border-radius: var(--radius-sm); +} +.tithi-lord-insight p { + margin: 0; + color: var(--text-body); + font-size: 12px; + line-height: 1.5; +} +.tithi-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.tithi-chip-row span { + padding: 4px 8px; + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; +} +.workspace-status strong { + color: var(--text-heading); + font-size: 13px; +} +.workspace-library { + display: grid; + gap: 8px; + margin-top: 10px; + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-card); +} +.workspace-library.empty { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} +.workspace-library label span { + display: block; + margin-bottom: 4px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 800; +} +.workspace-library select { + width: 100%; + min-height: 34px; + padding: 0 8px; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-heading); + font-size: 12px; +} +.workspace-chart-list { + display: grid; + gap: 6px; +} +.workspace-chart-row { + display: grid; + gap: 2px; + padding: 8px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.workspace-case-row { + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} +.workspace-chart-row strong { + color: var(--text-heading); + font-size: 12px; + line-height: 1.35; +} +.workspace-chart-row span { + color: var(--text-muted); + font-size: 11px; +} +.case-meta-line { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 3px 0; +} +.case-meta-line span, +.case-meta-line em { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 1px 6px; + border: 1px solid var(--border-light); + border-radius: 4px; + background: var(--bg-card); + color: var(--text-secondary); + font-size: 10px; + font-style: normal; + font-weight: 800; + line-height: 1.25; +} +.case-meta-line em { + border-color: rgba(15, 118, 110, 0.22); + background: rgba(15, 118, 110, 0.08); + color: #0f766e; +} +.case-workspace-summary { + min-width: 0; + margin-top: 10px; +} +.case-workspace-counts { + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.case-workspace-counts span { + padding: 4px 8px; + border-radius: var(--radius-sm); + background: var(--bg-surface); + border: 1px solid var(--border-light); + color: var(--text-secondary); + font-size: 11px; + font-weight: 800; +} +.case-workspace-controls { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(3, minmax(112px, 140px)); + gap: 8px; + margin: 10px 0 8px; +} +.case-workspace-controls input, +.case-workspace-controls select { + min-width: 0; + min-height: 34px; + padding: 0 10px; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-heading); + font-size: 12px; +} +.case-bulk-actions { + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0 0 8px; +} +.case-preview-panel { + min-width: 0; + min-height: 48px; + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.case-preview-empty { + color: var(--text-muted); + font-size: 12px; +} +.case-preview-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.case-preview-head span { + display: block; + color: var(--text-secondary); + font-size: 11px; + font-weight: 800; +} +.case-preview-head strong { + display: block; + margin-top: 3px; + color: var(--text-heading); + font-size: 13px; + line-height: 1.35; + overflow-wrap: anywhere; +} +.case-preview-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} +.case-preview-card { + padding: 8px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-card); +} +.case-preview-card span { + display: block; + color: var(--text-secondary); + font-size: 11px; +} +.case-preview-card strong { + display: block; + margin-top: 3px; + color: var(--text-heading); + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; +} +.case-preview-footer { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + color: var(--text-muted); + font-size: 11px; +} +.provenance-actions { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} +.provenance-action { + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-heading); + font-size: 12px; + font-weight: 700; + cursor: pointer; +} +.provenance-action:hover { + border-color: var(--accent); +} +.file-action { + position: relative; + display: inline-flex; + align-items: center; + overflow: hidden; + cursor: pointer; +} +.file-action input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} +.workspace-import-status { + min-height: 18px; + margin-top: 8px; + color: var(--text-muted); + font-size: 12px; +} +.panchanga-range-controls { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} +.panchanga-range-controls label { + min-width: 0; +} +.panchanga-range-controls span, +.panchanga-condition-filter > span { + display: block; + margin-bottom: 4px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; +} +.panchanga-range-controls input, +.panchanga-range-controls select { + width: 100%; + min-height: 34px; + padding: 0 8px; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-heading); + font-size: 12px; +} +.panchanga-condition-filter { + grid-column: span 2; + min-width: 0; +} +.panchanga-condition-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + margin-bottom: 4px; +} +.panchanga-condition-head span { + margin-bottom: 0; +} +.panchanga-condition-head select { + max-width: 120px; + min-height: 30px; +} +.panchanga-condition-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + padding: 8px; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-input); +} +.panchanga-condition-options label { + display: flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 3px 5px; + border-radius: var(--radius-xs); + cursor: pointer; +} +.panchanga-condition-options label:hover { + background: var(--bg-surface); +} +.panchanga-condition-options input { + width: 14px; + min-height: 14px; + padding: 0; + accent-color: var(--accent); +} +.panchanga-condition-options small { + color: var(--text-body); + font-size: 11px; + line-height: 1.25; +} +.panchanga-condition-guide { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; + margin: 10px 0; +} +.panchanga-condition-guide div { + padding: 9px 10px; + border: 1px solid var(--border-light); + border-left: 3px solid var(--purple); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.panchanga-condition-guide strong, +.panchanga-condition-guide span { + display: block; +} +.panchanga-condition-guide strong { + color: var(--text-heading); + font-size: 12px; +} +.panchanga-condition-guide span { + margin-top: 3px; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; +} +.calculation-settings-panel { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.calculation-settings-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 10px; +} +.calculation-settings-head strong { + color: var(--text-heading); +} +.calculation-settings-head span, +.calculation-settings-note { + color: var(--text-muted); + font-size: 12px; + line-height: 1.5; +} +.calculation-settings-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} +.calculation-settings-grid label { + min-width: 0; +} +.calculation-settings-grid span { + display: block; + margin-bottom: 4px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; +} +.calculation-settings-grid select { + width: 100%; + min-height: 34px; + padding: 0 8px; + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-heading); + font-size: 12px; +} +.calculation-settings-note { + margin-top: 9px; +} +.terminology-mode-preview { + display: grid; + gap: 4px; + margin-top: 10px; + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.terminology-mode-preview strong { + color: var(--text-heading); + font-size: 13px; +} +.terminology-mode-preview span { + color: var(--text-muted); + font-size: 12px; + line-height: 1.5; +} +.calculation-settings-save { + margin-top: 10px; +} +.rule-variant-panel { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-light); +} +.rule-variant-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.trust-center-panel { + border-left: 3px solid var(--blue); +} +.trust-status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 10px; +} +.trust-status-card { + min-width: 0; + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.trust-status-card span, +.trust-status-card small { + display: block; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; +} +.trust-status-card strong { + display: block; + margin: 4px 0; + color: var(--text-heading); + font-size: 14px; + overflow-wrap: anywhere; +} +.trust-center-copy { + display: grid; + gap: 6px; + margin: 8px 0 10px; +} +.runtime-health-panel { + margin: 10px 0; + padding: 10px; + border: 1px solid var(--border-light); + border-left: 3px solid var(--amber); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.runtime-health-panel.runtime-health-ok { + border-left-color: var(--green); +} +.runtime-health-panel.runtime-health-warn { + border-left-color: var(--red); +} +.runtime-health-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 8px; +} +.runtime-health-item { + min-width: 0; + padding: 9px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-white); +} +.runtime-health-item span, +.runtime-health-item small { + display: block; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; +} +.runtime-health-item strong { + display: block; + margin: 3px 0; + color: var(--text-heading); + font-size: 13px; + overflow-wrap: anywhere; +} +.runtime-health-note { + margin: 8px 0 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} +.terminology-mode-panel { + margin: 10px 0; + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.terminology-mode-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.terminology-mode-option { + min-width: 0; + padding: 9px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-white); + cursor: pointer; +} +.terminology-mode-option.active { + border-color: var(--blue); + box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.18); +} +.terminology-mode-option input { + margin-right: 6px; +} +.terminology-mode-option strong, +.terminology-mode-option span, +.terminology-mode-option small { + display: block; +} +.terminology-mode-option strong { + margin-top: 4px; + color: var(--text-heading); + font-size: 13px; +} +.terminology-mode-option span { + color: var(--blue); + font-size: 11px; + font-weight: 700; +} +.terminology-mode-option small { + margin-top: 4px; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; +} +.terminology-mode-save { + margin-top: 10px; +} +.trust-center-copy p { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} +.provenance-action.danger, +.mini-action.danger { + border-color: var(--red); + color: var(--red); +} +.panchanga-festival-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px; + margin: 10px 0; +} +.panchanga-festival-card { + padding: 10px; + border: 1px solid var(--border-light); + border-left: 3px solid var(--green); + border-radius: var(--radius-sm); + background: var(--bg-white); + overflow-wrap: anywhere; +} +.panchanga-festival-card.is-candidate { + border-left-color: var(--amber); +} +.panchanga-festival-card strong, +.panchanga-festival-card span, +.panchanga-festival-card small { + display: block; +} +.panchanga-festival-card strong { + color: var(--text-heading); + font-size: 12px; +} +.panchanga-festival-card span, +.panchanga-festival-card small { + color: var(--text-muted); + font-size: 11px; +} +.panchanga-festival-card p { + margin: 5px 0; + color: var(--text-body); + font-size: 11px; + line-height: 1.45; +} +.panchanga-month-grid { + display: grid; + grid-template-columns: repeat(7, minmax(86px, 1fr)); + gap: 1px; + margin: 10px 0 12px; + overflow-x: auto; + border: 1px solid var(--border-light); + background: var(--border-light); + border-radius: var(--radius-sm); +} +.panchanga-month-head, +.panchanga-month-cell { + min-width: 86px; + background: var(--bg-card); +} +.panchanga-month-head { + padding: 7px 8px; + color: var(--text-heading); + font-size: 11px; + font-weight: 800; + text-align: center; +} +.panchanga-month-cell { + min-height: 112px; + padding: 8px; +} +.panchanga-month-cell.is-empty { + background: var(--bg-surface); +} +.panchanga-month-date { + display: flex; + justify-content: space-between; + gap: 6px; + margin-bottom: 6px; +} +.panchanga-month-date strong { + color: var(--text-heading); + font-size: 13px; +} +.panchanga-month-date span, +.panchanga-month-cell small, +.panchanga-month-cell em { + color: var(--text-muted); + font-size: 11px; + font-style: normal; +} +.panchanga-month-cell p { + margin: 0 0 4px; + color: var(--text-heading); + font-size: 12px; + font-weight: 700; + line-height: 1.35; +} +.panchanga-month-cell small, +.panchanga-month-cell em { + display: block; + line-height: 1.35; +} +.panchanga-tag-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 5px; +} +.panchanga-tag-row span { + display: inline-flex !important; + align-items: center; + max-width: 100%; + min-height: 20px; + padding: 2px 6px; + border: 1px solid rgba(15, 118, 110, 0.22); + border-radius: 4px; + background: rgba(15, 118, 110, 0.08); + color: #0f766e !important; + font-size: 10px !important; + font-weight: 800; + line-height: 1.25; + word-break: break-word; +} +.panchanga-condition-chip { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 0; +} +.panchanga-condition-chip span { + display: inline-flex !important; + align-items: center; + max-width: 100%; + min-height: 20px; + padding: 2px 6px; + border: 1px solid rgba(67, 56, 202, 0.22); + border-radius: 4px; + background: rgba(67, 56, 202, 0.08); + color: #4338ca !important; + font-size: 10px !important; + font-weight: 800; + line-height: 1.25; + word-break: break-word; +} +.panchanga-activity-badge { + display: inline-flex !important; + align-items: center; + width: fit-content; + max-width: 128px; + min-height: 22px; + padding: 2px 7px; + border: 1px solid var(--border-light); + border-radius: 4px; + background: var(--bg-surface); + color: var(--text-secondary) !important; + font-size: 11px !important; + font-weight: 800; + line-height: 1.25; + white-space: normal; +} +.panchanga-activity-badge.is-good { + border-color: rgba(22, 101, 52, 0.2); + background: rgba(22, 101, 52, 0.08); + color: #166534 !important; +} +.panchanga-activity-badge.is-bad { + border-color: rgba(185, 28, 28, 0.2); + background: rgba(185, 28, 28, 0.08); + color: #b91c1c !important; +} +.panchanga-week-status { + margin-top: 10px; + padding: 10px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} +.panchanga-week-loading { + color: var(--text-heading); + font-weight: 700; +} +.panchanga-week-error { + color: #b91c1c; + font-weight: 700; +} +.panchanga-range-summary { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; + margin-bottom: 8px; +} +.panchanga-range-summary strong { + color: var(--text-heading); + font-size: 13px; +} +.panchanga-range-summary span { + color: var(--text-muted); + font-size: 11px; + text-align: right; + max-width: 55%; +} +.panchanga-range-summary small { + display: block; + color: var(--text-muted); + font-size: 11px; + text-align: right; +} +.panchanga-week-table-wrap { + overflow-x: auto; +} +.panchanga-week-table { + width: 100%; + min-width: 1240px; + border-collapse: collapse; + background: var(--bg-card); +} +.panchanga-week-table th, +.panchanga-week-table td { + padding: 8px; + border-bottom: 1px solid var(--border-light); + text-align: left; + vertical-align: top; + font-size: 12px; +} +.panchanga-week-table th { + color: var(--text-heading); + font-weight: 700; +} +.panchanga-week-table td span { + display: block; + margin-top: 2px; + color: var(--text-muted); + font-size: 11px; +} +.provenance-table-wrap { + overflow-x: auto; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.provenance-table { + width: 100%; + border-collapse: collapse; + min-width: 620px; + background: var(--bg-surface); +} +.provenance-table th, +.provenance-table td { + padding: 10px; + border-bottom: 1px solid var(--border-light); + text-align: left; + vertical-align: top; + font-size: 12px; + line-height: 1.45; +} +.provenance-table th { + width: 120px; + color: var(--text-heading); + font-weight: 700; +} +.provenance-table td { + color: var(--text-secondary); +} +.roadmap-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + /* === 生时校正全屏模态 === */ .rect-overlay { position: fixed; inset: 0; z-index: 998; @@ -1738,4 +6097,38 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .rect-prompt-btn { width: 100%; text-align: center; } .rect-modal, .rect-panel { width: 100vw; } .rect-banner-btn { width: 100%; } + .technique-workbench-head { align-items: flex-start; } + .technique-action-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .provenance-grid, + .provenance-kv-grid, + .roadmap-grid { grid-template-columns: 1fr; } + .panchanga-range-controls, + .panchanga-condition-options, + .panchanga-condition-guide, + .panchanga-festival-details { grid-template-columns: 1fr; } + .panchanga-condition-filter { grid-column: span 1; } + .calculation-settings-head { flex-direction: column; } + .calculation-settings-grid, + .rule-variant-grid, + .first-use-grid, + .runtime-health-grid, + .trust-status-grid, + .terminology-mode-options, + .case-workspace-controls { grid-template-columns: 1fr; } + .case-workspace-counts, + .case-bulk-actions, + .workspace-chart-list, + .case-preview-panel { max-width: 100%; } + .panchanga-range-summary { display: block; } + .panchanga-range-summary span { display: block; max-width: none; margin-top: 4px; text-align: left; } + .panchanga-range-summary small { text-align: left; } + .panchanga-month-grid { grid-template-columns: repeat(7, minmax(76px, 1fr)); } + .panchanga-month-head, + .panchanga-month-cell { min-width: 76px; } + .provenance-head { display: block; } + .provenance-head strong { max-width: none; margin-top: 4px; text-align: left; } + .technique-result-grid, + .technique-varga-grid, + .technique-insight-grid, + .thematic-report-grid { grid-template-columns: 1fr; } } diff --git a/jyotish-app/subscription.js b/jyotish-app/subscription.js index f677a0a1..b9f6e8ec 100644 --- a/jyotish-app/subscription.js +++ b/jyotish-app/subscription.js @@ -4,6 +4,7 @@ */ import { getToken, getUser, getApiBase, fetchUser, isLoggedIn, apiPost } from './auth.js'; import { t } from './i18n.js'; +import { escapeHtml } from './security.js'; // ============================================================================ // 配置 @@ -50,6 +51,7 @@ async function setupCapacitorIAP() { } } catch (err) { console.warn('[Subscription] IAP setup failed:', err); + showSubscriptionNotice(buildSubscriptionRecoveryMessage(err, 'iap_setup')); } } @@ -78,7 +80,7 @@ async function handleSubscribe() { async function purchaseNative() { try { if (!_purchasesPlugin) { - alert(t('sub.iap.unavail')); + showSubscriptionNotice(buildSubscriptionRecoveryMessage(t('sub.iap.unavail'), 'iap')); return; } @@ -87,7 +89,7 @@ async function purchaseNative() { const product = offerings?.current?.availablePackages?.[0]?.product; if (!product) { - alert(t('sub.no.product')); + showSubscriptionNotice(buildSubscriptionRecoveryMessage(t('sub.no.product'), 'product')); return; } @@ -101,12 +103,12 @@ async function purchaseNative() { await verifyReceipt(purchaseResult); // 刷新用户信息 await fetchUser(); - alert(t('sub.success')); + showSubscriptionNotice(t('sub.success')); } } catch (err) { if (err.userCancelled) return; console.error('[Subscription] Purchase failed:', err); - alert(t('sub.failed') + (err.message || '')); + showSubscriptionNotice(buildSubscriptionRecoveryMessage(err, 'purchase')); } } @@ -124,6 +126,7 @@ async function verifyReceipt(purchaseResult) { }); } catch (err) { console.error('[Subscription] Receipt verification failed:', err); + showSubscriptionNotice(buildSubscriptionRecoveryMessage(err, 'verify')); // 即使验证失败,RevenueCat 已经记录了购买 } } @@ -133,7 +136,7 @@ async function verifyReceipt(purchaseResult) { // ============================================================================ export async function restorePurchases() { if (!_purchasesPlugin) { - alert(t('sub.restore.only')); + showSubscriptionNotice(buildSubscriptionRecoveryMessage(t('sub.restore.only'), 'restore')); return; } @@ -142,16 +145,46 @@ export async function restorePurchases() { if (result?.customerInfo?.activeSubscriptions?.length > 0) { // 恢复成功,验证并更新状态 await fetchUser(); - alert(t('sub.restore.ok')); + showSubscriptionNotice(t('sub.restore.ok')); } else { - alert(t('sub.restore.none')); + showSubscriptionNotice(t('sub.restore.none')); } } catch (err) { console.error('[Subscription] Restore failed:', err); - alert(t('sub.restore.fail') + (err.message || '')); + showSubscriptionNotice(buildSubscriptionRecoveryMessage(err, 'restore')); } } +function buildSubscriptionRecoveryMessage(error, context = 'subscription') { + const message = typeof error === 'string' ? error : (error?.message || ''); + const recovery = t('sub.recovery.iap'); + return `${message || t('sub.failed')}\n${recovery}\nTrust Center / 网页服务 /本地 API 服务`; +} + +function showSubscriptionNotice(message) { + const safeMessage = escapeHtml(String(message)).replace(/\n/g, '
    '); + const overlay = document.createElement('div'); + overlay.className = 'auth-modal-overlay open'; + overlay.style.cssText = 'opacity:1;visibility:visible;'; + overlay.innerHTML = ` +
    + +
    +
    + +

    ${t('auth.upgrade.title')}

    +

    ${safeMessage}

    +
    +
    +
    + `; + document.body.appendChild(overlay); + overlay.querySelector('#sub-notice-close').addEventListener('click', () => overlay.remove()); + overlay.addEventListener('click', e => { + if (e.target === overlay) overlay.remove(); + }); +} + // ============================================================================ // Web 环境 — 订阅说明弹窗 // ============================================================================ diff --git a/jyotish-app/vite.config.js b/jyotish-app/vite.config.js index 93eb1f48..b5dda167 100644 --- a/jyotish-app/vite.config.js +++ b/jyotish-app/vite.config.js @@ -10,5 +10,17 @@ export default defineConfig({ }, build: { target: 'esnext', + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('interpretation.js') || id.includes('yoga-') || id.includes('planet-house-details')) { + return 'jyotish-reference'; + } + if (id.includes('skill-map.js') || id.includes('mevg-audit.js')) { + return 'product-audit'; + } + }, + }, + }, }, }); diff --git a/progress.md b/progress.md new file mode 100644 index 00000000..cb7b26cd --- /dev/null +++ b/progress.md @@ -0,0 +1,294 @@ +# 印度占星产品化进度日志 + +## 2026-06-22 + +- 恢复上下文:项目根目录此前没有 `task_plan.md`、`findings.md`、`progress.md`。 +- 读取了 `planning-with-files-zh` 技能说明,按复杂任务要求建立磁盘工作记忆。 +- 检查了 `docs/research/product_gap_matrix_2026_06_22.md`,当前最高优先级为 richer Panchanga festival rules 和 search-by-condition。 +- 网络扫描 GitHub:确认 VedAstro、VedAstro.Python、kunjara/jyotish、panchanga_api、jyotidarshan 等仍是 Panchanga/Muhurta 同类对标对象。 +- 完成 Panchanga richer rules:`scripts/muhurta.py` 增加保守 vrata/festival-candidate 标签和 `condition_tags`。 +- 完成前端条件检索:`jyotish-app/main.js` 增加 `PANCHANGA_CONDITIONS`、`panchanga-condition`、行过滤、条件徽章、CSV/ICS 条件标签。 +- 完成样式与测试:`jyotish-app/style.css` 增加 `.panchanga-condition-chip`;`tests/test_muhurta.py`、`tests/test_api_server_security.py`、`tests/test_frontend_productization.py` 增加覆盖。 +- 验证:`python3 -m pytest tests/test_muhurta.py tests/test_api_server_security.py tests/test_frontend_productization.py -q` 通过。 +- 验证:`npm run build` 通过;`python3 scripts/audit_fragments.py --strict` 通过,65 registry / 37 commands / 32 API endpoints / 42 frontend files,0 problems。 +- 完成多人/家庭案例工作区 MVP:`jyotish-app/main.js` 增加 `CASE_GROUP_PRESETS`、`CASE_RELATION_PRESETS`、元数据归一化、统一 chart/pair/prashna 列表、group/relation 筛选、星盘打开、三类记录批量导出/删除。 +- 完成样式与测试:`jyotish-app/style.css` 增加 `.case-meta-line`;`tests/test_frontend_productization.py` 增加案例分组/关系 token。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py -q` 通过;`npm run build` 通过。 +- 网络扫描合盘/匹配开源:`ashtakoot kundli matching` 与 `jyotish matchmaking python` 无高价值直接结果;`vedic astrology compatibility matching` 命中 VedAstro MCP、dashaflow、Cosmic Harmony Match、MyRashifal 等,其中可复用内核仍以本地 `dashaflow` MIT 为准。 +- 完成关系报告模板:`jyotish-app/main.js` 增加 `buildRelationshipReportTemplate`、`renderRelationshipReport`,保存配对记录、复盘、当前工作流与 HTML 导出都携带 `relationshipReport`/`relationship_report`。 +- 完成 bi-wheel/composite-style 比较视图:新增双人轴线、行星 overlay 宫位、星座关系 tone、Sun/Moon/Venus/Mars midpoint;完整合盘深度区会渲染 `renderBiWheelComparisonView`。 +- 完成样式与测试:`jyotish-app/style.css` 增加 `.relationship-report-*`、`.biwheel-*`、`.composite-style-strip`;`tests/test_frontend_productization.py` 增加关系报告和比较视图 token。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py -q` 通过;`npm run build` 通过,仅保留既有 Vite chunk size warning。 +- 完成 `spouse_status_yoga.py` 关系折叠:`scripts/jyotish_api_server.py` 的 `/api/relationship` 返回 `spouse_status_yoga`;`jyotish-app/main.js` 增加 `buildSynastrySpouseStatusContext`、`renderSpouseStatusComparison`、保存复盘归一化和关系报告证据;`jyotish-app/export.js` 在 HTML 报告中输出 spouse-status 表。 +- 完成样式与测试:`jyotish-app/style.css` 增加 `.spouse-status-*`;`tests/test_api_server_security.py` 增加 relationship fragment 测试;`tests/test_frontend_productization.py` 增加 spouse-status token。 +- 验证:`python3 -m pytest tests/test_api_server_security.py tests/test_frontend_productization.py -q` 通过;`npm run build` 通过,仅保留既有 Vite chunk size warning。 +- 完成关系报告打印 polish:`jyotish-app/export.js` 的合盘 HTML 报告升级为 `relationship-deliverable`,包含结论 hero、证据卡、bi-wheel 轴线、overlay 表、midpoint、spouse-status、行动列表和边界说明,并增加 print break-inside 规则。 +- 完成测试:`tests/test_frontend_productization.py` 增加 relationship deliverable/export token。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py -q` 通过;`npm run build` 通过,仅保留既有 Vite chunk size warning。 +- 完成可编辑关系元数据:`jyotish-app/main.js` 的统一案例工作区增加 `workspace-edit-case`,支持编辑 chart/pair/prashna 标题、分组、关系类型和标签,并通过 `applyWorkspaceCaseMetadata` 归一化后写回原本地库。 +- 完成测试:`tests/test_frontend_productization.py` 增加 metadata editing token。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py -q` 通过;`npm run build` 通过,仅保留既有 Vite chunk size warning。 + +## 2026-06-23 + +- 本地/网络报告管线复核:外部 GitHub 命中的 Vedic report/PDF 项目大多是无许可证仓库、API SDK、产品壳或 notebook;决定复用本地 `vedic-astro-skills` 来源的 `scripts/report_builder.py`。 +- 完成后端报告 artifact/PDF 管线:`scripts/jyotish_api_server.py` 新增 `/api/report_artifact`,限制 HTML 体积,阻断 script/iframe/object/embed/on* 事件属性和 `javascript:` URL,写入 `/private/tmp/jyotish-reports`,并调用 `report_builder._html_to_pdf` 生成 PDF。 +- 完成安全降级:Playwright/Chromium 不可用时 API 返回后端生成 HTML 工件、`html_base64` 和 fallback 状态,不让导出流程空失败。 +- 完成前端 PDF 导出:`jyotish-app/api-bridge.js` 暴露 `generateReportArtifact`;`jyotish-app/export.js` 新增 `exportPDFReport` 和 `downloadBase64File`;`jyotish-app/index.html` 导出菜单新增 PDF;`jyotish-app/main.js` 懒加载导出分支支持 `data-format="pdf"`。 +- 完成测试与审计:`python3 -m pytest tests/test_api_server_security.py tests/test_frontend_productization.py -q` 通过;`python3 scripts/audit_fragments.py --strict` 通过,65 registry / 37 commands / 33 API endpoints / 42 frontend files,0 problems;`npm run build` 通过。 +- 完成关系时机/UL-DK 折叠:前端完整合盘上下文新增 `ulDkTiming`,聚合 7星制/8星制 DK、UL、当前 Dasha 与 7宫主/关系自然征象触发;当前合盘、保存配对回放、关系报告模板都能展示“UL/DK 与关系时机”证据卡。 +- 完成后端关系时机补强:`/api/relationship` 新增 `relationship_timing`,复用 `darakaraka_reader.py` 与 `jaimini.py`,并返回 `darakaraka_reader.py`/`jaimini.py` fragment source;HTML/PDF 合盘报告新增 `uldk-print-grid`。 +- 完成导出体验 polish:导出菜单新增 `aria-live` 状态区,导出期间禁用按钮/菜单项,PDF 回退以可见状态反馈提示,避免重复点击和“点了没反应”。 +- 完成构建体积优化:`export.js` 改为动态导入,`vite.config.js` 将 reference/audit chunks 拆分;生产构建主 JS 保持在约 356K。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py -q` 通过;`npm run build` 通过;`git diff --check` 通过。 +- 完成 Panchanga 搜索增强:条件筛选从单选升级为多选组合,支持 `has_vrata`、`festival_candidate`、`spiritual_practice`、`auspicious_activity`、`avoid_new_start`、`good_choghadiya` 的 AND 组合筛选,并在结果上方展示条件说明;CSV/ICS 继续使用筛选后的日期。 +- 完成 Panchanga search/details 补强:后端返回 `search_summary` 和逐日 `festival_details`;前端新增“满足全部/满足任一”模式、节日说明卡和 location-aware 坐标/时区摘要。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`git diff --check` 通过。 +- 额外验证:`python3 scripts/audit_fragments.py --strict` 通过。 +- 完成计算设置选择器:参数/日历中心新增 Calculation Settings 面板,支持保存 ayanamsa、node、house、sunrise、geocoder 策略到 localStorage;排盘请求会携带策略字段,星盘对象、provenance、JSON/HTML 导出都会记录这些设置。 +- 约束说明:当前核心排盘仍以 Lahiri / Mean Node / Whole Sign 为主路径,Raman/KP/True node 等先作为策略记录与后续统一引擎切换入口,避免 UI 假装已改变底层黄经。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`git diff --check` 通过。 +- 完成规则/技法检索目录与 API Explorer:`scripts/jyotish_api_server.py` 新增 `/api/technique_catalog` 和白名单 `/api/technique_example`;`jyotish-app/api-bridge.js`/`public/api-bridge.js` 暴露 `getTechniqueCatalog`、`runTechniqueExample`;`jyotish-app/skill-map.js` 的目录卡片可携带 endpoint,用当前星盘生成 payload 并试算 API,保留 workbench fallback。 +- 完成样式与测试:`jyotish-app/style.css` 补 Explorer call/sample 样式;`tests/test_api_server_security.py` 验证 catalog/runnable examples;`tests/test_frontend_productization.py` 验证 bridge、目录、payload builder、endpoint/action 映射。 +- 验证:`python3 -m pytest tests/test_api_server_security.py tests/test_frontend_productization.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`python3 scripts/audit_fragments.py --strict` 通过,65 registry / 37 commands / 35 API endpoints / 42 frontend files,0 problems。 +- 当前下一最高优先级:规则变体/流派 toggles 与候选碎片归档。 +- 完成规则变体/流派 toggles 可见化:Calculation Settings 新增 Yoga、Jaimini Karaka、KP significator、Ashtakavarga、Shadbala、Dasha reference 六类 Rule Variants;保存后进入排盘 payload、星盘对象 provenance、JSON/HTML/PDF 导出。 +- 约束说明:Rule Variants 当前作为解释口径和导出审计记录;非当前选项标注为 staged,后续逐项接入实时算法切换,避免 UI 假装已经改变底层判断。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`git diff --check` 通过;`python3 scripts/audit_fragments.py --strict` 通过,候选碎片从 8 个降至 6 个。 +- 当前下一最高优先级:继续分流 `dasha_analyzer.py`、`reading_orchestrator.py`、`report_orchestrator.py`、`orchestrator_bridge.py`、`hermes_bridge.py`、`mevg_automation.py`。 +- 完成 Yoga/Shadbala 规则碎片真实接入:`/api/yogas` 复用 `curse_yoga_detector.py` 返回 `curse_yogas`、`curse_count`、风险等级与规则口径;`/api/shadbala` 复用 `shadbala_advanced.py` 返回 `advanced_layer`、Kala VMDH、Yuddha Bala、Sputa Drishti 与规则口径。 +- 完成前端 Explorer 渲染:`jyotish-app/skill-map.js` 新增 Shadbala/Yoga 专属结果卡,显示规则来源、凶星合相风险、Kala/Yuddha/Sputa 摘要和下一步边界提示。 +- 修复碎片警告:移除 `shadbala_advanced.py` 中未使用且会触发 SwissEph 属性警告的日出调用,保持轻量 Hora 近似与主 Shadbala 一致。 +- 验证:`python3 -m pytest tests/test_api_server_security.py tests/test_frontend_productization.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`python3 scripts/audit_fragments.py --strict` 通过,0 problems,候选碎片保持 6 个。 +- 当前下一最高优先级:把 `dasha_analyzer.py` 决定为 API/报告 Dasha 叙事接入或归档;随后处理 report/reading orchestrator 与 bridge/automation 类碎片。 +- 完成 Dasha 候选碎片接入:`/api/dasha` 在 Vimshottari 模式下新增 `vimshottari_analysis`,复用 `dasha_analyzer.py` 的真实 Mahadasha 起点、当前 Antardasha 计算,并结合 `dasha_calculator_enhanced.py` 输出五级层级;主 `periods` 合同保持不变。 +- 完成 Dasha 前端可见化:Skill workbench 和主界面“多 Dasha 系统”详情都会显示当前 MD/AD、五级层级、Nakshatra/Pada、关键词与 fragment source。 +- 验证:`python3 -m pytest tests/test_api_server_security.py tests/test_frontend_productization.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`python3 scripts/audit_fragments.py --strict` 通过,候选碎片从 6 个降至 4 个。 +- 当前下一最高优先级:处理 `reading_orchestrator.py`、`orchestrator_bridge.py`、`hermes_bridge.py`、`mevg_automation.py` 的产品归属。 +- 完成主题化报告编排器接入:新增 `/api/thematic_report`,复用 `report_orchestrator.py` 生成五主题 summary/narrative/evidence/conflict/timing/recommendations,并把 `reading_orchestrator.py`、`orchestrator_bridge.py` 作为 registry 输出路径纳入真实引用链。 +- 完成前端承载:`api-bridge.js`/`public/api-bridge.js` 暴露 `computeThematicReport`,Skill workbench 新增“主题报告”动作,Technique Directory/API Explorer 可试算 `/api/thematic_report`,结果以主题卡展示强度、时间锚点、证据和建议。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py -q` 通过;`python3 scripts/audit_fragments.py --strict` 通过,66 registry / 36 API endpoints / 42 frontend files,0 problems,候选碎片从 4 个降至 2 个。 +- 当前下一最高优先级:处理剩余 `hermes_bridge.py` 与 `mevg_automation.py`;两者偏外部学习/自动化门控,需做非破坏性产品归属或工具化决策。 +- 完成主题报告/编排碎片归档:`/api/thematic_report` 返回 `fragment_sources` 与 `workflow_orchestration`,声明 `report_orchestrator.py`、`reading_orchestrator.py`、`orchestrator_bridge.py` 的分工;Skill workbench 主题报告结果会展示这些来源。 +- 完成 MEVG 自动化接入:`/api/case_validation` 返回 `mevg_gate`,只读 `mevg_automation.py` 的门控协议/状态文件,不运行子进程、不写状态;无 `tests/mevg_state.json` 时明确返回 `NOT_INITIALIZED`。 +- 完成 Hermes 归档决策:`hermes_bridge.py` 属于外部个人 agent/WorkBuddy 学习桥,依赖 `hermes_memory_core.py` 且默认写 `~/.workbuddy`,不进入印度占星网页/app 默认产品面,已加入 `scripts/audit_fragments.py` 的忽略清单。 +- 当前下一最高优先级:跑全量回归、build、碎片审计,确认候选碎片清零。 +- 完成回归确认:`python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`git diff --check` 通过;`python3 scripts/audit_fragments.py --strict` 通过,候选碎片清零。 +- 当前下一最高优先级:从“碎片清理”转向真实用户端质量,优先把 `/api/thematic_report` 从样例 evidence 升级为消费真实 full-reading 模块证据。 +- 完成主题报告真实证据链:`/api/thematic_report` 在没有自定义 evidence、但提供 birth/chart payload 时进入 `derived_chart_evidence` 模式,best-effort 调用 chart、dasha、yogas、shadbala、ashtakavarga、relationship、career、Jaimini 模块,生成 marriage/career/wealth/health/spirituality 五主题证据。 +- 完成前端证据透明度:Skill workbench 主题报告结果显示 `evidence_source`、`module_status`、warning 数和 real/sample fallback 状态,让用户能区分真实计算报告与示例报告。 +- 验证:`python3 -m pytest tests/test_api_server_security.py::test_thematic_report_derives_evidence_from_birth_payload tests/test_api_server_security.py::test_thematic_report_declares_orchestrator_fragments tests/test_frontend_productization.py::test_skill_workbench_exposes_all_expected_advanced_actions -q` 通过;`npm run build` 通过。 +- 当前下一最高优先级:补 Technique Directory/API Explorer 的方法文档、可复制 cURL/OpenAPI 示例,并继续检查同品类产品 polish 缺口。 +- 完成方法文档/API 示例:`/api/technique_catalog` 新增 `api_docs` 与每行 `method_docs`,白名单 endpoint 自动生成 cURL、最小 OpenAPI operation、方法 notes;主题报告等多 endpoint 技法会按 id/name/domain 优先绑定最相关 API。 +- 完成前端 API 文档展示:Technique Directory 卡片显示方法摘要、边界和 API doc key;Explorer 样例结果显示 `cURL / OpenAPI` 折叠区,支持复制本地 API 调用片段。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py::test_skill_workbench_exposes_all_expected_advanced_actions tests/test_frontend_productization.py::test_frontend_backend_contracts_with_api_handler -q` 通过;`npm run build` 通过。 +- 当前下一最高优先级:执行全量回归/审计后,转向 P2 同品类产品 polish:PWA/桌面包装审计、隐私信任中心、术语模式与可替换星历底座。 +- 完成 PWA/信任中心 MVP:新增 `manifest.webmanifest`、`pwa-icon.svg`、`sw.js`,HTML 关联 manifest/theme color,主入口注册 service worker、监听 install prompt,并在参数/日历页展示 PWA 状态。 +- 完成本地数据 Trust Center:参数/日历页显示 Local-first 数据说明、本地星盘/配对/问事数量、API/AI 边界;提供“安装为应用”“导出本地资料”“清空本地资料”按钮,清空动作保留 `window.confirm` 二次确认。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py::test_provenance_panchanga_workspace_panel_is_productized tests/test_frontend_productization.py::test_mobile_layout_keeps_dense_sections_single_column -q` 通过;`npm run build` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:全量回归/审计后,推进术语模式和星历底座可替换性说明。 +- 完成术语模式产品化:Trust Center 新增入门/专业/梵文优先三档;tooltip 会按当前模式切换标题、名称对照和说明深度;provenance、Trust Center 本地导出、JSON/HTML 报告都会记录当前术语偏好。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py::test_provenance_panchanga_workspace_panel_is_productized tests/test_frontend_productization.py::test_mobile_layout_keeps_dense_sections_single_column -q` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:补桌面包装说明/spike,并继续星历底座可替换性分析。 +- 完成主题报告 full-reading 证据链升级:`/api/thematic_report` 在出生数据路径下优先调用 `cmd_full_reading`,读取 full-reading modules,并把 marriage_counting、Vivah Saham、Dasa convergence、Dhana Yoga、validation、D30、D20/Jaimini 等真实模块折叠进五主题证据。 +- 修复主题报告兼容性 Bug:full-reading 的 Dasha timeline 中 `antardasha` 可能是对象,已在 `_normalize_thematic_dasha_timeline` 中统一提取 lord/name 并归一化年份,避免 `report_orchestrator.TimingAnchorBuilder` 拼接时报 TypeError。 +- 完成前端证据透明度增强:Skill workbench 主题报告结果显示 `full-reading: modules` 与 `full_reading_modules` 来源,普通用户可区分 full-reading 实算证据和 sample fallback。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`python3 scripts/audit_fragments.py --strict` 通过,66 registry / 36 API endpoints / 43 frontend files / candidate_count 0;`git diff --check` 通过。 +- 当前下一最高优先级:术语模式与星历底座抽象,先检查现有 glossary/i18n/calculation settings 是否已有半接入碎片,再补用户端切换入口与导出/provenance 记录。 +- 完成术语模式与星历底座 MVP:Calculation Settings 增加 `ephemerisBackend` 与 `terminologyMode`,兼容旧 `jyotish_terminology_mode`;tooltip 支持 balanced/beginner/professional 三种解释层,Trust Center、provenance、HTML/JSON 导出都会记录当前模式与星历底座。 +- 产品边界:`xalen-ephemeris` 先作为 Apache-2.0 可行性记录,不改变当前 Swiss Ephemeris 主路径;避免 UI 暗示已替换核心黄经。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`python3 scripts/audit_fragments.py --strict` 通过;`git diff --check` 通过。 +- 当前下一最高优先级:桌面包装说明与运行健康检查入口,让普通用户能判断本地 API/PWA/导出能力是否可用。 +- 完成普通用户运行健康检查入口:`api-bridge.js`/`public/api-bridge.js` 暴露 `getAPIHealth()`;Trust Center 新增 Runtime Health 面板和“运行健康检查”按钮,串联 `/api/health`、`/api/capability_audit`、PWA 状态、Pake/Tauri 桌面路线与 preflight 命令。 +- 修复普通用户可用性 Bug:此前 Trust Center 只展示静态 API 说明,用户无法判断本地 Python API 是否在线;现在失败时给出 `npm run web` / `python3 scripts/jyotish_api_server.py` / `python3 scripts/desktop_packaging_preflight.py` 的具体恢复路径。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py::test_api_bridge_exports_productized_backend_actions tests/test_frontend_productization.py::test_provenance_panchanga_workspace_panel_is_productized tests/test_frontend_productization.py::test_mobile_layout_keeps_dense_sections_single_column tests/test_frontend_productization.py::test_desktop_packaging_spike_is_documented_and_checkable -q` 通过;`npm run build` 通过。 +- 当前下一最高优先级:跑全量回归/碎片审计后,继续首次使用引导与普通用户空状态路径。 +- 会话恢复检查:`session-catchup.py` 提示前一窗口存在未同步上下文;已按建议读取 `task_plan.md`、`progress.md`、`findings.md` 并查看 `git diff --stat`,继续在原计划文件内同步状态。 +- 完成首屏首次使用引导:`jyotish-app/index.html` 新增 `first-use-panel`,提供“运行健康检查”“填入示例盘”“识别已有星盘”三条入口;`main.js` 新增 `DEMO_BIRTH`、`setupFirstUsePanel`、`fillDemoBirth`、`runFirstUseHealthCheck`、`focusFirstUseImport`,并把本地星盘库空状态改成可行动提示。 +- 完成首屏样式与移动端守门:`style.css` 新增 first-use 操作区样式,并把 `.first-use-grid` 纳入现有 768px 单列响应式守门。 +- 测试先行:先新增 `test_first_use_onboarding_is_actionable` 并确认红灯失败于缺失 `first-use-panel`;实现后继续复跑到产品矩阵同步阶段。 +- 当前下一最高优先级:完成首屏测试转绿后,运行构建/preflight/diff 检查,再用真实浏览器做桌面/移动首次运行冒烟。 +- 浏览器冒烟发现并修复首跑 Banner Bug:Python API 返回的 `ascendant` 不含 `lord`、`Moon` 不含 `nakshatra/nakshatra_pada`,前端原本直拼字段会显示 `undefined`;新增 `getAscendantLord`、`normalizePlanetRecord`、`formatMoonNakshatra`,从星座推导上升主星,缺星宿时隐藏该行。 +- 新增回归守门:`test_chart_banner_avoids_undefined_api_fields` 先红灯捕捉 Banner 直拼问题,修复后转绿。 +- 真实浏览器验证:使用系统 Google Chrome + Codex bundled Playwright 访问 `http://127.0.0.1:5173/`,完成首屏健康检查、示例盘填入、生成星盘、移动端 390px 单列检查;输出确认 `hasUndefined=false`、`chartVisible=true`、`consoleErrors=[]`。 +- 当前下一最高优先级:跑全量前端测试、build、desktop preflight、diff check,并关闭本地 API/Vite 会话。 +- 完成首次使用入口接线复核:输入页已有“运行健康检查 / 填入示例盘 / 识别已有星盘”三入口;修正首次使用健康检查读取 `/api/capability_audit` 的字段口径,使用 `registry.technique_count` 与 `surfaces.api_endpoint_count`,避免显示模糊的“技法目录已返回”。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`python3 scripts/audit_fragments.py --strict` 通过,candidate_count 0;`git diff --check` 通过;`python3 tests/run_frontend_runtime_smoke.py --start-if-needed` 通过,66 registry / 66 excellent UX / 66 productized。 +- 浏览器交互守门:Playwright Python 包存在,但 Chromium 二进制下载多次超时/断连,已停止卡住的安装进程;当前以 runtime smoke + curl 首页/manifest/api-bridge + packaging preflight 作为可执行首跑验证。 +- 完成高级技法错误恢复提示:Skill Workbench/API Explorer 的 `renderWorkbenchError` 现在提示先到 Trust Center 运行健康检查,并给出 `npm run web` / `python3 scripts/jyotish_api_server.py` 的恢复动作。 +- 当前下一最高优先级:继续导出/报告失败恢复和 AI/API key 安全提示,降低普通用户在 PDF、AI 聊天和外部凭证配置上的误操作。 +- 官方文档核对:OpenAI API authentication 文档明确 API key 是 secret,不应暴露在浏览器/app 客户端代码里,应从服务端环境变量或密钥管理服务读取;据此修正 AI 聊天配置提示。 +- 完成 AI/API key 安全提示:`ai-chat.js` 新增 `buildAISetupGuidance`,未登录与本地默认回复都提示通过服务端 `/api/chat` 或后端代理连接模型,并说明 `OPENAI_API_KEY` 放在服务端环境变量;移除“浏览器控制台 localStorage 配置 endpoint”的旧提示。 +- 完成导出失败恢复:`main.js` 新增 `getPDFExportRecoveryMessage` / `getGenericExportRecoveryMessage`,PDF fallback 明确“已改为导出 HTML 报告”,并指向 Trust Center 健康检查与 `python3 scripts/jyotish_api_server.py` 恢复路径;导出异常不再弹窗打断。 +- 新增测试:`test_ai_chat_guides_server_side_secret_handling` 先红灯后转绿,并守住 `buildAISetupGuidance` 只定义一次;`test_export_failure_recovery_guides_health_check` 先红灯后转绿。 +- 当前下一最高优先级:跑完整前端产品化测试、API 安全测试、runtime smoke、build、preflight、audit fragments 与 diff check,然后继续 ephemeris abstraction feasibility。 +- 验证完成:`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`python3 tests/run_frontend_runtime_smoke.py --start-if-needed` 通过;`npm run build` 通过;`python3 scripts/desktop_packaging_preflight.py` 通过;`python3 scripts/audit_fragments.py --strict` 通过,problem_count/warning_count/candidate_count 均为 0;相关 `git diff --check` 通过。 +- 当前下一最高优先级:进入 ephemeris abstraction feasibility,先扫描本地 `xalen`/VedAstro/PyJHora/SwissEph 记录与现有 calculation settings,再决定做探针脚本还是 API contract。 +- 完成 AI 安全提示修复:`ai-chat.js` 不再在默认回复中引导用户通过浏览器控制台/localStorage 配置 AI endpoint;新增 `buildAISetupGuidance()`,明确 `OPENAI_API_KEY` 必须放在服务端环境变量,并通过服务端 `/api/chat` 或后端代理使用。 +- 完成导出失败恢复提示:`main.js` 新增 `buildExportRecoveryMessage()`;PDF 失败时在 `export-status` 中提示先运行 Trust Center 健康检查、启动本地 API,或先导出 HTML 再浏览器打印 PDF,不再用 alert 打断。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py::test_ai_chat_guides_server_side_secret_handling tests/test_frontend_productization.py::test_provenance_panchanga_workspace_panel_is_productized -q` 通过;`npm run build` 通过;`git diff --check` 通过。 +- 当前下一最高优先级:跑全量回归/碎片审计后,继续检查 `api-bridge.js` 中旧版 `YINDUZHANXING_AI_KEY` 浏览器密钥路径是否应降级为 legacy warning 或移除。 +- 完成浏览器 AI key 路径降级:`api-bridge.js`/`public/api-bridge.js` 不再读取 `YINDUZHANXING_AI_KEY`、不再暴露 `apiKey`、不再向旧 relay 发送 Bearer token;`aiReading/aiFullReading/aiQuickInsight` 保留函数名但返回 `AI_BROWSER_KEY_DISABLED` 安全指引,AI 对话统一走登录后的服务端 `/api/chat` 或后端代理。 +- 修复过境对比旧 key 传播:`renderTransitCompareTab` 请求本地 `/api/transit` 时不再从 `window.JyotishAPI.apiKey` 拼 `Authorization` header。 +- 文档同步:README 历史说明改为当前浏览器构建禁用模型 API key 直连,避免普通用户继续按旧方式配置。 +- 验证:`python3 -m pytest tests/test_frontend_productization.py::test_api_bridge_exports_productized_backend_actions tests/test_frontend_productization.py::test_ai_chat_guides_server_side_secret_handling -q` 通过;`npm run build` 通过。 +- 当前下一最高优先级:执行全量回归/碎片审计后,继续检查登录/订阅/API 调用失败是否有用户端恢复提示。 +- Ephemeris TDD 红灯:新增 `test_ephemeris_abstraction_feasibility_is_probeable` 后先运行 `python3 -B -m pytest tests/test_frontend_productization.py::test_ephemeris_abstraction_feasibility_is_probeable -q`,按预期失败于缺少 `scripts/ephemeris_backend_probe.py`。 +- 完成星历后端可行性探针:新增 `scripts/ephemeris_backend_probe.py`,只读检查 `swisseph_python`、`swisseph_wasm`、`xalen_ephemeris`、`vedastro`、`pyjhora_benchmark` 的可用性、`license_posture` 与 `replacement_readiness`,输出 JSON。 +- 完成星历可行性文档:新增 `docs/research/ephemeris_abstraction_feasibility_2026_06_23.md`,同步 `docs/research/product_gap_matrix_2026_06_22.md`、`task_plan.md`、`findings.md`。 +- 当前下一最高优先级:跑探针/测试/build/preflight/audit 回归后,继续进入星历 backend adapter contract 与 longitude parity matrix。 +- Ephemeris contract TDD 红灯:新增 `test_ephemeris_adapter_contract_and_parity_matrix_are_defined` 后先运行,按预期失败于缺少 `scripts/ephemeris_adapter_contract.py`。 +- 完成星历 adapter contract:新增 `scripts/ephemeris_adapter_contract.py`,复用现有 `compute_chart_data` 生成 `swisseph_python` baseline,定义 `EphemerisAdapterContract`、`PARITY_CASES`、`sun_moon_asc_nodes` 和 `acceptance_thresholds`。 +- 完成 parity matrix 文档:新增 `docs/research/ephemeris_adapter_contract_2026_06_23.md`,明确 Sun/Moon/Asc/Rahu/Ketu 的 `longitude_delta_arcsec` 验收阈值与 xalen/VedAstro/PyJHora 的接入边界。 +- 当前下一最高优先级:跑 contract 脚本、测试、build、preflight、fragment audit、diff check,然后继续候选 adapter spike 或 AI/export 交互 smoke。 +- 完成 runtime smoke 补强:`tests/run_frontend_runtime_smoke.py` 现在 POST `/api/report_artifact` 并验证 HTML fallback artifact,同时读取 `/api-bridge.js` 确认 `AI_BROWSER_KEY_DISABLED`、`server_side_only` 和无前端 Bearer key 泄漏。 +- 完成候选星历 adapter spike:新增 `scripts/ephemeris_candidate_adapter_spike.py` 与 `docs/research/ephemeris_candidate_adapter_spike_2026_06_23.md`,将 `swisseph_wasm_candidate` 和 `xalen_ephemeris_candidate` 都挡在 `license_gate` / `parity_gate_required` / `runtime_setting_exposure` 之后。 +- 当前下一最高优先级:跑候选 spike 与全量回归;随后继续检查登录/订阅/API 调用失败恢复提示。 +- 调试修复:`scripts/ephemeris_adapter_contract.py` 首跑暴露 `PARITY_CASES` 的 `id/label` 被混传进 dataclass,已修正为只传计算输入字段,保留 `id/label` 用于矩阵行。 +- 验证完成:`python3 scripts/ephemeris_backend_probe.py` 通过,确认 SwissEph Python/WASM 可用、xalen 未本地接入、VedAstro/PyJHora 为基准;`python3 scripts/ephemeris_adapter_contract.py` 通过并生成 3 组 baseline parity rows。 +- 回归完成:`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build` 通过;`python3 scripts/desktop_packaging_preflight.py` 通过;`python3 scripts/audit_fragments.py --strict` 通过,problem/warning/candidate 均为 0;相关 `git diff --check` 通过。 +- 当前下一最高优先级:检查候选星历 adapter 的本地可执行性;若没有 xalen 本地二进制/源码,则先做 SwissEph WASM parity smoke 或转向 AI/export 实际交互 smoke。 +- 候选 adapter spike 复核:本地有 Rust/Cargo,但没有 xalen 源码或二进制;SwissEph WASM 资产存在,`@swisseph/browser` package license 为 `AGPL-3.0`,`swisseph-wasm` package license 为 `GPL-3.0-or-later`。 +- 完成许可证门禁结构化:`scripts/ephemeris_candidate_adapter_spike.py` 新增 `package_license` 输出和 distribution gate,避免把 GPL/AGPL WASM fallback 误当作低风险生产替换后端。 +- 当前下一最高优先级:跑候选 gate 测试和全量回归后,转向 AI/export runtime smoke 或 xalen 隔离获取/构建 spike。 +- 验证完成:`python3 scripts/ephemeris_candidate_adapter_spike.py` 输出 `package_license`,`test_ephemeris_candidate_adapter_spike_is_gated` 通过;全量 `tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py` 通过;`npm run build`、`desktop_packaging_preflight.py`、`audit_fragments.py --strict`、相关 `git diff --check` 均通过。 +- 当前下一最高优先级:从星历 gate 转向真实用户运行路径,优先检查 AI/export runtime smoke 是否覆盖点击失败恢复、服务端密钥提示和 PDF/HTML fallback。 +- 真实浏览器 AI 交互验证:打开 `http://127.0.0.1:5173/`,示例盘生成后打开 AI 面板,输入“请概述这个星盘”并按 Enter;面板在 1280x720 视口内,回复明确提示 `/api/chat`、服务端环境变量 `OPENAI_API_KEY`、不要把 key 放浏览器,且无 `jyotish_ai_endpoint` 或浏览器控制台配置文案。 +- 修复 AI 安全残留:删除 `ai-chat.js` 旧版 `localStorage.getItem('jyotish_ai_endpoint')` 自定义 endpoint fetch 路径;`test_ai_chat_guides_server_side_secret_handling` 现在禁止 `jyotish_ai_endpoint` 和 `Custom endpoint failed`。 +- Runtime smoke 补强:`tests/run_frontend_runtime_smoke.py` 现在同时检查 `api-bridge.js` 和 `ai-chat.js`,确认 AI 浏览器 key 禁用、聊天配置指向服务端 `/api/chat` 和 `OPENAI_API_KEY`。 +- 当前下一最高优先级:跑 runtime smoke、全量回归、build、审计和 diff check;然后继续导出点击路径是否需要更强可见状态守门。 +- 完成登录/订阅/API 恢复提示:`auth.js` 新增 `buildAuthRecoveryMessage`、`showAuthError` 与安全响应解析,登录/注册/Apple/token 校验失败时会提示 Trust Center、`npm run web`、`python3 scripts/jyotish_api_server.py`;`subscription.js` 新增 `buildSubscriptionRecoveryMessage`、`showSubscriptionNotice`,IAP/恢复购买/收据验证失败不再只弹 alert,并对消息做 HTML 转义。 +- 验证完成:`test_auth_and_subscription_failures_have_recovery_guidance` 通过;全量 `tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py` 通过;`tests/run_frontend_runtime_smoke.py --start-if-needed` 通过;`npm run build` 通过;`ephemeris_backend_probe.py`、`ephemeris_adapter_contract.py`、`ephemeris_candidate_adapter_spike.py`、`desktop_packaging_preflight.py`、`audit_fragments.py --strict` 和 `git diff --check` 均通过。 +- 当前下一最高优先级:继续检查主排盘、关系工作流、Skill Workbench 的 API 调用失败恢复是否同样完整。 +- 完成 API bridge 与主排盘失败恢复:`api-bridge.js`/`public/api-bridge.js` 安全解析非 JSON 响应,主排盘表单新增 `chart-compute-status` 可见状态,失败时提示 Trust Center、`npm run web`、`python3 scripts/jyotish_api_server.py`,不再用 alert 打断。 +- 完成 AI chat API 失败恢复:`ai-chat.js` 对 `/api/chat` 使用安全响应解析;服务端 AI 不可用时提示服务端环境变量 `OPENAI_API_KEY`、Trust Center 与本地 API 启动路径,同时保留本地解释 fallback。 +- 完成 Transit/互动 API 失败恢复:`renderTransitCompareTab` 不再直接 `resp.json()`,非 JSON/HTTP 错误会显示可行动恢复提示;Transit 主渲染、关系合盘、Prashna、校正时间应用统一走 `buildInteractiveAPIRecoveryMessage`/`renderInlineAPIError`,避免裸错误和旧版启动命令散落。 +- 当前下一最高优先级:跑完整回归、runtime smoke、build、星历/打包/碎片审计与 diff check;若通过,进入真实浏览器点击级 smoke,覆盖生成星盘、AI chat、导出、Transit/合盘/问事失败态。 +- 真实浏览器点击级 smoke 发现并修复首盘渲染残留:示例盘生成后页面仍有 `☽ undefined Pundefined` 与 Raman 详情 `(undefined)`;已改为 `buildChartSummary` 复用 `formatMoonNakshatra`,`analysis-renderers.js` 新增 `sanitizeRamanDetail` 将缺失星座显示为“缺星座”。 +- 验证完成:`python3 -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;浏览器复验示例盘后 `hasUndefined=false`、`hasNaN=false`、控制台无 error;`npm run build` 通过;`python3 tests/run_frontend_runtime_smoke.py --start-if-needed` 通过;`python3 scripts/audit_fragments.py --strict` 与 `git diff --check` 通过。 +- 当前下一最高优先级:把真实浏览器点击级 smoke 固化为可重复脚本,继续覆盖 AI chat、导出、Transit/合盘/问事失败态。 +- 完成真实浏览器点击级 smoke 固化:新增 `tests/run_frontend_click_smoke.py`,动态启动本地 API/Vite,向页面注入 `YINDUZHANXING_API_BASE`,使用系统 Chrome 真实点击示例盘生成、AI chat、HTML 导出、Transit、合盘、问事,并输出 JSON 结果。 +- 点击级 smoke 发现并修复 HTML 导出真 Bug:`computeNakshatraAdvanced` 中对象简写误写 `sub_lord,`,运行时抛 `sub_lord is not defined`,导致 HTML 报告导出失败;已修为 `sub_lord: subLord`,并在产品化测试中禁止回归。 +- 验证完成:`tests/run_frontend_click_smoke.py` 通过,HTML 导出状态为“HTML 报告已开始下载,可直接打开或打印”;`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build`、`desktop_packaging_preflight.py`、`audit_fragments.py --strict`、`run_frontend_runtime_smoke.py --start-if-needed`、三条 ephemeris 脚本和 `git diff --check` 均通过。 +- 当前下一最高优先级:把真实浏览器守门扩展到移动端/离线/PWA 安装与无 API 失败路径,避免只验证在线桌面 happy path。 +- 完成移动端/离线/PWA 点击守门扩展:`tests/run_frontend_click_smoke.py` 支持 `--mode core|mobile|offline|all`;`mobile` 跑 390x844 真机视口,`offline` 故意指向未监听 API 端口,验证健康检查恢复提示、浏览器 fallback 排盘和 manifest/serviceWorker。 +- 真实移动端修复:mobile smoke 发现 AI FAB 在 390px 视口被星盘/行星表截获,并发现页面横向溢出 528px;已把移动 FAB 改为左侧安全区定位、AI panel 改为 `100vw` 左侧滑入,并给 chart/table 容器补 `minmax(0,1fr)`/`min-width:0`/`max-width:100%`。 +- 完成 Pake/Tauri 非破坏性本机探测:`scripts/desktop_packaging_preflight.py` 新增 `toolchain_probe`,只读探测 node/npm/rustc/cargo/xcodebuild/pake/tauri;本机 node/npm/rust/cargo 可用,pake/tauri CLI 未安装,xcodebuild 只有 CommandLineTools 因此 `macos_signing_notarization=false`。 +- 验证完成:`python3 tests/run_frontend_click_smoke.py --mode all` 通过;`python3 -B -m pytest tests/test_frontend_productization.py -q` 通过;`npm run build` 通过;`python3 scripts/desktop_packaging_preflight.py` 输出 `toolchain_probe.non_destructive=true`;相关 `git diff --check` 通过。 +- 当前下一最高优先级:继续检查 PDF fallback 的真实点击路径、PWA 离线 shell 二次加载、以及移动端长页面关键标签切换。 +- 完成 PDF fallback 真实点击守门:`run_pdf_fallback_smoke` 会生成示例盘后 monkeypatch `window.JyotishAPI.generateReportArtifact` 返回 HTML fallback,真实点击“导出 PDF 报告”,验证用户看到“PDF 渲染器不可用 / 已改为导出 HTML 报告 / Trust Center / npm run web / python3 scripts/jyotish_api_server.py”。 +- 完成 PWA 离线 shell 二次加载守门:`run_offline_shell_reload_smoke` 等待 service worker 控制后切换 `context.set_offline(True)` 并 reload,确认 `first-use-panel` 仍可加载,离线 JS module 请求的 `ERR_FAILED` 被归入 `offline_shell_expected_console_errors`,不污染真正 console error。 +- 完成移动端长标签切换守门:`run_mobile_tab_smoke` 在 390x844 视口依次点击 Complete、Vargas、Synastry、Prashna、Transit Compare,确认对应 panel active、无 `undefined/NaN`、无非豁免横向溢出。 +- 验证完成:`python3 tests/run_frontend_click_smoke.py --mode all` 通过,输出 `pdf_fallback_checked=true`、`offline_shell_reload_checked=true`、`mobile_tab_switch_checked=true`;`python3 -B -m pytest tests/test_frontend_productization.py -q` 通过;`npm run build` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:检查报告/导出后端 artifact 的用户可见完整性,包括 HTML/PDF artifact 状态、失败恢复、下载命名和普通用户可理解提示。 +- 完成移动/离线/PWA 点击守门:`tests/run_frontend_click_smoke.py` 新增 `--mode online|offline|all`;在线模式补移动首屏和 `manifest.webmanifest`/`serviceWorker` 检查;离线模式只启动前端,用未监听 API 端口验证 `first-use-health` 与排盘 fallback 恢复提示。 +- 完成质量门接入:`scripts/run_quality_gate.py` 默认运行 `tests/run_frontend_click_smoke.py --mode all`,并提供 `--skip-frontend-click` 给无浏览器环境跳过。 +- 验证完成:`python3 tests/run_frontend_click_smoke.py --mode all` 通过,在线/离线 console_errors 均为空,离线连接拒绝被归类为 `expected_offline_console_errors`;`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build`、`run_frontend_runtime_smoke.py --start-if-needed`、`desktop_packaging_preflight.py`、`audit_fragments.py --strict`、`git diff --check` 均通过。 +- 当前下一最高优先级:继续补普通用户“桌面应用/安装后首次打开”路线说明与验证,让 PWA/Pake/Tauri 路径从文档提示进一步变成可执行检查。 +- 完成桌面首启路线可执行化:`scripts/desktop_packaging_preflight.py` 新增 `first_launch_checks`,输出 PWA installed shell、Pake first launch、Tauri sidecar readiness 三条可执行检查;README 与 `docs/research/desktop_packaging_spike_2026_06_23.md` 增加 `tests/run_frontend_click_smoke.py --mode all` 和“安装后首次打开”步骤。 +- 调试修正:首轮 click smoke 抓到 390px 移动视口 `scrollWidth=528`;诊断确认来源是 `.section-tabs` 合法横向滚动项,而非页面主体溢出。已将 smoke 的溢出检测改为排除 `.section-tabs` 等明确滚动容器,继续抓其他非预期溢出。 +- 验证完成:`python3 tests/run_frontend_click_smoke.py --mode all` 通过;`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py tests/test_muhurta.py -q` 通过;`npm run build`、`run_frontend_runtime_smoke.py --start-if-needed`、`desktop_packaging_preflight.py`、`audit_fragments.py --strict`、`git diff --check` 均通过。 +- 当前下一最高优先级:做 Pake/Tauri 本机可用性探测脚本,只检测 CLI、Rust/Node、签名/sidecar 前置条件,不生成真实包,避免破坏性打包。 +- 完成 PDF fallback / PWA 离线 shell / 移动长标签守门:`tests/run_frontend_click_smoke.py --mode all` 覆盖 PDF 渲染器不可用时 HTML fallback 提示、移动端 Complete/Vargas/Synastry/Prashna/Transit Compare 标签切换、service worker 真离线二次加载。 +- 修复 PWA 离线 shell 噪声:`jyotish-app/public/sw.js` 现在只在 `request.mode === 'navigate'` 时 fallback 到 `/index.html`,避免 JS module 离线请求拿到 HTML 并触发 MIME 错误;断网资源错误会被 smoke 归类为 expected,真正异常仍留在 `console_errors`。 +- 当前下一最高优先级:跑完整回归链后,继续检查报告/导出后端 artifact 的用户可见完整性。 +- 完成报告/导出 artifact 契约收口:`/api/report_artifact` 现在对 HTML、PDF 成功、PDF fallback 都返回 `artifact_status`、`primary_artifact`、`download_filename`、`download_mime`、`fallback_reason`、`user_message`、`next_action`,并在 `delivery` 中提供同构字段。 +- 完成前端 artifact 下载与提示收口:`exportPDFReport` 优先使用后端 `download_filename`/`download_mime`,保留 `downloaded_filename` 作为实际浏览器下载记录;`formatReportArtifactStatus` 优先使用后端 `user_message`,PDF fallback 继续显示 HTML 降级、文件名与打印 PDF 路径。 +- 验证完成:新增 report artifact PDF fallback 单元测试先红灯后转绿;`tests/run_frontend_runtime_smoke.py --start-if-needed` 输出 `artifact_status=html_ready` 与 `download_filename`;`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py -q` 通过;`npm run build` 在 `jyotish-app/` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:补真实浏览器导入/案例库工作流守门,覆盖文本/PDF 星盘导入入口、识别失败恢复、保存案例、重新打开和导出案例库,继续排查移动端导出菜单与 Trust Center 长内容。 +- 完成导入/案例库真实浏览器守门补强:`tests/run_frontend_click_smoke.py --mode workspace` 先用不完整出生资料验证“仍需手动补充:出生时间、出生地经纬度、时区”,再用完整 Delhi 文本资料验证识别质量分、自动填表、生成星盘、保存本地星盘、重新打开、保存到案例库、导出已选和导出全库。 +- 验证完成:`python3 tests/run_frontend_click_smoke.py --mode workspace --keep-logs` 通过,下载 `jyotish-selected-cases-2026-06-23.json` 与 `jyotish-case-library-2026-06-23.json`,console_errors 为空。 +- 当前下一最高优先级:排查移动端导出菜单与 Trust Center 长内容,确认 390px 视口下菜单不遮挡、长面板无横向溢出、健康检查/本地数据/安装说明在移动端可读可操作。 +- 完成导入/案例库真实点击守门:`tests/run_frontend_click_smoke.py` 新增 `run_import_workspace_smoke` 和 `--mode workspace`,真实输入出生资料文本,确认识别质量 100、填表、生成星盘、保存本地星盘、返回输入页重开保存星盘、进入参数/日历工作区保存当前盘、导出已选案例和整库。 +- 验证完成:`python3 tests/run_frontend_click_smoke.py --mode workspace` 通过;`python3 tests/run_frontend_click_smoke.py --mode all` 通过并包含 `import_workspace_checked=true`、`jyotish-selected-cases-*.json`、`jyotish-case-library-*.json`;`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py -q` 通过;`python3 tests/run_frontend_runtime_smoke.py --start-if-needed` 通过;`npm run build` 在 `jyotish-app/` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:排查移动端导出菜单与 Trust Center 长内容,确认 390px 视口下菜单不遮挡、长面板无横向溢出、健康检查/本地数据/安装说明在移动端可读可操作。 +- 完成移动端导出菜单与 Trust Center 长内容守门:`tests/run_frontend_click_smoke.py --mode mobile-trust` 在 390px 视口检查导出菜单五项、JSON 下载、Trust Center 健康检查、本地资料导出、关键按钮文案和长面板横向溢出。 +- 修复移动端问题:Trust Center 健康检查成功后 `renderAll()` 会重渲染面板,原先状态被 PWA 默认说明覆盖;新增 `getTrustCenterStatusMessage` 保留健康检查结果。工作区案例块在移动端会因内部 360px 宽度加 padding 越界,已给 case workspace 相关块补 `min-width:0`、移动单列和 `max-width:100%`。 +- 完成点击 smoke 超时/质量门接入:`tests/run_frontend_click_smoke.py --timeout 1` 验证超时路径输出 `process_snapshot` 与日志尾部,PID 清理后 `ps` 无残留;`scripts/run_quality_gate.py` 新增 `--frontend-click-timeout` 并传给点击 smoke 默认 `--timeout 240`。 +- 验证完成:`python3 tests/run_frontend_click_smoke.py --mode all` 通过;`python3 tests/run_frontend_click_smoke.py --mode mobile-trust --timeout 120` 通过;`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py -q` 通过;`python3 tests/run_frontend_runtime_smoke.py --start-if-needed` 通过;`npm run build` 在 `jyotish-app/` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:给 PDF/文本星盘导入的文件上传分支补真实浏览器守门,覆盖 PDF 文本抽取失败恢复、文本文件上传解析、导入后字段质量提示与移动端文件选择入口。 +- 完成 PDF/文本文件上传导入守门:`tests/run_frontend_click_smoke.py --mode import-files` 真实上传 `.txt` 出生资料,验证识别质量 100、字段填入到 1988-11-09 06:45 Mumbai/UTC+5:30;monkeypatch PDF import API 抛错后上传 `.pdf`,验证“PDF文本抽取失败”和“可复制PDF文字后粘贴到文本框”恢复文案;移动 390px 视口确认“上传文件”入口可见且触控高度达标。 +- 修复移动端文件入口:`.chart-import-actions button, .chart-import-file` 的 `min-height` 从 38px 提升到 44px,满足移动端基本触控目标。 +- 稳定全量点击 smoke:workspace 案例库导出改为 `page.expect_download()` 明确等待 `jyotish-selected-cases-*.json` 与 `jyotish-case-library-*.json`,避免长链路中下载事件偶发未捕获。 +- 验证完成:`python3 tests/run_frontend_click_smoke.py --mode import-files --timeout 120` 通过;`python3 tests/run_frontend_click_smoke.py --mode all --timeout 240` 通过并包含 `import_files`;`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py -q` 通过;`python3 tests/run_frontend_runtime_smoke.py --start-if-needed` 通过;`npm run build` 在 `jyotish-app/` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:把普通用户安装/启动文档与质量门输出统一,减少“npm run web / python API / PWA 安装”多入口带来的认知负担。 +- 完成普通用户启动路径统一:README 新增“普通用户启动路径”,将网页启动、本地 API、Trust Center 健康检查、PWA 边界和完整自检命令压成一条路径;`scripts/run_quality_gate.py` 的失败摘要也输出同一套网页/API/Trust Center 步骤。 +- 验证完成:`test_desktop_packaging_spike_is_documented_and_checkable` 先红灯后转绿;`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py -q` 通过;`python3 tests/run_frontend_runtime_smoke.py --start-if-needed` 通过;`npm run build` 在 `jyotish-app/` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:继续检查 README/应用内 Trust Center/失败恢复文案的术语一致性,把“npm run web / npm run dev / python API / PWA”统一成普通用户可理解的一组标签。 +- 完成术语一致性收口:`main.js`、`api-bridge.js`、`public/api-bridge.js`、`ai-chat.js`、`i18n.js`、`skill-map.js` 的普通用户可见恢复提示统一为“按 README 的普通用户启动路径启动网页服务和本地 API 服务”,Trust Center 成功状态统一为“本地 API 服务、能力目录和 PWA 安装壳”。 +- 边界约束:README 与 `scripts/run_quality_gate.py` 继续保留可复制命令;应用界面不再散落 `npm run web`、`npm run dev`、完整 `python3 scripts/jyotish_api_server.py --host ...` 这类开发者命令。 +- 验证完成:术语聚焦 pytest 8 项通过;`python3 -B -m pytest tests/test_frontend_productization.py tests/test_api_server_security.py -q` 通过;`python3 tests/run_frontend_click_smoke.py --mode mobile-trust --timeout 120` 通过;`python3 tests/run_frontend_runtime_smoke.py --start-if-needed` 通过;`npm run build` 在 `jyotish-app/` 通过;`git diff --check` 通过。 +- 当前下一最高优先级:梳理质量门分层与运行成本,把快速开发守门、完整浏览器守门、发布前守门拆得更清楚,避免遗漏真实用户路径也避免每步都跑超长链路。 +- 启动整机与 Git 云端地毯式遗漏审计:当前仓库 remote 为 `git@github.com:732642856/yinduzhanxing.git`,分支 `codex/release-hygiene-ci`;初步只读枚举发现整机存在 `.workbuddy/skills/jyotish-vedic-astrology`、`Projects/星轨资料恢复/*/jyotish-vedic-astrology`、`engines-repo/jyotish`、`WorkBuddy/*/jyotish-fragments`、多个历史审计/验证报告、PDF/Docx 资料库和多个 star-track/占星相关 git 仓库。 +- 审计边界:只读索引与摘要,不删除、不移动、不上传整机资料;密钥/凭证类文件只记录路径和命中类别,不读取内容;远端仓库以当前认证/公开可访问 refs 为准,若被凭证阻断则记录阻断点。 +- 远端探测进展:`git ls-remote` 走 SSH 被 22 端口超时阻断;改用 HTTPS 成功列出 `main`、`codex/release-hygiene-ci` 和 v6.0.47-v6.0.52 tags。GitHub REST API 匿名请求被 rate limit,后续以 HTTPS git 协议和 `/tmp` mirror 为主。 +- 安全发现:多个历史仓库 remote URL 内嵌 GitHub token;后续报告只记录“存在嵌入 token 的 remote”与路径类别,不复述 token 字符串。 +- 旧审计报告提取到的候选遗漏:Navatara/Tara Bala、Kantaka Shani、Pushkar Navamsa、Ishta/Kashta Phala、Ashtakavarga PAV/Prashtara/Kakshya/Yoga Pinda、Bhava Bala、Sripathi/Placidus、Vimshottari 多起算点、36 Sahams、Tajika 强度体系、完整 Prashna/KP Horary、Muhurta 求解器、精微分盘 D24/D30/D60 深度模板。 +- 完成云端 mirror 第一轮:`/tmp/yinduzhanxing-cloud` 来自 GitHub HTTPS mirror,HEAD 为 `11bdee3ba1f480aff38440ad58cfbb81bfa5567d`,文件数 720,tree `d3a89944bb1c319120f61f66a88c879d0fa28375`。本地工作区文件数 1525,其中大量是 build/cache/node_modules/dist 与未提交产品化改动。 +- 完成遗漏映射文档:新增 `docs/research/whole_machine_git_audit_2026_06_23.md`,记录整机资料源、远端 refs、历史报告共识、当前 registry/API/frontend 覆盖矩阵和下一优先级。 +- 第一轮结论:旧报告的 30 类缺口已有一批被 v6.9 后续补上(Kantaka Shani、Pushkara、Kakshya、Bhava Bala、Trisphuta、部分 Sahams/Prashna/Muhurta),真正仍缺第一类产品化闭环的是 Ashtakavarga Prashtara/Yoga Pinda、Sripathi/Placidus 用户可控切换、KP Horary、Tajika Harsha/Panchavargiya、Muhurta date-range solver、Sayanadi/Shayanadi 与精微分盘深度模板。 +- 当前下一最高优先级:从 Ashtakavarga Prashtara / Yoga Pinda 开始实现,因为它在历史报告中反复出现,且当前只有 BAV/SAV 总分和参考代码,没有专业工具应有的来源贡献表与 Pinda 层。 +- 完成 Ashtakavarga Prashtara / Yoga Pinda 产品闭环:`scripts/ashtakavarga.py` 保留并验证 `calc_prastara_av` 的 7×12×8 PAV 矩阵,新增 `calc_yoga_pinda` 一等契约;`/api/ashtakavarga` 返回 `yoga_pinda_summary` 和 `rule_variants.selected += yoga_pinda`;Skill workbench 显示 `Ashtakavarga / PAV / Sodhita / Yoga Pinda`、Yoga Pinda 卡片与校验标签。 +- 完成 Ashtakavarga 守门:`tests/test_ashtakavarga_invariants.py` 不再依赖本机缺失的 Hypothesis,改为确定性 invariant;新增 PAV 回推 BAV、Yoga Pinda 与 legacy Shodhya Pinda 兼容断言;产品化测试要求 API 返回 `pav.matrix_shape` 与 `yoga_pinda_summary`。 +- 验证完成:`python3 -m pytest -q tests/test_ashtakavarga_invariants.py` 4 项通过;`python3 -m pytest -q tests/test_frontend_productization.py -k "technique_catalog or ashtakavarga or api_runtime"` 通过;`python3 -m json.tool references/technique_registry.json` 与 `git diff --check` 通过。 +- 当前下一最高优先级:推进 Sripathi/Placidus 房宫算法用户可控切换与 parity 守门,因为历史审计多次指出它仍停留在 staged policy/局部代码层,尚未形成用户可验证的房宫算法切换闭环。 +- 完成移动端导出菜单与 Trust Center 长内容守门:`tests/run_frontend_click_smoke.py --mode mobile-trust --keep-logs` 在 390x844 移动视口下验证导出菜单 JSON/HTML/PDF/SVG/PNG 不溢出,JSON 星盘下载成功,Trust Center 健康检查通过,本地资料 JSON 导出成功,长面板无横向溢出且无 console error。 +- 验证输出:`mobile_trust_export_checked=true`,下载 `jyotish-chart-1990-01-01.json` 与 `jyotish-local-data-2026-06-23.json`,`health_status=健康检查通过:本地 API、能力目录和 PWA 状态已记录。` +- 当前下一最高优先级:给真实浏览器点击级 smoke 增加命令级超时/残留进程诊断,避免 `--mode all` 或局部模式异常时留下 API/Vite/Chromium 后台进程。 +- 完成真实浏览器 smoke 超时与残留诊断:`tests/run_frontend_click_smoke.py` 新增 `--timeout`、`run_with_timeout`、`ClickSmokeTimeoutError`、`process_snapshot` 与 `force_stop_process`,超时失败会输出 API/Vite pid、running 状态和日志尾部,并在 finally 强制清理子进程。 +- TDD 验证:先让 `test_click_smoke_covers_core_interactive_workflows` 要求 `--timeout/run_with_timeout/process_snapshot/force_stop_process` 后红灯,再实现;`python3 tests/run_frontend_click_smoke.py --mode mobile-trust --timeout 1 --keep-logs` 按预期失败并输出 process_snapshot,随后 ps 未发现残留 API/Vite 进程。 +- 正常路径复验:`python3 tests/run_frontend_click_smoke.py --mode mobile-trust --timeout 120 --keep-logs` 通过,移动 Trust/export 仍返回 `mobile_trust_export_checked=true`。 +- 当前下一最高优先级:跑完整回归链与碎片审计,然后继续推进普通用户长链路质量门的剩余缺口。 +- 完成普通用户长链路质量门摘要:`scripts/run_quality_gate.py` 新增 `format_failure_summary`、stdout/stderr 尾部、cwd、JSON reason/process_snapshot 提取;真实失败时会提示聚焦命令、`--keep-logs` 与 Trust Center/API 恢复路径。 +- 修复质量门真实 Bug:`npm run build` 原先在仓库根目录执行导致找不到 `package.json`,已新增 `APP = ROOT / "jyotish-app"` 并让前端 build 在 `cwd=APP` 执行。 +- 修复 click smoke 真实 Bug:`run_import_file_smoke` 已等待 PDF 失败恢复文案但没有赋值 `pdf_import_recovery`,导致 `--mode all` NameError;已保存该文案并通过 `--mode import-files` 和 `--mode all` 验证。 +- 验证完成:`python3 tests/run_frontend_click_smoke.py --mode import-files --timeout 120 --keep-logs` 通过;`python3 scripts/run_quality_gate.py --skip-slow --skip-yoga-logic --frontend-click-timeout 240` 通过,覆盖 compile、JSON、capability/fragment audit、BPHS invariants、185 个 pytest、前端 build、runtime smoke、全量 click smoke。 +- 当前下一最高优先级:把普通用户安装/启动文档与质量门输出统一,减少“npm run web / python API / PWA 安装”多入口带来的认知负担。 +- 完成普通用户启动路径与术语统一:README、质量门失败摘要、AI/API/auth/subscription/Skill Workbench/Trust Center/离线恢复提示统一使用“普通用户启动路径 / 网页服务 / 本地 API 服务 / PWA 安装壳 / Trust Center”;用户面旧称 `npm run web`、`PWA shell`、`PWA 壳`、`Local API` 已从应用与 README/质量门中移除。 +- 修复离线恢复提示:offline click smoke 发现首用健康检查失败文案缺少“普通用户启动路径”,已补回并验证 `offline_recovery_guidance_visible=true`。 +- 验证完成:`python3 -B -m pytest tests/test_frontend_productization.py -q` 通过;`npm run build` 通过;`python3 tests/run_frontend_runtime_smoke.py --start-if-needed` 通过;`python3 tests/run_frontend_click_smoke.py --mode mobile-trust --timeout 120 --keep-logs` 通过;`python3 tests/run_frontend_click_smoke.py --mode offline --timeout 120 --keep-logs` 通过。 +- 当前下一最高优先级:梳理质量门分层与运行成本,把快速开发守门、完整浏览器守门、发布前守门拆得更清楚,避免遗漏真实用户路径也避免每步都跑超长链路。 +- 完成质量门分层:`scripts/run_quality_gate.py` 新增 `QUALITY_GATE_PROFILES` 与 `--profile quick|browser|release`;quick 适合普通代码/文案修改,browser 覆盖 runtime 与真实浏览器路径,release 恢复慢速 golden cases 与 Yoga 逻辑报告;`--frontend-click-mode` 可局部复验 `mobile-trust/import-files/all` 等浏览器路径。 +- 文档与守门同步:README 新增“质量门分层”,`tests/test_frontend_productization.py` 增加静态契约,防止 profile、README 命令或关键浏览器模式后续漂移。 +- 验证完成:先让 `test_quality_gate_declares_fast_browser_release_profiles` 红灯确认缺口,再实现转绿;`python3 scripts/run_quality_gate.py --profile quick` 通过;`python3 scripts/run_quality_gate.py --profile browser --frontend-click-mode mobile-trust --frontend-click-timeout 120` 通过,覆盖 187 个核心 pytest、前端 build、runtime smoke 与 mobile-trust click smoke;`python3 -B -m pytest tests/test_frontend_productization.py -q` 与 `git diff --check` 通过。 +- 当前下一最高优先级:推进 Sripathi/Placidus 房宫算法用户可控切换与 parity 守门,先确认现有 `bhava_chalit`/UI/API 是否只是显示 staged policy,再补用户可验证闭环。 +- 完成 Sripathi/Placidus 用户可控切换:`jyotish-app/skill-map.js` 的 Bhava Chalit 工作台读取 `calculationSettings.houseSystem`,请求带上出生时间地点 payload,不再硬编码 `sripati`;结果渲染宫位制、可选系统、第一宫边界和行星 Rashi→Bhava 迁移摘要。 +- 完成 Bhava Chalit API parity 契约:`/api/bhava_chalit` 返回 `requested_house_system`、`selected_house_system`、`available_house_systems`、`calculation_note` 与 `fallback_reason`;Placidus/Koch 使用 swisseph JD + lat/lon/tz,缺依赖或参数时降级 Sripati 并解释原因。 +- TDD 验证:新增测试先红灯抓到 Placidus 缺 JD/location 参数、前端硬编码 Sripati;实现后 `test_bhava_chalit_endpoint_exposes_user_selected_house_systems`、`test_bhava_chalit_uses_user_selected_house_system` 转绿。 +- 验证完成:`python3 -B -m pytest tests/test_api_server_security.py tests/test_frontend_productization.py tests/test_bhava_chalit.py -q` 通过;`npm run build` 通过;`python3 -m py_compile scripts/jyotish_api_server.py scripts/bhava_chalit.py` 通过;`python3 scripts/run_quality_gate.py --profile quick` 通过,覆盖 189 个核心 pytest、前端 build 与 runtime smoke;`git diff --check` 通过。 +- 当前下一最高优先级:推进 KP Horary 产品化闭环,先复核 `scripts/prashna.py`、VedicAstro horary 参考与当前 KP 快读,确认是否缺 ruling planets/sub-lord/house significator 的普通用户可验证输出。 +- 完成 KP Horary 结构化证据闭环:`scripts/prashna.py` 新增 `build_kp_horary_evidence`,复用本地 KP sub-lord 与 significator 规则,返回 question houses、ruling planets、cuspal sub-lord、house significators 与 judgement matrix;`/api/prashna` 支持可选 `horary_number` 1-249 并返回 `kp_horary`。 +- 完成普通用户端承载:Prashna 面板渲染 “KP Horary:Ruling Planets / Sub Lord / Significators” 证据块;问事 workflow、案例保存和导出都保留 `kp_horary`,避免只保存 YES/NO 结论。 +- TDD 验证:新增 API 测试先红灯抓到 `kp_horary` 缺失;新增前端产品化 token 先红灯抓到 `renderKPHoraryEvidence/ruling_planets/house_significators` 缺失;实现后聚焦测试转绿。 +- 验证完成:`python3 -B -m pytest tests/test_kp_system.py tests/test_api_server_security.py tests/test_frontend_productization.py -q` 通过;`npm run build` 通过;`python3 -m py_compile scripts/prashna.py scripts/kp_system.py scripts/jyotish_api_server.py` 通过;`python3 scripts/run_quality_gate.py --profile quick` 通过,覆盖 189 个核心 pytest、前端 build 与 runtime smoke;`git diff --check` 通过。 +- 当前下一最高优先级:推进 Tajika Harsha/Panchavargiya Bala 产品化闭环,先检查 `scripts/tajika.py`、`scripts/varshaphala.py`、`/api/annual` 和 Skill Workbench 的年运强度层缺口。 +- 完成 Tajika Harsha/Panchavargiya Bala 产品化闭环:`scripts/tajika.py` 新增 `calc_tajika_strength_layers`,输出 Harsha Bala、Panchavargiya 五分盘强度、综合排序、最强/最弱行星和下一步提示;`scripts/solar_return.py` 与 `scripts/varshaphala.py` 年报路径均追加 `tajika_strength`,避免 API/旧入口能力碎片。 +- 完成普通用户端承载:`jyotish-app/skill-map.js` 的 Varshaphala / Tajika 结果新增“年度强度/年度风险”卡片和 Harsha Bala / Panchavargiya Bala 证据展开,不再只显示 Tajika Yoga 数量与 JSON。 +- TDD 验证:新增算法、年度 API、前端产品化三条红灯测试,确认缺口后实现并转绿;`tests/test_tajika.py`、年度 API 聚焦测试、前端聚焦测试均通过。 +- 验证完成:`python3 -B -m pytest tests/test_tajika.py tests/test_api_server_security.py::test_annual_endpoint_returns_varshaphala_report tests/test_frontend_productization.py::test_annual_workbench_renders_tajika_strength_layers -q` 通过;`python3 -m py_compile scripts/tajika.py scripts/solar_return.py scripts/varshaphala.py scripts/jyotish_api_server.py` 通过;`npm run build` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:推进 Muhurta date-range solver,把当前单日评分升级为可按活动、日期范围与约束搜索的普通用户择日工作流。 +- 完成 Muhurta date-range solver 产品化闭环:`scripts/muhurta.py` 新增 `muhurta_range_search`,复用 `panchanga_range_report` 的日历、Rahu Kala/Yamaganda/Gulika、Choghadiya、Hora 与活动评分,输出 `best_windows`、`rejected_dates`、`constraints` 和 `next_action`。 +- 完成 `/api/muhurta` 范围入口:传入 `start_date/end_date/activity/limit/lat/lon/tz` 时返回 `range_search`,同时保留原单日 `report` 兼容旧调用,并继续限制 63 天以内。 +- 完成普通用户端承载:`jyotish-app/skill-map.js` 的 Muhurta 结果新增“范围择日 · 择日候选”展开区,显示候选日期、评分、活动结论、推荐 Choghadiya/Hora 窗口、Panchanga 证据和过滤原因。 +- TDD 验证:新增核心 solver、API、前端产品化三条红灯测试,确认缺口后实现并转绿;修复候选全合格时 `rejected_dates` 为空导致用户缺少过滤解释的问题。 +- 验证完成:`python3 -B -m pytest tests/test_muhurta.py tests/test_api_server_security.py::test_muhurta_endpoint_returns_activity_checks tests/test_api_server_security.py::test_muhurta_endpoint_returns_date_range_solver tests/test_api_server_security.py::test_panchanga_range_endpoint_returns_calendar_rows tests/test_api_server_security.py::test_panchanga_range_endpoint_uses_location_when_available tests/test_frontend_productization.py::test_muhurta_workbench_renders_date_range_solver -q` 通过;`python3 -m py_compile scripts/muhurta.py scripts/jyotish_api_server.py` 通过;`npm run build` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:推进 Sayanadi/Shayanadi Avastha 与 D24/D30/D60 深度模板产品化,把已有计算碎片变成 API/前端可验证的解释层。 +- 完成 Sayanadi/Shayanadi Avastha 与 D24/D30/D60 深度模板产品化:新增 `scripts/deep_varga_avastha.py`,聚合 `avastha_calculator.py`、`divisional_charts_extended.py`、`trimshamsa_d30.py`,输出 `avastha_summary`、`deep_varga_templates`、D24/D30/D60 关键行星卡、risk_flags 与 next_action。 +- 完成 API 承载:新增 `/api/deep_varga_avastha` 与 `_compute_deep_varga_avastha`,Technique Explorer dispatch、方法说明和示例 payload 均接入,避免该能力只停留在本地碎片。 +- 完成普通用户端承载:`jyotish-app/skill-map.js` 新增 `deepVargaAvastha` 工作台按钮和 `renderDeepVargaAvasthaResult`,展示 Sayanadi/Shayanadi 主导状态、弱状态行星、D24/D30/D60 模板、risk_flags 与下一步边界。 +- TDD 验证:新增 `tests/test_deep_varga_avastha.py`、API 测试、前端产品化测试,先红灯确认模块/API/action 缺失,后实现转绿。 +- 验证完成:`python3 -B -m pytest tests/test_deep_varga_avastha.py tests/test_api_server_security.py::test_deep_varga_avastha_endpoint_returns_templates tests/test_api_server_security.py::test_divisional_yoga_endpoint_returns_varga_yoga_summary tests/test_frontend_productization.py::test_deep_varga_avastha_workbench_renders_templates tests/test_frontend_productization.py::test_skill_workbench_exposes_all_expected_advanced_actions -q` 通过;`python3 -m py_compile scripts/deep_varga_avastha.py scripts/jyotish_api_server.py scripts/avastha_calculator.py scripts/divisional_charts_extended.py scripts/trimshamsa_d30.py` 通过;`npm run build` 通过;相关 `git diff --check` 通过。 +- 当前下一最高优先级:二轮整机/Git/开源对标审计与全球排名更新,复核第一类缺口是否已闭环,并重新生成下一批优先级。 +- 完成二轮整机/Git/开源对标审计与全球排名更新:`python3 scripts/audit_fragments.py --strict` 显示 registry 68 技法、37 API、frontend 43 文件、候选碎片 0、hard problems 0、warnings 0;`docs/research/whole_machine_git_audit_2026_06_23.md` 已追加 2026-06-24 二轮结论与全球排名口径。 +- 修复二轮审计发现的产品目录缺口:`deep_varga_avastha` 已有 API 与 Skill Workbench,但未进入 registry/catalog/audit command map;新增注册表条目、audit 映射、后端产品化/UX/目录推断,并增加测试要求 `/api/deep_varga_avastha` 出现在能力审计、Technique Explorer filter、sample payload 与 runnable example。 +- TDD 验证:新增断言后先红灯(`KeyError: deep_varga_avastha` 与 catalog 缺 `/api/deep_varga_avastha`),修复后 `python3 -B -m pytest tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources tests/test_api_server_security.py::test_technique_catalog_exposes_runnable_api_examples -q` 通过;`python3 -m json.tool references/technique_registry.json`、`python3 -m py_compile scripts/jyotish_api_server.py scripts/audit_fragments.py scripts/deep_varga_avastha.py`、`python3 scripts/audit_fragments.py --strict` 通过。 +- 当前下一最高优先级:发布/仓库卫生与 release 质量门收口,优先确认未跟踪产品文件不会在 GitHub/普通用户安装路径中遗漏,再跑完整 browser/release profile。 +- 完成发布/仓库卫生第一步:`scripts/run_quality_gate.py` 新增 `RELEASE_CRITICAL_UNTRACKED_PATHS`、`release_hygiene_check` 与 release profile 的 `check_release_hygiene`;先用聚焦测试红灯确认缺口,再实现守门。 +- 真实风险复现:新增 release hygiene 后在当前工作区按预期失败,列出 28 个关键产品文件仍未跟踪;随后将这些文件纳入 Git 暂存,`release_hygiene_check` 复跑通过,`audit_fragments.py --strict` 当前 `workspace_residue.untracked_count=0`。 +- 旧测试调整:`test_fragment_audit_blocks_registry_surface_drift` 不再强制要求一定存在 untracked 文件,改为检查字段存在和列表结构,适配发布卫生目标。 +- 验证完成:`python3 -B -m pytest tests/test_api_server_security.py::test_fragment_audit_blocks_registry_surface_drift tests/test_frontend_productization.py::test_release_quality_gate_tracks_untracked_product_files -q` 通过;`python3 scripts/run_quality_gate.py --profile quick` 通过,覆盖 195 个核心 pytest、npm build 与 runtime smoke,runtime smoke 显示 registry/productized/UX 均为 68。 +- 当前下一最高优先级:跑完整 browser/release profile,并检查云端分支同步状态,确认普通用户从 GitHub 拉取不会丢失网页/API/PWA/高级工作台关键文件。 +- 完成完整 browser/release profile:`python3 scripts/run_quality_gate.py --profile browser --frontend-click-timeout 240` 与 `python3 scripts/run_quality_gate.py --profile release --frontend-click-timeout 240` 均通过;release 覆盖 release hygiene、195+核心 pytest、runtime smoke、真实浏览器 all smoke、golden cases 与 Yoga 逻辑校验。 +- 修复 release profile 暴露的 Yoga 逻辑校验 Bug:`scripts/validate_logic_v2.py` 现在用 `extract_skill_rule_ids` 安全跳过算法级无 `rule_id` Yoga,并默认使用当前仓库路径,不再在 import 时把 `~/.workbuddy/.../scripts` 插入 `sys.path` 污染后续 `import prashna`。 +- 验证细节:新增 `test_yoga_logic_validation_tolerates_algorithmic_yogas_without_rule_id` 与 `test_yoga_logic_validation_import_does_not_shadow_project_modules` 先红灯后转绿;`validate_logic_v2.py` 输出 Precision 96.48%、Recall 93.99%、F1 95.22%,并写入当前仓库 `references/validation_logic_report.json`。 +- 云端同步检查:`git ls-remote https://github.com/732642856/yinduzhanxing.git` 可访问,远端 `codex/release-hygiene-ci` 仍在 `11bdee3...`;当前本地大量改动已暂存/修改但尚未 commit/push,下一步应提交并推送分支。 diff --git a/references/technique_registry.json b/references/technique_registry.json index 50c83f24..b92f6093 100644 --- a/references/technique_registry.json +++ b/references/technique_registry.json @@ -317,7 +317,7 @@ "modules.ashtakavarga" ], "audit_label": "Ashtakavarga", - "missing_impact": "Transit strength filter missing." + "missing_impact": "covered: SAV/BAV/PAV/Sodhita/Yoga Pinda are exposed through /api/ashtakavarga and the Skill workbench." }, "avastha": { "name": "Avastha", @@ -1059,7 +1059,9 @@ "kp" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "kp" + ], "output_paths": [ "scripts/kp_system.py" ], @@ -1075,7 +1077,9 @@ "prashna" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "prashna" + ], "output_paths": [ "scripts/prashna.py" ], @@ -1091,7 +1095,9 @@ "synastry" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "synastry" + ], "output_paths": [ "scripts/synastry.py" ], @@ -1107,7 +1113,10 @@ "remedies" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "remedies", + "full-reading" + ], "output_paths": [ "scripts/remedies.py" ], @@ -1123,7 +1132,10 @@ "bhava" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "bhava-bala", + "full-reading" + ], "output_paths": [ "scripts/bhava_bala.py" ], @@ -1139,7 +1151,9 @@ "muhurtha" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "muhurta" + ], "output_paths": [ "scripts/muhurtha_election.py" ], @@ -1155,7 +1169,9 @@ "career" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "career" + ], "output_paths": [ "scripts/career_analysis.py" ], @@ -1171,7 +1187,9 @@ "relationship" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "relationship" + ], "output_paths": [ "scripts/relationship_analysis.py" ], @@ -1187,7 +1205,11 @@ "varshaphala" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "solar-return", + "tajika", + "full-reading" + ], "output_paths": [ "scripts/varshaphala.py" ], @@ -1203,9 +1225,12 @@ "ashtakavarga" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "ashtakavarga", + "full-reading" + ], "output_paths": [ - "scripts/ashtakavarga_pav.py" + "scripts/ashtakavarga.py" ], "audit_label": "ashtakavarga.py calc_prastara_av()", "missing_impact": "covered" @@ -1219,13 +1244,37 @@ "ashtakavarga" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "ashtakavarga", + "full-reading" + ], "output_paths": [ - "scripts/ashtakavarga_sodhita.py" + "scripts/ashtakavarga.py" ], "audit_label": "ashtakavarga.py calc_sodhita_av()", "missing_impact": "covered" }, + "ashtakavarga_yoga_pinda": { + "status": "covered", + "version": "6.9.14", + "note": "ashtakavarga.py calc_yoga_pinda() — Yoga Pinda / Shodhya Pinda first-class contract", + "name": "ashtakavarga.py calc_yoga_pinda()", + "domains": [ + "ashtakavarga", + "strength" + ], + "knowledge_refs": [], + "commands": [ + "ashtakavarga", + "full-reading" + ], + "output_paths": [ + "modules.ashtakavarga.yoga_pinda", + "/api/ashtakavarga.yoga_pinda_summary" + ], + "audit_label": "ashtakavarga.py calc_yoga_pinda()", + "missing_impact": "covered" + }, "kakshya": { "status": "covered", "version": "6.9.3", @@ -1235,7 +1284,9 @@ "kakshya" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "kakshya" + ], "output_paths": [ "scripts/kakshya.py" ], @@ -1251,7 +1302,9 @@ "transit" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "transit-trigger" + ], "output_paths": [ "scripts/transit_trigger.py" ], @@ -1267,13 +1320,42 @@ "divisional" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "divisional-yoga" + ], "output_paths": [ "scripts/divisional_yoga.py" ], "audit_label": "divisional_yoga.py", "missing_impact": "covered" }, + "deep_varga_avastha": { + "status": "covered", + "version": "7.1.0", + "note": "deep_varga_avastha.py — Sayanadi/Shayanadi状态汇总 + D24/D30/D60深层模板卡", + "name": "Deep Varga Avastha / Sayanadi-Shayanadi + D24/D30/D60", + "domains": [ + "divisional", + "avastha", + "d24", + "d30", + "d60", + "strength" + ], + "knowledge_refs": [ + "references/varga-system-quick-reference.md", + "references/technique-capability-matrix.md" + ], + "commands": [ + "deep-varga-avastha" + ], + "output_paths": [ + "scripts/deep_varga_avastha.py", + "/api/deep_varga_avastha.report.deep_varga_templates" + ], + "audit_label": "Deep Varga Avastha", + "missing_impact": "covered" + }, "birth_time_rectifier": { "status": "covered", "version": "6.9.3", @@ -1283,7 +1365,9 @@ "birth" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "rectification" + ], "output_paths": [ "scripts/birth_time_rectifier.py" ], @@ -1299,7 +1383,9 @@ "case" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "case-validation" + ], "output_paths": [ "scripts/case_validator.py" ], @@ -1315,12 +1401,40 @@ "misconceptions" ], "knowledge_refs": [], - "commands": [], + "commands": [ + "case-validation", + "full-reading" + ], "output_paths": [ "scripts/misconceptions.py" ], "audit_label": "misconceptions.py", "missing_impact": "covered" + }, + "thematic_report_orchestrator": { + "status": "covered", + "version": "6.9.15", + "note": "report_orchestrator.py — 五主题报告、证据链聚合、冲突裁决与 Dasha 时间锚定;/api/thematic_report 可被用户端技法工作台调用", + "name": "Thematic Report Orchestrator / 主题化报告编排", + "domains": [ + "report", + "reading", + "marriage", + "career", + "timing" + ], + "knowledge_refs": [], + "commands": [ + "thematic-report", + "full-reading" + ], + "output_paths": [ + "scripts/report_orchestrator.py", + "scripts/reading_orchestrator.py", + "scripts/orchestrator_bridge.py" + ], + "audit_label": "report_orchestrator.py", + "missing_impact": "covered" } }, "routes": { diff --git a/references/validation_logic_report.json b/references/validation_logic_report.json index b6b12ca9..d20a4001 100644 --- a/references/validation_logic_report.json +++ b/references/validation_logic_report.json @@ -108,13 +108,13 @@ "false_positives": [ { "chart": "Albert Einstein", - "rule_id": "bvr_bhaga_chumbana_yoga", - "rule_name": "Bhaga Chumbana Yoga" + "rule_id": "bvr_dharidhra_11_precise", + "rule_name": "Dharidhra Yoga" }, { "chart": "Albert Einstein", - "rule_id": "bvr_dharidhra_11_precise", - "rule_name": "Dharidhra Yoga" + "rule_id": "bvr_bhaga_chumbana_yoga", + "rule_name": "Bhaga Chumbana Yoga" }, { "chart": "Nikola Tesla", @@ -133,13 +133,13 @@ }, { "chart": "Alexander Fleming", - "rule_id": "bvr_yukthi_samanwithavagmi_yoga", - "rule_name": "Yukthi Samanwithavagmi Yoga" + "rule_id": "bvr_bhaga_chumbana_yoga", + "rule_name": "Bhaga Chumbana Yoga" }, { "chart": "Alexander Fleming", - "rule_id": "bvr_bhaga_chumbana_yoga", - "rule_name": "Bhaga Chumbana Yoga" + "rule_id": "bvr_yukthi_samanwithavagmi_yoga", + "rule_name": "Yukthi Samanwithavagmi Yoga" }, { "chart": "Max Planck", @@ -148,13 +148,13 @@ }, { "chart": "Richard Feynman", - "rule_id": "bvr_018_ubhayachara_precise", - "rule_name": "Ubhayachara Yoga" + "rule_id": "bvr_nishkapata_precise", + "rule_name": "Nishkapata Yoga" }, { "chart": "Richard Feynman", - "rule_id": "bvr_nishkapata_precise", - "rule_name": "Nishkapata Yoga" + "rule_id": "bvr_018_ubhayachara_precise", + "rule_name": "Ubhayachara Yoga" }, { "chart": "Mahatma Gandhi", @@ -183,13 +183,13 @@ }, { "chart": "Barack Obama", - "rule_id": "bvr_yukthi_samanwithavagmi_yoga", - "rule_name": "Yukthi Samanwithavagmi Yoga" + "rule_id": "bvr_bandhubhisthyaktha_precise", + "rule_name": "Bandhubhisthyaktha Yoga" }, { "chart": "Barack Obama", - "rule_id": "bvr_bandhubhisthyaktha_precise", - "rule_name": "Bandhubhisthyaktha Yoga" + "rule_id": "bvr_yukthi_samanwithavagmi_yoga", + "rule_name": "Yukthi Samanwithavagmi Yoga" }, { "chart": "Pablo Picasso", @@ -223,13 +223,13 @@ }, { "chart": "Lionel Messi", - "rule_id": "brahma_yoga", - "rule_name": "Brahma Yoga" + "rule_id": "bvr_017_vosi_precise", + "rule_name": "Vosi Yoga" }, { "chart": "Lionel Messi", - "rule_id": "bvr_017_vosi_precise", - "rule_name": "Vosi Yoga" + "rule_id": "brahma_yoga", + "rule_name": "Brahma Yoga" }, { "chart": "Cristiano Ronaldo", @@ -256,6 +256,11 @@ "rule_id": "bvr_karma_malika_precise", "rule_name": "Karma Malika Yoga" }, + { + "chart": "Princess Diana", + "rule_id": "bvr_bandhu_pujya_yoga", + "rule_name": "Bandhu Pujya Yoga" + }, { "chart": "Princess Diana", "rule_id": "bvr_kaahala_yoga", @@ -266,11 +271,6 @@ "rule_id": "bvr_ayatna_griha_prapta_yoga", "rule_name": "Ayatna Griha Prapta Yoga" }, - { - "chart": "Princess Diana", - "rule_id": "bvr_bandhu_pujya_yoga", - "rule_name": "Bandhu Pujya Yoga" - }, { "chart": "J. Krishnamurti", "rule_id": "bvr_dehasthoulya_yoga", @@ -278,13 +278,13 @@ }, { "chart": "Ramana Maharshi", - "rule_id": "bvr_utthama_graha_yoga", - "rule_name": "Utthama Griha Yoga" + "rule_id": "bvr_matru_sneha_yoga", + "rule_name": "Matru Sneha Yoga" }, { "chart": "Ramana Maharshi", - "rule_id": "bvr_matru_sneha_yoga", - "rule_name": "Matru Sneha Yoga" + "rule_id": "bvr_utthama_graha_yoga", + "rule_name": "Utthama Griha Yoga" } ], "false_negatives": [ @@ -297,14 +297,6 @@ "bahu_puthra_yoga_221" ] }, - { - "chart": "Marie Curie", - "rule_id": "parvata_yoga", - "rule_name": "Parvata Yoga", - "pyjhora_names": [ - "parvata_yoga" - ] - }, { "chart": "Marie Curie", "rule_id": "bvr_grihanasa_yoga", @@ -314,6 +306,14 @@ "grihanasa_yoga_192" ] }, + { + "chart": "Marie Curie", + "rule_id": "parvata_yoga", + "rule_name": "Parvata Yoga", + "pyjhora_names": [ + "parvata_yoga" + ] + }, { "chart": "Marie Curie", "rule_id": "bvr_thrikaala_gnana_yoga", @@ -362,10 +362,11 @@ }, { "chart": "Mahatma Gandhi", - "rule_id": "brahma_yoga", - "rule_name": "Brahma Yoga", + "rule_id": "bvr_matrudeerghayur_yoga", + "rule_name": "Matrudeerghayur Yoga", "pyjhora_names": [ - "brahma_yoga" + "matrudeerghayur_yoga_196", + "matrudeerghayur_yoga_197" ] }, { @@ -378,11 +379,10 @@ }, { "chart": "Mahatma Gandhi", - "rule_id": "bvr_matrudeerghayur_yoga", - "rule_name": "Matrudeerghayur Yoga", + "rule_id": "brahma_yoga", + "rule_name": "Brahma Yoga", "pyjhora_names": [ - "matrudeerghayur_yoga_196", - "matrudeerghayur_yoga_197" + "brahma_yoga" ] }, { @@ -394,14 +394,6 @@ "nishkapata_yoga_206" ] }, - { - "chart": "Indira Gandhi", - "rule_id": "bvr_utthama_graha_yoga", - "rule_name": "Utthama Griha Yoga", - "pyjhora_names": [ - "utthama_graha_yoga" - ] - }, { "chart": "Indira Gandhi", "rule_id": "bvr_sumukha_precise", @@ -411,11 +403,11 @@ ] }, { - "chart": "Nelson Mandela", - "rule_id": "bvr_parannabhojana_yoga", - "rule_name": "Parannabhojana Yoga", + "chart": "Indira Gandhi", + "rule_id": "bvr_utthama_graha_yoga", + "rule_name": "Utthama Griha Yoga", "pyjhora_names": [ - "parannabhojana_yoga" + "utthama_graha_yoga" ] }, { @@ -426,6 +418,28 @@ "apakeerthi_yoga" ] }, + { + "chart": "Nelson Mandela", + "rule_id": "bvr_parannabhojana_yoga", + "rule_name": "Parannabhojana Yoga", + "pyjhora_names": [ + "parannabhojana_yoga" + ] + }, + { + "chart": "Abraham Lincoln", + "rule_id": "bvr_dharidhra_11_precise", + "rule_name": "Dharidhra Yoga", + "pyjhora_names": [ + "dharidhra_yoga_144", + "dharidhra_yoga_147", + "dharidhra_yoga_148", + "dharidhra_yoga_149", + "dharidhra_yoga_150", + "dharidhra_yoga_151", + "dharidhra_yoga_152" + ] + }, { "chart": "Abraham Lincoln", "rule_id": "bvr_thrikaala_gnana_yoga", @@ -435,7 +449,7 @@ ] }, { - "chart": "Abraham Lincoln", + "chart": "Margaret Thatcher", "rule_id": "bvr_dharidhra_11_precise", "rule_name": "Dharidhra Yoga", "pyjhora_names": [ @@ -456,20 +470,6 @@ "matru_satrutwa_yoga" ] }, - { - "chart": "Margaret Thatcher", - "rule_id": "bvr_dharidhra_11_precise", - "rule_name": "Dharidhra Yoga", - "pyjhora_names": [ - "dharidhra_yoga_144", - "dharidhra_yoga_147", - "dharidhra_yoga_148", - "dharidhra_yoga_149", - "dharidhra_yoga_150", - "dharidhra_yoga_151", - "dharidhra_yoga_152" - ] - }, { "chart": "Rabindranath Tagore", "rule_id": "bvr_kapata_yoga", @@ -496,22 +496,6 @@ "thrikaala_gnana_yoga" ] }, - { - "chart": "Wolfgang Mozart", - "rule_id": "bvr_pittharoga_yoga", - "rule_name": "Pittharoga Yoga", - "pyjhora_names": [ - "pittharoga_yoga" - ] - }, - { - "chart": "Wolfgang Mozart", - "rule_id": "bvr_theevrabuddhi_yoga", - "rule_name": "Theevrabuddhi Yoga", - "pyjhora_names": [ - "theevrabuddhi_yoga" - ] - }, { "chart": "Wolfgang Mozart", "rule_id": "bvr_raja_bhanga_yoga", @@ -531,6 +515,22 @@ "kapata_yoga_204" ] }, + { + "chart": "Wolfgang Mozart", + "rule_id": "bvr_pittharoga_yoga", + "rule_name": "Pittharoga Yoga", + "pyjhora_names": [ + "pittharoga_yoga" + ] + }, + { + "chart": "Wolfgang Mozart", + "rule_id": "bvr_theevrabuddhi_yoga", + "rule_name": "Theevrabuddhi Yoga", + "pyjhora_names": [ + "theevrabuddhi_yoga" + ] + }, { "chart": "Pablo Picasso", "rule_id": "bvr_raja_bhanga_yoga", @@ -780,6 +780,14 @@ "brahma_yoga" ] }, + { + "chart": "Pope Francis", + "rule_id": "bvr_bhratruvriddhi_precise", + "rule_name": "Bhratruvriddhi Yoga", + "pyjhora_names": [ + "bhratruvriddhi_yoga" + ] + }, { "chart": "Pope Francis", "rule_id": "bvr_raja_bhanga_yoga", @@ -789,14 +797,6 @@ "raja_bhanga_yoga_299" ] }, - { - "chart": "Pope Francis", - "rule_id": "bvr_bhratruvriddhi_precise", - "rule_name": "Bhratruvriddhi Yoga", - "pyjhora_names": [ - "bhratruvriddhi_yoga" - ] - }, { "chart": "J. Krishnamurti", "rule_id": "bvr_grihanasa_yoga", @@ -824,11 +824,10 @@ }, { "chart": "Paramahansa Yogananda", - "rule_id": "bvr_dattha_puthra_yoga", - "rule_name": "Dattha Puthra Yoga", + "rule_id": "bvr_bandhubhisthyaktha_precise", + "rule_name": "Bandhubhisthyaktha Yoga", "pyjhora_names": [ - "dattha_puthra_yoga_222", - "dattha_puthra_yoga_223" + "bandhubhisthyaktha_yoga" ] }, { @@ -842,10 +841,11 @@ }, { "chart": "Paramahansa Yogananda", - "rule_id": "bvr_bandhubhisthyaktha_precise", - "rule_name": "Bandhubhisthyaktha Yoga", + "rule_id": "bvr_dattha_puthra_yoga", + "rule_name": "Dattha Puthra Yoga", "pyjhora_names": [ - "bandhubhisthyaktha_yoga" + "dattha_puthra_yoga_222", + "dattha_puthra_yoga_223" ] } ] diff --git a/scripts/ashtakavarga.py b/scripts/ashtakavarga.py index fbbc6abc..8d71a019 100644 --- a/scripts/ashtakavarga.py +++ b/scripts/ashtakavarga.py @@ -260,21 +260,17 @@ def calc_ashtakavarga(planets: Dict, asc_sign_idx: int) -> Dict: # 5. 含 Lagna 的完整 SAV(386 = 337 + 49) full_sav = [sav[i] + lagna_sav[i] for i in range(12)] - # 6. Shodhya Pinda 计算 - shodhya = {} - for pname in SEVEN_PLANETS: - if pname in source_sign_idx and pname in bav_results: - own_sign = source_sign_idx[pname] - bindu_at_own = bav_results[pname]['bindus'][own_sign] - sign_weight = 4 # 简化:星座权重统一为4 - rashi_pinda = bindu_at_own * sign_weight - graha_pinda = bindu_at_own * PLANET_WEIGHTS.get(pname, 5) - shodhya[pname] = { - 'rashi_pinda': rashi_pinda, - 'graha_pinda': graha_pinda, - 'total_pinda': rashi_pinda + graha_pinda, - 'bindu_at_own_sign': bindu_at_own, - } + # 6. Shodhya/Yoga Pinda 计算 + yoga_pinda = calc_yoga_pinda(bav_results, planets, asc_sign_idx) + shodhya = { + pname: { + 'rashi_pinda': row['rashi_pinda'], + 'graha_pinda': row['graha_pinda'], + 'total_pinda': row['yoga_pinda'], + 'bindu_at_own_sign': row['bindu_at_own_sign'], + } + for pname, row in yoga_pinda.get('planets', {}).items() + } # 7. 排名 ranked_sav = sorted(enumerate(sav_assessment), key=lambda x: x[1]['score'], reverse=True) @@ -311,6 +307,7 @@ def calc_ashtakavarga(planets: Dict, asc_sign_idx: int) -> Dict: 'bav_validation': bav_validation, 'all_bav_valid': all_valid, 'shodhya_pinda': shodhya, + 'yoga_pinda': yoga_pinda, 'strongest_signs': [SIGNS[i] for i, _ in ranked_sav[:3]], 'weakest_signs': [SIGNS[i] for i, _ in ranked_sav[-3:]], 'house_scores': _map_to_houses(sav_assessment, asc_sign_idx), @@ -425,6 +422,7 @@ def calc_prastara_av(planets: Dict, asc_sign_idx: int) -> Dict: return { 'method': 'Prastara Ashtakavarga (PAV)', 'version': '1.0', + 'matrix_shape': {'planets': len(SEVEN_PLANETS), 'signs': 12, 'sources': len(ALL_SOURCES)}, 'pav': pav, 'pav_summary': pav_summary, 'validation': validation, @@ -432,6 +430,67 @@ def calc_prastara_av(planets: Dict, asc_sign_idx: int) -> Dict: } +def calc_yoga_pinda(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict: + """ + 计算 Yoga Pinda / Shodhya Pinda 汇总。 + + 本项目沿用既有 v2.1 口径:取行星本星座的 BAV bindu,分别乘以星座权重与行星权重; + 两者相加作为 Yoga Pinda。该函数把原本嵌在 calc_ashtakavarga() 内部的结果提升为 + 一等契约,便于 API、前端与测试直接引用。 + """ + source_sign_idx = {} + for pname in SEVEN_PLANETS: + if pname in planets: + sign = planets[pname].get('sign', '') + if sign in SIGNS: + source_sign_idx[pname] = SIGNS.index(sign) + source_sign_idx['Lagna'] = asc_sign_idx + + rows = {} + all_valid = True + for pname in SEVEN_PLANETS: + bav_row = bav_results.get(pname, {}) + bindus = bav_row.get('bindus', []) + own_sign = source_sign_idx.get(pname) + if own_sign is None or len(bindus) != 12: + all_valid = False + continue + + bindu_at_own = bindus[own_sign] + sign_weight = 4 + planet_weight = PLANET_WEIGHTS.get(pname, 5) + rashi_pinda = bindu_at_own * sign_weight + graha_pinda = bindu_at_own * planet_weight + rows[pname] = { + 'sign': SIGNS[own_sign], + 'bindu_at_own_sign': bindu_at_own, + 'rashi_weight': sign_weight, + 'planet_weight': planet_weight, + 'rashi_pinda': rashi_pinda, + 'graha_pinda': graha_pinda, + 'yoga_pinda': rashi_pinda + graha_pinda, + } + + ranked = sorted(rows.items(), key=lambda item: item[1]['yoga_pinda'], reverse=True) + strongest = ranked[0][0] if ranked else None + weakest = ranked[-1][0] if ranked else None + total = sum(item['yoga_pinda'] for item in rows.values()) + + return { + 'method': 'Yoga Pinda', + 'version': '1.0', + 'planets': rows, + 'summary': { + 'strongest_planet': strongest, + 'weakest_planet': weakest, + 'total_yoga_pinda': total, + 'average_yoga_pinda': round(total / len(rows), 2) if rows else 0, + }, + 'all_valid': all_valid and len(rows) == len(SEVEN_PLANETS), + 'validation_note': 'Yoga Pinda = Rashi Pinda + Graha Pinda;当前复用既有 v2.1 Shodhya Pinda 权重口径。', + } + + def calc_sodhita_av(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict: """ 计算 Sodhita Ashtakavarga — 净化后的八分行星力量表 diff --git a/scripts/audit_fragments.py b/scripts/audit_fragments.py new file mode 100644 index 00000000..67d83a93 --- /dev/null +++ b/scripts/audit_fragments.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +"""Audit local Jyotish project fragments against real product surfaces. + +The capability registry can say that a technique is covered, but this audit +checks whether the claim has a concrete path through CLI, API, frontend source, +tests, or a real file. It is intentionally stdlib-only so it can run inside the +normal quality gate. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY_PATH = ROOT / "references" / "technique_registry.json" +SCRIPTS_DIR = ROOT / "scripts" +APP_DIR = ROOT / "jyotish-app" +TESTS_DIR = ROOT / "tests" +OPEN_SOURCE_DIR = ROOT / "references" / "open_source_sources" + +API_COMMAND_MAP = { + "chart": "/api/chart", + "kp": "/api/kp", + "prashna": "/api/prashna", + "synastry": "/api/synastry", + "ashtakoot": "/api/synastry", + "dasha": "/api/dasha", + "remedies": "/api/remedies", + "sade_sati": "/api/sade_sati", + "pancha_mahapurusha": "/api/pancha_mahapurusha", + "career": "/api/career", + "relationship": "/api/relationship", + "full-reading": "/api/chart", + "tajika": "/api/annual", + "solar-return": "/api/annual", + "muhurta": "/api/muhurta", + "panchanga-range": "/api/panchanga_range", + "bhava-chalit": "/api/bhava_chalit", + "sudarshana": "/api/sudarshana", + "nakshatra-full": "/api/nakshatra_full", + "varga-full": "/api/varga_full", + "jaimini": "/api/jaimini", + "ashtakavarga": "/api/ashtakavarga", + "shadbala": "/api/shadbala", + "yoga": "/api/yogas", + "aspects": "/api/aspects", + "rectification": "/api/rectification_gate", + "case-validation": "/api/case_validation", + "deep-varga-avastha": "/api/deep_varga_avastha", + "divisional-yoga": "/api/divisional_yoga", + "kakshya": "/api/kakshya", + "bhava-bala": "/api/bhava_bala", + "transit-trigger": "/api/transit", + "audit-capabilities": "/api/capability_audit", + "thematic-report": "/api/thematic_report", + "report-artifact": "/api/report_artifact", +} + +FRONTEND_MARKERS = { + "remedies": ["remedies-section", "remedies.evidence_chain"], + "career": ["computeCareer", "事业分析", "career analysis"], + "relationship": ["computeRelationship", "感情分析", "relationship analysis"], + "kp": ["computeKP", "tab-kp", "kp-section"], + "prashna": ["computePrashna", "tab-prashna"], + "synastry": ["computeSynastry", "tab-synastry"], + "muhurta": ["computeMuhurta", "muhurta"], + "panchanga-range": ["computePanchangaRange", "panchanga-range"], + "solar-return": ["computeAnnual", "varshaphala", "tajika"], + "tajika": ["computeAnnual", "varshaphala", "tajika"], + "bhava-bala": ["computeBhavaBala", "bhava bala"], + "divisional-yoga": ["computeDivisionalYoga", "divisional yoga"], + "rectification": ["computeRectificationGate", "rectification"], + "case-validation": ["computeCaseValidation", "mevg", "case validation"], + "deep-varga-avastha": ["deepVargaAvastha", "deep_varga_avastha", "Sayanadi/Shayanadi", "D24/D30/D60"], + "transit-trigger": ["computeTransitTriggers", "transit trigger"], + "kakshya": ["computeKakshya", "kakshya"], +} + +SCRIPT_IGNORE = { + "__init__.py", + "_compute_one_chart.py", + "add_d60_to_test_charts.py", + "add_high_confidence_yogas_batch1.py", + "analyze_yoga_errors.py", + "audit_capabilities.py", + "audit_fragments.py", + "benchmark_yoga_coverage.py", + "build_planet_positions_60.py", + "build_standard_test_charts.py", + "chart_renderer.py", + "generate_yoga_rules.py", + "hermes_bridge.py", + "jyotish_api_server.py", + "jyotish_engine.py", + "oss_monitor.py", + "run_quality_gate.py", + "validate.py", + "validate_bphs_invariants.py", + "validate_logic_v2.py", + "validate_yoga_accuracy.py", +} + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return "" + + +def load_registry() -> dict[str, Any]: + return json.loads(read_text(REGISTRY_PATH)) + + +def scan_engine_commands() -> set[str]: + text = read_text(SCRIPTS_DIR / "jyotish_engine.py") + return set(re.findall(r"add_parser\(['\"]([^'\"]+)['\"]", text)) + + +def scan_api_endpoints() -> set[str]: + text = read_text(SCRIPTS_DIR / "jyotish_api_server.py") + return set(re.findall(r"path == ['\"](/api/[^'\"]+)['\"]", text)) + + +def scan_frontend() -> dict[str, Any]: + text_parts: list[str] = [] + files: list[str] = [] + if APP_DIR.exists(): + for path in APP_DIR.rglob("*"): + if any(part in {"node_modules", "dist", ".vite"} for part in path.parts): + continue + if path.suffix not in {".js", ".html", ".css"}: + continue + files.append(str(path.relative_to(ROOT))) + text_parts.append(read_text(path)) + text = "\n".join(text_parts) + return { + "file_count": len(files), + "api_paths": sorted(set(re.findall(r"['\"](/api/[^'\"]+)['\"]", text))), + "api_functions": sorted(set(re.findall(r"async function (compute[A-Z][A-Za-z0-9_]*|getCapabilityAudit)", text))), + "source_text": text, + } + + +def scan_test_text() -> str: + parts: list[str] = [] + if TESTS_DIR.exists(): + for path in TESTS_DIR.rglob("*.py"): + parts.append(read_text(path)) + return "\n".join(parts) + + +def git_untracked() -> list[str]: + try: + completed = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + cwd=ROOT, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + if completed.returncode != 0: + return [] + return sorted(line for line in completed.stdout.splitlines() if line) + + +def git_lost_found() -> list[str]: + root = ROOT / ".git" / "lost-found" + if not root.exists(): + return [] + return sorted(str(path.relative_to(ROOT)) for path in root.rglob("*") if path.is_file()) + + +def first_token_path(value: str) -> str: + return value.split()[0] if value else value + + +def output_path_exists(path: str) -> tuple[bool, str]: + if path.startswith("scripts/"): + rel = first_token_path(path) + return (ROOT / rel).exists(), rel + if path.endswith(".py"): + return (ROOT / path).exists(), path + return True, path + + +def command_surface(command: str, engine_commands: set[str], api_endpoints: set[str]) -> str | None: + if command in engine_commands: + return "engine" + endpoint = API_COMMAND_MAP.get(command) + if endpoint and endpoint in api_endpoints: + return "api" + if command.startswith("scripts/") and (ROOT / first_token_path(command)).exists(): + return "script" + return None + + +def frontend_reaches(command: str, frontend: dict[str, Any]) -> bool: + endpoint = API_COMMAND_MAP.get(command) + if endpoint and endpoint in frontend["api_paths"]: + return True + source = frontend["source_text"].lower() + markers = FRONTEND_MARKERS.get(command, []) + return any(marker.lower() in source for marker in markers) + + +def registry_script_refs(registry: dict[str, Any]) -> set[str]: + refs: set[str] = set() + for technique in registry.get("techniques", {}).values(): + for field in ("commands", "output_paths"): + for value in technique.get(field, []): + if isinstance(value, str) and value.startswith("scripts/"): + refs.add(Path(first_token_path(value)).name) + return refs + + +def source_referenced_scripts(*texts: str) -> set[str]: + combined = "\n".join(texts) + refs: set[str] = set() + for path in SCRIPTS_DIR.glob("*.py"): + stem = path.stem + if re.search(rf"\b(import|from)\s+{re.escape(stem)}\b", combined): + refs.add(path.name) + if path.name in combined or stem in combined: + refs.add(path.name) + return refs + + +def find_script_fragments(registry: dict[str, Any], frontend: dict[str, Any], test_text: str) -> dict[str, Any]: + api_text = read_text(SCRIPTS_DIR / "jyotish_api_server.py") + engine_text = read_text(SCRIPTS_DIR / "jyotish_engine.py") + app_text = frontend["source_text"] + referenced = set(SCRIPT_IGNORE) + referenced |= registry_script_refs(registry) + referenced |= source_referenced_scripts(api_text, engine_text, app_text, test_text) + candidates = [] + for path in sorted(SCRIPTS_DIR.glob("*.py")): + if path.name in referenced: + continue + candidates.append(path.name) + return { + "ignored_count": len(SCRIPT_IGNORE), + "referenced_count": len(referenced), + "candidate_count": len(candidates), + "candidates": candidates, + } + + +def list_open_source_sources() -> list[dict[str, Any]]: + if not OPEN_SOURCE_DIR.exists(): + return [] + result = [] + for path in sorted(p for p in OPEN_SOURCE_DIR.iterdir() if p.is_dir()): + files = [p for p in path.rglob("*") if p.is_file() and ".git" not in p.parts] + result.append({ + "name": path.name, + "path": str(path.relative_to(ROOT)), + "file_count": len(files), + "has_license": any(p.name.lower() in {"license", "license.md", "licence"} for p in files), + "has_readme": any(p.name.lower() == "readme.md" for p in files), + }) + return result + + +def audit() -> dict[str, Any]: + registry = load_registry() + techniques = registry.get("techniques", {}) + engine_commands = scan_engine_commands() + api_endpoints = scan_api_endpoints() + frontend = scan_frontend() + test_text = scan_test_text() + + problems: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] + rows: list[dict[str, Any]] = [] + + for tech_id, technique in sorted(techniques.items()): + status = technique.get("status") + commands = [c for c in technique.get("commands", []) if isinstance(c, str)] + output_paths = [p for p in technique.get("output_paths", []) if isinstance(p, str)] + command_surfaces = {command: command_surface(command, engine_commands, api_endpoints) for command in commands} + frontend_commands = [command for command in commands if frontend_reaches(command, frontend)] + + if status in {"covered", "complete"} and not commands: + problems.append({ + "kind": "covered_without_command", + "technique_id": tech_id, + "message": "covered/complete techniques must declare at least one real command or API command", + }) + + for command, surface in command_surfaces.items(): + if surface is None: + problems.append({ + "kind": "unreachable_command", + "technique_id": tech_id, + "command": command, + "message": "registry command is not found in CLI commands, API endpoints, or script files", + }) + + for path in output_paths: + ok, checked = output_path_exists(path) + if not ok: + problems.append({ + "kind": "missing_output_path", + "technique_id": tech_id, + "output_path": path, + "checked_path": checked, + }) + + api_commands = [cmd for cmd, surface in command_surfaces.items() if surface == "api"] + if api_commands and not frontend_commands: + warnings.append({ + "kind": "api_without_frontend_marker", + "technique_id": tech_id, + "commands": api_commands, + "message": "API exists, but no obvious frontend bridge/topic marker was detected", + }) + + rows.append({ + "id": tech_id, + "status": status, + "commands": commands, + "command_surfaces": command_surfaces, + "frontend_commands": frontend_commands, + "output_paths": output_paths, + }) + + fragments = find_script_fragments(registry, frontend, test_text) + open_sources = list_open_source_sources() + untracked = git_untracked() + lost_found = git_lost_found() + + return { + "valid": not problems, + "problem_count": len(problems), + "warning_count": len(warnings), + "registry": { + "technique_count": len(techniques), + "version": registry.get("version"), + }, + "surfaces": { + "engine_command_count": len(engine_commands), + "engine_commands": sorted(engine_commands), + "api_endpoint_count": len(api_endpoints), + "api_endpoints": sorted(api_endpoints), + "frontend_file_count": frontend["file_count"], + "frontend_api_paths": frontend["api_paths"], + "frontend_api_functions": frontend["api_functions"], + }, + "fragments": fragments, + "open_source_sources": { + "source_count": len(open_sources), + "sources": open_sources, + }, + "workspace_residue": { + "untracked_count": len(untracked), + "untracked_files": untracked, + "git_lost_found_count": len(lost_found), + "git_lost_found_files": lost_found, + }, + "problems": problems, + "warnings": warnings, + "rows": rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Audit Jyotish registry, product surfaces, and local fragments") + parser.add_argument("--strict", action="store_true", help="Return non-zero when hard problems are found") + args = parser.parse_args() + result = audit() + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 1 if args.strict and not result["valid"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/deep_varga_avastha.py b/scripts/deep_varga_avastha.py new file mode 100644 index 00000000..a67c9915 --- /dev/null +++ b/scripts/deep_varga_avastha.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Deep Varga + Avastha interpretation layer for user-facing reports.""" + +from __future__ import annotations + +from collections import Counter +from typing import Dict, List, Optional + +from avastha_calculator import AvasthaCalculator +from divisional_charts_extended import DivisionalChartsCalculator, VargaType +from trimshamsa_d30 import calc_d30_chart + + +SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"] +CLASSICAL_PLANETS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"] +KEY_PLANETS = { + "D24": ["Mercury", "Jupiter", "Moon", "Sun"], + "D30": ["Mars", "Saturn", "Rahu", "Ketu", "Venus"], + "D60": ["Sun", "Moon", "Jupiter", "Saturn", "Ketu"], +} +TRIK_HOUSES = {6, 8, 12} +KENDRA_TRIKONA = {1, 4, 5, 7, 9, 10} + + +def build_deep_varga_avastha_report(planet_lons: Dict[str, float], asc_lon: float = 0.0) -> Dict: + """Build Sayanadi/Shayanadi avastha and D24/D30/D60 interpretive templates.""" + normalized_lons = { + planet: float(lon) % 360 + for planet, lon in planet_lons.items() + if _is_number(lon) + } + if not normalized_lons: + raise ValueError("planet_lons must include longitude data") + + avastha_summary = _build_avastha_summary(normalized_lons, asc_lon) + deep_templates = _build_deep_varga_templates(normalized_lons, asc_lon) + priority_cards = _priority_cards(avastha_summary, deep_templates) + return { + "method": "Sayanadi/Shayanadi Avastha + D24/D30/D60 Deep Templates", + "source": "avastha_calculator.py + divisional_charts_extended.py + trimshamsa_d30.py", + "summary": { + "headline": _headline(avastha_summary, deep_templates), + "priority_cards": priority_cards, + "next_action": "把 D24 用于学习与证书,D30 用于风险压力,D60 用于根层业力;必须与 D1、Dasha、Transit 同读。", + }, + "avastha_summary": avastha_summary, + "deep_varga_templates": deep_templates, + } + + +def _build_avastha_summary(planet_lons: Dict[str, float], asc_lon: float) -> Dict: + calculator = AvasthaCalculator() + planet_states = {} + state_counter: Counter[str] = Counter() + weak_planets = [] + strong_planets = [] + asc_sign_idx = _sign_idx(asc_lon) + for planet in CLASSICAL_PLANETS: + lon = planet_lons.get(planet) + if lon is None: + continue + sign_idx = _sign_idx(lon) + degree = lon % 30 + house = ((sign_idx - asc_sign_idx) % 12) + 1 + result = calculator.calculate_all_avasthas( + planet, + SIGNS[sign_idx], + degree, + house, + conjunctions=_conjunctions_for(planet, planet_lons), + aspects=[], + is_combust=False, + is_retrograde=False, + ) + avasthas = result.get("avasthas", {}) + shayanadi = avasthas.get("Shayanadi", {}) + bala = avasthas.get("Bala", {}) + state_counter[shayanadi.get("state", "unknown")] += 1 + strength = float(bala.get("strength") or 0) + row = { + "planet": planet, + "sign": SIGNS[sign_idx], + "house": house, + "Bala": bala, + "Jagrat": avasthas.get("Jagrat", {}), + "Deeptadi": avasthas.get("Deeptadi", {}), + "Lajjitadi": avasthas.get("Lajjitadi", {}), + "Shayanadi": shayanadi, + "interpretation": result.get("interpretation", ""), + "remedies": result.get("remedies", []), + } + planet_states[planet] = row + if strength >= 0.7: + strong_planets.append({"planet": planet, "strength": strength, "state": bala.get("state")}) + elif strength <= 0.5: + weak_planets.append({"planet": planet, "strength": strength, "state": bala.get("state")}) + return { + "dominant_states": [{"state": state, "count": count} for state, count in state_counter.most_common()], + "strong_planets": strong_planets, + "weak_planets": weak_planets, + "planet_states": planet_states, + "next_action": "优先复核弱 Bala 或 Nidra/Shayana 行星对应的 Dasha;强 Bala 行星可作为可用资源。", + } + + +def _build_deep_varga_templates(planet_lons: Dict[str, float], asc_lon: float) -> Dict: + calc = DivisionalChartsCalculator() + selected = { + "D24": VargaType.D24, + "D30": VargaType.D30, + "D60": VargaType.D60, + } + templates = {} + for key, varga_type in selected.items(): + chart = calc._calculate_single_varga(varga_type, planet_lons, asc_lon) + if key == "D24": + templates[key] = _d24_template(chart) + elif key == "D30": + d30_chart = calc_d30_chart(planet_lons, asc_lon) + templates[key] = _d30_template(chart, d30_chart) + else: + templates[key] = _d60_template(chart) + return templates + + +def _d24_template(chart: Dict) -> Dict: + cards = _key_planet_cards(chart, "D24") + support = [card for card in cards if card["house"] in KENDRA_TRIKONA] + pressure = [card for card in cards if card["house"] in TRIK_HOUSES] + return { + "division": "D24", + "theme": "education_learning", + "title": "D24 Chaturvimsamsa 学习/证书模板", + "template_cards": cards, + "support_factors": support, + "risk_flags": [{"planet": card["planet"], "reason": "D24 trika house pressure"} for card in pressure], + "next_action": "用 Mercury/Jupiter/Moon 判断学习方式、考试状态和导师资源;再与 Dasha 的教育触发交叉。", + } + + +def _d30_template(chart: Dict, d30_chart: Dict) -> Dict: + cards = _key_planet_cards(chart, "D30") + risk_flags = [] + concentration = d30_chart.get("malefic_concentration") or {} + if concentration.get("risk_level"): + risk_flags.append({"type": "malefic_concentration", "reason": concentration.get("risk_level")}) + for card in cards: + if card["house"] in TRIK_HOUSES: + risk_flags.append({"planet": card["planet"], "reason": f"D30 house {card['house']} pressure"}) + return { + "division": "D30", + "theme": "risk_crisis", + "title": "D30 Trimsamsa 压力/危机模板", + "template_cards": cards, + "risk_flags": risk_flags, + "planet_states": d30_chart.get("planet_states", {}), + "malefic_concentration": concentration, + "next_action": "D30 只用于风险侧证;健康、法律、事故等主题必须分开现实建议与占星提示。", + } + + +def _d60_template(chart: Dict) -> Dict: + cards = _key_planet_cards(chart, "D60") + rooted = [card for card in cards if card["house"] in KENDRA_TRIKONA] + lessons = [card for card in cards if card["house"] in TRIK_HOUSES] + return { + "division": "D60", + "theme": "karma_root", + "title": "D60 Shashtiamsa 根层业力模板", + "template_cards": cards, + "support_factors": rooted, + "risk_flags": [{"planet": card["planet"], "reason": "D60 trika karmic lesson"} for card in lessons], + "next_action": "D60 对出生时间极敏感;若出生时间未精确校正,只把它作为背景主题,不作为事件承诺。", + } + + +def _key_planet_cards(chart: Dict, division: str) -> List[Dict]: + planets = chart.get("planets", {}) + cards = [] + for planet in KEY_PLANETS.get(division, CLASSICAL_PLANETS): + item = planets.get(planet) + if not item: + continue + house = int(item.get("house") or 0) + cards.append({ + "planet": planet, + "sign": item.get("sign"), + "house": house, + "degree": item.get("degree"), + "role": _planet_role(division, planet), + "reading": _varga_reading(division, planet, house), + }) + return cards + + +def _priority_cards(avastha_summary: Dict, templates: Dict) -> List[Dict]: + cards = [] + weak = avastha_summary.get("weak_planets") or [] + if weak: + cards.append({ + "title": "Avastha 需补强", + "value": ", ".join(item["planet"] for item in weak[:3]), + "note": "弱 Bala / 不活跃状态会降低对应 Dasha 兑现质量。", + }) + for division in ("D24", "D30", "D60"): + template = templates.get(division, {}) + flags = template.get("risk_flags") or [] + cards.append({ + "title": division, + "value": template.get("title", division), + "note": f"{len(flags)} 个风险/压力标记;{len(template.get('template_cards') or [])} 个关键行星。", + }) + return cards + + +def _headline(avastha_summary: Dict, templates: Dict) -> str: + states = avastha_summary.get("dominant_states") or [] + top_state = states[0]["state"] if states else "unknown" + d30_flags = len((templates.get("D30") or {}).get("risk_flags") or []) + return f"主导 Shayanadi 状态为 {top_state};D30 发现 {d30_flags} 个压力标记。" + + +def _planet_role(division: str, planet: str) -> str: + roles = { + "D24": {"Mercury": "学习方法", "Jupiter": "导师/高等教育", "Moon": "记忆与适应", "Sun": "证书与权威"}, + "D30": {"Mars": "冲突/急性风险", "Saturn": "慢性压力", "Rahu": "异常风险", "Ketu": "切断/损失", "Venus": "关系压力"}, + "D60": {"Sun": "根层使命", "Moon": "潜意识模式", "Jupiter": "福德", "Saturn": "业力课题", "Ketu": "前缘/释放"}, + } + return roles.get(division, {}).get(planet, "key planet") + + +def _varga_reading(division: str, planet: str, house: int) -> str: + if house in KENDRA_TRIKONA: + return f"{planet} 在 {division} 的 {house} 宫,作为该主题的可用支撑。" + if house in TRIK_HOUSES: + return f"{planet} 在 {division} 的 {house} 宫,提示该主题需要谨慎与现实复核。" + return f"{planet} 在 {division} 的 {house} 宫,作为中性背景证据。" + + +def _conjunctions_for(target: str, planet_lons: Dict[str, float], orb: float = 8.0) -> List[str]: + target_lon = planet_lons.get(target) + if target_lon is None: + return [] + result = [] + for planet, lon in planet_lons.items(): + if planet == target: + continue + if abs(((lon - target_lon + 180) % 360) - 180) <= orb: + result.append(planet) + return result + + +def _sign_idx(lon: float) -> int: + return int((float(lon) % 360) / 30) % 12 + + +def _is_number(value: object) -> bool: + try: + float(value) + return True + except (TypeError, ValueError): + return False diff --git a/scripts/desktop_packaging_preflight.py b/scripts/desktop_packaging_preflight.py new file mode 100644 index 00000000..02c56f0e --- /dev/null +++ b/scripts/desktop_packaging_preflight.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Check whether the Jyotish web app is ready for PWA/Pake/Tauri packaging.""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "jyotish-app" + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def require(condition: bool, label: str, failures: list[str]) -> None: + if not condition: + failures.append(label) + + +def probe_command(binary: str, args: list[str] | None = None) -> dict: + path = shutil.which(binary) + result = { + "binary": binary, + "available": bool(path), + "path": path, + "version": None, + } + if not path: + return result + try: + completed = subprocess.run( + [path, *(args or ["--version"])], + capture_output=True, + text=True, + timeout=4, + check=False, + ) + result["version"] = (completed.stdout or completed.stderr).strip().splitlines()[0] if (completed.stdout or completed.stderr).strip() else "" + except Exception as exc: + result["version"] = f"probe_failed: {exc}" + return result + + +def toolchain_probe() -> dict: + probes = { + "node": probe_command("node"), + "npm": probe_command("npm"), + "rustc": probe_command("rustc"), + "cargo": probe_command("cargo"), + "xcodebuild": probe_command("xcodebuild", ["-version"]), + "pake": probe_command("pake"), + "tauri": probe_command("tauri"), + } + xcode_version = probes["xcodebuild"].get("version") or "" + xcode_ready = bool(probes["xcodebuild"]["available"] and "requires Xcode" not in xcode_version) + return { + "non_destructive": True, + "note": "Probe only checks local CLI presence/version; it does not run npm build, pake, tauri build, signing, notarization, or generate packages.", + "license_gate": { + "pake": "Pake upstream is GPL-3.0; review distribution compatibility before shipping a bundled desktop app.", + "tauri": "Tauri app distribution still needs platform permissions, sidecar lifecycle design, signing_notarization, and Apple Developer decisions on macOS.", + }, + "commands": probes, + "readiness": { + "pwa": True, + "pake": bool(probes["node"]["available"] and probes["npm"]["available"] and probes["rustc"]["available"] and probes["cargo"]["available"] and probes["pake"]["available"]), + "tauri": bool(probes["node"]["available"] and probes["npm"]["available"] and probes["rustc"]["available"] and probes["cargo"]["available"] and probes["tauri"]["available"]), + "macos_signing_notarization": xcode_ready, + }, + "warnings": [ + "xcodebuild exists but full Xcode is not selected; macOS signing_notarization is not ready." + ] if probes["xcodebuild"]["available"] and not xcode_ready else [], + } + + +def main() -> int: + failures: list[str] = [] + package = json.loads(read(APP / "package.json")) + manifest = json.loads(read(APP / "public" / "manifest.webmanifest")) + sw = read(APP / "public" / "sw.js") + html = read(APP / "index.html") + api_server = read(ROOT / "scripts" / "jyotish_api_server.py") + main_js = read(APP / "main.js") + click_smoke = read(ROOT / "tests" / "run_frontend_click_smoke.py") + + scripts = package.get("scripts", {}) + require("build" in scripts, "jyotish-app/package.json missing build script", failures) + require("preview" in scripts, "jyotish-app/package.json missing preview script", failures) + require(manifest.get("name") == "Jyotish Vedic Astrology", "manifest name mismatch", failures) + require(manifest.get("display") == "standalone", "manifest display must be standalone", failures) + require(manifest.get("scope") == "/", "manifest scope must be /", failures) + require(manifest.get("start_url") == "/", "manifest start_url must be /", failures) + require(bool(manifest.get("theme_color")), "manifest theme_color missing", failures) + require(any(icon.get("src") == "/pwa-icon.svg" for icon in manifest.get("icons", [])), "manifest icon missing", failures) + require("CACHE_NAME = 'jyotish-shell-v1'" in sw, "service worker cache name missing", failures) + require("url.pathname.startsWith('/api/')" in sw, "service worker must bypass API requests", failures) + require("caches.match('/index.html')" in sw, "service worker fallback missing", failures) + require('rel="manifest"' in html and "/manifest.webmanifest" in html, "index missing manifest link", failures) + require("/pwa-icon.svg" in html, "index missing app icon", failures) + require("JYOTISH_API_HOST', '127.0.0.1'" in api_server, "API host default must stay loopback", failures) + require("Trust Center" in main_js and "Local-first" in main_js, "Trust Center status missing", failures) + require("pwa-install" in main_js and "promptPWAInstall" in main_js, "PWA install action missing", failures) + require(bool(re.search(r"127\.0\.0\.1:5200", main_js)), "Trust Center must show loopback API boundary", failures) + require("tests/run_frontend_click_smoke.py" in read(ROOT / "scripts" / "run_quality_gate.py"), "quality gate must run browser click smoke", failures) + require("--mode" in click_smoke and "all" in click_smoke, "click smoke must support --mode all", failures) + require("offline_recovery_guidance_visible" in click_smoke, "click smoke must verify offline recovery guidance", failures) + require("manifest.webmanifest" in click_smoke and "serviceWorker" in click_smoke, "click smoke must verify PWA installed shell", failures) + + if failures: + print(json.dumps({"valid": False, "failures": failures}, ensure_ascii=False, indent=2)) + return 1 + first_launch_checks = [ + { + "path": "PWA installed shell", + "command": "python3 tests/run_frontend_click_smoke.py --mode all", + "expected": "manifest.webmanifest, serviceWorker, mobile shell, online workflow, and offline recovery guidance", + }, + { + "path": "Pake first launch", + "command": "cd jyotish-app && npm run build && npm run preview -- --host 127.0.0.1 --port 4173", + "expected": "URL shell can open the built app; local API still needs python3 scripts/jyotish_api_server.py on 127.0.0.1:5200", + }, + { + "path": "Tauri sidecar readiness", + "command": "python3 scripts/desktop_packaging_preflight.py", + "expected": "loopback API boundary, sidecar route, manifest, service worker, and Trust Center remain visible before scaffolding", + }, + ] + print(json.dumps({ + "valid": True, + "packaging_paths": ["pwa", "pake-url-shell", "tauri-sidecar-spike"], + "app_dir": str(APP), + "api_default": "127.0.0.1:5200", + "first_launch_checks": first_launch_checks, + "toolchain_probe": toolchain_probe(), + }, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ephemeris_adapter_contract.py b/scripts/ephemeris_adapter_contract.py new file mode 100644 index 00000000..a56b48ae --- /dev/null +++ b/scripts/ephemeris_adapter_contract.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Ephemeris adapter contract and parity baseline runner. + +The current production backend is swisseph_python. This module defines the data +contract a future candidate_backend must satisfy, then emits SwissEph baseline +rows for stable parity cases. +""" + +from __future__ import annotations + +import json +import math +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from jyotish_engine import HAS_SWE, compute_chart_data # noqa: E402 + + +BODY_KEYS = ["Sun", "Moon", "Ascendant", "Rahu", "Ketu"] + + +@dataclass(frozen=True) +class EphemerisAdapterContract: + """Required input/output shape for any candidate_backend.""" + + backend: str + year: int + month: int + day: int + hour: int + minute: int + lat: float + lon: float + tz: float + ayanamsa_policy: str = "lahiri" + node_policy: str = "mean" + body_list: tuple[str, ...] = tuple(BODY_KEYS) + + +PARITY_CASES: list[dict[str, Any]] = [ + { + "id": "beijing_first_use_demo", + "label": "First-use demo chart", + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "lat": 39.9042, + "lon": 116.4074, + "tz": 8.0, + }, + { + "id": "delhi_lagna_boundary", + "label": "Delhi ascendant and node parity", + "year": 1984, + "month": 10, + "day": 31, + "hour": 6, + "minute": 30, + "lat": 28.6139, + "lon": 77.2090, + "tz": 5.5, + }, + { + "id": "new_york_moon_boundary", + "label": "Western timezone Moon/nakshatra boundary guard", + "year": 2001, + "month": 9, + "day": 11, + "hour": 8, + "minute": 46, + "lat": 40.7128, + "lon": -74.0060, + "tz": -4.0, + }, +] + + +acceptance_thresholds = { + "sun_moon_asc_nodes": { + "Sun": {"longitude_delta_arcsec": 1.0}, + "Moon": {"longitude_delta_arcsec": 1.0}, + "Ascendant": {"longitude_delta_arcsec": 5.0}, + "Rahu": {"longitude_delta_arcsec": 2.0}, + "Ketu": {"longitude_delta_arcsec": 2.0}, + }, + "metadata": [ + "candidate_backend must report ayanamsa_value", + "candidate_backend must report retrograde where body speed is available", + "candidate_backend must include backend/version/source metadata", + ], +} + + +def _round_float(value: Any, digits: int = 6) -> float | None: + try: + numeric = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(numeric): + return None + return round(numeric, digits) + + +def _body_row(name: str, data: dict[str, Any]) -> dict[str, Any]: + return { + "body": name, + "sidereal_longitude": _round_float(data.get("degree_raw", data.get("lon", data.get("degree")))), + "sign": data.get("sign"), + "degree_in_sign": _round_float(data.get("degree_in_sign_raw", data.get("degree_in_sign"))), + "speed": _round_float(data.get("speed")), + "retrograde": bool(data.get("retrograde")) if data.get("retrograde") is not None else None, + "nakshatra": data.get("nakshatra"), + "nakshatra_pada": data.get("nakshatra_pada"), + } + + +def run_swisseph_python(case: dict[str, Any]) -> dict[str, Any]: + if not HAS_SWE: + return { + "backend": "swisseph_python", + "available": False, + "error": "swisseph module is not installed", + } + + chart, _asc_idx, jd, ayanamsa = compute_chart_data( + case["year"], + case["month"], + case["day"], + case["hour"], + case["minute"], + case["lat"], + case["lon"], + case["tz"], + "mean", + ) + if not chart: + return { + "backend": "swisseph_python", + "available": False, + "error": "compute_chart_data returned no chart", + } + + ascendant = chart.get("ascendant", {}) + bodies = {"Ascendant": _body_row("Ascendant", ascendant)} + for body in ["Sun", "Moon", "Rahu", "Ketu"]: + bodies[body] = _body_row(body, chart.get("planets", {}).get(body, {})) + + return { + "backend": "swisseph_python", + "available": True, + "julian_day": _round_float(jd), + "ayanamsa_value": _round_float(ayanamsa), + "node_policy": "mean", + "body_list": BODY_KEYS, + "bodies": bodies, + } + + +def run_candidate_backend(case: dict[str, Any], backend: str) -> dict[str, Any]: + if backend == "swisseph_python": + return run_swisseph_python(case) + return { + "backend": backend, + "available": False, + "status": "candidate_backend_not_configured", + "reason": "Add an adapter implementation before parity comparison.", + } + + +def build_parity_matrix(candidate_backend: str = "swisseph_python") -> dict[str, Any]: + rows = [] + for case in PARITY_CASES: + contract_input = { + key: case[key] + for key in ["year", "month", "day", "hour", "minute", "lat", "lon", "tz"] + } + contract = EphemerisAdapterContract(backend=candidate_backend, **contract_input) + baseline = run_swisseph_python(case) + candidate = run_candidate_backend(case, candidate_backend) + rows.append( + { + "case": case["id"], + "label": case["label"], + "contract": asdict(contract), + "baseline": baseline, + "candidate_backend": candidate, + "longitude_delta_arcsec": { + body: 0.0 if baseline.get("available") and candidate_backend == "swisseph_python" else None + for body in BODY_KEYS + }, + } + ) + return { + "valid": all(row["baseline"].get("available") for row in rows), + "contract_name": "EphemerisAdapterContract", + "baseline_backend": "swisseph_python", + "candidate_backend": candidate_backend, + "sun_moon_asc_nodes": BODY_KEYS, + "acceptance_thresholds": acceptance_thresholds, + "PARITY_CASES": [case["id"] for case in PARITY_CASES], + "rows": rows, + } + + +def main() -> int: + candidate = sys.argv[1] if len(sys.argv) > 1 else "swisseph_python" + result = build_parity_matrix(candidate) + print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if result["valid"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ephemeris_backend_probe.py b/scripts/ephemeris_backend_probe.py new file mode 100644 index 00000000..355bd34b --- /dev/null +++ b/scripts/ephemeris_backend_probe.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Probe Jyotish ephemeris backend readiness without network access. + +This script is intentionally read-only. It turns the ephemeris roadmap into a +repeatable check: what is available now, what is only a benchmark, and what is +not ready to replace the current Swiss Ephemeris path. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] + + +def _exists(*parts: str) -> bool: + return (ROOT.joinpath(*parts)).exists() + + +def _read_text(*parts: str) -> str: + path = ROOT.joinpath(*parts) + if not path.exists(): + return "" + return path.read_text(encoding="utf-8", errors="ignore") + + +def _python_module_status(module_name: str) -> dict[str, Any]: + spec = importlib.util.find_spec(module_name) + return { + "module": module_name, + "available": spec is not None, + "origin": getattr(spec, "origin", None) if spec else None, + } + + +def _package_has_dependency(package_text: str, dependency: str) -> bool: + return f'"{dependency}"' in package_text or f"'{dependency}'" in package_text + + +def build_probe() -> dict[str, Any]: + package_json = _read_text("jyotish-app", "package.json") + product_gap = _read_text("docs", "research", "product_gap_matrix_2026_06_22.md") + open_source_scan = _read_text("docs", "research", "open_source_scan_2026_06_22.md") + + swisseph_python_module = _python_module_status("swisseph") + swisseph_python_available = swisseph_python_module["available"] and "import swisseph as swe" in _read_text( + "scripts", "jyotish_api_server.py" + ) + swisseph_wasm_files = [ + "jyotish-app/lib/swisseph-wasm/swisseph.js", + "jyotish-app/public/swisseph/swisseph.js", + ] + swisseph_wasm_available = any(_exists(*path.split("/")) for path in swisseph_wasm_files) + + vedastro_local = _exists("references", "open_source_sources", "VedicAstro") + vedastro_scan = "VedAstro/VedAstro" in open_source_scan or "VedAstro/VedAstro" in product_gap + pyjhora_scan = "naturalstupid/PyJHora" in open_source_scan or "PyJHora" in product_gap + xalen_scan = "xalen-ephemeris" in product_gap + + candidate_backends: dict[str, dict[str, Any]] = { + "swisseph_python": { + "available": bool(swisseph_python_available), + "replacement_readiness": "primary", + "license_posture": "current production path; keep Swiss Ephemeris license/data boundary explicit", + "evidence": [ + "scripts/jyotish_api_server.py imports swisseph", + swisseph_python_module, + ], + "next_step": "keep as canonical longitude source until another backend passes parity cases", + }, + "swisseph_wasm": { + "available": bool(swisseph_wasm_available), + "replacement_readiness": "fallback", + "license_posture": "browser fallback using Swiss Ephemeris WASM assets; same boundary as Swiss Ephemeris", + "evidence": [ + path for path in swisseph_wasm_files if _exists(*path.split("/")) + ] + + [ + { + "@swisseph/browser": _package_has_dependency(package_json, "@swisseph/browser"), + "swisseph-wasm": _package_has_dependency(package_json, "swisseph-wasm"), + } + ], + "next_step": "keep for local-first browser degradation, not as a separate accuracy baseline", + }, + "xalen_ephemeris": { + "available": False, + "replacement_readiness": "spike_only", + "license_posture": "Apache-2.0 candidate from vedika-io/xalen-ephemeris; no local adapter yet", + "evidence": [ + "tracked in product gap matrix" if xalen_scan else "not found in local docs", + "no local references/open_source_sources/xalen mirror detected", + ], + "next_step": "add an isolated Rust/CLI parity spike before exposing as selectable runtime backend", + }, + "vedastro": { + "available": bool(vedastro_local or vedastro_scan), + "replacement_readiness": "product_api_benchmark", + "license_posture": "MIT product/API benchmark; C# stack should stay behind a service/API boundary if reused", + "evidence": [ + "references/open_source_sources/VedicAstro" if vedastro_local else "no local mirror detected by probe", + "tracked in open-source scan" if vedastro_scan else "not tracked in current scan", + ], + "next_step": "reuse API/OpenAPI/product lessons; do not mix C# core into Python path without an adapter contract", + }, + "pyjhora_benchmark": { + "available": bool(pyjhora_scan or _python_module_status("jhora")["available"]), + "replacement_readiness": "benchmark_only", + "license_posture": "AGPL benchmark/oracle only unless downstream license posture changes", + "evidence": [ + "tracked as naturalstupid/PyJHora in open-source scan" if pyjhora_scan else "not tracked in current scan", + _python_module_status("jhora"), + ], + "next_step": "use public examples and expected outputs for parity tests; do not copy AGPL implementation code", + }, + } + + valid = candidate_backends["swisseph_python"]["available"] or candidate_backends["swisseph_wasm"]["available"] + return { + "valid": bool(valid), + "candidate_backends": candidate_backends, + "license_posture": { + "direct_reuse": ["MIT", "Apache-2.0"], + "benchmark_only": ["AGPL", "GPL", "unknown/no-license"], + "current_boundary": "Swiss Ephemeris remains the production ephemeris source until parity and license checks pass.", + }, + "replacement_readiness": { + "primary": ["swisseph_python"], + "fallback": ["swisseph_wasm"], + "spike_only": ["xalen_ephemeris"], + "product_api_benchmark": ["vedastro"], + "benchmark_only": ["pyjhora_benchmark"], + }, + "recommendation": [ + "Do not replace SwissEph in core calculations yet.", + "Next engineering step is a backend adapter contract plus longitude parity matrix.", + "Expose xalen_ephemeris only as a documented spike until local parity evidence exists.", + ], + } + + +def main() -> int: + result = build_probe() + print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if result["valid"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ephemeris_candidate_adapter_spike.py b/scripts/ephemeris_candidate_adapter_spike.py new file mode 100644 index 00000000..11175bd9 --- /dev/null +++ b/scripts/ephemeris_candidate_adapter_spike.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Read-only spike status for future ephemeris candidate adapters. + +The script does not load alternate calculation engines. It records whether a +candidate_backend is executable enough to enter the parity gate defined by +scripts/ephemeris_adapter_contract.py. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict + + +ROOT = Path(__file__).resolve().parents[1] + + +def _exists(*parts: str) -> bool: + return ROOT.joinpath(*parts).exists() + + +def _read(*parts: str) -> str: + path = ROOT.joinpath(*parts) + if not path.exists(): + return "" + return path.read_text(encoding="utf-8", errors="ignore") + + +def _package_dependency(name: str) -> bool: + package = _read("jyotish-app", "package.json") + return f'"{name}"' in package or f"'{name}'" in package + + +def _package_license(*parts: str) -> str: + text = _read(*parts) + if not text: + return "not_found" + try: + data = json.loads(text) + except json.JSONDecodeError: + return "unreadable" + return str(data.get("license") or "unspecified") + + +def build_spike() -> Dict[str, Any]: + swisseph_wasm_assets = [ + "jyotish-app/public/swisseph-wasm/wasm/swisseph.wasm", + "jyotish-app/public/swisseph/swisseph.wasm", + "jyotish-app/lib/swisseph-wasm/swisseph.js", + ] + swisseph_wasm_candidate = { + "candidate_backend": "swisseph_wasm_candidate", + "candidate_adapter_spike": "asset_detected_no_runtime_switch", + "available": any(_exists(*path.split("/")) for path in swisseph_wasm_assets), + "evidence": [path for path in swisseph_wasm_assets if _exists(*path.split("/"))], + "dependencies": { + "@swisseph/browser": _package_dependency("@swisseph/browser"), + "swisseph-wasm": _package_dependency("swisseph-wasm"), + }, + "package_license": { + "@swisseph/browser": _package_license("jyotish-app", "node_modules", "@swisseph", "browser", "package.json"), + "swisseph-wasm": _package_license("jyotish-app", "node_modules", "swisseph-wasm", "package.json"), + }, + "license_gate": "Swiss Ephemeris WASM remains under Swiss Ephemeris licensing boundaries; verify commercial/GPL compatibility before distribution claims.", + "distribution_gate": "AGPL-3.0 and GPL-3.0-or-later packages must not be treated as low-risk proprietary desktop/PWA dependencies.", + "runtime_setting_exposure": "blocked_until_parity_gate_required", + "parity_gate_required": "Must emit EphemerisAdapterContract rows and pass swisseph_python longitude_delta_arcsec thresholds.", + } + + xalen_local_paths = [ + "references/open_source_sources/xalen-ephemeris", + "references/open_source_sources/xalen", + ] + xalen_ephemeris_candidate = { + "candidate_backend": "xalen_ephemeris_candidate", + "candidate_adapter_spike": "documented_no_local_executable", + "available": any(_exists(*path.split("/")) for path in xalen_local_paths), + "evidence": [path for path in xalen_local_paths if _exists(*path.split("/"))] or ["no local xalen mirror detected"], + "license_gate": "Apache-2.0 candidate from vedika-io/xalen-ephemeris; fetch/build/review before any adapter code is added.", + "runtime_setting_exposure": "blocked_until_parity_gate_required", + "parity_gate_required": "Must produce Sun/Moon/Asc/Rahu/Ketu rows compatible with EphemerisAdapterContract.", + } + + return { + "valid": True, + "candidate_adapter_spike": True, + "runtime_setting_exposure": "do_not_expose_non_swisseph_backend_yet", + "license_gate": { + "swisseph_wasm_candidate": swisseph_wasm_candidate["license_gate"], + "xalen_ephemeris_candidate": xalen_ephemeris_candidate["license_gate"], + }, + "package_license": swisseph_wasm_candidate["package_license"], + "parity_gate_required": "Run scripts/ephemeris_adapter_contract.py with real candidate rows before settings exposure.", + "candidate_backends": { + "swisseph_wasm_candidate": swisseph_wasm_candidate, + "xalen_ephemeris_candidate": xalen_ephemeris_candidate, + }, + "next_step": "Implement an isolated candidate adapter only after local executable assets and license review are present.", + } + + +def main() -> int: + print(json.dumps(build_spike(), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/extended_dashas.py b/scripts/extended_dashas.py index 6328f680..aaec9480 100644 --- a/scripts/extended_dashas.py +++ b/scripts/extended_dashas.py @@ -322,7 +322,6 @@ def calc_tithi_ashtottari(birth_date, tithi_num): def calc_tithi_yogini(birth_date, tithi_num): """Tithi Yogini Dasha (36年周期)""" - from extended_dashas import YOGINI_LORDS, YOGINI_YEARS return calc_generic_dasha(birth_date, YOGINI_LORDS, YOGINI_YEARS) def calc_patyayini(birth_date, asc_sign_idx): @@ -383,7 +382,27 @@ def calc_any_dasha(name: str, birth_date, **kwargs) -> List[Dict]: return calc_generic_dasha(birth_date, DASHA_ORDER, [4]*9) if calc_fn: try: + if name == 'kalachakra': + return calc_fn(birth_date, kwargs.get('moon_nak_idx', 0), kwargs.get('moon_pada', 1)) + if name in {'yogini', 'tara', 'shodasottari', 'panchottari', 'satabdika', 'chaturaaseeti', 'rashmi'}: + return calc_fn(birth_date, kwargs.get('moon_nak_idx', 0)) + if name == 'narayana': + return calc_fn(birth_date, kwargs.get('asc_sign_idx', 0)) + if name == 'shoola': + return calc_fn(birth_date, kwargs.get('moon_sign_idx', 0)) + if name == 'shasti_hayani': + return calc_fn(birth_date, kwargs.get('sun_sign_idx', 0)) + if name == 'navamsa': + return calc_fn(birth_date, kwargs.get('d9_asc_sign_idx', kwargs.get('asc_sign_idx', 0))) + if name == 'kendradi': + return calc_fn(birth_date, kwargs.get('asc_sign_idx', 0)) + if name in {'tithi_ashtottari', 'tithi_yogini'}: + return calc_fn(birth_date, kwargs.get('tithi_num', 1)) + if name == 'patyayini': + return calc_fn(birth_date, kwargs.get('asc_sign_idx', 0)) + if name == 'naisargika': + return calc_fn(birth_date) return calc_fn(birth_date, **kwargs) - except: + except Exception: return calc_generic_dasha(birth_date, DASHA_ORDER, [4]*9) return [] diff --git a/scripts/jaimini.py b/scripts/jaimini.py index 73fb79c8..09a6cf9e 100644 --- a/scripts/jaimini.py +++ b/scripts/jaimini.py @@ -16,6 +16,7 @@ MIT复用来源: - jaimini-tropical (tunanfang-pixel): Pada命名、Graha Pada与Jaimini特殊点结构 """ from typing import Dict, List, Tuple, Optional +from datetime import datetime, timedelta import math SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', @@ -655,6 +656,148 @@ def _karakamsha_interpretations(sign, lord): } +def _julian_day(year: int, month: int, day: int, hour: float = 12.0) -> float: + if month <= 2: + year -= 1 + month += 12 + a = int(year / 100) + b = 2 - a + int(a / 4) + return ( + int(365.25 * (year + 4716)) + + int(30.6001 * (month + 1)) + + day + + b + - 1524.5 + + hour / 24.0 + ) + + +def _solar_obliquity(jd: float) -> float: + t = (jd - 2451545.0) / 36525.0 + eps0 = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t * t * t + return eps0 / 3600.0 + + +def calc_sunrise_utc_hours(year: int, month: int, day: int, lat: float, lon: float) -> float: + """ + Calculate sunrise UTC decimal hours for a date/location. + + MIT-adapted from jaimini-tropical's sunrise helper; accuracy is sufficient for + product-facing Special Lagna timing and avoids a hard dependency on SwissEph. + """ + jd = _julian_day(year, month, day, 12.0) + mean_anomaly = (357.5291 + 0.98560028 * (jd - 2451545.0)) % 360 + center = ( + 1.9148 * math.sin(math.radians(mean_anomaly)) + + 0.0200 * math.sin(math.radians(2 * mean_anomaly)) + + 0.0003 * math.sin(math.radians(3 * mean_anomaly)) + ) + sun_lon = (mean_anomaly + center + 180.10248 + 0.000048 * (jd - 2451545.0) * 360) % 360 + eps = _solar_obliquity(jd) + declination = math.degrees( + math.asin(math.sin(math.radians(sun_lon)) * math.sin(math.radians(eps))) + ) + lat_rad = math.radians(lat) + dec_rad = math.radians(declination) + cos_ha = ( + math.cos(math.radians(90.833)) + - math.sin(lat_rad) * math.sin(dec_rad) + ) / (math.cos(lat_rad) * math.cos(dec_rad)) + cos_ha = max(-1.0, min(1.0, cos_ha)) + hour_angle = math.degrees(math.acos(cos_ha)) + + b = math.radians(360.0 * (jd - 2451545.0 - 0.5) / 365.25) + eq_time = 229.18 * ( + 0.000075 + + 0.001868 * math.cos(b) + - 0.032077 * math.sin(b) + - 0.014615 * math.cos(2 * b) + - 0.040849 * math.sin(2 * b) + ) + solar_noon = (720.0 - 4.0 * lon - eq_time) / 60.0 + return (solar_noon - hour_angle / 15.0) % 24.0 + + +def _elapsed_ghatis_from_sunrise(sunrise_utc_hours: float, birth_utc_hours: float) -> float: + diff_hours = birth_utc_hours - sunrise_utc_hours + if diff_hours < 0: + diff_hours += 24 + return diff_hours / 0.4 + + +def _special_lagna_payload(name: str, full_name: str, sign_idx: int, degree_in_sign: float, + ghatis_elapsed: float, night_birth: bool = False) -> Dict: + sign = _sign_name(sign_idx) + payload = { + 'name': name, + 'full_name': full_name, + 'sign': sign, + 'sign_idx': sign_idx % 12, + 'lord': SIGN_LORDS[sign], + 'degree_in_sign': round(degree_in_sign % 30, 4), + 'longitude': round((sign_idx % 12) * 30 + (degree_in_sign % 30), 4), + 'ghatis_elapsed': round(ghatis_elapsed, 4), + } + if night_birth: + payload['night_birth'] = True + return payload + + +def calc_special_lagnas_precise( + asc_sign_idx: int, + year: int, + month: int, + day: int, + hour: int, + minute: int = 0, + lat: float = 0.0, + lon: float = 0.0, + tz_offset: float = 0.0, +) -> Dict: + """ + Sunrise-correct Jaimini Special Lagnas: HL/GL/VL. + + Uses local birth time, converts it to UTC, calculates local sunrise in UTC, + then maps elapsed Ghatis from sunrise to HL/GL. VL remains Ascendant-derived. + """ + local_dt = datetime(year, month, day, int(hour), int(minute)) + utc_dt = local_dt - timedelta(hours=tz_offset) + birth_utc_hours = utc_dt.hour + utc_dt.minute / 60.0 + utc_dt.second / 3600.0 + sunrise_utc = calc_sunrise_utc_hours(utc_dt.year, utc_dt.month, utc_dt.day, lat, lon) + ghatis = _elapsed_ghatis_from_sunrise(sunrise_utc, birth_utc_hours) + ghati_floor = int(ghatis) + fraction = ghatis - ghati_floor + + hl_idx = ghati_floor % 12 if ghati_floor % 2 else (7 - ghati_floor) % 12 + gl_idx = ghati_floor % 12 + vl_idx = (asc_sign_idx * 3) % 12 + + sunrise_local_hours = (sunrise_utc + tz_offset) % 24 + sunrise_local_minutes = int(round(sunrise_local_hours * 60)) % (24 * 60) + local_birth_hours = int(hour) + int(minute) / 60.0 + before_sunrise = local_birth_hours < sunrise_local_hours + return { + 'method': 'Special Lagnas HL/GL/VL sunrise-correct (jaimini-tropical MIT adapted)', + 'capability_status': 'covered', + 'precision': 'sunrise_correct', + 'sunrise_utc_hours': round(sunrise_utc, 4), + 'sunrise_local_time': f'{sunrise_local_minutes // 60:02d}:{sunrise_local_minutes % 60:02d}', + 'birth_utc_hours': round(birth_utc_hours, 4), + 'ghatis_elapsed_from_sunrise': round(ghatis, 4), + 'HL': _special_lagna_payload('HL', 'Hora Lagna', hl_idx, fraction * 30.0, ghatis, before_sunrise), + 'GL': _special_lagna_payload('GL', 'Ghati Lagna', gl_idx, fraction * 30.0, ghatis, before_sunrise), + 'VL': { + 'name': 'VL', + 'full_name': 'Varnada Lagna', + 'sign': _sign_name(vl_idx), + 'sign_idx': vl_idx, + 'lord': SIGN_LORDS[_sign_name(vl_idx)], + 'method': 'Ascendant sign × 3', + }, + 'note': 'HL/GL以出生地日出为起点计算;GL对24分钟边界敏感,出生时间不准时应结合生时校正。' + } + + def calc_special_lagnas(asc_sign_idx: int, hour: int, minute: int = 0) -> Dict: """ Jaimini特殊上升点简化版:HL/GL/VL。 diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index b2fce008..59c3a030 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -7,17 +7,134 @@ 启动: python3 scripts/jyotish_api_server.py --port 5200 """ +import argparse +import base64 +import io import json, sys, os, math +import importlib.util +import re from datetime import datetime, timedelta from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.parse import urlparse, parse_qs +from urllib.parse import urlparse 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) +_LOCAL_MODULE_CACHE = {} + + +def _load_local_module(module_name): + cached = _LOCAL_MODULE_CACHE.get(module_name) + if cached: + return cached + module_path = os.path.join(SCRIPTS_DIR, f'{module_name}.py') + spec = importlib.util.spec_from_file_location(f'_jyotish_local_{module_name}', module_path) + if not spec or not spec.loader: + raise ImportError(f'Cannot load local module: {module_name}') + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + _LOCAL_MODULE_CACHE[module_name] = module + return module SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', 'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] +DEFAULT_ALLOWED_ORIGINS = { + 'http://localhost:3456', + 'http://127.0.0.1:3456', + 'http://localhost:3457', + 'http://127.0.0.1:3457', + 'http://localhost:5173', + 'http://127.0.0.1:5173', +} +MAX_REQUEST_BYTES = 2 * 1024 * 1024 +MAX_IMPORT_FILE_BYTES = 1536 * 1024 +MAX_IMPORT_TEXT_CHARS = 500_000 +MAX_REPORT_HTML_CHARS = 1_200_000 +MAX_REPORT_BASE64_BYTES = 8 * 1024 * 1024 +REPORT_ARTIFACT_DIR = os.path.join('/private/tmp', 'jyotish-reports') + +API_COMMAND_MAP = { + 'chart': '/api/chart', + 'kp': '/api/kp', + 'prashna': '/api/prashna', + 'synastry': '/api/synastry', + 'ashtakoot': '/api/synastry', + 'dasha': '/api/dasha', + 'remedies': '/api/remedies', + 'sade_sati': '/api/sade_sati', + 'pancha_mahapurusha': '/api/pancha_mahapurusha', + 'career': '/api/career', + 'relationship': '/api/relationship', + 'full-reading': '/api/chart', + 'tajika': '/api/annual', + 'solar-return': '/api/annual', + 'muhurta': '/api/muhurta', + 'panchanga-range': '/api/panchanga_range', + 'bhava-chalit': '/api/bhava_chalit', + 'sudarshana': '/api/sudarshana', + 'nakshatra-full': '/api/nakshatra_full', + 'varga-full': '/api/varga_full', + 'jaimini': '/api/jaimini', + 'ashtakavarga': '/api/ashtakavarga', + 'shadbala': '/api/shadbala', + 'yoga': '/api/yogas', + 'aspects': '/api/aspects', + 'rectification': '/api/rectification_gate', + 'case-validation': '/api/case_validation', + 'divisional-yoga': '/api/divisional_yoga', + 'deep-varga-avastha': '/api/deep_varga_avastha', + 'kakshya': '/api/kakshya', + 'bhava-bala': '/api/bhava_bala', + 'transit-trigger': '/api/transit', + 'audit-capabilities': '/api/capability_audit', + 'thematic-report': '/api/thematic_report', + 'report-artifact': '/api/report_artifact', +} + +TECHNIQUE_EXAMPLE_ENDPOINTS = { + '/api/ashtakavarga', + '/api/bhava_bala', + '/api/bhava_chalit', + '/api/career', + '/api/case_validation', + '/api/dasha', + '/api/divisional_yoga', + '/api/deep_varga_avastha', + '/api/jaimini', + '/api/kakshya', + '/api/kp', + '/api/muhurta', + '/api/nakshatra_full', + '/api/pancha_mahapurusha', + '/api/prashna', + '/api/rectification_gate', + '/api/relationship', + '/api/remedies', + '/api/sade_sati', + '/api/shadbala', + '/api/sudarshana', + '/api/synastry', + '/api/thematic_report', + '/api/transit', + '/api/varga_full', + '/api/yogas', +} + +SAMPLE_PLANETS = { + 'Sun': {'lon': 280.0, 'degree': 10.0, 'degree_in_sign': 10.0, 'sign_idx': 9, 'sign': 'Capricorn', 'house': 10}, + 'Moon': {'lon': 123.0, 'degree': 3.0, 'degree_in_sign': 3.0, 'sign_idx': 4, 'sign': 'Leo', 'house': 5}, + 'Mars': {'lon': 210.0, 'degree': 0.0, 'degree_in_sign': 0.0, 'sign_idx': 7, 'sign': 'Scorpio', 'house': 8}, + 'Mercury': {'lon': 275.0, 'degree': 5.0, 'degree_in_sign': 5.0, 'sign_idx': 9, 'sign': 'Capricorn', 'house': 10}, + 'Jupiter': {'lon': 15.0, 'degree': 15.0, 'degree_in_sign': 15.0, 'sign_idx': 0, 'sign': 'Aries', 'house': 1}, + 'Venus': {'lon': 330.0, 'degree': 0.0, 'degree_in_sign': 0.0, 'sign_idx': 11, 'sign': 'Pisces', 'house': 12}, + 'Saturn': {'lon': 300.0, 'degree': 0.0, 'degree_in_sign': 0.0, 'sign_idx': 10, 'sign': 'Aquarius', 'house': 11}, + 'Rahu': {'lon': 45.0, 'degree': 15.0, 'degree_in_sign': 15.0, 'sign_idx': 1, 'sign': 'Taurus', 'house': 2}, + 'Ketu': {'lon': 225.0, 'degree': 15.0, 'degree_in_sign': 15.0, 'sign_idx': 7, 'sign': 'Scorpio', 'house': 8}, +} +SAMPLE_ASCENDANT = {'sign': 'Aries', 'sign_idx': 0, 'degree': 12.0, 'degree_in_sign': 12.0, 'lon': 12.0} + # 城市数据库(简化版) CITY_DB = { '北京': (39.9, 116.4, 8), '上海': (31.2, 121.5, 8), '广州': (23.1, 113.3, 8), @@ -36,34 +153,54 @@ CITY_DB = { } +class BadRequest(ValueError): + """Client-side request validation failed.""" + + class JyotishAPIHandler(BaseHTTPRequestHandler): + server_version = 'JyotishAPI/6.9.14' + def _json(self, data, status=200): self.send_response(status) self.send_header('Content-Type', 'application/json; charset=utf-8') - self.send_header('Access-Control-Allow-Origin', '*') + self._send_cors_headers() + self.send_header('X-Content-Type-Options', 'nosniff') self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') - self.send_header('Access-Control-Allow-Headers', 'Content-Type') + self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization') + self.send_header('Vary', 'Origin') self.end_headers() self.wfile.write(json.dumps(data, ensure_ascii=False, default=str).encode()) + def _send_cors_headers(self): + origin = self.headers.get('Origin') + allowed = getattr(self.server, 'allowed_origins', DEFAULT_ALLOWED_ORIGINS) + if origin in allowed: + self.send_header('Access-Control-Allow-Origin', origin) + def do_OPTIONS(self): self._json({}) def do_GET(self): path = urlparse(self.path).path if path == '/api/health': - self._json({'status': 'ok', 'version': '6.9.14', 'modules': 'KP/Synastry/Prashna/Remedies/PMC/SadeSati'}) + self._json({ + 'status': 'ok', + 'version': '6.9.14', + 'modules': 'Chart/KP/Synastry/Prashna/Remedies/Dasha/Varga/Jaimini/Ashtakavarga/Shadbala/Yoga/Aspects/Tajika/Muhurta/BhavaChalit/BhavaBala/Sudarshana/Nakshatra/Transit/RectificationGate/CaseValidation/DivisionalYoga/Kakshya', + }) elif path == '/api/cities': self._json(list(CITY_DB.keys())) + elif path == '/api/capability_audit': + self._json(self._capability_audit()) + elif path == '/api/technique_catalog': + self._json(self._technique_catalog()) else: self._json({'error': 'Not found'}, 404) def do_POST(self): path = urlparse(self.path).path - length = int(self.headers.get('Content-Length', 0)) - body = json.loads(self.rfile.read(length)) if length > 0 else {} - try: + body = self._read_json_body() if path == '/api/chart': result = self._compute_chart(body) self._json(result) @@ -79,6 +216,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): elif path == '/api/synastry': result = self._compute_synastry(body) self._json(result) + elif path == '/api/dasha': + result = self._compute_dasha_system(body) + self._json(result) elif path == '/api/sade_sati': result = self._compute_sade_sati(body) self._json(result) @@ -91,27 +231,1251 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): elif path == '/api/relationship': result = self._compute_relationship(body) self._json(result) + elif path == '/api/import_chart': + result = self._import_chart_text(body) + self._json(result) + elif path == '/api/report_artifact': + result = self._compute_report_artifact(body) + self._json(result) + elif path == '/api/annual': + result = self._compute_annual(body) + self._json(result) + elif path == '/api/muhurta': + result = self._compute_muhurta(body) + self._json(result) + elif path == '/api/panchanga_range': + result = self._compute_panchanga_range(body) + self._json(result) + elif path == '/api/bhava_chalit': + result = self._compute_bhava_chalit(body) + self._json(result) + elif path == '/api/sudarshana': + result = self._compute_sudarshana(body) + self._json(result) + elif path == '/api/nakshatra_full': + result = self._compute_nakshatra_full(body) + self._json(result) + elif path == '/api/varga_full': + result = self._compute_varga_full(body) + self._json(result) + elif path == '/api/jaimini': + result = self._compute_jaimini(body) + self._json(result) + elif path == '/api/ashtakavarga': + result = self._compute_ashtakavarga(body) + self._json(result) + elif path == '/api/shadbala': + result = self._compute_shadbala(body) + self._json(result) + elif path == '/api/yogas': + result = self._compute_yogas_api(body) + self._json(result) + elif path == '/api/aspects': + result = self._compute_aspects(body) + self._json(result) + elif path == '/api/rectification_gate': + result = self._compute_rectification_gate(body) + self._json(result) + elif path == '/api/case_validation': + result = self._compute_case_validation(body) + self._json(result) + elif path == '/api/divisional_yoga': + result = self._compute_divisional_yoga(body) + self._json(result) + elif path == '/api/deep_varga_avastha': + result = self._compute_deep_varga_avastha(body) + self._json(result) + elif path == '/api/kakshya': + result = self._compute_kakshya(body) + self._json(result) + elif path == '/api/bhava_bala': + result = self._compute_bhava_bala_api(body) + self._json(result) + elif path == '/api/transit': + result = self._compute_transit_triggers(body) + self._json(result) + elif path == '/api/thematic_report': + result = self._compute_thematic_report(body) + self._json(result) + elif path == '/api/technique_example': + result = self._compute_technique_example(body) + self._json(result) else: self._json({'error': f'Unknown endpoint: {path}'}, 404) + except BadRequest as e: + self._json({'error': str(e)}, 400) + except Exception: + import logging + logging.exception("[api_server] request failed for %s", path) + self._json({'error': 'Internal server error'}, 500) + + def _read_json_body(self): + raw_length = self.headers.get('Content-Length', '0') + try: + length = int(raw_length) + except ValueError as e: + raise BadRequest('Invalid Content-Length') from e + if length < 0: + raise BadRequest('Invalid Content-Length') + if length > MAX_REQUEST_BYTES: + raise BadRequest(f'Request body too large; max {MAX_REQUEST_BYTES} bytes') + if length == 0: + return {} + try: + body = json.loads(self.rfile.read(length)) + except json.JSONDecodeError as e: + raise BadRequest('Invalid JSON body') from e + if not isinstance(body, dict): + raise BadRequest('JSON body must be an object') + return body + + def _get_int(self, body, key, default, min_value=None, max_value=None): + value = body.get(key, default) + try: + number = int(value) + except (TypeError, ValueError) as e: + raise BadRequest(f'{key} must be an integer') from e + self._check_range(key, number, min_value, max_value) + return number + + def _get_float(self, body, key, default, min_value=None, max_value=None): + value = body.get(key, default) + try: + number = float(value) + except (TypeError, ValueError) as e: + raise BadRequest(f'{key} must be a number') from e + if not math.isfinite(number): + raise BadRequest(f'{key} must be finite') + self._check_range(key, number, min_value, max_value) + return number + + def _check_range(self, key, number, min_value, max_value): + if min_value is not None and number < min_value: + raise BadRequest(f'{key} must be >= {min_value}') + if max_value is not None and number > max_value: + raise BadRequest(f'{key} must be <= {max_value}') + + def _validate_planets(self, planets): + if not isinstance(planets, dict): + raise BadRequest('planets must be an object') + return planets + + def _normalize_degree(self, body, key, default): + return self._get_float(body, key, default, 0, 360) % 360 + + def _safe_report_slug(self, value): + slug = re.sub(r'[^a-zA-Z0-9._-]+', '-', str(value or 'jyotish-report')).strip('-._') + return (slug[:80] or 'jyotish-report') + + def _validate_report_html(self, html): + if not isinstance(html, str) or not html.strip(): + raise BadRequest('html must be a non-empty string') + if len(html) > MAX_REPORT_HTML_CHARS: + raise BadRequest(f'html too large; max {MAX_REPORT_HTML_CHARS} characters') + active_patterns = [ + r'<\s*script\b', + r'<\s*iframe\b', + r'<\s*object\b', + r'<\s*embed\b', + r'\son[a-z]+\s*=', + r'javascript\s*:', + ] + if any(re.search(pattern, html, re.IGNORECASE) for pattern in active_patterns): + raise BadRequest('report html cannot include active content') + return html + + def _artifact_base64(self, path): + size = os.path.getsize(path) + if size > MAX_REPORT_BASE64_BYTES: + return None + with open(path, 'rb') as fh: + return base64.b64encode(fh.read()).decode('ascii') + + def _compute_report_artifact(self, body): + html = self._validate_report_html(body.get('html')) + fmt = body.get('format', 'html') + if fmt not in {'html', 'pdf'}: + raise BadRequest('format must be html or pdf') + slug = self._safe_report_slug(body.get('name') or body.get('filename')) + stamp = datetime.utcnow().strftime('%Y%m%d-%H%M%S-%f') + os.makedirs(REPORT_ARTIFACT_DIR, exist_ok=True) + html_filename = f'{slug}-{stamp}.html' + html_path = os.path.join(REPORT_ARTIFACT_DIR, html_filename) + with open(html_path, 'w', encoding='utf-8') as fh: + fh.write(html) + + result = { + 'success': True, + 'endpoint': 'report_artifact', + 'format': fmt, + 'html_filename': html_filename, + 'html_path': html_path, + 'html_size_kb': round(os.path.getsize(html_path) / 1024, 1), + 'html_base64': self._artifact_base64(html_path), + 'mime': 'text/html;charset=utf-8', + 'source': 'scripts/report_builder.py', + 'artifact_status': 'html_ready', + 'primary_artifact': 'html', + 'download_filename': html_filename, + 'download_mime': 'text/html;charset=utf-8', + 'fallback_reason': None, + 'user_message': 'HTML report artifact generated.', + 'next_action': 'Open the downloaded HTML file directly, or print it to PDF from the browser.', + 'delivery': { + 'artifact_status': 'html_ready', + 'format': 'html', + 'filename': html_filename, + 'mime': 'text/html;charset=utf-8', + 'fallback': False, + 'user_message': 'HTML report artifact generated.', + 'next_action': 'Open the downloaded HTML file directly, or print it to PDF from the browser.', + }, + } + if fmt == 'pdf': + pdf_filename = f'{slug}-{stamp}.pdf' + pdf_path = os.path.join(REPORT_ARTIFACT_DIR, pdf_filename) + try: + ok = _load_local_module('report_builder')._html_to_pdf(html_path, pdf_path) + except Exception as exc: + ok = False + result['pdf_error'] = str(exc) + result.update({ + 'pdf_available': bool(ok and os.path.exists(pdf_path)), + 'pdf_filename': pdf_filename, + 'pdf_path': pdf_path if os.path.exists(pdf_path) else None, + }) + if result['pdf_available']: + result.update({ + 'format': 'pdf', + 'mime': 'application/pdf', + 'pdf_size_kb': round(os.path.getsize(pdf_path) / 1024, 1), + 'pdf_base64': self._artifact_base64(pdf_path), + 'artifact_status': 'pdf_ready', + 'primary_artifact': 'pdf', + 'download_filename': pdf_filename, + 'download_mime': 'application/pdf', + 'fallback_reason': None, + 'user_message': 'PDF report artifact generated.', + 'next_action': 'Open the downloaded PDF file for reading, printing, or archiving.', + 'delivery': { + 'artifact_status': 'pdf_ready', + 'format': 'pdf', + 'filename': pdf_filename, + 'mime': 'application/pdf', + 'fallback': False, + 'user_message': 'PDF report artifact generated.', + 'next_action': 'Open the downloaded PDF file for reading, printing, or archiving.', + }, + }) + else: + result['fallback'] = 'html' + result['message'] = 'PDF renderer unavailable; HTML artifact generated as fallback.' + result['fallback_reason'] = result.get('pdf_error') or 'PDF renderer unavailable' + result['artifact_status'] = 'pdf_fallback_html_ready' + result['primary_artifact'] = 'html' + result['download_filename'] = html_filename + result['download_mime'] = 'text/html;charset=utf-8' + result['user_message'] = 'PDF renderer unavailable; HTML report artifact generated as fallback.' + result['next_action'] = 'Open the downloaded HTML file directly, or print it to PDF from the browser.' + result['delivery'] = { + 'artifact_status': 'pdf_fallback_html_ready', + 'format': 'html', + 'filename': html_filename, + 'mime': 'text/html;charset=utf-8', + 'fallback': True, + 'fallback_reason': result['fallback_reason'], + 'user_message': result['user_message'], + 'next_action': result['next_action'], + } + return result + + def _compute_thematic_report(self, body): + report_orchestrator = _load_local_module('report_orchestrator') + 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 + + 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): + derived_context = self._derive_thematic_evidence(chart_data, report_orchestrator) + + if derived_context: + chart = self._build_thematic_chart_data(derived_context.get('chart_data', chart_data), report_orchestrator) + else: + chart = self._build_thematic_chart_data(chart_data, report_orchestrator) + orchestrator = report_orchestrator.ThematicReportOrchestrator(chart) + + if has_custom_evidence: + self._inject_thematic_evidence(orchestrator, custom_evidence, report_orchestrator) + mode = 'custom_evidence' + elif derived_context and derived_context.get('evidence'): + self._inject_thematic_evidence(orchestrator, derived_context['evidence'], report_orchestrator) + mode = 'derived_chart_evidence' + else: + self._inject_sample_thematic_evidence(orchestrator, report_orchestrator) + mode = 'sample_evidence' + + theme_values = self._requested_thematic_report_themes(body, report_orchestrator) + reports = {} + for theme in theme_values: + report = orchestrator.generate_report(theme) + reports[theme.value] = report.to_dict() + + return { + 'success': True, + 'endpoint': 'thematic_report', + 'source': 'scripts/report_orchestrator.py', + 'fragment_sources': [ + 'report_orchestrator.py', + 'reading_orchestrator.py', + 'orchestrator_bridge.py', + ], + 'workflow_orchestration': self._thematic_workflow_status( + reading_orchestrator, + orchestrator_bridge, + report_orchestrator, + theme_values, + ), + 'mode': mode, + 'evidence_source': 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 _thematic_workflow_status(self, reading_orchestrator, orchestrator_bridge, report_orchestrator, theme_values): + reading_themes = [] + for theme in getattr(reading_orchestrator, 'ReadingTheme'): + reading_themes.append({ + 'key': theme.value, + 'label': theme.name, + }) + report_themes = [theme.value for theme in getattr(report_orchestrator, 'ThemeName')] + selected = [theme.value for theme in theme_values] + return { + 'stage': 'report_pipeline_bridge', + 'reading_theme_count': len(reading_themes), + 'reading_themes': reading_themes, + 'report_themes': report_themes, + 'selected_report_themes': selected, + 'bridge': { + 'class': getattr(orchestrator_bridge, 'OrchestratorBridge').__name__, + 'capabilities': [ + 'chapter_to_technique_results', + 'inject_dasha_results', + 'inject_full_reading_modules', + ], + }, + 'boundary': 'reading_orchestrator 的 registry 执行层尚未绑定到全部实时 API;当前产品路径使用 report_orchestrator 生成报告,并显式声明桥接能力。', + } + + def _requested_thematic_report_themes(self, body, report_orchestrator): + raw = body.get('themes', body.get('theme', 'all')) + if raw in (None, '', 'all'): + values = [theme.value for theme in report_orchestrator.ThemeName] + elif isinstance(raw, str): + values = [raw] + elif isinstance(raw, list): + values = raw + else: + raise BadRequest('theme/themes must be a string, list, or all') + mapping = {theme.value: theme for theme in report_orchestrator.ThemeName} + themes = [] + for value in values: + key = str(value).strip().lower() + if key not in mapping: + raise BadRequest(f'Unknown thematic report theme: {value}') + themes.append(mapping[key]) + return themes or list(report_orchestrator.ThemeName) + + def _build_thematic_chart_data(self, raw, report_orchestrator): + if not isinstance(raw, dict) or not raw: + return report_orchestrator.MockDataFactory.create_sample_chart() + birth_chart_data = report_orchestrator.BirthChartData + dasha_timeline = self._normalize_thematic_dasha_timeline(raw) + return birth_chart_data( + d1_houses=self._safe_dict(raw.get('d1_houses') or raw.get('houses')), + d9_houses=self._safe_dict(raw.get('d9_houses') or raw.get('navamsa_houses')), + d10_houses=self._safe_dict(raw.get('d10_houses') or raw.get('dashamsa_houses')), + d2_houses=self._safe_dict(raw.get('d2_houses')), + d20_houses=self._safe_dict(raw.get('d20_houses')), + d30_houses=self._safe_dict(raw.get('d30_houses')), + d60_houses=self._safe_dict(raw.get('d60_houses')), + planets=self._safe_dict(raw.get('planets')), + current_dasha=self._current_thematic_dasha_label(raw), + dasha_timeline=dasha_timeline, + yogas=raw.get('yogas') if isinstance(raw.get('yogas'), list) else [], + ashtakavarga=self._safe_dict(raw.get('ashtakavarga')), + ) + + def _safe_dict(self, value): + return value if isinstance(value, dict) else {} + + def _current_thematic_dasha_label(self, raw): + dasha = raw.get('dasha') if isinstance(raw.get('dasha'), dict) else {} + current = dasha.get('current_dasha') if isinstance(dasha.get('current_dasha'), dict) else {} + md = dasha.get('current_md') or current.get('mahadasha') or current.get('maha') or current.get('lord') or raw.get('current_md') + ad = dasha.get('current_ad') or current.get('antardasha') or current.get('antar') or raw.get('current_ad') + if md and ad: + return f'{md}-{ad}' + return str(md or raw.get('current_dasha') or '') + + def _normalize_thematic_dasha_timeline(self, raw): + dasha = raw.get('dasha') if isinstance(raw.get('dasha'), dict) else {} + source = raw.get('dasha_timeline') or dasha.get('timeline') or raw.get('periods') or [] + if not isinstance(source, list): + return [] + timeline = [] + for period in source[:24]: + if not isinstance(period, dict): + continue + start_year = self._year_from_any(period.get('start') or period.get('start_date'), datetime.utcnow().year) + end_year = self._year_from_any(period.get('end') or period.get('end_date'), start_year + 1) + maha = self._thematic_period_lord(period.get('mahadasha') or period.get('maha') or period.get('lord') or period.get('name')) + antar = self._thematic_period_lord(period.get('antardasha') or period.get('antar') or period.get('sub_lord')) + timeline.append({ + 'mahadasha': maha or 'Unknown', + 'antardasha': antar or 'Unknown', + 'start': start_year, + 'end': end_year, + }) + return timeline + + def _thematic_period_lord(self, value): + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(value, dict): + for key in ('lord', 'planet', 'name', 'mahadasha', 'maha', 'antardasha', 'antar'): + item = value.get(key) + if isinstance(item, str) and item.strip(): + return item.strip() + return '' + + def _year_from_any(self, value, default): + if isinstance(value, (int, float)): + return int(value) + match = re.search(r'\d{4}', str(value or '')) + return int(match.group(0)) if match else default + + def _inject_sample_thematic_evidence(self, orchestrator, report_orchestrator): + factory = report_orchestrator.MockDataFactory() + orchestrator.add_techniques(report_orchestrator.ThemeName.MARRIAGE, factory.create_marriage_techniques()) + orchestrator.add_techniques(report_orchestrator.ThemeName.CAREER, factory.create_career_techniques()) + orchestrator.add_techniques(report_orchestrator.ThemeName.WEALTH, factory.create_wealth_techniques()) + orchestrator.add_techniques(report_orchestrator.ThemeName.HEALTH, factory.create_health_techniques()) + orchestrator.add_techniques(report_orchestrator.ThemeName.SPIRITUALITY, factory.create_spirituality_techniques()) + + def _inject_thematic_evidence(self, orchestrator, evidence, report_orchestrator): + theme_map = {theme.value: theme for theme in report_orchestrator.ThemeName} + strength_map = {level.value: level for level in report_orchestrator.StrengthLevel} + for theme_name, items in evidence.items(): + theme = theme_map.get(str(theme_name).strip().lower()) + if not theme: + raise BadRequest(f'Unknown evidence theme: {theme_name}') + if not isinstance(items, list): + raise BadRequest('evidence theme values must be arrays') + results = [] + for index, item in enumerate(items[:40]): + if not isinstance(item, dict): + raise BadRequest('evidence items must be objects') + strength = strength_map.get(str(item.get('strength', 'moderate')).strip().lower(), report_orchestrator.StrengthLevel.MODERATE) + results.append(report_orchestrator.TechniqueResult( + technique=str(item.get('technique') or f'{theme.value}_evidence_{index + 1}')[:80], + chart=str(item.get('chart') or 'D1')[:24], + conclusion=str(item.get('conclusion') or item.get('summary') or '未提供结论')[:800], + sentiment=str(item.get('sentiment') or 'neutral').strip().lower(), + strength=strength, + details=item.get('details') if isinstance(item.get('details'), dict) else {}, + )) + orchestrator.add_techniques(theme, results) + + def _can_derive_thematic_evidence(self, raw): + if not isinstance(raw, dict) or not raw: + return False + has_birth = all(raw.get(key) is not None for key in ('year', 'month', 'day')) + has_planets = isinstance(raw.get('planets'), dict) and bool(raw.get('planets')) + return has_birth or has_planets + + def _derive_thematic_evidence(self, raw, report_orchestrator): + warnings = [] + module_status = {} + + def collect(key, fn): + try: + value = fn() + module_status[key] = 'ok' + return value + except Exception as exc: + module_status[key] = 'warning' + warnings.append({'module': key, 'error': str(exc)[:240]}) + 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 + full_modules = full_reading.get('modules', {}) if isinstance(full_reading, dict) else {} + chart = None + if full_reading: + chart = self._chart_from_full_reading(full_reading) + if not chart and has_birth: + chart = collect('chart', lambda: self._compute_chart(raw)) + if not chart: + normalized, _, _ = self._normalized_planets_from_body(raw) + chart = { + 'success': True, + 'planets': normalized, + 'ascendant': raw.get('ascendant', SAMPLE_ASCENDANT), + 'houses': raw.get('houses', {}), + 'dasha': raw.get('dasha', {}), + 'yogas': raw.get('yogas', []), + 'source': 'provided_planets', + } + module_status.setdefault('chart', 'provided_planets') + + payload = dict(raw) + payload['planets'] = chart.get('planets') or payload.get('planets') or {} + payload['ascendant'] = chart.get('ascendant') or payload.get('ascendant') or SAMPLE_ASCENDANT + if chart.get('dasha') and not isinstance(payload.get('dasha'), str): + payload['dasha'] = chart.get('dasha') + asc_sign = payload.get('asc_sign') or payload['ascendant'].get('sign') or SIGNS[payload['ascendant'].get('sign_idx', 0)] + payload['asc_sign'] = asc_sign if asc_sign in SIGNS else SIGNS[0] + dasha_payload = {**payload, 'dasha': 'vimshottari'} + if chart.get('dasha', {}).get('current_md'): + dasha_payload['current_md'] = chart['dasha']['current_md'] + + dasha = full_modules.get('dasha') or collect('dasha', lambda: self._compute_dasha_system(dasha_payload)) + yogas = full_modules.get('yoga') or collect('yogas', lambda: self._compute_yogas_api(payload)) + shadbala = full_modules.get('shadbala') or collect('shadbala', lambda: self._compute_shadbala(payload)) + ashtakavarga = full_modules.get('ashtakavarga') or collect('ashtakavarga', lambda: self._compute_ashtakavarga(payload)) + relationship = collect('relationship', lambda: self._compute_relationship({ + **payload, + 'dasha_info': self._thematic_dasha_info(chart, dasha), + })) + career = collect('career', lambda: self._compute_career(payload)) + jaimini = full_modules.get('jaimini') or collect('jaimini', lambda: self._compute_jaimini({**payload, 'mode': 'all'})) + + enriched = { + **payload, + 'houses': chart.get('houses') or raw.get('houses') or {}, + 'dasha': dasha or chart.get('dasha') or {}, + 'periods': (dasha or {}).get('periods') or (dasha or {}).get('timeline') or [], + 'yogas': chart.get('yogas') or [], + 'ashtakavarga': ashtakavarga or {}, + } + if yogas and isinstance(yogas.get('result'), dict): + enriched['yogas'] = list(enriched['yogas']) + (yogas['result'].get('extended_yogas') or []) + elif isinstance(yogas, dict): + enriched['yogas'] = list(enriched['yogas']) + (yogas.get('yogas') or []) + + evidence = self._build_derived_thematic_evidence( + enriched, + dasha=dasha, + yogas=yogas, + shadbala=shadbala, + ashtakavarga=ashtakavarga, + relationship=relationship, + career=career, + jaimini=jaimini, + full_reading=full_reading, + full_modules=full_modules, + report_orchestrator=report_orchestrator, + ) + return { + 'chart_data': enriched, + 'evidence': evidence, + 'module_status': module_status, + 'warnings': warnings, + 'evidence_counts': {theme: len(items) for theme, items in evidence.items()}, + 'full_reading_used': bool(full_reading), + 'full_reading_summary': full_reading.get('summary', {}) if isinstance(full_reading, dict) else {}, + 'full_reading_module_count': len(full_modules) if isinstance(full_modules, dict) else 0, + } + + def _thematic_evidence_source(self, mode, derived_context, has_custom_evidence): + if mode == 'derived_chart_evidence' and derived_context: + full_reading_used = bool(derived_context.get('full_reading_used')) + return { + 'mode': mode, + 'source': 'full_reading_modules' if full_reading_used else 'birth_or_chart_payload', + 'sample_fallback': False, + 'full_reading_used': full_reading_used, + 'full_reading_module_count': derived_context.get('full_reading_module_count', 0), + 'full_reading_summary': derived_context.get('full_reading_summary', {}), + 'module_status': derived_context.get('module_status', {}), + 'warning_count': len(derived_context.get('warnings', [])), + 'warnings': derived_context.get('warnings', [])[:6], + 'evidence_counts': derived_context.get('evidence_counts', {}), + } + return { + 'mode': mode, + 'source': 'caller_evidence' if has_custom_evidence else 'report_orchestrator.MockDataFactory', + 'sample_fallback': mode == 'sample_evidence', + } + + def _build_derived_thematic_evidence(self, chart_data, **context): + evidence = { + 'marriage': [], + 'career': [], + 'wealth': [], + 'health': [], + 'spirituality': [], + } + evidence['marriage'].extend(self._derived_marriage_evidence(chart_data, context)) + evidence['career'].extend(self._derived_career_evidence(chart_data, context)) + evidence['wealth'].extend(self._derived_wealth_evidence(chart_data, context)) + evidence['health'].extend(self._derived_health_evidence(chart_data, context)) + evidence['spirituality'].extend(self._derived_spirituality_evidence(chart_data, context)) + return evidence + + def _derived_marriage_evidence(self, chart_data, context): + h7 = self._theme_house_snapshot(chart_data, 7) + relationship = context.get('relationship') or {} + timing = relationship.get('relationship_timing') if isinstance(relationship, dict) else {} + full_modules = context.get('full_modules') if isinstance(context.get('full_modules'), dict) else {} + items = [ + self._theme_evidence( + 'D1-7th-house', + 'D1', + f"第7宫为{h7['sign']},宫内星体:{h7['planets_label']};用于判断婚姻承诺与伴侣互动基调。", + 'neutral' if h7['planets_label'] == '无' else 'positive', + 'moderate', + source='chart', + details=h7, + ), + ] + spouse = relationship.get('spouse_status_yoga') if isinstance(relationship, dict) else {} + if spouse: + items.append(self._theme_evidence( + 'Spouse-status-yoga', + 'D1/D9', + spouse.get('summary') or spouse.get('headline') or '配偶状态 Yoga 已由关系引擎计算,可作为婚姻主题证据。', + 'positive' if spouse.get('score', 0) and spouse.get('score', 0) >= 50 else 'neutral', + 'moderate', + source='relationship', + details={'fragment': 'spouse_status_yoga.py', 'score': spouse.get('score')}, + )) + if timing: + items.append(self._theme_evidence( + 'DK-UL-Dasha timing', + 'Dasha', + timing.get('summary') or 'DK、UL 与 Dasha 触发已进入婚姻时机证据链。', + 'positive' if timing.get('level') in {'strong', 'watch'} else 'neutral', + 'strong' if timing.get('level') == 'strong' else 'moderate', + source='relationship_timing', + details={'fragment': timing.get('source'), 'clues': timing.get('timing_clues', [])}, + )) + marriage_counting = full_modules.get('marriage_counting') if isinstance(full_modules, dict) else {} + if isinstance(marriage_counting, dict) and marriage_counting.get('interpretation'): + items.append(self._theme_evidence( + 'Marriage-counting', + 'D1/D9', + marriage_counting.get('interpretation'), + 'positive' if (marriage_counting.get('marriage_count') or 0) <= 1 else 'neutral', + 'moderate', + source='full_reading.modules.marriage_counting', + details={ + 'marriage_count': marriage_counting.get('marriage_count'), + 'd9_marriage_quality': marriage_counting.get('d9_marriage_quality'), + }, + )) + vivah = full_modules.get('vivah_saham') if isinstance(full_modules, dict) else {} + if isinstance(vivah, dict) and vivah.get('vivah_saham'): + items.append(self._theme_evidence( + 'Vivah-saham', + 'Tajika', + vivah.get('note') or 'Vivah Saham 已由 full-reading 计算,可作为婚姻事件敏感点。', + 'neutral', + 'moderate', + source='full_reading.modules.vivah_saham', + details=vivah.get('vivah_saham') if isinstance(vivah.get('vivah_saham'), dict) else vivah, + )) + return items + + def _derived_career_evidence(self, chart_data, context): + h10 = self._theme_house_snapshot(chart_data, 10) + career = context.get('career') or {} + shadbala = context.get('shadbala') or {} + full_modules = context.get('full_modules') if isinstance(context.get('full_modules'), dict) else {} + top_strength = self._top_shadbala_planet(shadbala) + items = [ + self._theme_evidence( + 'D1-10th-house', + 'D1', + f"第10宫为{h10['sign']},宫内星体:{h10['planets_label']};这是事业角色、名望与外在职责的主轴。", + 'positive' if h10['planets'] else 'neutral', + 'moderate', + source='chart', + details=h10, + ), + ] + if career: + items.append(self._theme_evidence( + 'Career-engine', + 'D1', + self._first_text_from_dict(career, ['summary', 'career_summary', 'dominant_theme', 'recommendation']) or '事业引擎已根据行星与上升星座生成职业倾向。', + 'positive', + 'moderate', + source='career_analysis.py', + details={'keys': sorted(career.keys())[:10]}, + )) + if top_strength: + items.append(self._theme_evidence( + 'Shadbala-career-support', + 'Strength', + f"Shadbala 排名中 {top_strength['planet']} 支持度最高({top_strength['rupas']} rupas),可作为事业执行力/资源侧证。", + 'positive', + 'moderate', + source='shadbala', + details=top_strength, + )) + convergence = full_modules.get('dasa_convergence') if isinstance(full_modules, dict) else {} + top_domains = convergence.get('top_convergent_domains') if isinstance(convergence, dict) else [] + career_domain = next( + ( + row for row in top_domains + if isinstance(row, dict) and any(token in str(row.get('domain', '')).lower() for token in ('career', 'profession', 'status', 'work')) + ), + None, + ) + if not career_domain and isinstance(convergence.get('domain_activations'), dict): + for domain, row in convergence['domain_activations'].items(): + if any(token in str(domain).lower() for token in ('career', 'profession', 'status', 'work')): + career_domain = {'domain': domain, **(row if isinstance(row, dict) else {})} + break + if career_domain: + items.append(self._theme_evidence( + 'Dasa-convergence-career', + 'Multi-Dasha', + career_domain.get('interpretation') or '多 Dasha 收敛模块已命中事业相关领域,需结合 Transit 确认兑现窗口。', + 'positive', + 'strong' if career_domain.get('convergence_level') in {'L2', 'L3'} else 'moderate', + source='full_reading.modules.dasa_convergence', + details=career_domain, + )) + return items + + def _derived_wealth_evidence(self, chart_data, context): + h2 = self._theme_house_snapshot(chart_data, 2) + h11 = self._theme_house_snapshot(chart_data, 11) + ashtakavarga = context.get('ashtakavarga') or {} + av_summary = self._ashtakavarga_summary_for_theme(ashtakavarga) + full_modules = context.get('full_modules') if isinstance(context.get('full_modules'), dict) else {} + items = [ + self._theme_evidence( + '2nd-and-11th-house', + 'D1', + f"第2宫({h2['sign']})与第11宫({h11['sign']})共同描述收入、积累与收益网络。", + 'neutral', + 'moderate', + source='chart', + details={'second': h2, 'eleventh': h11}, + ), + ] + if av_summary: + strongest = av_summary.get('strongest_houses') or [] + wealth_hit = any(row.get('house') in (2, 11) for row in strongest) + items.append(self._theme_evidence( + 'Ashtakavarga-wealth', + 'AV', + av_summary.get('headline') or 'Ashtakavarga 已生成财富宫位支持度。', + 'positive' if wealth_hit else 'neutral', + 'strong' if wealth_hit else 'moderate', + source='ashtakavarga', + details={'strongest_houses': strongest, 'sav_total': av_summary.get('sav_total')}, + )) + yogas_doshas = full_modules.get('yogas_doshas') if isinstance(full_modules, dict) else {} + dhana = yogas_doshas.get('dhana_yogas') if isinstance(yogas_doshas, dict) else {} + dhana_yogas = dhana.get('yogas') if isinstance(dhana, dict) else [] + if dhana_yogas: + items.append(self._theme_evidence( + 'Dhana-yoga-full-reading', + 'D1', + dhana.get('summary') or f"full-reading 检出 {len(dhana_yogas)} 条 Dhana Yoga,可作为财富主题的直接证据。", + 'positive', + 'strong' if any(row.get('strength') == 'strong' for row in dhana_yogas if isinstance(row, dict)) else 'moderate', + source='full_reading.modules.yogas_doshas', + details={'yogas': dhana_yogas[:4]}, + )) + return items + + def _derived_health_evidence(self, chart_data, context): + h6 = self._theme_house_snapshot(chart_data, 6) + h8 = self._theme_house_snapshot(chart_data, 8) + h12 = self._theme_house_snapshot(chart_data, 12) + yogas = context.get('yogas') or {} + curse = ((yogas.get('result') or {}).get('curse_yogas') or {}) if isinstance(yogas, dict) else {} + risk = curse.get('overall_risk') + full_modules = context.get('full_modules') if isinstance(context.get('full_modules'), dict) else {} + items = [ + self._theme_evidence( + '6-8-12-health-axis', + 'D1', + f"健康轴线:6宫{h6['sign']}、8宫{h8['sign']}、12宫{h12['sign']},用于观察疾病、突发与消耗主题。", + 'negative' if any(row['planets'] for row in (h6, h8, h12)) else 'neutral', + 'moderate', + source='chart', + details={'sixth': h6, 'eighth': h8, 'twelfth': h12}, + ), + ] + if risk: + items.append(self._theme_evidence( + 'Curse-yoga-risk', + 'D1', + f"凶星合相风险层返回 {risk},作为健康/压力主题的高风险提示证据之一。", + 'negative' if risk in {'high', 'medium'} else 'neutral', + 'moderate', + source='curse_yoga_detector.py', + details={'risk': risk, 'detected': curse.get('curses_detected', [])[:4]}, + )) + validation = full_modules.get('validation') if isinstance(full_modules, dict) else {} + if isinstance(validation, dict) and validation.get('checked'): + items.append(self._theme_evidence( + 'Calculation-validation-gate', + 'Validation', + f"full-reading 完成 {validation.get('checked')} 项计算校验,通过 {validation.get('passed')} 项;健康判断可基于已校验星盘继续审慎解释。", + 'positive' if validation.get('valid') else 'negative', + 'strong' if validation.get('valid') else 'moderate', + source='full_reading.modules.validation', + details={ + 'valid': validation.get('valid'), + 'passed': validation.get('passed'), + 'failed': validation.get('failed'), + }, + )) + trimshamsa = full_modules.get('trimshamsa_d30') if isinstance(full_modules, dict) else {} + crisis = trimshamsa.get('marriage_crisis') if isinstance(trimshamsa, dict) else None + if crisis: + items.append(self._theme_evidence( + 'Trimshamsa-D30-risk', + 'D30', + 'D30 Trimshamsa 已进入压力/危机侧证;健康主题需把风险提示与现实医疗信息分开处理。', + 'negative' if isinstance(crisis, dict) and crisis.get('risk_level') in {'high', 'medium'} else 'neutral', + 'moderate', + source='full_reading.modules.trimshamsa_d30', + details=crisis if isinstance(crisis, dict) else {'crisis': crisis}, + )) + return items + + def _derived_spirituality_evidence(self, chart_data, context): + h9 = self._theme_house_snapshot(chart_data, 9) + h12 = self._theme_house_snapshot(chart_data, 12) + planets = chart_data.get('planets') if isinstance(chart_data.get('planets'), dict) else {} + jupiter = planets.get('Jupiter', {}) if isinstance(planets.get('Jupiter'), dict) else {} + ketu = planets.get('Ketu', {}) if isinstance(planets.get('Ketu'), dict) else {} + jaimini = context.get('jaimini') or {} + karakamsha = self._extract_jaimini_karakamsha(jaimini) + full_modules = context.get('full_modules') if isinstance(context.get('full_modules'), dict) else {} + items = [ + self._theme_evidence( + '9th-and-12th-spirit', + 'D1', + f"第9宫({h9['sign']})与第12宫({h12['sign']})显示信念、导师、修行与出离倾向。", + 'positive' if h9['planets'] or h12['planets'] else 'neutral', + 'moderate', + source='chart', + details={'ninth': h9, 'twelfth': h12}, + ), + self._theme_evidence( + 'Jupiter-Ketu-spiritual-karaka', + 'D1', + f"Jupiter 位于{jupiter.get('sign', '未知')}第{jupiter.get('house', '-')}宫,Ketu 位于{ketu.get('sign', '未知')}第{ketu.get('house', '-')}宫。", + 'positive' if jupiter.get('house') in (1, 5, 9, 12) or ketu.get('house') in (9, 12) else 'neutral', + 'moderate', + source='chart', + details={'Jupiter': jupiter, 'Ketu': ketu}, + ), + ] + if karakamsha: + items.append(self._theme_evidence( + 'Karakamsha', + 'D9/D1', + karakamsha.get('interpretation') or karakamsha.get('description') or 'Karakamsha 已由 Jaimini 模块计算,用于灵性使命与内在驱动力。', + 'positive', + 'moderate', + source='jaimini.py', + details=karakamsha, + )) + d20 = self._extract_vimsamsa_spiritual_context(full_modules) + if d20: + items.append(self._theme_evidence( + 'Vimsamsa-D20-spiritual-context', + 'D20', + d20.get('summary') or 'D20 Vimsamsa 分盘已进入灵性主题证据链,用于观察修行、信念与内在追求。', + 'positive', + 'moderate', + source=d20.get('source', 'full_reading.modules.varga_full'), + details=d20, + )) + return items + + def _theme_evidence(self, technique, chart, conclusion, sentiment, strength, *, source, details=None): + return { + 'technique': technique, + 'chart': chart, + 'conclusion': str(conclusion)[:800], + 'sentiment': sentiment, + 'strength': strength, + 'details': { + **(details if isinstance(details, dict) else {}), + 'source': source, + 'derived': True, + }, + } + + 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 {} + asc = chart_data.get('ascendant') if isinstance(chart_data.get('ascendant'), dict) else {} + if house.get('sign'): + sign = house.get('sign') + else: + asc_idx = self._sign_idx_from_value(asc.get('sign_idx', asc.get('sign')), 0) + sign = SIGNS[(asc_idx + house_num - 1) % 12] + planets = [] + for planet, pdata in (chart_data.get('planets') or {}).items(): + if isinstance(pdata, dict) and pdata.get('house') == house_num: + planets.append(planet) + return { + 'house': house_num, + 'sign': sign, + 'planets': planets, + 'planets_label': '、'.join(planets) if planets else '无', + } + + def _top_shadbala_planet(self, shadbala): + if not isinstance(shadbala, dict): + return None + result = shadbala.get('result') if isinstance(shadbala.get('result'), dict) else shadbala + planets = result.get('planets') if isinstance(result, dict) else {} + rows = [] + for planet, data in (planets or {}).items(): + if not isinstance(data, dict): + continue + rupas = data.get('total_rupas', data.get('rupas')) + if isinstance(rupas, (int, float)): + rows.append({'planet': planet, 'rupas': round(rupas, 2)}) + rows.sort(key=lambda row: row['rupas'], reverse=True) + return rows[0] if rows else None + + def _ashtakavarga_summary_for_theme(self, ashtakavarga): + if not isinstance(ashtakavarga, dict): + return {} + summary = ashtakavarga.get('summary') + if isinstance(summary, dict) and summary: + return summary + sav = ashtakavarga.get('sav') if isinstance(ashtakavarga.get('sav'), dict) else {} + scores = sav.get('scores') if isinstance(sav.get('scores'), dict) else {} + if not scores: + return {} + rows = [] + for sign, score in scores.items(): + if not isinstance(score, (int, float)): + continue + sign_idx = SIGNS.index(sign) if sign in SIGNS else None + house = sign_idx + 1 if sign_idx is not None else None + rows.append({'sign': sign, 'house': house, 'score': score}) + rows.sort(key=lambda row: row['score'], reverse=True) + total = sum(row['score'] for row in rows) + return { + 'headline': 'Ashtakavarga SAV 已由 full-reading 计算,财富宫位以第2/11宫支持度为重点。', + 'strongest_houses': rows[:4], + 'sav_total': total, + 'source': 'full_reading.modules.ashtakavarga', + } + + def _extract_jaimini_karakamsha(self, jaimini): + if not isinstance(jaimini, dict): + return {} + result = jaimini.get('result') if isinstance(jaimini.get('result'), dict) else jaimini + karakamsha = result.get('karakamsha') if isinstance(result, dict) else {} + return karakamsha if isinstance(karakamsha, dict) else {} + + def _extract_vimsamsa_spiritual_context(self, full_modules): + if not isinstance(full_modules, dict): + return {} + varga = full_modules.get('varga_full') + if not isinstance(varga, dict): + return {} + d20 = varga.get('D20_Vimsamsa') or varga.get('D20') or varga.get('Vimsamsa') + if not isinstance(d20, dict): + return {} + placements = [] + for planet in ('Jupiter', 'Ketu', 'Moon', 'Sun', 'Ascendant'): + pdata = d20.get(planet) + if isinstance(pdata, dict): + placements.append({ + 'planet': planet, + 'sign': pdata.get('sign'), + 'house': pdata.get('house') or pdata.get('house_in_d20'), + 'dignity': pdata.get('dignity'), + }) + if not placements: + return {} + return { + 'summary': 'D20 Vimsamsa 已从 full-reading 分盘层读取,Jupiter/Ketu/Moon/Sun/Ascendant 作为灵性主题侧证。', + 'placements': placements, + 'source': 'full_reading.modules.varga_full', + } + + def _first_text_from_dict(self, value, keys): + if not isinstance(value, dict): + return '' + for key in keys: + item = value.get(key) + if isinstance(item, str) and item.strip(): + return item.strip() + if isinstance(item, dict): + nested = self._first_text_from_dict(item, keys) + if nested: + return nested + return '' + + def _thematic_dasha_info(self, chart, dasha): + current = {} + if isinstance(chart.get('dasha'), dict): + current.update(chart['dasha']) + analysis = dasha.get('vimshottari_analysis') if isinstance(dasha, dict) else None + if isinstance(analysis, dict): + md = ((analysis.get('current') or {}).get('mahadasha') or {}).get('lord') + ad = ((analysis.get('current') or {}).get('antardasha') or {}).get('lord') + if md: + current['maha_dasha'] = md + if ad: + current['antar_dasha'] = ad + return current + + def _parse_birth_datetime(self, body): + 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) + hour = self._get_float(body, 'hour', 12, 0, 23) + minute = self._get_float(body, 'minute', 0, 0, 59) + try: + return datetime(year, month, day, int(hour), int(minute)) + except ValueError as e: + raise BadRequest('Invalid birth date') from e + + def _compute_full_reading_for_thematic(self, body): + engine = _load_local_module('jyotish_engine') + 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) + hour = self._get_float(body, 'hour', 12, 0, 23) + minute = self._get_float(body, 'minute', 0, 0, 59) + lat = self._get_float(body, 'lat', 39.9, -90, 90) + lon = self._get_float(body, 'lon', 116.4, -180, 180) + tz = self._get_float(body, 'tz', 8, -14, 14) + try: + datetime(year, month, day, int(hour), int(minute)) + except ValueError as e: + raise BadRequest('Invalid birth date') from e + + node_mode = body.get('node_mode', body.get('nodeMode', 'mean')) + if not isinstance(node_mode, str) or node_mode not in {'mean', 'true'}: + node_mode = 'mean' + + args = type('Args', (), { + 'year': year, + 'month': month, + 'day': day, + 'hour': int(hour), + 'minute': int(minute), + 'lat': lat, + 'lon': lon, + 'tz': tz, + 'node_mode': node_mode, + 'age': body.get('age'), + 'today': body.get('today') or body.get('current_date'), + 'transit_date': body.get('transit_date'), + 'target_year': body.get('target_year'), + })() + result = engine.cmd_full_reading(args) + if not isinstance(result, dict) or not isinstance(result.get('modules'), dict): + raise BadRequest('full-reading did not return modules') + return result + + def _chart_from_full_reading(self, full_reading): + if not isinstance(full_reading, dict): + return None + modules = full_reading.get('modules') if isinstance(full_reading.get('modules'), dict) else {} + chart = full_reading.get('chart') if isinstance(full_reading.get('chart'), dict) else modules.get('chart') + if not isinstance(chart, dict) or not isinstance(chart.get('planets'), dict): + return None + normalized = dict(chart) + normalized['success'] = True + normalized['planets'] = chart.get('planets') or {} + normalized['ascendant'] = chart.get('ascendant') or SAMPLE_ASCENDANT + normalized['houses'] = chart.get('houses') or modules.get('house_map') or {} + normalized['dasha'] = modules.get('dasha') or chart.get('dasha') or {} + yoga_module = modules.get('yoga') if isinstance(modules.get('yoga'), dict) else {} + normalized['yogas'] = ( + chart.get('yogas') + or yoga_module.get('yogas') + or yoga_module.get('detected_yogas') + or [] + ) + normalized['ashtakavarga'] = modules.get('ashtakavarga') or chart.get('ashtakavarga') or {} + normalized['shadbala'] = modules.get('shadbala') or chart.get('shadbala') or {} + normalized['birth'] = chart.get('birth_info') or full_reading.get('birth_info') or {} + normalized['source'] = 'full_reading.modules.chart' + return normalized + + def _sign_idx_from_value(self, value, default=0): + if isinstance(value, str): + if value not in SIGNS: + raise BadRequest('sign must be a valid sign') + return SIGNS.index(value) + try: + idx = int(value) + except (TypeError, ValueError): + return default + return max(0, min(11, idx)) + + def _planet_lon(self, planets, planet): + data = planets.get(planet, {}) if isinstance(planets, dict) else {} + if not isinstance(data, dict): + return None + lon = data.get('lon') + if isinstance(lon, (int, float)) and math.isfinite(lon): + return lon % 360 + sign_idx = data.get('sign_idx') + if sign_idx is None and data.get('sign') in SIGNS: + sign_idx = SIGNS.index(data['sign']) + degree = data.get('degree', data.get('degree_in_sign', 0)) + try: + return (int(sign_idx) * 30 + float(degree)) % 360 + except (TypeError, ValueError): + return None + + def _fallback_dasha_periods(self, birth_dt, dasha_key, info): + extended_dashas = _load_local_module('extended_dashas') + DASHA_ORDER = extended_dashas.DASHA_ORDER + cycle_years = float(info.get('years') or 36) + if cycle_years <= 0: + cycle_years = 36 + period_years = round(cycle_years / len(DASHA_ORDER), 2) + periods = [] + current = birth_dt + for lord in DASHA_ORDER: + end_date = current + timedelta(days=period_years * 365.25636) + periods.append({ + 'lord': lord, + 'years': period_years, + 'start': current.strftime('%Y-%m-%d'), + 'end': end_date.strftime('%Y-%m-%d'), + }) + current = end_date + return periods + + def _import_chart_text(self, body): + filename = str(body.get('filename', ''))[:160] + text = body.get('text') + content_b64 = body.get('content_base64') + if text is not None: + if not isinstance(text, str): + raise BadRequest('text must be a string') + return self._import_text_response(text, filename, 'text') + if content_b64 is None: + raise BadRequest('text or content_base64 is required') + if not isinstance(content_b64, str): + raise BadRequest('content_base64 must be a string') + try: + data = base64.b64decode(content_b64, validate=True) except Exception as e: - self._json({'error': str(e)}, 500) + raise BadRequest('Invalid base64 content') from e + if len(data) > MAX_IMPORT_FILE_BYTES: + raise BadRequest(f'Import file too large; max {MAX_IMPORT_FILE_BYTES} bytes') + is_pdf = filename.lower().endswith('.pdf') or data.startswith(b'%PDF') + if is_pdf: + text, extractor = self._extract_pdf_text(data) + else: + text = data.decode('utf-8', errors='ignore') + extractor = 'text' + return self._import_text_response(text, filename, extractor) + + def _import_text_response(self, text, filename, extractor): + normalized = str(text or '').replace('\x00', '').strip() + if not normalized: + raise BadRequest('No extractable text found') + truncated = len(normalized) > MAX_IMPORT_TEXT_CHARS + if truncated: + normalized = normalized[:MAX_IMPORT_TEXT_CHARS] + return { + 'success': True, + 'filename': filename, + 'extractor': extractor, + 'text': normalized, + 'text_length': len(normalized), + 'truncated': truncated, + } + + def _extract_pdf_text(self, data): + errors = [] + try: + import pdfplumber + with pdfplumber.open(io.BytesIO(data)) as pdf: + text = '\n'.join((page.extract_text() or '') for page in pdf.pages[:12]) + if text.strip(): + return text, 'pdfplumber' + except Exception as e: + errors.append(str(e)) + try: + from pypdf import PdfReader + reader = PdfReader(io.BytesIO(data)) + text = '\n'.join((page.extract_text() or '') for page in reader.pages[:12]) + if text.strip(): + return text, 'pypdf' + except Exception as e: + errors.append(str(e)) + raise BadRequest('PDF has no extractable text; OCR is not supported yet') + + def _calc_vimshottari_periods(self, birth_dt, moon_lon): + extended_dashas = _load_local_module('extended_dashas') + DASHA_ORDER = extended_dashas.DASHA_ORDER + YEAR_DAYS = extended_dashas.YEAR_DAYS + dasha_years = [7, 20, 6, 10, 7, 18, 16, 19, 17] + nak_size = 360 / 27 + nak_idx = int(moon_lon / nak_size) % 27 + start_idx = nak_idx % len(DASHA_ORDER) + current = birth_dt + periods = [] + for i in range(len(DASHA_ORDER)): + idx = (start_idx + i) % len(DASHA_ORDER) + years = dasha_years[idx] + end_date = current + timedelta(days=years * YEAR_DAYS) + periods.append({ + 'lord': DASHA_ORDER[idx], + 'years': years, + 'start': current.strftime('%Y-%m-%d'), + 'end': end_date.strftime('%Y-%m-%d'), + }) + current = end_date + return periods def _compute_chart(self, body): """完整星盘计算""" - year = int(body.get('year', 1990)) - month = int(body.get('month', 6)) - day = int(body.get('day', 15)) - hour = float(body.get('hour', 12)) - minute = float(body.get('minute', 0)) - lat = float(body.get('lat', 39.9)) - lon = float(body.get('lon', 116.4)) - tz = float(body.get('tz', 8)) + 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) + hour = self._get_float(body, 'hour', 12, 0, 23) + minute = self._get_float(body, 'minute', 0, 0, 59) + lat = self._get_float(body, 'lat', 39.9, -90, 90) + lon = self._get_float(body, 'lon', 116.4, -180, 180) + tz = self._get_float(body, 'tz', 8, -14, 14) + try: + datetime(year, month, day) + except ValueError as e: + raise BadRequest('Invalid birth date') from e try: import swisseph as swe swe.set_ephe_path(os.path.join(SCRIPTS_DIR, '..', 'swiss_ephemeris')) - jd = swe.julday(year, month, day, hour + minute/60.0) + hour_ut = hour + minute / 60.0 - tz + jd = swe.julday(year, month, day, hour_ut) swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0) + ayanamsa = swe.get_ayanamsa(jd) planets_data = {} planet_ids = {'Sun': 0, 'Moon': 1, 'Mars': 4, 'Mercury': 2, 'Jupiter': 5, 'Venus': 3, 'Saturn': 6, 'Rahu': 10, 'Ketu': 20} @@ -120,15 +1484,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): for pid, pname in planet_names_rev.items(): if pid == 20: rahu_result, _ = swe.calc_ut(jd, 10) - planet_lon = (rahu_result[0] + 180) % 360 + planet_lon = (rahu_result[0] - ayanamsa + 180) % 360 else: result, _ = swe.calc_ut(jd, pid) - planet_lon = result[0] % 360 + planet_lon = (result[0] - ayanamsa) % 360 sign_idx = int(planet_lon / 30) % 12 planets_data[pname] = {'lon': planet_lon, 'sign_idx': sign_idx, 'sign': SIGNS[sign_idx], 'degree': planet_lon % 30} # Ascendant - asc_lon = swe.houses_ex(jd, lat, lon, b'E')[0][0] % 360 + asc_tropical = swe.houses_ex(jd, lat, lon, b'E')[0][0] % 360 + asc_lon = (asc_tropical - ayanamsa) % 360 asc_sign_idx = int(asc_lon / 30) % 12 asc_sign = SIGNS[asc_sign_idx] @@ -170,9 +1535,19 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): sade_sati = calc_sade_sati_complete(moon_lon, asc_lon, transit_saturn_lon) # Dasha清单 - from extended_dashas import get_available_dashas, DASHA_REGISTRY - dashas = get_available_dashas() - dasha_list = [{'key': k, 'name': DASHA_REGISTRY[k]['name'], 'years': DASHA_REGISTRY[k]['years'], 'type': DASHA_REGISTRY[k]['type']} for k in dashas] + extended_dashas = _load_local_module('extended_dashas') + dashas = extended_dashas.get_available_dashas() + dasha_list = [{'key': k, 'name': extended_dashas.DASHA_REGISTRY[k]['name'], 'years': extended_dashas.DASHA_REGISTRY[k]['years'], 'type': extended_dashas.DASHA_REGISTRY[k]['type']} for k in dashas] + + try: + jaimini = _load_local_module('jaimini') + special_lagnas = jaimini.calc_special_lagnas_precise( + asc_sign_idx, year, month, day, int(hour), int(minute), lat, lon, tz + ) + except Exception as e: + import logging + logging.warning(f"[api_server] special lagnas calculation failed: {e}") + special_lagnas = {} # Shadbala (v6.9.14: covered with external absolute-calibration cap) try: @@ -186,6 +1561,22 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): logging.warning(f"[api_server] shadbala calculation failed: {e}") shadbala_summary = {} + try: + remedies_module = _load_local_module('remedies') + remedies = remedies_module.recommend_remedies(shadbala_summary, active_dasha_lord=md_lord) + except Exception as e: + import logging + logging.warning(f"[api_server] remedies calculation failed: {e}") + remedies = {} + + try: + tithi_analyzer = _load_local_module('tithi_analyzer') + tithi_lord_analysis = tithi_analyzer.analyze_tithi({'planets': planets_data}) + except Exception as e: + import logging + logging.warning(f"[api_server] tithi lord analysis failed: {e}") + tithi_lord_analysis = {} + # Yoga扩展 (dashaflow MIT规则) try: from yoga_expansion import detect_all_yogas as detect_ey @@ -197,8 +1588,22 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): logging.warning(f"[api_server] yoga expansion detection failed: {e}") return { 'success': True, 'version': '6.9.14', - 'birth': {'date': f'{year}-{month:02d}-{day:02d}', 'time': f'{int(hour):02d}:{int(minute):02d}'}, - 'ascendant': {'sign': asc_sign, 'sign_idx': asc_sign_idx, 'degree': round(asc_lon % 30, 2)}, + 'birth': { + 'date': f'{year}-{month:02d}-{day:02d}', + 'time': f'{int(hour):02d}:{int(minute):02d}', + 'tz': f"UTC{'+' if tz >= 0 else ''}{tz}", + 'lat': lat, + 'lon': lon, + 'julian_day': round(jd, 6), + 'ayanamsa': round(ayanamsa, 4), + }, + 'ascendant': { + 'sign': asc_sign, + 'sign_idx': asc_sign_idx, + 'degree': round(asc_lon % 30, 2), + 'degree_in_sign': round(asc_lon % 30, 2), + 'lon': round(asc_lon, 4), + }, 'planets': planets_data, 'houses': houses, 'shadbala': shadbala_summary, 'dasha': { 'current_md': md_lord, @@ -208,6 +1613,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): }, 'yogas': yogas, 'sade_sati': sade_sati, + 'remedies': remedies, + 'tithi_lord_analysis': tithi_lord_analysis, + 'special_lagnas': special_lagnas, 'available_dashas': dasha_list, 'dasha_count': len(dasha_list), } @@ -239,13 +1647,31 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): s = (asc_sign_idx + h - 1) % 12 houses[h] = {'sign': SIGNS[s], 'sign_idx': s} + try: + jaimini = _load_local_module('jaimini') + special_lagnas = jaimini.calc_special_lagnas_precise( + asc_sign_idx, year, month, day, int(hour), int(minute), lat, lon, tz + ) + except Exception: + jaimini = _load_local_module('jaimini') + special_lagnas = jaimini.calc_special_lagnas(asc_sign_idx, int(hour), int(minute)) + return { 'success': True, 'version': '6.9.14-fallback', 'warning': 'Swiss Ephemeris未安装,使用简化计算', + 'birth': { + 'date': f'{year}-{month:02d}-{day:02d}', + 'time': f'{int(hour):02d}:{int(minute):02d}', + 'tz': f"UTC{'+' if tz >= 0 else ''}{tz}", + 'lat': lat, + 'lon': lon, + }, 'ascendant': {'sign': asc_sign, 'sign_idx': asc_sign_idx}, 'planets': planets, 'houses': houses, 'dasha': {'current_md': 'Moon', 'remaining_years': 5}, 'yogas': [], 'sade_sati': {'active': False}, + 'tithi_lord_analysis': _load_local_module('tithi_analyzer').analyze_tithi({'planets': planets}), + 'special_lagnas': special_lagnas, 'available_dashas': [], 'dasha_count': 0, } @@ -273,64 +1699,2756 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return yogas[:10] def _compute_remedies(self, body): - from remedies import recommend_remedies + remedies_module = _load_local_module('remedies') shadbala = body.get('shadbala', {}) doshas = body.get('doshas', []) dasha_lord = body.get('dasha_lord', '') - return recommend_remedies(shadbala, doshas=doshas, active_dasha_lord=dasha_lord) + if not isinstance(shadbala, dict): + raise BadRequest('shadbala must be an object') + if not isinstance(doshas, list): + raise BadRequest('doshas must be an array') + if not isinstance(dasha_lord, str): + raise BadRequest('dasha_lord must be a string') + return remedies_module.recommend_remedies(shadbala, doshas=doshas, active_dasha_lord=dasha_lord) def _compute_kp(self, body): - planets = body.get('planets', {}) - asc_idx = body.get('asc_sign_idx', 0) + planets = self._validate_planets(body.get('planets', {})) + asc_idx = self._get_int(body, 'asc_sign_idx', 0, 0, 11) from kp_system import calc_kp_analysis return calc_kp_analysis(planets, SIGNS[asc_idx]) def _compute_prashna(self, body): question_type = body.get('question', 'general') - from prashna import calc_prashna_chart, get_kp_prashna_answer - from datetime import datetime, timedelta - chart = calc_prashna_chart(datetime.now(), body.get('planets', {})) - answer = get_kp_prashna_answer(body.get('planets', {}), question_type, 15.5) - return {'prashna_chart': chart, 'kp_answer': answer} + if not isinstance(question_type, str): + raise BadRequest('question must be a string') + question_text = body.get('question_text', '') + if not isinstance(question_text, str): + raise BadRequest('question_text must be a string') + from prashna import ( + QUESTION_CATEGORIES, + analyze_lost_item, + build_kp_horary_evidence, + calc_prashna_chart, + calc_life_sphutas, + calc_sahams, + calc_sphutas, + detect_prashna_arudha, + get_kp_prashna_answer, + get_kp_prashna_answer_v2, + kunda_verify, + nadi_prashna_analysis, + prashna_timing_score, + ) + from datetime import datetime + planets = self._validate_planets(body.get('planets', {})) + asc_degree = self._normalize_degree(body, 'asc_degree', 15.5) + horary_number = body.get('horary_number') + if horary_number in ('', None): + horary_number = None + else: + horary_number = self._get_int(body, 'horary_number', None, 1, 249) + question_key = question_type[:80] + chart = calc_prashna_chart(datetime.now(), planets, asc_degree) + answer = get_kp_prashna_answer(planets, question_key, asc_degree) + answer_v2 = get_kp_prashna_answer_v2(planets, question_key, asc_degree) + kp_horary = build_kp_horary_evidence(planets, question_key, asc_degree, horary_number) + question_house = QUESTION_CATEGORIES.get(question_key, QUESTION_CATEGORIES['general'])['primary'] + arudha = detect_prashna_arudha(planets, asc_degree, question_house) + nadi = nadi_prashna_analysis(planets, asc_degree, question_key) + timing = prashna_timing_score(planets, asc_degree, question_key) + planet_lons = {} + for pname in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + p_lon = self._planet_lon(planets, pname) + if p_lon is not None: + planet_lons[pname] = p_lon + sphutas = calc_sphutas(planet_lons, asc_degree) + life_sphutas = calc_life_sphutas( + asc_degree, + planet_lons.get('Moon', 0), + planet_lons.get('Sun', 0), + sphutas.get('gulika', {}).get('longitude', 0), + ) + sahams = calc_sahams(planet_lons, asc_degree) + lost_item = analyze_lost_item(planet_lons, asc_degree) + kunda = kunda_verify(asc_degree) + conclusion = answer_v2.get('kp_answer') or answer.get('kp_answer') + return { + 'success': True, + 'question_text': question_text[:160], + 'question_category': question_key, + 'prashna_chart': chart, + 'kp_answer': answer, + 'kp_answer_v2': answer_v2, + 'kp_horary': kp_horary, + 'arudha': arudha, + 'nadi': nadi, + 'timing': timing, + 'sphutas': sphutas, + 'life_sphutas': life_sphutas, + 'sahams': sahams, + 'lost_item': lost_item, + 'kunda': kunda, + 'summary': { + 'conclusion': conclusion, + 'confidence': answer_v2.get('confidence', answer.get('confidence')), + 'primary_house': question_house, + 'question_lord': answer_v2.get('question_lord', answer.get('question_lord')), + 'next_action': timing.get('recommendation', '结合现实信息复核后再行动'), + }, + } def _compute_synastry(self, body): from synastry import calc_ashtakoot - return calc_ashtakoot(body.get('male_moon', 0), body.get('female_moon', 0)) + return calc_ashtakoot( + self._normalize_degree(body, 'male_moon', 0), + self._normalize_degree(body, 'female_moon', 0), + ) + + def _compute_dasha_system(self, body): + dasha_key = body.get('dasha', body.get('name', 'vimshottari')) + if not isinstance(dasha_key, str): + raise BadRequest('dasha must be a string') + dasha_key = dasha_key.strip().lower() + extended_dashas = _load_local_module('extended_dashas') + info = extended_dashas.DASHA_REGISTRY.get(dasha_key) + if not info: + raise BadRequest('Unknown dasha system') + + birth_dt = self._parse_birth_datetime(body) + planets = self._validate_planets(body.get('planets', {})) + ascendant = body.get('ascendant', {}) + if ascendant is not None and not isinstance(ascendant, dict): + raise BadRequest('ascendant must be an object') + + moon_lon = self._planet_lon(planets, 'Moon') + if moon_lon is None: + moon_lon = self._normalize_degree(body, 'moon_lon', 0) + sun_lon = self._planet_lon(planets, 'Sun') + if sun_lon is None: + sun_lon = self._normalize_degree(body, 'sun_lon', 0) + + nak_size = 360 / 27 + moon_nak_idx = int(moon_lon / nak_size) % 27 + moon_pada = int((moon_lon % nak_size) / (nak_size / 4)) + 1 + moon_sign_idx = int(moon_lon / 30) % 12 + sun_sign_idx = int(sun_lon / 30) % 12 + asc_sign_idx = self._sign_idx_from_value( + body.get('asc_sign_idx', ascendant.get('sign_idx', ascendant.get('sign'))), + 0, + ) + d9_asc_sign_idx = self._sign_idx_from_value(body.get('d9_asc_sign_idx', asc_sign_idx), asc_sign_idx) + tithi_num = self._get_int(body, 'tithi_num', 1, 1, 30) + + vimshottari_analysis = None + if dasha_key == 'vimshottari': + periods = self._calc_vimshottari_periods(birth_dt, moon_lon) + precision = 'calculator' + vimshottari_analysis = self._compute_vimshottari_analysis_layer( + birth_dt, + moon_lon, + body.get('today') or body.get('current_date'), + ) + else: + periods = extended_dashas.calc_any_dasha( + dasha_key, + birth_dt, + moon_nak_idx=moon_nak_idx, + moon_pada=moon_pada, + asc_sign_idx=asc_sign_idx, + moon_sign_idx=moon_sign_idx, + sun_sign_idx=sun_sign_idx, + d9_asc_sign_idx=d9_asc_sign_idx, + tithi_num=tithi_num, + ) + calc_fn = extended_dashas.DASHA_CALCULATORS.get(dasha_key) + precision = 'generic' if not calc_fn or calc_fn.__name__ == 'calc_generic_dasha' else 'calculator' + if not periods: + periods = self._fallback_dasha_periods(birth_dt, dasha_key, info) + precision = 'generic' + + result = { + 'success': True, + 'key': dasha_key, + 'name': info.get('name', dasha_key), + 'type': info.get('type', 'other'), + 'cycle_years': info.get('years'), + 'precision': precision, + 'periods': periods, + } + if vimshottari_analysis: + result['vimshottari_analysis'] = vimshottari_analysis + result['fragment_sources'] = ['dasha_analyzer.py', 'dasha_calculator_enhanced.py'] + return result + + def _compute_vimshottari_analysis_layer(self, birth_dt, moon_lon, current_date=None): + try: + analyzer = _load_local_module('dasha_analyzer') + enhanced = _load_local_module('dasha_calculator_enhanced') + nak_info, nak_progress, pada = analyzer.lon_to_nakshatra(moon_lon % 360) + timeline, elapsed, remaining, start_lord = analyzer.build_dasha_timeline( + birth_dt.strftime('%Y-%m-%d'), + nak_info, + nak_progress, + ) + today = self._parse_optional_date(current_date) if current_date else datetime.now() + current_idx, current_md = analyzer.find_current(timeline, today) + antardashas = analyzer.build_antardasha(current_md) + current_ad = analyzer.find_current_sub(antardashas, today) + years_into_md = max(0.0, (today - current_md['start']).days / 365.25636) + five_levels = enhanced.calculate_five_level_dasha(current_md['lord'], years_into_md) + formatted_timeline = [self._format_datetime_period(period) for period in timeline] + formatted_antardashas = [self._format_datetime_period(period) for period in antardashas] + current_period = self._format_datetime_period(current_md) + current_sub = self._format_datetime_period(current_ad) + remaining_days = max(0, (current_ad['end'] - today).days) + theme = enhanced.PLANET_MODERN_MEANINGS.get(current_md['lord'], {}) + return { + 'source': 'dasha_analyzer.py + dasha_calculator_enhanced.py', + 'nakshatra': { + 'name': nak_info[0], + 'lord': nak_info[1], + 'years': nak_info[2], + 'pada': pada, + 'progress_pct': round(nak_progress * 100, 2), + 'elapsed_years_at_birth': elapsed, + 'remaining_years_at_birth': remaining, + }, + 'current': { + 'mahadasha': current_period, + 'antardasha': current_sub, + 'remaining_days': remaining_days, + 'remaining_months': round(remaining_days / 30.44, 1), + 'theme': theme.get('theme', ''), + 'keywords': theme.get('keywords', []), + }, + 'five_levels': five_levels, + 'timeline_from_true_md_start': formatted_timeline, + 'current_antardashas': formatted_antardashas, + 'summary': { + 'headline': f"当前处于 {current_md['lord']} Mahadasha / {current_ad['lord']} Antardasha", + 'note': '该增强层复用 dasha_analyzer.py,主 periods 合同仍保持出生后周期列表。', + 'next_action': '用当前 MD/AD 作为时间主轴,再用本命承诺、Transit 和案例验证收敛事件。', + }, + 'current_index': current_idx, + } + except Exception as exc: + import logging + logging.warning(f"[api_server] vimshottari analysis layer failed: {exc}") + return None + + def _parse_optional_date(self, value): + if not isinstance(value, str) or not value.strip(): + raise BadRequest('current_date must be YYYY-MM-DD') + try: + return datetime.strptime(value.strip()[:10], '%Y-%m-%d') + except ValueError as e: + raise BadRequest('current_date must be YYYY-MM-DD') from e + + def _format_datetime_period(self, period): + return { + 'lord': period.get('lord'), + 'years': round(period.get('years', 0), 4) if isinstance(period.get('years'), (int, float)) else period.get('years'), + 'start': period.get('start').strftime('%Y-%m-%d') if hasattr(period.get('start'), 'strftime') else period.get('start'), + 'end': period.get('end').strftime('%Y-%m-%d') if hasattr(period.get('end'), 'strftime') else period.get('end'), + } def _compute_sade_sati(self, body): from sade_sati import calc_sade_sati_complete - return calc_sade_sati_complete(body.get('moon_degree', 0), body.get('asc_degree', 0), body.get('saturn_degree', 0)) + return calc_sade_sati_complete( + self._normalize_degree(body, 'moon_degree', 0), + self._normalize_degree(body, 'asc_degree', 0), + self._normalize_degree(body, 'saturn_degree', 0), + ) def _compute_pmc(self, body): from pancha_mahapurusha import assess_pmc_strength - return assess_pmc_strength(body.get('planets', {}), body.get('sun_degree')) + sun_degree = None + if body.get('sun_degree') is not None: + sun_degree = self._normalize_degree(body, 'sun_degree', 0) + return assess_pmc_strength(self._validate_planets(body.get('planets', {})), sun_degree) def _compute_career(self, body): from career_analysis import analyze_career - return analyze_career(body.get('planets', {}), body.get('asc_sign', 'Aries')) + asc_sign = body.get('asc_sign', 'Aries') + if asc_sign not in SIGNS: + raise BadRequest('asc_sign must be a valid sign') + return analyze_career(self._validate_planets(body.get('planets', {})), asc_sign) def _compute_relationship(self, body): from relationship_analysis import analyze_relationship - return analyze_relationship(body.get('planets', {}), body.get('asc_sign', 'Aries')) + from spouse_status_yoga import analyze_spouse_status + asc_sign = body.get('asc_sign', 'Aries') + if asc_sign not in SIGNS: + raise BadRequest('asc_sign must be a valid sign') + planets, planet_lons = self._planet_lons_from_body(body) + normalized, _, asc_sign_idx = self._normalized_planets_from_body({'planets': planets, 'ascendant': {'sign': asc_sign}}) + planets_for_analysis = normalized or planets + result = analyze_relationship(planets_for_analysis, asc_sign) + chart_data = { + 'ascendant': {'sign': asc_sign}, + 'planets': planets_for_analysis, + } + d9_data = body.get('d9') if isinstance(body.get('d9'), dict) else None + result['spouse_status_yoga'] = analyze_spouse_status(chart_data, d9_data) + result['relationship_timing'] = self._compute_relationship_timing_evidence( + body, + planets_for_analysis, + planet_lons, + asc_sign_idx, + asc_sign, + d9_data, + ) + result['fragment_sources'] = sorted(set(result.get('fragment_sources', []) + [ + 'relationship_analysis.py', + 'spouse_status_yoga.py', + 'darakaraka_reader.py', + 'jaimini.py', + ])) + return result + + def _compute_relationship_timing_evidence(self, body, planets, planet_lons, asc_sign_idx, asc_sign, d9_data=None): + jaimini = _load_local_module('jaimini') + varga = _load_local_module('varga') + evidence = [] + timing_clues = [] + planet_degs = {planet: lon % 30 for planet, lon in planet_lons.items()} + ck7 = jaimini.calc_chara_karaka_7(planet_degs) if planet_degs else {} + dk = (ck7.get('karaka_table') or {}).get('Darakaraka') or {} + dk_planet = dk.get('planet') + + darakaraka = None + if dk_planet: + d9_planets = {} + for planet, lon in planet_lons.items(): + d9_pos = varga.calc_varga(lon, 9) + d9_planets[planet] = { + 'sign': d9_pos.get('sign'), + 'house': d9_pos.get('house'), + 'degree': d9_pos.get('degree_in_sign', d9_pos.get('degree')), + 'degree_in_sign': d9_pos.get('degree_in_sign', d9_pos.get('degree')), + } + chart_data = { + 'ascendant': {'sign': asc_sign}, + 'planets': planets, + 'd9': {'planets': d9_planets}, + } + try: + darakaraka = _load_local_module('darakaraka_reader').analyze_darakaraka(chart_data, use_8_karaka=True) + timing_clues.extend(darakaraka.get('timing_clues') or []) + evidence.append({ + 'label': 'Darakaraka (DK)', + 'value': f"{dk_planet} · H{darakaraka.get('dk_house', '-')}", + 'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量 {darakaraka.get('marriage_quality_score', '-')}/100", + }) + except Exception as exc: + darakaraka = {'error': str(exc), 'dk_planet': dk_planet} + + upapada = jaimini.calc_upapada(asc_sign_idx, planet_lons) if planet_lons else None + if upapada: + evidence.append({ + 'label': 'Upapada Lagna (UL)', + 'value': f"{upapada.get('sign')} · 2nd {upapada.get('second_from_ul', '-')}", + 'note': upapada.get('description') or '婚姻外显与关系持续性证据', + }) + + h7_sign = SIGNS[(asc_sign_idx + 6) % 12] + h7_lord = { + 'Aries':'Mars','Taurus':'Venus','Gemini':'Mercury','Cancer':'Moon', + 'Leo':'Sun','Virgo':'Mercury','Libra':'Venus','Scorpio':'Mars', + 'Sagittarius':'Jupiter','Capricorn':'Saturn','Aquarius':'Saturn','Pisces':'Jupiter', + }.get(h7_sign) + dasha_focus = self._relationship_dasha_focus(body, dk_planet, h7_lord) + if dasha_focus: + timing_clues.extend(dasha_focus.get('clues', [])) + evidence.append({ + 'label': 'Dasha trigger', + 'value': dasha_focus.get('label', '待补充'), + 'note': dasha_focus.get('note', ''), + }) + + score = 0 + if darakaraka and not darakaraka.get('error'): + quality = darakaraka.get('marriage_quality_score') + if isinstance(quality, (int, float)): + score += 2 if quality >= 65 else 1 if quality >= 45 else 0 + if darakaraka.get('timing_clues'): + score += 1 + if upapada: + score += 1 + if dasha_focus and dasha_focus.get('level') != 'neutral': + score += 1 + level = 'strong' if score >= 4 else 'watch' if score >= 2 else 'thin' + summary = { + 'strong': 'DK、UL 与运限触发能形成较完整的关系时机证据链。', + 'watch': '已有 DK/UL 或运限线索,适合继续用 D9、行运和现实事件复核。', + 'thin': '关系时机证据仍偏薄,需要更完整出生数据或当前运限。', + }[level] + return { + 'level': level, + 'score': score, + 'summary': summary, + 'darakaraka': darakaraka, + 'upapada': upapada, + 'dasha_focus': dasha_focus, + 'timing_clues': list(dict.fromkeys(timing_clues))[:6], + 'evidence': evidence, + 'source': 'darakaraka_reader.py + jaimini.py', + } + + def _relationship_dasha_focus(self, body, dk_planet=None, h7_lord=None): + raw = body.get('dasha_info') or body.get('dasha') or {} + if not isinstance(raw, dict): + return None + candidates = [ + raw.get('maha_dasha'), raw.get('maha'), raw.get('md'), raw.get('maha_lord'), + raw.get('antar_dasha'), raw.get('antar'), raw.get('ad'), raw.get('antar_lord'), + raw.get('pratyantar'), raw.get('pd'), raw.get('pratyantar_lord'), + ] + active = [str(item) for item in candidates if item] + if not active: + return None + focus = {'Venus', 'Jupiter', 'Moon', 'Mars'} + if dk_planet: + focus.add(dk_planet) + if h7_lord: + focus.add(h7_lord) + hits = [planet for planet in active if planet in focus] + label = ' / '.join(active[:3]) + if hits: + return { + 'level': 'activated', + 'label': label, + 'hits': hits, + 'note': f"当前运限触及 {'、'.join(hits)},关系/承诺主题更容易被事件激活。", + 'clues': [f"Dasha 命中 {'、'.join(hits)},作为关系时机窗口观察。"], + } + return { + 'level': 'neutral', + 'label': label, + 'hits': [], + 'note': '当前运限未明显命中 Venus/Jupiter/Moon/Mars/DK/7主,关系时机需更多行运或事件证据。', + 'clues': [], + } + + def _planet_lons_from_body(self, body): + planets = self._validate_planets(body.get('planets', {})) + planet_lons = {} + for planet in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu']: + lon = self._planet_lon(planets, planet) + if lon is not None: + planet_lons[planet] = lon + return planets, planet_lons + + def _normalized_planets_from_body(self, body): + planets, planet_lons = self._planet_lons_from_body(body) + asc_sign_idx = self._asc_sign_idx_from_body(body) + normalized = {} + for planet, lon in planet_lons.items(): + source = planets.get(planet, {}) if isinstance(planets, dict) else {} + sign_idx = int(lon / 30) % 12 + sign = SIGNS[sign_idx] + house = source.get('house') if isinstance(source, dict) else None + if house is None: + house = ((sign_idx - asc_sign_idx) % 12) + 1 + normalized[planet] = { + **(source if isinstance(source, dict) else {}), + 'lon': lon, + 'degree': lon, + 'degree_in_sign': lon % 30, + 'sign_idx': sign_idx, + 'sign': sign, + 'house': int(house), + } + return normalized, planet_lons, asc_sign_idx + + def _asc_sign_idx_from_body(self, body): + ascendant = body.get('ascendant', {}) + if isinstance(ascendant, dict): + if ascendant.get('sign_idx') is not None: + return self._sign_idx_from_value(ascendant.get('sign_idx'), 0) + if ascendant.get('sign') in SIGNS: + return SIGNS.index(ascendant.get('sign')) + if ascendant.get('lon') is not None: + try: + return int((float(ascendant.get('lon')) % 360) / 30) % 12 + except (TypeError, ValueError) as e: + raise BadRequest('ascendant lon must be a number') from e + return int(self._asc_lon_from_body(body) / 30) % 12 + + def _chart_payload_from_body(self, body): + planets, planet_lons = self._planet_lons_from_body(body) + chart_data = {'planets': {}} + for planet, lon in planet_lons.items(): + sign_idx = int(lon / 30) % 12 + source = planets.get(planet, {}) if isinstance(planets, dict) else {} + chart_data['planets'][planet] = { + **(source if isinstance(source, dict) else {}), + 'degree': lon, + 'lon': lon, + 'sign_idx': sign_idx, + 'sign': SIGNS[sign_idx], + 'degree_in_sign': lon % 30, + } + ascendant = body.get('ascendant', {}) + if isinstance(ascendant, dict): + chart_data['ascendant'] = ascendant + return chart_data, planet_lons + + def _asc_lon_from_body(self, body, default=0): + ascendant = body.get('ascendant', {}) + if ascendant is None: + ascendant = {} + if not isinstance(ascendant, dict): + raise BadRequest('ascendant must be an object') + asc_lon = ascendant.get('lon', ascendant.get('degree_raw')) + if asc_lon is None and ascendant.get('sign') in SIGNS: + try: + return ( + SIGNS.index(ascendant['sign']) * 30 + + float(ascendant.get('degree_in_sign', ascendant.get('degree', 0))) + ) % 360 + except (TypeError, ValueError) as e: + raise BadRequest('ascendant degree must be a number') from e + if asc_lon is None and ascendant.get('sign_idx') is not None: + try: + sign_idx = int(ascendant.get('sign_idx')) % 12 + degree = float(ascendant.get('degree_in_sign', ascendant.get('degree', 0))) + return (sign_idx * 30 + degree) % 360 + except (TypeError, ValueError) as e: + raise BadRequest('ascendant sign_idx/degree must be numeric') from e + if asc_lon is None: + return self._normalize_degree(body, 'asc_lon', default) + try: + number = float(asc_lon) + except (TypeError, ValueError) as e: + raise BadRequest('ascendant lon must be a number') from e + if not math.isfinite(number): + raise BadRequest('ascendant lon must be finite') + return number % 360 + + def _parse_varga_divisions(self, value): + if value is None or value == '': + return None + if isinstance(value, str): + raw_items = [item.strip() for item in value.split(',') if item.strip()] + elif isinstance(value, list): + raw_items = value + else: + raise BadRequest('divisions must be a comma string or list') + divisions = [] + for item in raw_items: + token = str(item).strip().upper() + if token.startswith('D'): + token = token[1:] + if not re.fullmatch(r'\d+', token): + raise BadRequest('divisions must contain D-numbers such as D9 or 9') + division = int(token) + if division < 1 or division > 300: + raise BadRequest('division must be between 1 and 300') + divisions.append(division) + return divisions or None + + def _parse_varga_composite(self, value): + if value is None or value == '': + return None + if isinstance(value, str): + raw_parts = [part.strip() for part in value.split(',') if part.strip()] + elif isinstance(value, list): + raw_parts = value + else: + raise BadRequest('composite must be "m,n" or a two-item list') + if len(raw_parts) != 2: + raise BadRequest('composite requires exactly two division factors') + parts = [] + for part in raw_parts: + try: + number = int(part) + except (TypeError, ValueError) as e: + raise BadRequest('composite factors must be integers') from e + if number < 2 or number > 300: + raise BadRequest('composite factors must be between 2 and 300') + parts.append(number) + return parts[0], parts[1] + + def _compute_varga_full(self, body): + _, planet_lons = self._planet_lons_from_body(body) + if not planet_lons: + raise BadRequest('planets must include longitude data') + asc_lon = self._asc_lon_from_body(body) + divisions = self._parse_varga_divisions(body.get('divisions')) + composite = self._parse_varga_composite(body.get('composite')) + custom_n = None + if body.get('custom') is not None: + custom_n = self._get_int(body, 'custom', 0, 2, 300) + variant = body.get('variant') + if variant is not None and not isinstance(variant, str): + raise BadRequest('variant must be a string') + + mode_count = sum([ + custom_n is not None, + composite is not None, + bool(variant), + ]) + if mode_count > 1: + raise BadRequest('custom, composite, and variant modes are mutually exclusive') + + module = _load_local_module('divisional_charts_extended') + calc = module.DivisionalChartsCalculator() + + try: + if custom_n is not None: + result = {'custom_div': custom_n} + result['Ascendant'] = calc.calc_custom_varga(asc_lon, custom_n) + for planet, lon in planet_lons.items(): + result[planet] = calc.calc_custom_varga(lon, custom_n) + return { + 'success': True, + 'endpoint': 'varga_full', + 'mode': 'custom', + 'source': 'divisional_charts_extended', + 'result': result, + } + + if composite is not None: + outer, inner = composite + result = { + 'composite_div': f'D{outer}×D{inner}=D{outer * inner}', + 'outer': outer, + 'inner': inner, + } + result['Ascendant'] = calc.calc_composite_varga(asc_lon, outer, inner) + for planet, lon in planet_lons.items(): + result[planet] = calc.calc_composite_varga(lon, outer, inner) + return { + 'success': True, + 'endpoint': 'varga_full', + 'mode': 'composite', + 'source': 'divisional_charts_extended', + 'result': result, + } + + if variant: + if not divisions or len(divisions) != 1 or divisions[0] not in (2, 3): + raise BadRequest('variant mode requires exactly one division: D2 or D3') + division = divisions[0] + result = {'variant': variant, 'div': division} + result['Ascendant'] = calc.calc_varga_with_variant(asc_lon, division, variant) + for planet, lon in planet_lons.items(): + result[planet] = calc.calc_varga_with_variant(lon, division, variant) + return { + 'success': True, + 'endpoint': 'varga_full', + 'mode': 'variant', + 'source': 'divisional_charts_extended', + 'result': result, + } + + available = {varga.division: varga for varga in module.VargaType} + selected = [available[division] for division in (divisions or sorted(available))] + result = {} + for varga in selected: + key = f'D{varga.division}_{varga.varga_name}' + result[key] = calc._calculate_single_varga(varga, planet_lons, asc_lon) + except KeyError as e: + raise BadRequest('unsupported standard division; use custom mode for arbitrary D-N') from e + except ValueError as e: + raise BadRequest(str(e)) from e + + return { + 'success': True, + 'endpoint': 'varga_full', + 'mode': 'standard', + 'source': 'divisional_charts_extended', + 'divisions': [varga.division for varga in selected], + 'result': result, + } + + def _compute_jaimini(self, body): + planets, planet_lons, asc_sign_idx = self._normalized_planets_from_body(body) + if not planet_lons: + raise BadRequest('planets must include longitude data') + mode = body.get('mode', 'all') + if not isinstance(mode, str): + raise BadRequest('mode must be a string') + mode = mode.strip().lower() or 'all' + allowed_modes = {'all', 'karaka', 'dasha', 'karakamsha', 'arudha', 'special'} + if mode not in allowed_modes: + raise BadRequest(f'mode must be one of: {", ".join(sorted(allowed_modes))}') + antardasha = bool(body.get('antardasha', False)) + year = self._get_int(body, 'year', datetime.now().year, 1800, 2400) + month = self._get_int(body, 'month', 1, 1, 12) + hour = self._get_int(body, 'hour', 12, 0, 23) + minute = self._get_int(body, 'minute', 0, 0, 59) + jaimini = _load_local_module('jaimini') + varga = _load_local_module('varga') + planet_degs = {planet: lon % 30 for planet, lon in planet_lons.items()} + result = {} + if mode in ('all', 'karaka'): + result['chara_karaka_7'] = jaimini.calc_chara_karaka_7(planet_degs) + result['chara_karaka_8'] = jaimini.calc_chara_karaka_8(planet_degs) + if mode in ('all', 'dasha'): + if antardasha: + result['chara_dasha'] = jaimini.calc_chara_dasha_with_antardasha(asc_sign_idx, planet_lons, year, month) + else: + result['chara_dasha'] = jaimini.calc_chara_dasha(asc_sign_idx, planet_lons, year, month) + if mode in ('all', 'karakamsha'): + ck7 = result.get('chara_karaka_7') or jaimini.calc_chara_karaka_7(planet_degs) + ak_name = ck7['karaka_table']['Atmakaraka']['planet'] + ak_d9 = varga.calc_varga(planet_lons.get(ak_name, 0), 9) + result['karakamsha'] = jaimini.calc_karakamsha( + ak_d9.get('sign', 'Aries'), + ak_d9.get('degree_in_sign', 0), + ) + if mode in ('all', 'arudha'): + result['arudha_padas'] = jaimini.calc_arudha_padas(asc_sign_idx, planet_lons) + result['graha_padas'] = jaimini.calc_graha_padas(planet_lons) + if mode in ('all', 'special'): + result['special_lagnas'] = jaimini.calc_special_lagnas(asc_sign_idx, hour, minute) + return { + 'success': True, + 'endpoint': 'jaimini', + 'mode': mode, + 'ascendant': SIGNS[asc_sign_idx], + 'result': result, + } + + def _compute_ashtakavarga(self, body): + planets, _, asc_sign_idx = self._normalized_planets_from_body(body) + if not planets: + raise BadRequest('planets must include longitude data') + ashtakavarga = _load_local_module('ashtakavarga') + result = ashtakavarga.calc_ashtakavarga(planets, asc_sign_idx) + pav = ashtakavarga.calc_prastara_av(planets, asc_sign_idx) + sodhita = ashtakavarga.calc_sodhita_av(result.get('bav', {}), planets, asc_sign_idx) + yoga_pinda = result.get('yoga_pinda') or ashtakavarga.calc_yoga_pinda(result.get('bav', {}), planets, asc_sign_idx) + result['pav'] = pav + result['sodhita'] = sodhita + result['yoga_pinda'] = yoga_pinda + summary = self._summarize_ashtakavarga(result, pav, sodhita, yoga_pinda) + return { + 'success': True, + 'endpoint': 'ashtakavarga', + 'rule_variants': self._ashtakavarga_rule_variants(), + 'summary': summary, + 'pav_summary': summary['pav_summary'], + 'sodhita_summary': summary['sodhita_summary'], + 'yoga_pinda_summary': summary['yoga_pinda_summary'], + 'result': result, + } + + def _summarize_ashtakavarga(self, result, pav, sodhita, yoga_pinda=None): + house_rows = [] + for key, item in (result.get('house_scores') or {}).items(): + try: + house_num = int(str(key).split('_')[-1]) + except (TypeError, ValueError): + house_num = 0 + house_rows.append({ + 'house': house_num, + 'sign': item.get('sign'), + 'score': item.get('sav_score', 0), + 'level': item.get('level', ''), + }) + house_rows.sort(key=lambda item: item.get('score', 0), reverse=True) + strongest = house_rows[:3] + weakest = sorted(house_rows, key=lambda item: item.get('score', 0))[:3] + + pav_totals = [] + for planet, source_scores in (pav.get('pav_summary') or {}).items(): + total = sum(value for value in source_scores.values() if isinstance(value, (int, float))) + top_sources = sorted(source_scores.items(), key=lambda pair: pair[1], reverse=True)[:3] + pav_totals.append({ + 'planet': planet, + 'total': total, + 'top_sources': [{'source': source, 'bindus': bindus} for source, bindus in top_sources], + }) + pav_totals.sort(key=lambda item: item['total'], reverse=True) + + sodhita_scores = sodhita.get('sodhita_sav', {}).get('assessment', []) + sodhita_rank = sorted(sodhita_scores, key=lambda item: item.get('score', 0), reverse=True) + raw_total = result.get('sav', {}).get('total', 0) + sodhita_total = sodhita.get('sodhita_sav', {}).get('total', 0) + reduction_total = raw_total - sodhita_total if isinstance(raw_total, (int, float)) and isinstance(sodhita_total, (int, float)) else 0 + yoga_pinda = yoga_pinda or result.get('yoga_pinda') or {} + yoga_rows = yoga_pinda.get('planets') or {} + yoga_rank = sorted(yoga_rows.items(), key=lambda item: item[1].get('yoga_pinda', 0), reverse=True) + leader = strongest[0] if strongest else {} + headline = ( + f"Ashtakavarga总分{raw_total},重点支持H{leader.get('house')} {leader.get('sign')}" + if leader else f"Ashtakavarga总分{raw_total}" + ) + return { + 'headline': headline, + 'sav_total': raw_total, + 'sav_valid': result.get('sav', {}).get('valid'), + 'strongest_houses': strongest, + 'weakest_houses': weakest, + 'pav_summary': { + 'headline': f"PAV显示{pav_totals[0]['planet']}贡献结构最强" if pav_totals else 'PAV已生成贡献矩阵', + 'top_planets': pav_totals[:3], + 'validation_passed': pav.get('all_valid'), + }, + 'sodhita_summary': { + 'headline': f"Sodhita净化后总分{sodhita_total},扣减{reduction_total}", + 'top_signs': sodhita_rank[:3], + 'weak_signs': sorted(sodhita_scores, key=lambda item: item.get('score', 0))[:3], + 'reduction_total': reduction_total, + }, + 'yoga_pinda_summary': { + 'headline': ( + f"Yoga Pinda以{yoga_rank[0][0]}最高({yoga_rank[0][1].get('yoga_pinda', 0)})" + if yoga_rank else 'Yoga Pinda未返回' + ), + 'top_planets': [ + {'planet': planet, 'yoga_pinda': row.get('yoga_pinda'), 'sign': row.get('sign')} + for planet, row in yoga_rank[:3] + ], + 'weak_planets': [ + {'planet': planet, 'yoga_pinda': row.get('yoga_pinda'), 'sign': row.get('sign')} + for planet, row in sorted(yoga_rows.items(), key=lambda item: item[1].get('yoga_pinda', 0))[:3] + ], + 'total_yoga_pinda': yoga_pinda.get('summary', {}).get('total_yoga_pinda', 0), + 'validation_passed': yoga_pinda.get('all_valid'), + }, + 'next_action': '先用SAV定领域强弱,再用PAV追溯贡献源,用Sodhita检验净支持度,并用Yoga Pinda比较行星承载力。', + } + + def _compute_shadbala(self, body): + planets, planet_lons, asc_sign_idx = self._normalized_planets_from_body(body) + if not {'Sun', 'Moon'} <= set(planet_lons): + raise BadRequest('planets must include Sun and Moon longitude data') + birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23) + birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59) + result = _load_local_module('shadbala').calc_shadbala( + planets, + SIGNS[asc_sign_idx], + birth_hour, + planet_lons['Sun'], + planet_lons['Moon'], + birth_minute=birth_minute, + ) + advanced = self._compute_shadbala_advanced_layer(body, planets, result) + return { + 'success': True, + 'endpoint': 'shadbala', + 'rule_variants': self._shadbala_rule_variants(bool(advanced)), + 'advanced_layer': advanced, + 'result': result, + } + + def _compute_yogas_api(self, body): + planets, _, asc_sign_idx = self._normalized_planets_from_body(body) + if not planets: + raise BadRequest('planets must include longitude data') + asc_sign = SIGNS[asc_sign_idx] + extended = _load_local_module('yoga_expansion').detect_all_yogas(planets, asc_sign) + try: + engine_yogas = _load_local_module('yoga_engine').detect_yogas(planets, asc_sign) + except Exception: + engine_yogas = [] + curse_yogas = self._compute_curse_yoga_layer(body, planets, asc_sign) + result = { + 'extended_yogas': extended, + 'rule_engine_yogas': engine_yogas, + 'curse_yogas': curse_yogas, + 'summary': { + 'extended_count': len(extended), + 'rule_engine_count': len(engine_yogas), + 'curse_count': len(curse_yogas.get('curses_detected', [])), + 'risk': curse_yogas.get('overall_risk', 'low'), + }, + 'rule_variants': self._yoga_rule_variants(bool(curse_yogas.get('curses_detected'))), + } + return { + 'success': True, + 'endpoint': 'yogas', + 'ascendant': asc_sign, + 'rule_variants': result['rule_variants'], + 'result': result, + } + + def _ashtakavarga_rule_variants(self): + return { + 'selected': ['sav_bav', 'prastara_av', 'sodhita_av', 'yoga_pinda'], + 'available': [ + {'key': 'sav_bav', 'label': 'SAV/BAV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'}, + {'key': 'prastara_av', 'label': 'Prastara AV / PAV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'}, + {'key': 'sodhita_av', 'label': 'Sodhita AV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'}, + {'key': 'yoga_pinda', 'label': 'Yoga Pinda', 'status': 'active', 'source': 'scripts/ashtakavarga.py'}, + ], + 'boundary': '当前按本地 BAV 贡献规则生成 SAV、PAV、Sodhita 与 Yoga Pinda;后续可继续加入 Sarvashtakavarga 规则版本对比。', + } + + def _shadbala_rule_variants(self, advanced_enabled): + return { + 'selected': ['core_sixfold', 'advanced_evidence'] if advanced_enabled else ['core_sixfold'], + 'available': [ + {'key': 'core_sixfold', 'label': '六重力量主算法', 'status': 'active', 'source': 'scripts/shadbala.py'}, + {'key': 'advanced_evidence', 'label': 'Kala/Yuddha/Sputa 增强证据', 'status': 'active' if advanced_enabled else 'unavailable', 'source': 'scripts/shadbala_advanced.py'}, + ], + 'boundary': '增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。', + } + + def _yoga_rule_variants(self, curse_enabled): + return { + 'selected': ['extended_algorithm', 'json_rule_engine'] + (['curse_conjunctions'] if curse_enabled else []), + 'available': [ + {'key': 'extended_algorithm', 'label': '算法 Yoga 扩展', 'status': 'active', 'source': 'scripts/yoga_expansion.py'}, + {'key': 'json_rule_engine', 'label': 'JSON 规则引擎', 'status': 'active', 'source': 'scripts/yoga_engine.py + references/yoga_rules.json'}, + {'key': 'curse_conjunctions', 'label': '凶星合相命名', 'status': 'active', 'source': 'scripts/curse_yoga_detector.py'}, + ], + 'boundary': '凶星合相属于高风险提示层,只能作为 Yoga 证据之一,不能替代健康、法律或安全建议。', + } + + def _compute_curse_yoga_layer(self, body, planets, asc_sign): + current_dasha = body.get('current_dasha') or body.get('dasha_lord') + chart_data = { + 'ascendant': {'sign': asc_sign}, + 'planets': self._planets_for_legacy_fragments(planets), + } + context = body.get('context') + if isinstance(context, dict): + chart_data['context'] = context + try: + return _load_local_module('curse_yoga_detector').detect_curse_yogas(chart_data, current_dasha=current_dasha) + except Exception as exc: + import logging + logging.warning(f"[api_server] curse yoga layer failed: {exc}") + return {'curses_detected': [], 'overall_risk': 'unknown', 'risk_score': 0, 'error': 'curse yoga layer unavailable'} + + def _planets_for_legacy_fragments(self, planets): + legacy = {} + for planet, data in planets.items(): + if not isinstance(data, dict): + continue + row = dict(data) + row['lon'] = data.get('lon', data.get('degree')) + row['degree'] = data.get('degree_in_sign', data.get('degree', 0)) + row['degree_in_sign'] = row['degree'] + legacy[planet] = row + return legacy + + def _compute_shadbala_advanced_layer(self, body, planets, base_result): + try: + advanced_mod = _load_local_module('shadbala_advanced') + birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23) + birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59) + year = self._get_int(body, 'year', datetime.now().year, 1800, 2400) + month = self._get_int(body, 'month', 1, 1, 12) + day = self._get_int(body, 'day', 1, 1, 31) + lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90) + lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180) + tz = self._get_float(body, 'tz', body.get('birth_tz', 0), -14, 14) + hour_decimal = birth_hour + birth_minute / 60.0 + solar_lon = planets.get('Sun', {}).get('lon', 0) + base_planets = base_result.get('planets', {}) if isinstance(base_result, dict) else {} + comparison_planets = {} + kala_additions = {} + drik_sputa = {} + for planet, pdata in planets.items(): + if planet not in base_planets: + continue + comparison_planets[planet] = { + **pdata, + 'degree': pdata.get('lon', pdata.get('degree', 0)), + 'shadbala_total': base_planets.get(planet, {}).get('total_virupas', 0), + } + for planet in comparison_planets: + kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala( + planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz + ), 2) + drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2) + yuddha = advanced_mod.calc_yuddha_bala(comparison_planets) + active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0} + top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3] + return { + 'source': 'scripts/shadbala_advanced.py', + 'method': 'Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层', + 'kala_vmdh': kala_additions, + 'top_kala_support': [{'planet': planet, 'virupas': value} for planet, value in top_kala], + 'yuddha_bala': yuddha, + 'active_yuddha': active_yuddha, + 'sputa_drik_bala': drik_sputa, + 'next_action': '若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。', + } + except Exception as exc: + import logging + logging.warning(f"[api_server] shadbala advanced layer failed: {exc}") + return {} + + def _compute_aspects(self, body): + _, planet_lons = self._planet_lons_from_body(body) + if len(planet_lons) < 2: + raise BadRequest('planets must include at least two longitude values') + asc_lon = self._asc_lon_from_body(body) + result = _load_local_module('aspects').calc_all_aspects(planet_lons, asc_lon) + return { + 'success': True, + 'endpoint': 'aspects', + 'result': result, + } + + def _compute_annual(self, body): + target_year = self._get_int(body, 'target_year', datetime.now().year, 1800, 2400) + birth_dt = self._parse_birth_datetime(body) + lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90) + lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180) + tz = self._get_float(body, 'tz', body.get('birth_tz', 0), -14, 14) + solar_return = _load_local_module('solar_return') + report = solar_return.solar_return_full_report( + birth_dt.year, + birth_dt.month, + birth_dt.day, + birth_dt.hour, + birth_dt.minute, + lat, + lon, + tz, + target_year, + ) + return {'success': True, 'endpoint': 'annual', 'report': report} + + def _compute_muhurta(self, body): + has_range = body.get('start_date') or body.get('end_date') or body.get('start') or body.get('end') + query_date = body.get('date') or datetime.now().strftime('%Y-%m-%d') + if not isinstance(query_date, str): + raise BadRequest('date must be a string') + try: + query_dt = datetime.strptime(query_date[:10], '%Y-%m-%d') + except ValueError as e: + raise BadRequest('date must be YYYY-MM-DD') from e + activity = body.get('activity') + if activity is not None and not isinstance(activity, str): + raise BadRequest('activity must be a string') + hour_from_sunrise = self._get_float(body, 'hour_from_sunrise', 6.0, 0, 24) + sunrise = body.get('sunrise', '06:00') + sunset = body.get('sunset', '18:00') + if not isinstance(sunrise, str) or not isinstance(sunset, str): + raise BadRequest('sunrise/sunset must be HH:MM strings') + sun_lon = self._planet_lon(body.get('planets', {}), 'Sun') + moon_lon = self._planet_lon(body.get('planets', {}), 'Moon') + muhurta = _load_local_module('muhurta') + if sun_lon is None or moon_lon is None: + sun_lon, moon_lon = muhurta._approx_sun_moon_lon(query_dt.year, query_dt.month, query_dt.day) + weekday = (query_dt.weekday() + 1) % 7 # Python Mon=0; module Sun=0 + activities = [activity] if activity else None + report = muhurta.muhurta_full_report( + sun_lon, + moon_lon, + weekday, + hour_from_sunrise=hour_from_sunrise, + query_date_str=query_date[:10], + activities=activities, + ) + result = {'success': True, 'endpoint': 'muhurta', 'report': report} + if has_range: + start_raw = body.get('start_date') or body.get('start') or query_date + end_raw = body.get('end_date') or body.get('end') or start_raw + if not isinstance(start_raw, str) or not isinstance(end_raw, str): + raise BadRequest('start_date/end_date must be strings') + try: + start_dt = datetime.strptime(start_raw[:10], '%Y-%m-%d') + end_dt = datetime.strptime(end_raw[:10], '%Y-%m-%d') + except ValueError as e: + raise BadRequest('start_date/end_date must be YYYY-MM-DD') from e + if end_dt < start_dt: + raise BadRequest('end_date must be on or after start_date') + if (end_dt - start_dt).days > 62: + raise BadRequest('muhurta range must be <= 63 days') + lat = lon = tz = None + has_location = any(key in body for key in ('lat', 'lon', 'tz')) + if has_location: + lat = self._get_float(body, 'lat', 0, -90, 90) + lon = self._get_float(body, 'lon', 0, -180, 180) + tz = self._get_float(body, 'tz', 0, -14, 14) + limit = self._get_int(body, 'limit', 5, 1, 20) + try: + result['range_search'] = muhurta.muhurta_range_search( + start_dt.strftime('%Y-%m-%d'), + end_dt.strftime('%Y-%m-%d'), + activity=activity or 'business', + limit=limit, + hour_from_sunrise=hour_from_sunrise, + sunrise=sunrise, + sunset=sunset, + lat=lat, + lon=lon, + tz=tz, + ) + except ValueError as e: + raise BadRequest(str(e)) from e + return result + + def _compute_panchanga_range(self, body): + start_raw = body.get('start_date') or body.get('start') or datetime.now().strftime('%Y-%m-%d') + end_raw = body.get('end_date') or body.get('end') or start_raw + if not isinstance(start_raw, str) or not isinstance(end_raw, str): + raise BadRequest('start_date/end_date must be strings') + try: + start_dt = datetime.strptime(start_raw[:10], '%Y-%m-%d') + end_dt = datetime.strptime(end_raw[:10], '%Y-%m-%d') + except ValueError as e: + raise BadRequest('start_date/end_date must be YYYY-MM-DD') from e + if end_dt < start_dt: + raise BadRequest('end_date must be on or after start_date') + if (end_dt - start_dt).days > 62: + raise BadRequest('panchanga range must be <= 63 days') + + activity = body.get('activity') + if activity is not None and not isinstance(activity, str): + raise BadRequest('activity must be a string') + sunrise = body.get('sunrise', '06:00') + sunset = body.get('sunset', '18:00') + if not isinstance(sunrise, str) or not isinstance(sunset, str): + raise BadRequest('sunrise/sunset must be HH:MM strings') + hour_from_sunrise = self._get_float(body, 'hour_from_sunrise', 6.0, 0, 24) + lat = lon = tz = None + has_location = any(key in body for key in ('lat', 'lon', 'tz')) + if has_location: + lat = self._get_float(body, 'lat', 0, -90, 90) + lon = self._get_float(body, 'lon', 0, -180, 180) + tz = self._get_float(body, 'tz', 0, -14, 14) + muhurta = _load_local_module('muhurta') + try: + report = muhurta.panchanga_range_report( + start_dt.strftime('%Y-%m-%d'), + end_dt.strftime('%Y-%m-%d'), + hour_from_sunrise=hour_from_sunrise, + sunrise=sunrise, + sunset=sunset, + activity=activity, + lat=lat, + lon=lon, + tz=tz, + ) + except ValueError as e: + raise BadRequest(str(e)) from e + return {'success': True, 'endpoint': 'panchanga_range', 'report': report} + + def _compute_rectification_gate(self, body): + asc_lon = self._asc_lon_from_body(body) + declared_accuracy = body.get('declared_accuracy', body.get('accuracy', 'minute')) + time_source = body.get('time_source', 'family_clear') + if not isinstance(declared_accuracy, str): + raise BadRequest('declared_accuracy must be a string') + if not isinstance(time_source, str): + raise BadRequest('time_source must be a string') + + module = _load_local_module('birth_time_rectifier') + is_boundary, boundary_note = module.check_lagna_boundary(asc_lon) + effective_accuracy = module.get_effective_accuracy(declared_accuracy, time_source) + enabled_vargas = module.get_enabled_vargas(effective_accuracy) + normalized_planets, _, _ = self._normalized_planets_from_body(body) + recommended_events = module.recommend_event_types(normalized_planets) + confidence_seed = module.calculate_confidence(0, 0, effective_accuracy, is_boundary) + + disabled = sorted([key for key, value in enabled_vargas.items() if value == 'disabled']) + warned = sorted([key for key, value in enabled_vargas.items() if value == 'enabled_with_warning']) + enabled = sorted([key for key, value in enabled_vargas.items() if value == 'enabled']) + if is_boundary: + headline = '出生时间高度敏感,建议先做事件反验' + next_action = '优先录入婚姻、迁移、事业转折、健康危机等日期明确事件,再比较相邻候选时间。' + elif warned or disabled: + headline = '可读主盘,但高敏分盘需要降级' + next_action = 'D1 可正常阅读;D9/D10 以上结论需标注时间精度限制。' + else: + headline = '出生时间风险较低,可进入完整解盘' + next_action = '保留原始出生记录来源;重要预测仍建议用 Dasha/Transit/案例验证交叉确认。' + + return { + 'success': True, + 'endpoint': 'rectification_gate', + 'ascendant': { + 'lon': asc_lon, + 'sign': SIGNS[int(asc_lon / 30) % 12], + 'degree_in_sign': round(asc_lon % 30, 4), + }, + 'declared_accuracy': declared_accuracy, + 'time_source': time_source, + 'effective_accuracy': effective_accuracy, + 'lagna_boundary': { + 'is_sensitive': is_boundary, + 'note': boundary_note, + }, + 'enabled_vargas': enabled_vargas, + 'summary': { + 'headline': headline, + 'enabled': enabled, + 'warned': warned, + 'disabled': disabled, + 'confidence_floor': confidence_seed.get('assessment'), + 'recommended_events': recommended_events, + 'next_action': next_action, + }, + } + + def _compute_case_validation(self, body): + planets, _, _ = self._normalized_planets_from_body(body) + current_md = body.get('current_md', body.get('dasha_lord', '')) + if current_md is not None and not isinstance(current_md, str): + raise BadRequest('current_md must be a string') + predicted_events = body.get('predicted_events', []) + if predicted_events is None: + predicted_events = [] + if not isinstance(predicted_events, list): + raise BadRequest('predicted_events must be an array') + transit_desc = body.get('transit_desc', '') + if transit_desc is not None and not isinstance(transit_desc, str): + raise BadRequest('transit_desc must be a string') + + analysis = { + 'planets': {}, + 'dasha': { + 'current_md': current_md, + 'predicted_events': [str(item)[:120] for item in predicted_events[:12]], + }, + 'transit': transit_desc or {}, + } + for planet, data in planets.items(): + if planet not in {'Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'}: + continue + dignity = self._case_dignity_label(planet, data.get('sign')) + if dignity: + analysis['planets'][planet] = { + 'planet': planet, + 'sign': data.get('sign'), + 'house': data.get('house'), + 'dignity': dignity, + } + + validator = _load_local_module('case_validator') + raw = validator.validate_interpretation(analysis) + validations = raw.get('validations', []) + validated = [item for item in validations if item.get('validated')] + unvalidated = [item for item in validations if not item.get('validated')] + headline = '案例库支持度较强' if raw.get('overall_confidence', 0) >= 70 else '案例支持不足,需谨慎措辞' + if not validations: + headline = '当前星盘缺少可验证声明' + next_action = ( + '优先使用已验证配置做结论;未验证项只作为假设,并补充真实案例或外部来源。' + if unvalidated else + '可以进入解释层,但仍需保留具体事件预测的置信度上限。' + ) + mevg_gate = self._mevg_gate_status() + return { + 'success': True, + 'endpoint': 'case_validation', + 'fragment_sources': ['case_validator.py', 'mevg_automation.py'], + 'analysis': analysis, + 'result': raw, + 'mevg_gate': mevg_gate, + 'summary': { + 'headline': headline, + 'overall_confidence': raw.get('overall_confidence', 0), + 'validated_count': len(validated), + 'unvalidated_count': len(unvalidated), + 'case_base': raw.get('case_base', ''), + 'gate_status': mevg_gate.get('gate_status'), + 'top_cases': sorted({ + case + for item in validated + for case in item.get('cases', []) + if isinstance(case, str) + })[:8], + 'next_action': next_action, + }, + } + + def _mevg_gate_status(self): + try: + mevg = _load_local_module('mevg_automation') + state_path = getattr(mevg, 'MEVG_STATE_FILE', None) + checks = getattr(mevg, 'VALIDATION_CHECKS', []) + threshold = getattr(mevg, 'GATE_THRESHOLD', 0.30) + if not state_path or not os.path.exists(state_path): + return { + 'source': 'mevg_automation.py', + 'gate_status': 'NOT_INITIALIZED', + 'threshold': threshold, + 'case_count': 0, + 'failed_count': 0, + 'fail_rate': 0, + 'checks': checks, + 'next_action': '运行 MEVG 案例验证后再把外部门控作为预测置信度依据。', + } + with open(state_path, 'r', encoding='utf-8') as fh: + state = json.load(fh) + cases = state.get('cases', {}) if isinstance(state, dict) else {} + failed = sum(1 for item in cases.values() if isinstance(item, dict) and item.get('verdict') == 'FAIL') + fail_rate = failed / len(cases) if cases else 0 + return { + 'source': 'mevg_automation.py', + 'gate_status': state.get('gate_status', 'UNKNOWN'), + 'threshold': threshold, + 'case_count': len(cases), + 'failed_count': failed, + 'fail_rate': round(fail_rate, 3), + 'last_updated': state.get('last_updated'), + 'checks': checks, + 'next_action': '若门控 CLOSED,应先校准失败案例,再输出新的预测型解读。', + } + except Exception as exc: + import logging + logging.warning(f"[api_server] MEVG gate status unavailable: {exc}") + return { + 'source': 'mevg_automation.py', + 'gate_status': 'UNAVAILABLE', + 'error': str(exc), + 'next_action': 'MEVG 自动化状态不可用;预测项必须降级为待验证假设。', + } + + def _case_dignity_label(self, planet, sign): + exalted = { + 'Sun': 'Aries', + 'Moon': 'Taurus', + 'Mars': 'Capricorn', + 'Mercury': 'Virgo', + 'Jupiter': 'Cancer', + 'Venus': 'Pisces', + 'Saturn': 'Libra', + } + debilitated = { + 'Sun': 'Libra', + 'Moon': 'Scorpio', + 'Mars': 'Cancer', + 'Mercury': 'Pisces', + 'Jupiter': 'Capricorn', + 'Venus': 'Virgo', + 'Saturn': 'Aries', + } + own_signs = { + 'Sun': {'Leo'}, + 'Moon': {'Cancer'}, + 'Mars': {'Aries', 'Scorpio'}, + 'Mercury': {'Gemini', 'Virgo'}, + 'Jupiter': {'Sagittarius', 'Pisces'}, + 'Venus': {'Taurus', 'Libra'}, + 'Saturn': {'Capricorn', 'Aquarius'}, + } + if not sign: + return '' + if exalted.get(planet) == sign: + return 'exalted' + if debilitated.get(planet) == sign: + return 'debilitated' + if sign in own_signs.get(planet, set()): + return 'own_sign' + if planet == 'Mars': + return 'strong' + if planet == 'Venus' and sign in {'Taurus', 'Libra', 'Pisces'}: + return 'strong' + return '' + + def _compute_divisional_yoga(self, body): + normalized, _, asc_sign_idx = self._normalized_planets_from_body(body) + if not normalized: + raise BadRequest('planets must include longitude data') + divisions = body.get('divisions', ['D9', 'D10', 'D12']) + if isinstance(divisions, str): + divisions = [item.strip().upper() for item in divisions.split(',') if item.strip()] + if not isinstance(divisions, list): + raise BadRequest('divisions must be a list or comma string') + allowed = {'D9', 'D10', 'D12'} + selected = [] + for item in divisions: + token = str(item).strip().upper() + if token not in allowed: + raise BadRequest('divisional_yoga supports D9, D10, and D12') + selected.append(token) + selected = selected or ['D9', 'D10', 'D12'] + module = _load_local_module('divisional_yoga') + results = {} + total = 0 + for division in selected: + yogas = module.detect_varga_yogas(normalized, division, SIGNS[asc_sign_idx]) + results[division] = { + 'yoga_count': len(yogas), + 'yogas': yogas, + } + total += len(yogas) + headline = '分盘 Yoga 有可用证据' if total else '分盘 Yoga 暂无强命中' + return { + 'success': True, + 'endpoint': 'divisional_yoga', + 'ascendant': SIGNS[asc_sign_idx], + 'divisions': selected, + 'result': results, + 'summary': { + 'headline': headline, + 'total_yogas': total, + 'next_action': '把 D9 用于关系/内在成熟,D10 用于事业兑现,D12 用于家族与父母主题;不要把分盘 Yoga 当作单点结论。', + }, + } + + def _compute_deep_varga_avastha(self, body): + _, planet_lons, _ = self._normalized_planets_from_body(body) + if not planet_lons: + raise BadRequest('planets must include longitude data') + asc_lon = self._asc_lon_from_body(body) + report = _load_local_module('deep_varga_avastha').build_deep_varga_avastha_report( + planet_lons, + asc_lon=asc_lon, + ) + return { + 'success': True, + 'endpoint': 'deep_varga_avastha', + 'report': report, + } + + def _compute_kakshya(self, body): + normalized, _, asc_sign_idx = self._normalized_planets_from_body(body) + if not normalized: + raise BadRequest('planets must include longitude data') + result = _load_local_module('kakshya').calc_kakshya_scores(normalized, asc_sign_idx) + planets = result.get('planets', {}) + strongest = sorted( + planets.items(), + key=lambda item: item[1].get('kakshya_strength', 0), + reverse=True, + )[:3] + weakest = sorted( + planets.items(), + key=lambda item: item[1].get('kakshya_strength', 0), + )[:3] + avg = sum(item.get('kakshya_strength', 0) for item in planets.values()) / max(len(planets), 1) + headline = 'Kakshya 度数层支持较强' if avg >= 6.5 else 'Kakshya 度数层需要谨慎使用' + return { + 'success': True, + 'endpoint': 'kakshya', + 'ascendant': SIGNS[asc_sign_idx], + 'result': result, + 'summary': { + 'headline': headline, + 'average_strength': round(avg, 2), + 'strongest': [{'planet': name, **data} for name, data in strongest], + 'weakest': [{'planet': name, **data} for name, data in weakest], + 'next_action': '把 Kakshya 用作 Ashtakavarga/Transit 的度数级触发层,只在已有 Dasha 或主题承诺时提高事件窗口权重。', + }, + } + + def _compute_bhava_bala_api(self, body): + normalized, _, asc_sign_idx = self._normalized_planets_from_body(body) + if not normalized: + raise BadRequest('planets must include longitude data') + asc_lon = self._asc_lon_from_body(body) + asc_sign = SIGNS[asc_sign_idx] + asc_degree = asc_lon % 30 + house_signs = [SIGNS[(asc_sign_idx + i) % 12] for i in range(12)] + house_degrees = [asc_degree for _ in range(12)] + planet_shadbala = self._planet_shadbala_from_body(body, normalized) + result = _load_local_module('bhava_bala').calc_bhava_bala( + house_signs, + house_degrees, + asc_sign, + asc_degree, + normalized, + planet_shadbala, + ) + houses = result.get('houses', []) + strongest = sorted(houses, key=lambda item: item.get('total', 0), reverse=True)[:3] + weakest = sorted(houses, key=lambda item: item.get('total', 0))[:3] + headline = '宫位力量结构清晰' if strongest and strongest[0].get('total', 0) >= 45 else '宫位力量偏分散' + return { + 'success': True, + 'endpoint': 'bhava_bala', + 'ascendant': asc_sign, + 'result': result, + 'summary': { + 'headline': headline, + 'strongest': strongest, + 'weakest': weakest, + 'next_action': '优先阅读最强宫位对应的人生领域;最弱宫位只作为风险提示,需要结合宫主、Dasha 与 Transit 确认。', + }, + } + + def _planet_shadbala_from_body(self, body, planets): + raw = body.get('planet_shadbala') or body.get('shadbala') or {} + result = {} + if isinstance(raw, dict): + for planet, value in raw.items(): + if isinstance(value, dict): + number = value.get('virupas', value.get('total_virupas', value.get('score', value.get('rupas')))) + else: + number = value + try: + result[planet] = float(number) + except (TypeError, ValueError): + continue + for planet in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn']: + if planet in result: + continue + pdata = planets.get(planet, {}) + dignity = self._case_dignity_label(planet, pdata.get('sign')) + score = 35.0 + if dignity in {'exalted', 'own_sign'}: + score = 55.0 + elif dignity == 'strong': + score = 45.0 + elif dignity == 'debilitated': + score = 24.0 + if pdata.get('house') in (1, 4, 7, 10): + score += 5.0 + result[planet] = score + return result + + def _compute_transit_triggers(self, body): + start_raw = body.get('start', body.get('start_date', datetime.now().strftime('%Y-%m-%d'))) + end_raw = body.get('end', body.get('end_date')) + if not isinstance(start_raw, str): + raise BadRequest('start must be YYYY-MM-DD') + if end_raw is not None and not isinstance(end_raw, str): + raise BadRequest('end must be YYYY-MM-DD') + try: + start_date = datetime.strptime(start_raw[:10], '%Y-%m-%d') + end_date = datetime.strptime((end_raw or (start_date + timedelta(days=90)).strftime('%Y-%m-%d'))[:10], '%Y-%m-%d') + except ValueError as e: + raise BadRequest('start/end must be YYYY-MM-DD') from e + if end_date < start_date: + raise BadRequest('end must be after start') + if (end_date - start_date).days > 730: + raise BadRequest('transit search range must be <= 730 days') + + planets_to_check = body.get('planets_to_check') + raw_planets = body.get('natal_planets', body.get('chart_planets')) + if raw_planets is None and isinstance(body.get('planets'), dict): + raw_planets = body.get('planets') + elif raw_planets is None and isinstance(body.get('planets'), list): + planets_to_check = body.get('planets') + raw_planets = {} + if planets_to_check is not None: + if not isinstance(planets_to_check, list): + raise BadRequest('planets_to_check must be an array') + planets_to_check = [str(item) for item in planets_to_check[:9]] + + planets = self._validate_planets(raw_planets or {}) + body_for_asc = {**body, 'planets': planets} + asc_lon = self._asc_lon_from_body(body_for_asc) + natal_data = {'asc': asc_lon, 'planets': planets} + result = _load_local_module('transit_trigger').search_all_transit_triggers( + natal_data, + start_date, + end_date, + planets_to_check=planets_to_check, + ) + top_triggers = result.get('triggers', [])[:6] + headline = '发现可观察过境触发点' if result.get('total_triggers', 0) else '当前区间未发现精确触发' + return { + 'success': True, + 'endpoint': 'transit', + 'result': result, + 'triggers': result.get('triggers', []), + 'summary': { + 'headline': headline, + 'period': result.get('search_period', {}), + 'total_triggers': result.get('total_triggers', 0), + 'top_triggers': top_triggers, + 'next_action': '把过境触发作为时间窗口,不单独定事件;优先与 Dasha、Ashtakavarga、Kakshya 和本命承诺交叉确认。', + }, + } + + def _compute_bhava_chalit(self, body): + _, planet_lons = self._planet_lons_from_body(body) + if not planet_lons: + raise BadRequest('planets must include longitude data') + ascendant = body.get('ascendant', {}) + if not isinstance(ascendant, dict): + raise BadRequest('ascendant must be an object') + asc_lon = ascendant.get('lon') + if asc_lon is None and ascendant.get('sign') in SIGNS: + asc_lon = SIGNS.index(ascendant['sign']) * 30 + float(ascendant.get('degree_in_sign', ascendant.get('degree', 0))) + if asc_lon is None: + asc_lon = self._normalize_degree(body, 'asc_lon', 0) + else: + asc_lon = float(asc_lon) % 360 + mc_lon = self._normalize_degree(body, 'mc_lon', (asc_lon + 270) % 360) + house_system = body.get('house_system', 'sripati') + if not isinstance(house_system, str): + raise BadRequest('house_system must be a string') + requested_house_system = house_system.lower().strip() + mode = body.get('mode', 'compare') + if not isinstance(mode, str): + raise BadRequest('mode must be a string') + calculator = _load_local_module('bhava_chalit').BhavaChalitCalculator() + available_house_systems = sorted(calculator.HOUSE_SYSTEMS.keys()) + if requested_house_system not in calculator.HOUSE_SYSTEMS: + raise BadRequest(f'unknown house_system: {house_system}') + selected_house_system = requested_house_system + jd = lat = lon = None + fallback_reason = '' + calculation_note = f'Bhava Chalit uses {calculator.HOUSE_SYSTEMS[selected_house_system]}.' + if selected_house_system in {'placidus', 'koch'}: + try: + 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) + hour = self._get_float(body, 'hour', 12, 0, 23) + minute = self._get_float(body, 'minute', 0, 0, 59) + lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90) + lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180) + tz = self._get_float(body, 'tz', body.get('birth_tz', 0), -14, 14) + datetime(year, month, day) + import swisseph as swe + hour_ut = hour + minute / 60.0 - tz + jd = swe.julday(year, month, day, hour_ut) + calculation_note = f'{selected_house_system} cusps use swisseph houses with birth JD and location.' + except Exception as exc: + fallback_reason = f'{selected_house_system} requires swisseph plus valid birth date, lat, lon and tz; fell back to sripati: {exc}' + selected_house_system = 'sripati' + jd = lat = lon = None + calculation_note = 'Fallback to Sripati because time-based house cusps were unavailable.' + if mode == 'chart': + result = calculator.get_bhava_chalit_chart(planet_lons, asc_lon, mc_lon, selected_house_system, jd, lat, lon) + elif mode == 'boundaries': + result = calculator.calculate_bhava_boundaries(asc_lon, mc_lon, selected_house_system, jd, lat, lon) + else: + result = calculator.compare_rashi_vs_bhava(planet_lons, asc_lon, mc_lon, selected_house_system, jd, lat, lon) + result.setdefault('summary', { + 'total_planets': len(planet_lons), + 'shifted_count': result.get('shifted_count', 0), + 'shifted_names': [shift.get('planet') for shift in result.get('shifts', []) if isinstance(shift, dict)], + }) + result['requested_house_system'] = requested_house_system + result['selected_house_system'] = selected_house_system + result['available_house_systems'] = available_house_systems + result['fallback_reason'] = fallback_reason + result['calculation_note'] = calculation_note + return { + 'success': True, + 'endpoint': 'bhava_chalit', + 'mode': mode, + 'requested_house_system': requested_house_system, + 'selected_house_system': selected_house_system, + 'available_house_systems': available_house_systems, + 'fallback_reason': fallback_reason, + 'calculation_note': calculation_note, + 'result': result, + } + + def _compute_sudarshana(self, body): + _, planet_lons = self._planet_lons_from_body(body) + if not {'Sun', 'Moon'} <= set(planet_lons): + raise BadRequest('planets must include Sun and Moon longitude data') + ascendant = body.get('ascendant', {}) + if not isinstance(ascendant, dict): + raise BadRequest('ascendant must be an object') + asc_lon = ascendant.get('lon') + if asc_lon is None and ascendant.get('sign') in SIGNS: + asc_lon = SIGNS.index(ascendant['sign']) * 30 + float(ascendant.get('degree_in_sign', ascendant.get('degree', 0))) + if asc_lon is None: + asc_lon = self._normalize_degree(body, 'asc_lon', 0) + else: + asc_lon = float(asc_lon) % 360 + house = body.get('house') + if house is not None: + house = self._get_int(body, 'house', 1, 1, 12) + result = _load_local_module('sudarshana_chakra').calc_sudarshana_chakra(planet_lons, asc_lon, house) + return {'success': True, 'endpoint': 'sudarshana', 'result': result} + + def _compute_nakshatra_full(self, body): + chart_data, _ = self._chart_payload_from_body(body) + age = body.get('age') + if age is not None: + age = self._get_float(body, 'age', 0, 0, 150) + transit_date = body.get('transit_date') + if transit_date is not None and not isinstance(transit_date, str): + raise BadRequest('transit_date must be a string') + result = _load_local_module('nakshatra_advanced').nakshatra_full_report( + chart_data, + age=age, + transit_date=transit_date, + ) + return {'success': True, 'endpoint': 'nakshatra_full', 'result': result} + + def _capability_audit(self): + registry = self._read_technique_registry() + techniques = registry.get('techniques', {}) + engine_commands = self._scan_engine_commands() + api_endpoints = self._scan_api_endpoints() + app_tabs = self._scan_app_tabs() + local_sources = self._scan_local_open_source_sources() + command_set = set(engine_commands) + api_command_map = API_COMMAND_MAP + api_backed_commands = sorted( + command + for command, endpoint in api_command_map.items() + if endpoint in api_endpoints or command in command_set + ) + app_visible_topics = self._app_visible_topics(app_tabs) + registry_commands = sorted({ + command + for technique in techniques.values() + for command in technique.get('commands', []) + if isinstance(command, str) + }) + registry_only_commands = sorted(set(registry_commands) - command_set) + engine_not_api = sorted(command_set - set(api_backed_commands)) + status_counts = {} + domain_counts = {} + for technique in techniques.values(): + status = technique.get('status', 'unknown') + status_counts[status] = status_counts.get(status, 0) + 1 + for domain in technique.get('domains', []): + domain_counts[domain] = domain_counts.get(domain, 0) + 1 + + direct_sources = [s for s in local_sources if s.get('reuse') == 'direct'] + caution_sources = [s for s in local_sources if s.get('reuse') == 'caution'] + unknown_sources = [s for s in local_sources if s.get('reuse') == 'unknown'] + priority_gaps = self._build_priority_gaps(techniques, engine_not_api, app_visible_topics) + productization = self._build_productization_matrix( + techniques, + command_set, + set(api_backed_commands), + app_visible_topics, + ) + ux_productization = self._build_ux_productization_matrix(productization['rows']) + + return { + 'success': True, + 'generated_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z', + 'registry': { + 'version': registry.get('version'), + 'technique_count': len(techniques), + 'status_counts': status_counts, + 'domain_counts': dict(sorted(domain_counts.items())), + 'source_inspiration': registry.get('source_inspiration', []), + }, + 'techniques': [ + { + 'id': key, + 'name': value.get('name', key), + 'status': value.get('status', 'unknown'), + 'domains': value.get('domains', []), + 'commands': value.get('commands', []), + 'output_paths': value.get('output_paths', []), + 'audit_label': value.get('audit_label', value.get('name', key)), + 'limitation': value.get('limitation', ''), + 'missing_impact': value.get('missing_impact', ''), + } + for key, value in sorted(techniques.items()) + ], + 'surfaces': { + 'engine_command_count': len(engine_commands), + 'engine_commands': engine_commands, + 'api_endpoint_count': len(api_endpoints), + 'api_endpoints': api_endpoints, + 'api_backed_commands': api_backed_commands, + 'engine_not_api': engine_not_api, + 'registry_only_commands': registry_only_commands, + 'app_tab_count': len(app_tabs), + 'app_tabs': app_tabs, + 'app_visible_topics': app_visible_topics, + }, + 'local_open_source': { + 'source_count': len(local_sources), + 'direct_reuse_count': len(direct_sources), + 'caution_count': len(caution_sources), + 'unknown_count': len(unknown_sources), + 'sources': local_sources, + }, + 'external_research': self._external_research_matrix(), + 'priority_gaps': priority_gaps, + 'productization': productization, + 'ux_productization': ux_productization, + } + + def _technique_catalog(self): + audit = self._capability_audit() + rows = self._build_technique_catalog_rows(audit) + domains = sorted({ + domain + for row in rows + for domain in row.get('domains', []) + }) + endpoints = sorted({ + endpoint + for row in rows + for endpoint in row.get('api_endpoints', []) + }) + return { + 'success': True, + 'generated_at': audit.get('generated_at'), + 'registry': audit.get('registry', {}), + 'summary': { + 'technique_count': len(rows), + 'domain_count': len(domains), + 'api_endpoint_count': len(endpoints), + 'runnable_count': sum(1 for row in rows if row.get('runnable')), + }, + 'filters': { + 'domains': domains, + 'levels': ['productized', 'api_backed', 'engine_or_full_reading', 'registry_only'], + 'statuses': sorted({row.get('status') for row in rows if row.get('status')}), + 'api_endpoints': endpoints, + }, + 'api_command_map': API_COMMAND_MAP, + 'example_payloads': self._technique_example_payloads(), + 'api_docs': self._technique_api_docs(), + 'techniques': rows, + } + + def _build_technique_catalog_rows(self, audit): + product_rows = { + row.get('id'): row + for row in audit.get('productization', {}).get('rows', []) + if row.get('id') + } + ux_rows = { + row.get('id'): row + for row in audit.get('ux_productization', {}).get('rows', []) + if row.get('id') + } + api_endpoints = set(audit.get('surfaces', {}).get('api_endpoints', [])) + rows = [] + for technique in audit.get('techniques', []): + product = product_rows.get(technique.get('id'), {}) + ux = ux_rows.get(technique.get('id'), {}) + commands = sorted(set((technique.get('commands') or []) + (product.get('commands') or []))) + mapped = [ + API_COMMAND_MAP.get(command) + for command in commands + if API_COMMAND_MAP.get(command) in api_endpoints + ] + runnable = [endpoint for endpoint in mapped if endpoint in TECHNIQUE_EXAMPLE_ENDPOINTS] + primary_endpoint = self._primary_catalog_endpoint(technique, mapped, runnable) + rows.append({ + 'id': technique.get('id'), + 'name': technique.get('name'), + 'audit_label': technique.get('audit_label'), + 'status': technique.get('status'), + 'domains': sorted(set((technique.get('domains') or []) + (product.get('domains') or []))), + 'commands': commands, + 'api_commands': product.get('api_commands', []), + 'api_endpoints': sorted(set(mapped)), + 'runnable': bool(runnable), + 'example_endpoint': runnable[0] if runnable else '', + 'method_docs': self._technique_method_doc(technique, product, primary_endpoint), + 'output_paths': sorted(set((technique.get('output_paths') or []) + (product.get('output_paths') or []))), + 'level': product.get('level', 'registry_only'), + 'reason': product.get('reason') or technique.get('limitation') or '', + 'next_action': product.get('next_action') or ux.get('ux_next_action') or technique.get('missing_impact') or '', + 'ux_level': ux.get('ux_level', 'not_user_ready'), + 'ux_score': ux.get('ux_score', 0), + 'visible_markers': product.get('visible_markers', []), + }) + order = {'registry_only': 0, 'engine_or_full_reading': 1, 'api_backed': 2, 'productized': 3} + return sorted(rows, key=lambda row: (order.get(row.get('level'), 0), row.get('name') or '')) + + def _primary_catalog_endpoint(self, technique, mapped, runnable): + candidates = runnable or mapped or [] + if not candidates: + return '' + text = ' '.join([ + str(technique.get('id', '')), + str(technique.get('name', '')), + str(technique.get('audit_label', '')), + ' '.join(technique.get('domains') or []), + ]).lower() + endpoint_keywords = [ + ('/api/thematic_report', ['thematic', '主题', 'report_orchestrator', 'reading', 'report']), + ('/api/relationship', ['relationship', 'spouse', '婚姻', '感情']), + ('/api/career', ['career', '事业']), + ('/api/dasha', ['dasha', 'vimshottari']), + ('/api/yogas', ['yoga', 'dosha']), + ('/api/shadbala', ['shadbala']), + ('/api/deep_varga_avastha', ['deep_varga_avastha', 'deep varga', 'avastha', 'sayanadi', 'shayanadi', 'd24', 'd30', 'd60']), + ('/api/ashtakavarga', ['ashtakavarga']), + ('/api/jaimini', ['jaimini', 'karaka', 'arudha']), + ] + for endpoint, keywords in endpoint_keywords: + if endpoint in candidates and any(keyword in text for keyword in keywords): + return endpoint + return candidates[0] + + def _compute_technique_example(self, body): + endpoint = body.get('endpoint') + if not isinstance(endpoint, str): + raise BadRequest('endpoint must be a string') + endpoint = endpoint.strip() + if endpoint not in TECHNIQUE_EXAMPLE_ENDPOINTS: + raise BadRequest('endpoint is not runnable from technique explorer') + payloads = self._technique_example_payloads() + payload = body.get('payload') + if payload is None: + payload = payloads.get(endpoint) + if not isinstance(payload, dict): + raise BadRequest('payload must be an object') + payload = self._sanitize_technique_example_payload(endpoint, payload) + result = self._dispatch_technique_endpoint(endpoint, payload) + return { + 'success': True, + 'endpoint': 'technique_example', + 'target_endpoint': endpoint, + 'sample_payload': payload, + 'result': result, + } + + def _dispatch_technique_endpoint(self, endpoint, payload): + dispatch = { + '/api/ashtakavarga': self._compute_ashtakavarga, + '/api/bhava_bala': self._compute_bhava_bala_api, + '/api/bhava_chalit': self._compute_bhava_chalit, + '/api/career': self._compute_career, + '/api/case_validation': self._compute_case_validation, + '/api/dasha': self._compute_dasha_system, + '/api/deep_varga_avastha': self._compute_deep_varga_avastha, + '/api/divisional_yoga': self._compute_divisional_yoga, + '/api/jaimini': self._compute_jaimini, + '/api/kakshya': self._compute_kakshya, + '/api/kp': self._compute_kp, + '/api/muhurta': self._compute_muhurta, + '/api/nakshatra_full': self._compute_nakshatra_full, + '/api/pancha_mahapurusha': self._compute_pmc, + '/api/prashna': self._compute_prashna, + '/api/rectification_gate': self._compute_rectification_gate, + '/api/relationship': self._compute_relationship, + '/api/remedies': self._compute_remedies, + '/api/sade_sati': self._compute_sade_sati, + '/api/shadbala': self._compute_shadbala, + '/api/sudarshana': self._compute_sudarshana, + '/api/synastry': self._compute_synastry, + '/api/thematic_report': self._compute_thematic_report, + '/api/transit': self._compute_transit_triggers, + '/api/varga_full': self._compute_varga_full, + '/api/yogas': self._compute_yogas_api, + } + return dispatch[endpoint](payload) + + def _sanitize_technique_example_payload(self, endpoint, payload): + text = json.dumps(payload, ensure_ascii=False, default=str) + if len(text) > 120_000: + raise BadRequest('technique example payload too large') + if endpoint == '/api/transit': + start_raw = str(payload.get('start', payload.get('start_date', datetime.now().strftime('%Y-%m-%d'))))[:10] + try: + start = datetime.strptime(start_raw, '%Y-%m-%d') + except ValueError as e: + raise BadRequest('transit start must be YYYY-MM-DD') from e + end = payload.get('end') or payload.get('end_date') + if end: + try: + end_dt = datetime.strptime(str(end)[:10], '%Y-%m-%d') + except ValueError as e: + raise BadRequest('transit end must be YYYY-MM-DD') from e + if (end_dt - start).days > 180: + payload = {**payload, 'end': (start + timedelta(days=180)).strftime('%Y-%m-%d')} + else: + payload = {**payload, 'end': (start + timedelta(days=90)).strftime('%Y-%m-%d')} + return payload + + def _technique_api_docs(self): + docs = {} + payloads = self._technique_example_payloads() + for endpoint in sorted(TECHNIQUE_EXAMPLE_ENDPOINTS): + payload = payloads.get(endpoint, {}) + docs[endpoint] = { + 'method': 'POST', + 'endpoint': endpoint, + 'curl': self._curl_example(endpoint, payload), + 'openapi': self._openapi_operation(endpoint, payload), + 'notes': self._endpoint_method_notes(endpoint), + } + return docs + + def _technique_method_doc(self, technique, product, endpoint): + return { + 'summary': product.get('reason') or technique.get('limitation') or technique.get('audit_label') or technique.get('name') or '', + 'boundary': product.get('next_action') or technique.get('missing_impact') or '把该技法作为证据层使用,并结合 Dasha、Transit、案例验证共同收敛。', + 'primary_endpoint': endpoint, + 'source_paths': sorted(set((technique.get('output_paths') or []) + (product.get('output_paths') or [])))[:8], + 'api_doc_key': endpoint if endpoint in TECHNIQUE_EXAMPLE_ENDPOINTS else '', + } + + def _curl_example(self, endpoint, payload): + body = json.dumps(payload or {}, ensure_ascii=False, indent=2, sort_keys=True) + return ( + "curl -sS -X POST http://127.0.0.1:5200" + f"{endpoint} \\\n" + " -H 'Content-Type: application/json' \\\n" + " --data-binary " + + json.dumps(body, ensure_ascii=False) + ) + + def _openapi_operation(self, endpoint, payload): + return { + 'path': endpoint, + 'post': { + 'summary': self._endpoint_summary(endpoint), + 'operationId': self._endpoint_operation_id(endpoint), + 'requestBody': { + 'required': True, + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'additionalProperties': True, + 'example': payload or {}, + }, + }, + }, + }, + 'responses': { + '200': { + 'description': 'Successful Jyotish calculation result', + 'content': { + 'application/json': { + 'schema': {'type': 'object', 'additionalProperties': True}, + }, + }, + }, + '400': {'description': 'Invalid payload or unsupported option'}, + }, + }, + } + + def _endpoint_operation_id(self, endpoint): + return 'post' + ''.join(part.title() for part in endpoint.strip('/').replace('_', '-').split('-')) + + def _endpoint_summary(self, endpoint): + labels = { + '/api/ashtakavarga': 'Compute SAV/BAV/PAV/Sodhita Ashtakavarga evidence', + '/api/bhava_bala': 'Compute Bhava Bala house strength evidence', + '/api/bhava_chalit': 'Compare Rashi and Bhava Chalit house placements', + '/api/career': 'Compute career analysis from planets and ascendant', + '/api/case_validation': 'Run case validation and MEVG gate status', + '/api/dasha': 'Compute Dasha periods and Vimshottari analysis layer', + '/api/deep_varga_avastha': 'Compute Sayanadi/Shayanadi avastha and D24/D30/D60 templates', + '/api/divisional_yoga': 'Detect D9/D10/D12 divisional yogas', + '/api/jaimini': 'Compute Jaimini karaka, arudha, dasha, and karakamsha data', + '/api/kakshya': 'Compute Kakshya degree-level trigger support', + '/api/kp': 'Compute KP significator and sublord analysis', + '/api/muhurta': 'Compute Muhurta day quality evidence', + '/api/nakshatra_full': 'Compute advanced Nakshatra report', + '/api/pancha_mahapurusha': 'Assess Pancha Mahapurusha yoga strength', + '/api/prashna': 'Compute Prashna chart and answer evidence', + '/api/rectification_gate': 'Evaluate birth-time precision gate', + '/api/relationship': 'Compute relationship and spouse-status evidence', + '/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha', + '/api/sade_sati': 'Compute Sade Sati status and phase', + '/api/shadbala': 'Compute Shadbala plus advanced evidence layer', + '/api/sudarshana': 'Compute Sudarshana Chakra evidence', + '/api/synastry': 'Compute 16-factor/Ashtakoot compatibility score', + '/api/thematic_report': 'Generate thematic report with sample/custom/derived evidence', + '/api/transit': 'Search transit trigger windows', + '/api/varga_full': 'Compute divisional chart positions', + '/api/yogas': 'Compute yoga rule-engine and curse-yoga evidence', + } + return labels.get(endpoint, f'Run {endpoint} calculation') + + def _endpoint_method_notes(self, endpoint): + notes = { + '/api/thematic_report': '传 birth/chart payload 时会进入 derived_chart_evidence;只传 theme 时使用样例证据。', + '/api/shadbala': 'advanced_layer 是证据补充,不覆盖主 Shadbala 总分。', + '/api/yogas': 'curse_yogas 是高风险提示层,不能替代健康/法律/安全建议。', + '/api/case_validation': 'MEVG 只读门控不运行外部子进程。', + '/api/report_artifact': '报告 artifact 不在 Technique Explorer 白名单内,避免把任意 HTML 当作样例执行。', + } + return notes.get(endpoint, '样例 payload 可直接复制到本地 API;正式报告仍需结合上下文和边界说明。') + + def _technique_example_payloads(self): + today = datetime.utcnow().strftime('%Y-%m-%d') + transit_end = (datetime.utcnow() + timedelta(days=60)).strftime('%Y-%m-%d') + base = { + 'planets': SAMPLE_PLANETS, + 'ascendant': SAMPLE_ASCENDANT, + } + birth = { + 'year': 1990, + 'month': 1, + 'day': 1, + 'hour': 12, + 'minute': 0, + 'lat': 28.6, + 'lon': 77.2, + 'tz': 5.5, + } + return { + '/api/ashtakavarga': base, + '/api/bhava_bala': base, + '/api/bhava_chalit': {**base, 'mode': 'compare', 'house_system': 'sripati'}, + '/api/career': {'planets': SAMPLE_PLANETS, 'asc_sign': 'Aries'}, + '/api/case_validation': {**base, 'current_md': 'Jupiter', 'predicted_events': ['事业巅峰', '关系发展'], 'transit_desc': 'Double Jupiter Saturn activation'}, + '/api/dasha': {**base, **birth, 'dasha': 'vimshottari'}, + '/api/deep_varga_avastha': base, + '/api/divisional_yoga': {**base, 'divisions': ['D9', 'D10', 'D12']}, + '/api/jaimini': {**base, **birth, 'mode': 'all'}, + '/api/kakshya': base, + '/api/kp': {'planets': SAMPLE_PLANETS, 'asc_sign_idx': 0}, + '/api/muhurta': {'date': today, 'activity': 'business', 'hour_from_sunrise': 6.0}, + '/api/nakshatra_full': {**base, 'age': 36, 'transit_date': today}, + '/api/pancha_mahapurusha': {'planets': SAMPLE_PLANETS, 'sun_degree': SAMPLE_PLANETS['Sun']['lon']}, + '/api/prashna': {'planets': SAMPLE_PLANETS, 'question': 'general'}, + '/api/rectification_gate': {**base, 'declared_accuracy': 'minute', 'time_source': 'family_clear'}, + '/api/relationship': {'planets': SAMPLE_PLANETS, 'asc_sign': 'Aries', 'dasha_info': {'maha_dasha': 'Venus', 'antar_dasha': 'Jupiter'}}, + '/api/remedies': {'shadbala': {'Sun': {'rupas': 4.1}, 'Moon': {'rupas': 3.8}}, 'doshas': ['manglik'], 'dasha_lord': 'Venus'}, + '/api/sade_sati': {'moon_degree': SAMPLE_PLANETS['Moon']['lon'], 'asc_degree': SAMPLE_ASCENDANT['lon'], 'saturn_degree': SAMPLE_PLANETS['Saturn']['lon']}, + '/api/shadbala': {**base, **birth}, + '/api/sudarshana': base, + '/api/synastry': {'male_moon': SAMPLE_PLANETS['Moon']['lon'], 'female_moon': 243.0}, + '/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']}, + '/api/yogas': base, + } + + def _read_technique_registry(self): + path = os.path.join(REPO_ROOT, 'references', 'technique_registry.json') + try: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + if isinstance(data, dict): + return data + except (OSError, json.JSONDecodeError): + pass + return {'version': None, 'techniques': {}} + + def _scan_engine_commands(self): + path = os.path.join(SCRIPTS_DIR, 'jyotish_engine.py') + try: + with open(path, 'r', encoding='utf-8') as f: + text = f.read() + except OSError: + return [] + return sorted(set(re.findall(r"add_parser\(['\"]([^'\"]+)['\"]", text))) + + def _scan_api_endpoints(self): + path = os.path.abspath(__file__) + try: + with open(path, 'r', encoding='utf-8') as f: + text = f.read() + except OSError: + return [] + return sorted(set(re.findall(r"path == ['\"](/api/[^'\"]+)['\"]", text))) + + def _scan_app_tabs(self): + path = os.path.join(REPO_ROOT, 'jyotish-app', 'index.html') + try: + with open(path, 'r', encoding='utf-8') as f: + text = f.read() + except OSError: + return [] + return sorted(set(re.findall(r'data-tab="([^"]+)"', text))) + + def _scan_app_source_text(self): + root = os.path.join(REPO_ROOT, 'jyotish-app') + if not os.path.isdir(root): + return '' + chunks = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [ + d for d in dirnames + if d not in {'node_modules', 'dist', '.vite', '.git'} + ] + for filename in filenames: + if not filename.endswith(('.js', '.html', '.css')): + continue + try: + with open(os.path.join(dirpath, filename), 'r', encoding='utf-8', errors='ignore') as f: + chunks.append(f.read(40000)) + except OSError: + continue + return '\n'.join(chunks).lower() + + def _scan_local_open_source_sources(self): + root = os.path.join(REPO_ROOT, 'references', 'open_source_sources') + if not os.path.isdir(root): + return [] + sources = [] + for name in sorted(os.listdir(root)): + path = os.path.join(root, name) + if not os.path.isdir(path): + continue + files = [] + for dirpath, dirnames, filenames in os.walk(path): + dirnames[:] = [d for d in dirnames if d not in {'.git', '__pycache__', '.pytest_cache'}] + for filename in filenames: + rel = os.path.relpath(os.path.join(dirpath, filename), path) + files.append(rel) + if len(files) >= 500: + break + if len(files) >= 500: + break + license_name = self._detect_source_license(path, files) + if license_name == 'unknown': + license_name = self._license_from_research_index(name) + sources.append({ + 'name': name, + 'path': os.path.relpath(path, REPO_ROOT), + 'file_count': len(files), + 'license': license_name, + 'reuse': self._reuse_level(license_name), + 'modules': self._infer_source_modules(files), + 'has_readme': any(os.path.basename(f).lower() == 'readme.md' for f in files), + 'has_skill': any(os.path.basename(f).lower() == 'skill.md' for f in files), + }) + return sources + + def _detect_source_license(self, source_path, files): + license_files = [ + f for f in files + if os.path.basename(f).lower() in {'license', 'license.md', 'licence', 'licence.md', 'copying'} + ] + text = '' + for rel in license_files[:2]: + try: + with open(os.path.join(source_path, rel), 'r', encoding='utf-8', errors='ignore') as f: + text += '\n' + f.read(6000) + except OSError: + continue + haystack = (text + '\n' + source_path).lower() + if 'agpl' in haystack: + return 'AGPL' + if 'gpl' in haystack and 'lesser' not in haystack: + return 'GPL' + if 'apache license' in haystack or 'apache-2.0' in haystack: + return 'Apache-2.0' + if 'mit license' in haystack or '/mit' in haystack: + return 'MIT' + return 'unknown' + + def _license_from_research_index(self, source_name): + scan_path = os.path.join(REPO_ROOT, 'references', 'open-source-jyotish-scan-2026.md') + integration_path = os.path.join(REPO_ROOT, 'references', 'open_source_sources', 'INTEGRATION_REPORT.md') + text = '' + for path in (scan_path, integration_path): + try: + with open(path, 'r', encoding='utf-8', errors='ignore') as f: + text += '\n' + f.read() + except OSError: + continue + if not text: + return 'unknown' + escaped = re.escape(source_name) + patterns = [ + rf'\|\s*\*\*{escaped}\*\*\s*\|[^|\n]*\|\s*(MIT|Apache-2\.0|AGPL|GPL)', + rf'{escaped}[^\n]{{0,80}}\((MIT|Apache-2\.0|AGPL|GPL)', + rf'{escaped}[^\n]{{0,80}}\|\s*(MIT|Apache-2\.0|AGPL|GPL)', + ] + for pattern in patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + license_name = match.group(1) + if license_name.lower() == 'mit': + return 'MIT (research)' + if license_name.lower().startswith('apache'): + return 'Apache-2.0 (research)' + return f'{license_name.upper()} (research)' + return 'unknown' + + def _reuse_level(self, license_name): + base_license = (license_name or '').split(' ', 1)[0] + if base_license in {'MIT', 'Apache-2.0'}: + return 'direct' + if base_license in {'AGPL', 'GPL'}: + return 'caution' + return 'unknown' + + def _infer_source_modules(self, files): + module_keywords = { + 'ashtakavarga': ['ashtakavarga'], + 'shadbala': ['shadbala', 'strength'], + 'dasha': ['dasha', 'dasa', 'dashas'], + 'jaimini': ['jaimini', 'karaka', 'arudha'], + 'kp': ['kp', 'sublord', 'horary'], + 'prashna': ['prashna', 'prasna', 'horary'], + 'muhurta': ['muhurta', 'muhurtha'], + 'tajika': ['tajika', 'varsha', 'solar_return'], + 'synastry': ['synastry', 'matchmaking', 'compat'], + 'remedies': ['remedies', 'upaya'], + 'panchanga': ['panchang', 'panchanga', 'tithi'], + 'varga': ['varga', 'divisional'], + 'yoga': ['yoga'], + 'transit': ['transit', 'gochar'], + } + lower_files = [f.lower() for f in files] + found = [] + for module, keywords in module_keywords.items(): + if any(any(keyword in f for keyword in keywords) for f in lower_files): + found.append(module) + return found + + def _app_visible_topics(self, tabs): + mapping = { + 'chart': 'D1/Rashi', + 'complete': 'Full Reading', + 'karaka': 'Jaimini Karaka', + 'houses': 'Bhava', + 'aspects': 'Aspects', + 'yogas': 'Yoga', + 'vargas': 'Varga', + 'ashtakavarga': 'Ashtakavarga', + 'shadbala': 'Shadbala', + 'dasha': 'Dasha', + 'transit': 'Transit', + 'deep': 'PACDARES/Argala', + 'extended': 'Bhava Bala/Vimsopaka', + 'remedies': 'Remedies', + 'synastry': 'Synastry', + 'prashna': 'Prashna', + 'kp': 'KP', + 'verify': 'Verification', + 'transit-compare': 'Transit Compare', + } + topics = [mapping[t] for t in tabs if t in mapping] + source_text = self._scan_app_source_text() + source_markers = { + 'Muhurta': ['computemuhurta', 'muhurta'], + 'Tajika': ['computeannual', 'varshaphala', 'tajika'], + 'Solar Return': ['computeannual', 'solar return', 'varshaphala'], + 'Bhava Chalit': ['computebhavachalit', 'bhava chalit', 'bhava_chalit'], + 'Sudarshana': ['computesudarshana', 'sudarshana'], + 'Nakshatra Full': ['computenakshatrafull', 'nakshatra_full'], + 'Varga Full': ['computevargafull', 'varga_full'], + 'Jaimini': ['computejaimini', '/api/jaimini'], + 'Ashtakavarga': ['computeashtakavarga', '/api/ashtakavarga'], + 'Shadbala': ['computeshadbala', '/api/shadbala'], + 'Yoga': ['computeyogas', '/api/yogas'], + 'Aspects': ['computeaspects', '/api/aspects'], + 'Birth Rectification': ['computerectificationgate', '/api/rectification_gate', 'rectification', 'rect-prompt'], + 'Case Validation': ['computecasevalidation', '/api/case_validation', 'case validation', 'mevg'], + 'Deep Varga Avastha': ['deepvargaavastha', '/api/deep_varga_avastha', 'sayanadi/shayanadi', 'd24/d30/d60'], + 'Divisional Yoga': ['computedivisionalyoga', '/api/divisional_yoga', 'divisional yoga'], + 'Kakshya': ['computekakshya', '/api/kakshya', 'kakshya'], + 'Career': ['computecareer', '/api/career', 'career analysis', '事业分析'], + 'Relationship': ['computerelationship', '/api/relationship', 'relationship analysis', '感情分析'], + 'KP System': ['computekp', '/api/kp', 'kp sublord', 'kp分析'], + 'Prashna': ['computeprashna', '/api/prashna', 'prashna', '问事'], + 'Synastry 16-factor': ['computesynastry', '/api/synastry', 'ashtakoot', '合盘'], + 'Bhava Bala': ['computebhavabala', '/api/bhava_bala', 'bhava bala'], + 'Transit Trigger': ['computetransittriggers', '/api/transit', 'transit trigger', '过境触发'], + 'Thematic Report': ['computethematicreport', '/api/thematic_report', 'thematic report', '主题化报告'], + } + for topic, markers in source_markers.items(): + if any(marker in source_text for marker in markers) and topic not in topics: + topics.append(topic) + return topics + + def _build_priority_gaps(self, techniques, engine_not_api, app_visible_topics): + api_gap_commands = {'varga-full', 'bhava-chalit', 'sudarshana', 'tajika', 'solar-return', 'muhurta', 'nakshatra-full', 'audit-capabilities'} + gaps = [] + for command in sorted(api_gap_commands & set(engine_not_api)): + related = [ + value.get('name', key) + for key, value in techniques.items() + if command in value.get('commands', []) + ][:4] + gaps.append({ + 'kind': 'engine_not_api', + 'command': command, + 'priority': 'high', + 'reason': '引擎已有命令,但 Web API/用户端未直接承载完整工作流。', + 'related_techniques': related, + }) + visible_lower = ' '.join(app_visible_topics).lower() + for topic in ['muhurta', 'tajika', 'solar return', 'bhava chalit', 'sudarshana']: + if topic not in visible_lower: + gaps.append({ + 'kind': 'app_visibility', + 'topic': topic, + 'priority': 'medium', + 'reason': '前端缺少独立入口或专题化展示,用户不容易发现已有能力。', + }) + return gaps[:12] + + def _build_productization_matrix(self, techniques, engine_commands, api_backed_commands, app_visible_topics): + app_text = ' '.join(app_visible_topics).lower() + productized_markers = { + 'd1': ['d1', 'rashi', 'chart', '本命盘'], + 'd9': ['d9', 'navamsa', 'varga', '分盘'], + 'd10': ['d10', 'dasamsa', 'varga'], + 'varga': ['varga', '分盘'], + 'jaimini': ['jaimini', 'karaka', 'arudha'], + 'karaka': ['karaka', 'jaimini'], + 'arudha': ['arudha', 'jaimini'], + 'ashtakavarga': ['ashtakavarga'], + 'shadbala': ['shadbala'], + 'yoga': ['yoga', '格局'], + 'dosha': ['dosha', 'remedies'], + 'dasha': ['dasha'], + 'nakshatra': ['nakshatra'], + 'muhurta': ['muhurta'], + 'tajika': ['tajika', 'solar return', 'varshaphala'], + 'annual': ['tajika', 'solar return', 'varshaphala'], + 'bhava': ['bhava'], + 'bhava-bala': ['bhava bala', 'bhava'], + 'transit': ['transit'], + 'transit-trigger': ['transit trigger', 'transit', '过境触发'], + 'kp': ['kp'], + 'prashna': ['prashna'], + 'synastry': ['synastry'], + 'relationship': ['relationship', 'synastry'], + 'remedies': ['remedies'], + 'aspects': ['aspects'], + 'sudarshana': ['sudarshana'], + 'career': ['career'], + 'marriage': ['navamsa', 'synastry', 'relationship'], + 'birth': ['birth rectification', 'rectification', '生时校正'], + 'rectification': ['birth rectification', 'rectification', '生时校正'], + 'case': ['case validation', 'mevg', '验证'], + 'validation': ['case validation', 'mevg', '验证'], + 'misconceptions': ['case validation', 'mevg', '验证'], + 'muhurtha': ['muhurta'], + 'varshaphala': ['tajika', 'solar return', 'varshaphala'], + 'deep-varga-avastha': ['deep varga avastha', 'deep varga', 'd24/d30/d60'], + 'avastha': ['deep varga avastha', 'sayanadi/shayanadi'], + 'd24': ['d24/d30/d60'], + 'd30': ['d24/d30/d60'], + 'd60': ['d24/d30/d60'], + 'divisional': ['divisional yoga', 'varga', '分盘'], + 'kakshya': ['kakshya', 'ashtakavarga'], + } + api_only_commands = sorted(api_backed_commands - {'chart', 'full-reading'}) + rows = [] + summary = { + 'productized': 0, + 'api_backed': 0, + 'engine_or_full_reading': 0, + 'registry_only': 0, + } + + for key, technique in sorted(techniques.items()): + commands = [c for c in technique.get('commands', []) if isinstance(c, str)] + domains = [d for d in technique.get('domains', []) if isinstance(d, str)] + output_paths = technique.get('output_paths', []) + inferred_commands = self._inferred_commands_for_technique(key, technique) + all_commands = sorted(set(commands + inferred_commands)) + api_commands = [command for command in commands if command in api_backed_commands] + api_commands = sorted(set(api_commands + [command for command in inferred_commands if command in api_backed_commands])) + engine_hits = [command for command in all_commands if command in engine_commands] + marker_terms = [] + for token in [key, *domains, *all_commands]: + token_lower = token.lower() + marker_terms.extend(productized_markers.get(token_lower, [])) + marker_terms.append(token_lower.replace('-', ' ')) + marker_terms.append(token_lower.replace('_', ' ')) + visible_markers = sorted({ + marker + for marker in marker_terms + if marker and marker in app_text + }) + + if visible_markers and (api_commands or 'full-reading' in commands or output_paths): + level = 'productized' + reason = '前端已有可见入口,并且有 API/full-reading/输出路径承载。' + elif api_commands: + level = 'api_backed' + reason = '已有 Web API,但用户端需要更明确的专题解释或引导。' + elif engine_hits or 'full-reading' in commands or output_paths: + level = 'engine_or_full_reading' + reason = '引擎、完整解盘或输出路径已覆盖,但缺少独立产品化入口。' + else: + level = 'registry_only' + reason = '注册表中存在,但未自动识别到命令/API/前端入口。' + + summary[level] += 1 + rows.append({ + 'id': key, + 'name': technique.get('name', key), + 'level': level, + 'reason': reason, + 'domains': domains, + 'commands': all_commands, + 'api_commands': api_commands, + 'output_paths': output_paths, + 'visible_markers': visible_markers[:6], + 'status': technique.get('status', 'unknown'), + 'next_action': self._productization_next_action(level, all_commands, api_commands, visible_markers), + }) + + next_queue = [ + row for row in rows + if row['level'] in {'api_backed', 'engine_or_full_reading', 'registry_only'} + ] + priority_order = {'api_backed': 0, 'engine_or_full_reading': 1, 'registry_only': 2} + next_queue.sort(key=lambda row: (priority_order[row['level']], row['name'])) + return { + 'summary': summary, + 'rows': rows, + 'next_queue': next_queue[:18], + 'api_only_commands': api_only_commands, + } + + def _inferred_commands_for_technique(self, key, technique): + domains = set(technique.get('domains') or []) + name = str(technique.get('name', '')).lower() + path_text = ' '.join(str(path).lower() for path in technique.get('output_paths', [])) + inferred = [] + if key == 'career_engine' or 'career_analysis.py' in path_text or 'career' in domains: + inferred.append('career') + if key == 'relationship_engine' or 'relationship_analysis.py' in path_text or 'relationship' in domains: + inferred.append('relationship') + if key == 'kp_system' or 'kp_system.py' in path_text or 'kp' in domains: + inferred.append('kp') + if key == 'prashna' or 'prashna.py' in path_text or 'prashna' in domains: + inferred.append('prashna') + if key == 'synastry_16factor' or 'synastry.py' in path_text or 'synastry' in domains: + inferred.append('synastry') + if key == 'remedies' or 'remedies.py' in path_text or 'remedies' in domains: + inferred.append('remedies') + if key == 'bhava_bala' or 'bhava_bala.py' in path_text: + inferred.append('bhava-bala') + if key == 'transit_trigger' or 'transit_trigger.py' in path_text: + inferred.append('transit-trigger') + if key == 'thematic_report_orchestrator' or 'report_orchestrator.py' in path_text or {'report', 'reading'} & domains: + inferred.append('thematic-report') + if key == 'divisional_yoga' or 'divisional_yoga.py' in path_text: + inferred.append('divisional-yoga') + if key == 'deep_varga_avastha' or 'deep_varga_avastha.py' in path_text: + inferred.append('deep-varga-avastha') + if key == 'birth_time_rectifier' or 'birth_time_rectifier.py' in path_text: + inferred.append('rectification') + if key == 'case_validator' or 'case_validator.py' in path_text: + inferred.append('case-validation') + if key == 'kakshya' or 'kakshya.py' in path_text: + inferred.append('kakshya') + if key == 'muhurtha' or 'muhurtha' in domains or 'muhurtha_election.py' in path_text: + inferred.append('muhurta') + if key == 'varshaphala' or 'varshaphala' in domains or 'varshaphala.py' in path_text: + inferred.append('solar-return') + if 'ashtakavarga' in domains and 'prastara' in name: + inferred.append('ashtakavarga') + if 'ashtakavarga' in domains and 'sodhita' in name: + inferred.append('ashtakavarga') + return sorted(set(inferred)) + + def _productization_next_action(self, level, commands, api_commands, visible_markers): + if level == 'api_backed': + return '把 API 结果转成结论卡、证据卡和交叉验证提示。' + if level == 'engine_or_full_reading': + if any(command in {'transit', 'narayana-dasha', 'nakshatra-dasha', 'vivah-saham', 'transit-ll7l'} for command in commands): + return '优先 API 化,并加入高级技法工作台或专题 Tab。' + return '梳理到完整解盘主流程,补用户可见入口。' + if level == 'registry_only': + return '确认是否仍是有效技法;若有效,补命令/API/输出路径。' + if visible_markers: + return '继续优化解释层和移动端可读性。' + return '保持监控。' + + def _build_ux_productization_matrix(self, product_rows): + rows = [] + summary = { + 'excellent': 0, + 'usable': 0, + 'thin': 0, + 'not_user_ready': 0, + } + criteria_keys = [ + 'clear_entry', + 'human_readable_conclusion', + 'evidence_chain', + 'next_action', + 'json_hidden', + 'mobile_scannable', + ] + for row in product_rows: + criteria = self._ux_criteria_for_row(row) + score = sum(1 for key in criteria_keys if criteria.get(key)) + if score >= 5: + ux_level = 'excellent' + elif score >= 4: + ux_level = 'usable' + elif score >= 2: + ux_level = 'thin' + else: + ux_level = 'not_user_ready' + summary[ux_level] += 1 + missing = [key for key in criteria_keys if not criteria.get(key)] + rows.append({ + **row, + 'ux_score': score, + 'ux_level': ux_level, + 'criteria': criteria, + 'missing_ux': missing, + 'ux_next_action': self._ux_next_action(ux_level, missing, row), + }) + queue = [row for row in rows if row['ux_level'] != 'excellent'] + level_order = {'not_user_ready': 0, 'thin': 1, 'usable': 2, 'excellent': 3} + queue.sort(key=lambda row: (level_order[row['ux_level']], -row['ux_score'], row['name'])) + return { + 'criteria': criteria_keys, + 'summary': summary, + 'rows': rows, + 'next_queue': queue[:18], + } + + def _ux_criteria_for_row(self, row): + level = row.get('level') + commands = set(row.get('commands') or []) + api_commands = set(row.get('api_commands') or []) + domains = set(row.get('domains') or []) + visible_markers = set(row.get('visible_markers') or []) + name = row.get('name', '').lower() + row_id = row.get('id', '').lower() + has_api = bool(api_commands - {'full-reading'}) or any( + command in api_commands + for command in {'dasha', 'jaimini', 'ashtakavarga', 'shadbala', 'yoga', 'muhurta', 'solar-return', 'sudarshana', 'nakshatra-full', 'varga-full', 'rectification', 'case-validation', 'deep-varga-avastha', 'divisional-yoga', 'kakshya', 'career', 'relationship', 'kp', 'prashna', 'synastry', 'remedies', 'bhava-bala', 'transit-trigger', 'thematic-report'} + ) + source_backed = level == 'productized' and bool(row.get('output_paths')) + clear_entry = level == 'productized' and bool(visible_markers) + human_readable = bool( + source_backed + or commands & {'full-reading', 'chart', 'dasha', 'jaimini', 'ashtakavarga', 'muhurta', 'solar-return', 'sudarshana', 'rectification', 'case-validation', 'deep-varga-avastha', 'divisional-yoga', 'kakshya', 'career', 'relationship', 'kp', 'prashna', 'synastry', 'remedies', 'bhava-bala', 'transit-trigger', 'thematic-report'} + or domains & {'core', 'relationship', 'marriage', 'career', 'remedies', 'muhurta', 'muhurtha', 'tajika', 'varshaphala', 'birth', 'case', 'misconceptions', 'divisional', 'kakshya', 'kp', 'prashna', 'synastry', 'ashtakavarga', 'bhava', 'transit', 'avastha', 'd24', 'd30', 'd60'} + or any(token in name for token in ['reading', 'analysis', 'report']) + ) + evidence_chain = bool( + has_api + or source_backed + or commands & {'full-reading', 'varga-full', 'ashtakavarga', 'shadbala', 'jaimini', 'yoga', 'dasha', 'case-validation', 'deep-varga-avastha', 'divisional-yoga', 'kakshya', 'career', 'relationship', 'kp', 'prashna', 'synastry', 'remedies', 'bhava-bala', 'transit-trigger', 'thematic-report'} + or domains & {'strength', 'timing', 'varga', 'dasha', 'jaimini', 'ashtakavarga', 'remedies', 'birth', 'case', 'misconceptions', 'divisional', 'kakshya', 'kp', 'prashna', 'synastry', 'bhava', 'transit', 'avastha', 'd24', 'd30', 'd60'} + ) + next_action = bool( + domains & {'remedies', 'muhurta', 'muhurtha', 'career', 'relationship', 'marriage', 'timing', 'event', 'birth', 'case', 'varshaphala', 'divisional', 'kakshya', 'kp', 'prashna', 'synastry', 'bhava', 'transit', 'avastha', 'd24', 'd30', 'd60'} + or row.get('next_action') + ) + hidden_json_rows = { + 'bhava_bala', + 'birth_time_rectifier', + 'career_engine', + 'case_validator', + 'deep_varga_avastha', + 'divisional_yoga', + 'kakshya', + 'misconceptions', + 'relationship_engine', + 'remedies', + 'transit_trigger', + 'full_reading_strict', + } + json_hidden = level == 'productized' and ( + bool(api_commands) + or row_id in hidden_json_rows + or (not row_id.endswith('_engine') and '.py' not in name) + ) + mobile_scannable = clear_entry and (human_readable or evidence_chain) + return { + 'clear_entry': clear_entry, + 'human_readable_conclusion': human_readable, + 'evidence_chain': evidence_chain, + 'next_action': next_action, + 'json_hidden': json_hidden, + 'mobile_scannable': mobile_scannable, + } + + def _ux_next_action(self, ux_level, missing, row): + if ux_level == 'excellent': + return '保持现有入口,继续做文案和移动端微调。' + if 'human_readable_conclusion' in missing: + return '把计算结果转成用户可读结论卡,并说明它支持哪个判断。' + if 'clear_entry' in missing: + return '补清晰入口或把该技法合并进完整解盘主流程。' + if 'evidence_chain' in missing: + return '补证据链:引用具体行星、宫位、分盘、Dasha 或相位。' + if 'json_hidden' in missing: + return '默认隐藏 JSON,只保留展开查看;主视图展示摘要。' + if 'mobile_scannable' in missing: + return '重排移动端卡片密度,保证三分钟内能读到结论。' + return row.get('next_action') or '补用户体验解释层。' + + def _external_research_matrix(self): + return [ + { + 'name': 'PyJHora', + 'url': 'https://github.com/naturalstupid/PyJHora', + 'license': 'AGPL-3.0', + 'reuse': 'benchmark_only', + 'notes': '最强传统算法对标;许可证不适合直接复制进 MIT 仓库。', + 'best_for': ['Dasha族', '分盘变体', 'Ashtakavarga高级层', 'JHora口径校准'], + }, + { + 'name': 'dashaflow', + 'url': 'https://github.com/adarshj322/dashaflow', + 'license': 'MIT', + 'reuse': 'direct', + 'notes': '本地已有源码;适合复用 Ashtakavarga、Shadbala、Jaimini、合盘、Muhurta 的轻量实现。', + 'best_for': ['Ashtakavarga', 'Shadbala', 'Jaimini', 'Synastry', 'Muhurta'], + }, + { + 'name': 'jyotishganit', + 'url': 'https://github.com/northtara/jyotishganit', + 'license': 'MIT', + 'reuse': 'direct', + 'notes': '高精度结构化计算与 JSON-LD 输出思路,适合用于数据层校准。', + 'best_for': ['D1-D60', 'Panchanga', 'Shadbala', 'JSON-LD'], + }, + { + 'name': 'VedicAstro', + 'url': 'https://github.com/diliprk/VedicAstro', + 'license': 'unspecified/readme badge', + 'reuse': 'review_before_copy', + 'notes': 'KP 与 Horary 很有价值,但复制前必须确认许可证文件。', + 'best_for': ['KP Sublord', 'ABCD Significators', 'Horary'], + }, + { + 'name': 'VedAstro', + 'url': 'https://github.com/VedAstro/VedAstro', + 'license': 'MIT', + 'reuse': 'direct_or_port', + 'notes': 'C# API 平台范式,可参考 Web/API 产品化结构。', + 'best_for': ['API Platform', 'Muhurta', 'Panchanga', 'AI Astrologer'], + }, + { + 'name': 'xalen-ephemeris', + 'url': 'https://github.com/vedika-io/xalen-ephemeris', + 'license': 'Apache-2.0', + 'reuse': 'direct_or_port', + 'notes': 'Rust 高精度星历与多传统系统,适合未来替代/补强天文底座。', + 'best_for': ['Ephemeris', 'Ayanamsa', 'House Systems', 'KP/Jaimini/Tajika'], + }, + ] -def start_server(port=5200): - server = HTTPServer(('0.0.0.0', port), JyotishAPIHandler) - print(f'🔮 Jyotish API v6.9.14 running on http://localhost:{port}') +def _parse_allowed_origins(value): + if not value: + return DEFAULT_ALLOWED_ORIGINS + return {item.strip() for item in value.split(',') if item.strip()} + + +def start_server(port=5200, host='127.0.0.1', allowed_origins=None): + server = HTTPServer((host, port), JyotishAPIHandler) + server.allowed_origins = allowed_origins or DEFAULT_ALLOWED_ORIGINS + print(f'Jyotish API v6.9.14 running on http://{host}:{port}') + print(f' CORS origins: {", ".join(sorted(server.allowed_origins))}') print(f' POST /api/chart — 完整星盘计算') print(f' POST /api/remedies — 补救建议') print(f' POST /api/kp — KP分析') print(f' POST /api/prashna — 卜卦') print(f' POST /api/synastry — 合盘') + print(f' POST /api/dasha — 单Dasha时间线') print(f' POST /api/sade_sati — 土星周期') print(f' POST /api/pancha_mahapurusha — 五王瑜伽') print(f' POST /api/career — 事业分析') print(f' POST /api/relationship — 感情分析') + print(f' POST /api/annual — 年运/Tajika') + print(f' POST /api/muhurta — 择日') + print(f' POST /api/panchanga_range — Panchanga日期范围') + print(f' POST /api/bhava_chalit — Bhava Chalit') + print(f' POST /api/sudarshana — Sudarshana Chakra') + print(f' POST /api/nakshatra_full — Nakshatra深层报告') + print(f' POST /api/varga_full — BPHS扩展分盘/变体/自定义/复合') + print(f' POST /api/jaimini — Jaimini Karaka/Arudha/Chara Dasha') + print(f' POST /api/ashtakavarga — Ashtakavarga SAV/BAV') + print(f' POST /api/shadbala — Shadbala六重力量') + print(f' POST /api/yogas — Yoga格局检测') + print(f' POST /api/aspects — 精确相位') + print(f' POST /api/report_artifact — HTML/PDF报告工件生成') + print(f' POST /api/thematic_report — 主题化报告/冲突裁决') print(f' GET /api/health — 健康检查') print(f' GET /api/cities — 城市列表') + print(f' GET /api/capability_audit — 能力审计') server.serve_forever() if __name__ == '__main__': - port = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[1] == '--port' else 5200 - start_server(port) + parser = argparse.ArgumentParser(description='Jyotish API server') + parser.add_argument('--port', type=int, default=5200) + parser.add_argument('--host', default=os.environ.get('JYOTISH_API_HOST', '127.0.0.1')) + parser.add_argument( + '--allow-origin', + action='append', + default=[], + help='Allowed browser origin; may be repeated. Defaults to local Vite origins.', + ) + args = parser.parse_args() + env_origins = _parse_allowed_origins(os.environ.get('JYOTISH_ALLOWED_ORIGINS')) + cli_origins = set(args.allow_origin) + start_server(args.port, host=args.host, allowed_origins=cli_origins or env_origins) diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 9f597c48..d8bed69a 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -3900,6 +3900,38 @@ def cmd_full_reading(args): except Exception as e: report['errors'].append(f"shadbala: {e}") + # ── Step 10.1: Remedies 补救建议 ── + try: + from remedies import recommend_remedies + shadbala_for_remedies = {} + for pn, pdata in (report['modules'].get('shadbala') or {}).get('planets', {}).items(): + if isinstance(pdata, dict): + shadbala_for_remedies[pn] = { + 'total_rupas': pdata.get('total_rupas', pdata.get('rupas', 1.0)), + 'strength_level': pdata.get('strength_level', pdata.get('level', '')), + } + yd = report['modules'].get('yogas_doshas') or {} + doshas = [] + dosha_sources = [ + ('mangal_dosha', 'Mangal Dosha'), + ('kaal_sarp_dosha', 'Kaal Sarp Dosha'), + ('pitra_dosha', 'Pitra Dosha'), + ] + for key, label in dosha_sources: + item = yd.get(key) if isinstance(yd, dict) else None + if isinstance(item, dict) and item.get('has_dosha'): + doshas.append(label) + dasha = report['modules'].get('dasha') or {} + current_dasha = dasha.get('current_dasha') if isinstance(dasha, dict) else {} + active_dasha_lord = current_dasha.get('lord') if isinstance(current_dasha, dict) else None + report['modules']['remedies'] = recommend_remedies( + shadbala_for_remedies, + active_dasha_lord=active_dasha_lord, + doshas=doshas, + ) + except Exception as e: + report['errors'].append(f"remedies: {e}") + # ── Step 10.5: Avasthas 行星状态 (v4.4.0) ── try: from avastha_calculator import AvasthaCalculator diff --git a/scripts/muhurta.py b/scripts/muhurta.py index 26d40dee..3b864ab8 100644 --- a/scripts/muhurta.py +++ b/scripts/muhurta.py @@ -15,6 +15,7 @@ Muhurta 是印度占星的择时系统,核心是 Panchanga 五要素: from __future__ import annotations from typing import Dict, List, Optional, Tuple from datetime import datetime, timedelta +import calendar import math # ── Vara(周日行星)────────────────────────────────────────────────── @@ -136,6 +137,66 @@ KARANA_QUALITY = { 'Naga': 'asubha', 'Kimstughna': 'subha' } +RAHU_KALA_SEGMENTS = { + 0: 8, # Sunday + 1: 2, # Monday + 2: 7, # Tuesday + 3: 5, # Wednesday + 4: 6, # Thursday + 5: 4, # Friday + 6: 3, # Saturday +} +YAMAGANDA_SEGMENTS = { + 0: 5, + 1: 4, + 2: 3, + 3: 2, + 4: 1, + 5: 7, + 6: 6, +} +GULIKA_SEGMENTS = { + 0: 7, + 1: 6, + 2: 5, + 3: 4, + 4: 3, + 5: 2, + 6: 1, +} + +TITHI_BOUNDARY_DEGREES = 12.0 +NAKSHATRA_BOUNDARY_DEGREES = 360.0 / 27.0 +YOGA_BOUNDARY_DEGREES = 360.0 / 27.0 + +CHOGHADIYA_DAY_SEQUENCE = { + 0: ['Udveg', 'Chal', 'Labh', 'Amrit', 'Kal', 'Shubh', 'Rog', 'Udveg'], + 1: ['Amrit', 'Kal', 'Shubh', 'Rog', 'Udveg', 'Chal', 'Labh', 'Amrit'], + 2: ['Rog', 'Udveg', 'Chal', 'Labh', 'Amrit', 'Kal', 'Shubh', 'Rog'], + 3: ['Labh', 'Amrit', 'Kal', 'Shubh', 'Rog', 'Udveg', 'Chal', 'Labh'], + 4: ['Shubh', 'Rog', 'Udveg', 'Chal', 'Labh', 'Amrit', 'Kal', 'Shubh'], + 5: ['Chal', 'Labh', 'Amrit', 'Kal', 'Shubh', 'Rog', 'Udveg', 'Chal'], + 6: ['Kal', 'Shubh', 'Rog', 'Udveg', 'Chal', 'Labh', 'Amrit', 'Kal'], +} +CHOGHADIYA_NIGHT_SEQUENCE = { + 0: ['Shubh', 'Amrit', 'Chal', 'Rog', 'Kal', 'Labh', 'Udveg', 'Shubh'], + 1: ['Chal', 'Rog', 'Kal', 'Labh', 'Udveg', 'Shubh', 'Amrit', 'Chal'], + 2: ['Kal', 'Labh', 'Udveg', 'Shubh', 'Amrit', 'Chal', 'Rog', 'Kal'], + 3: ['Udveg', 'Shubh', 'Amrit', 'Chal', 'Rog', 'Kal', 'Labh', 'Udveg'], + 4: ['Amrit', 'Chal', 'Rog', 'Kal', 'Labh', 'Udveg', 'Shubh', 'Amrit'], + 5: ['Rog', 'Kal', 'Labh', 'Udveg', 'Shubh', 'Amrit', 'Chal', 'Rog'], + 6: ['Labh', 'Udveg', 'Shubh', 'Amrit', 'Chal', 'Rog', 'Kal', 'Labh'], +} +CHOGHADIYA_QUALITY = { + 'Amrit': 'auspicious', + 'Shubh': 'auspicious', + 'Labh': 'auspicious', + 'Chal': 'usable', + 'Udveg': 'inauspicious', + 'Kal': 'inauspicious', + 'Rog': 'inauspicious', +} + # ── 核心计算函数 ───────────────────────────────────────────────────── @@ -301,6 +362,686 @@ def calc_abhijit_muhurta(sunrise_ut: Optional[float] = None, return result +def _parse_hhmm(value: str, default: str) -> Tuple[int, int]: + if not value: + value = default + try: + hour_str, minute_str = value.split(':', 1) + hour = int(hour_str) + minute = int(minute_str) + except (ValueError, AttributeError) as exc: + raise ValueError('time must be HH:MM') from exc + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError('time must be HH:MM') + return hour, minute + + +def _minutes_to_hhmm(minutes: float) -> str: + total = int(round(minutes)) % (24 * 60) + return f'{total // 60:02d}:{total % 60:02d}' + + +def _segment_window(segment: int, sunrise_min: int, sunset_min: int) -> Dict: + day_duration = sunset_min - sunrise_min + if day_duration <= 0: + day_duration += 24 * 60 + segment_len = day_duration / 8.0 + start = sunrise_min + (segment - 1) * segment_len + end = start + segment_len + return { + 'segment': segment, + 'start': _minutes_to_hhmm(start), + 'end': _minutes_to_hhmm(end), + 'duration_minutes': round(segment_len, 1), + } + + +def _clock_minutes(value: str, default: str) -> int: + hour, minute = _parse_hhmm(value, default) + return hour * 60 + minute + + +def _window_from_minutes(start_min: float, end_min: float) -> Dict: + return { + 'start': _minutes_to_hhmm(start_min), + 'end': _minutes_to_hhmm(end_min), + 'duration_minutes': round(end_min - start_min, 1), + } + + +def _split_window(start_min: float, end_min: float, parts: int) -> List[Tuple[float, float]]: + duration = end_min - start_min + if duration <= 0: + duration += 24 * 60 + step = duration / parts + return [(start_min + idx * step, start_min + (idx + 1) * step) for idx in range(parts)] + + +def _solar_event_utc_hours(year: int, month: int, day: int, lat: float, lon: float, event: str) -> float: + """Approximate sunrise/sunset UTC decimal hours using the Jaimini helper model.""" + jd = 367 * year - int(7 * (year + int((month + 9) / 12)) / 4) + int(275 * month / 9) + day + 1721013.5 + d = jd - 2451545.0 + mean_anomaly = (357.5291 + 0.98560028 * d) % 360 + center = ( + 1.9148 * math.sin(math.radians(mean_anomaly)) + + 0.0200 * math.sin(math.radians(2 * mean_anomaly)) + + 0.0003 * math.sin(math.radians(3 * mean_anomaly)) + ) + sun_lon = (mean_anomaly + center + 180.10248 + 0.000048 * d * 360) % 360 + t = d / 36525.0 + eps0 = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t * t * t + eps = eps0 / 3600.0 + declination = math.degrees( + math.asin(math.sin(math.radians(sun_lon)) * math.sin(math.radians(eps))) + ) + lat_rad = math.radians(lat) + dec_rad = math.radians(declination) + denom = math.cos(lat_rad) * math.cos(dec_rad) + if abs(denom) < 1e-9: + raise ValueError('sunrise/sunset undefined near pole for this date') + cos_ha = ( + math.cos(math.radians(90.833)) + - math.sin(lat_rad) * math.sin(dec_rad) + ) / denom + cos_ha = max(-1.0, min(1.0, cos_ha)) + hour_angle = math.degrees(math.acos(cos_ha)) + b = math.radians(360.0 * (d - 0.5) / 365.25) + eq_time = 229.18 * ( + 0.000075 + + 0.001868 * math.cos(b) + - 0.032077 * math.sin(b) + - 0.014615 * math.cos(2 * b) + - 0.040849 * math.sin(2 * b) + ) + solar_noon = (720.0 - 4.0 * lon - eq_time) / 60.0 + if event == 'sunrise': + return (solar_noon - hour_angle / 15.0) % 24.0 + if event == 'sunset': + return (solar_noon + hour_angle / 15.0) % 24.0 + raise ValueError('event must be sunrise or sunset') + + +def _local_hours_from_jd(event_jd: float, local_midnight_jd: float) -> float: + return (event_jd - local_midnight_jd) * 24.0 + + +def _swisseph_solar_times_local( + year: int, + month: int, + day: int, + lat: float, + lon: float, + tz: float, +) -> Optional[Dict]: + """Return precise local sunrise/sunset via pyswisseph when available.""" + try: + import swisseph as swe + except Exception: + return None + + try: + local_midnight_jd = swe.julday(year, month, day, -tz) + geopos = (lon, lat, 0.0) + flags = getattr(swe, 'FLG_SWIEPH', 2) + rise_res, rise_tret = swe.rise_trans(local_midnight_jd, swe.SUN, swe.CALC_RISE, geopos, flags=flags) + set_res, set_tret = swe.rise_trans(local_midnight_jd, swe.SUN, swe.CALC_SET, geopos, flags=flags) + except Exception: + return None + + if rise_res != 0 or set_res != 0: + return None + + sunrise_local = _local_hours_from_jd(rise_tret[0], local_midnight_jd) + sunset_local = _local_hours_from_jd(set_tret[0], local_midnight_jd) + if not (0.0 <= sunrise_local < 24.0 and 0.0 <= sunset_local < 24.0): + return None + sunrise_utc = (sunrise_local - tz) % 24.0 + sunset_utc = (sunset_local - tz) % 24.0 + return { + 'policy': 'swisseph_rise_trans', + 'lat': round(lat, 5), + 'lon': round(lon, 5), + 'tz': tz, + 'sunrise': _minutes_to_hhmm(sunrise_local * 60), + 'sunset': _minutes_to_hhmm(sunset_local * 60), + 'sunrise_local_hours': round(sunrise_local, 4), + 'sunset_local_hours': round(sunset_local, 4), + 'sunrise_utc_hours': round(sunrise_utc, 4), + 'sunset_utc_hours': round(sunset_utc, 4), + } + + +def calc_sunrise_sunset_local( + year: int, + month: int, + day: int, + lat: float, + lon: float, + tz: float, +) -> Dict: + """Local sunrise/sunset HH:MM for a date and location, precise when SwissEph is available.""" + swisseph_result = _swisseph_solar_times_local(year, month, day, lat, lon, tz) + if swisseph_result: + return swisseph_result + + sunrise_utc = _solar_event_utc_hours(year, month, day, lat, lon, 'sunrise') + sunset_utc = _solar_event_utc_hours(year, month, day, lat, lon, 'sunset') + sunrise_local = (sunrise_utc + tz) % 24.0 + sunset_local = (sunset_utc + tz) % 24.0 + return { + 'policy': 'location_aware_solar_event_approximation', + 'lat': round(lat, 5), + 'lon': round(lon, 5), + 'tz': tz, + 'sunrise': _minutes_to_hhmm(sunrise_local * 60), + 'sunset': _minutes_to_hhmm(sunset_local * 60), + 'sunrise_utc_hours': round(sunrise_utc, 4), + 'sunset_utc_hours': round(sunset_utc, 4), + } + + +def calc_choghadiya_windows( + weekday: int, + sunrise: str = '06:00', + sunset: str = '18:00', +) -> Dict: + """Calculate day and night Choghadiya windows from sunrise/sunset.""" + sunrise_min = _clock_minutes(sunrise, '06:00') + sunset_min = _clock_minutes(sunset, '18:00') + next_sunrise_min = sunrise_min + 24 * 60 + day_sequence = CHOGHADIYA_DAY_SEQUENCE[weekday % 7] + night_sequence = CHOGHADIYA_NIGHT_SEQUENCE[weekday % 7] + + def build_rows(sequence: List[str], windows: List[Tuple[float, float]]) -> List[Dict]: + return [ + { + 'index': idx + 1, + 'name': name, + 'quality': CHOGHADIYA_QUALITY.get(name, 'mixed'), + **_window_from_minutes(start, end), + } + for idx, (name, (start, end)) in enumerate(zip(sequence, windows)) + ] + + return { + 'policy': 'traditional_day_night_8_choghadiya_segments', + 'day': build_rows(day_sequence, _split_window(sunrise_min, sunset_min, 8)), + 'night': build_rows(night_sequence, _split_window(sunset_min, next_sunrise_min, 8)), + } + + +def calc_hora_windows( + weekday: int, + sunrise: str = '06:00', + sunset: str = '18:00', +) -> Dict: + """Calculate planetary Hora windows for daytime and nighttime.""" + sunrise_min = _clock_minutes(sunrise, '06:00') + sunset_min = _clock_minutes(sunset, '18:00') + next_sunrise_min = sunrise_min + 24 * 60 + vara_lord = VARA_LORDS[weekday % 7][1] + start_idx = VARA_START_IDX.get(vara_lord, 0) + + def lord_for(offset: int) -> str: + return HORA_ORDER[(start_idx + offset) % len(HORA_ORDER)] + + return { + 'policy': 'planetary_hora_day_night_12_segments', + 'day': [ + {'index': idx + 1, 'lord': lord_for(idx), **_window_from_minutes(start, end)} + for idx, (start, end) in enumerate(_split_window(sunrise_min, sunset_min, 12)) + ], + 'night': [ + {'index': idx + 13, 'lord': lord_for(idx + 12), **_window_from_minutes(start, end)} + for idx, (start, end) in enumerate(_split_window(sunset_min, next_sunrise_min, 12)) + ], + } + + +def _jd_for_local_time(year: int, month: int, day: int, local_hours: float, tz: float) -> Optional[float]: + try: + import swisseph as swe + return swe.julday(year, month, day, local_hours - tz) + except Exception: + return None + + +def _local_time_from_jd(jd: float, tz: float) -> str: + local_dt = datetime(2000, 1, 1) + timedelta(days=jd - 2451544.5, hours=tz) + return local_dt.strftime('%Y-%m-%d %H:%M') + + +def _swisseph_sun_moon_lon(jd: float) -> Optional[Tuple[float, float]]: + try: + import swisseph as swe + swe.set_sid_mode(swe.SIDM_LAHIRI) + flags = getattr(swe, 'FLG_SWIEPH', 2) | getattr(swe, 'FLG_SIDEREAL', 65536) + sun = swe.calc_ut(jd, swe.SUN, flags)[0][0] % 360 + moon = swe.calc_ut(jd, swe.MOON, flags)[0][0] % 360 + return sun, moon + except Exception: + return None + + +def _find_next_boundary( + jd_start: float, + value_func, + boundary_size: float, + max_days: float, + samples: int = 96, +) -> Optional[float]: + start_value = value_func(jd_start) + if start_value is None: + return None + current_slot = int(start_value / boundary_size) + previous_jd = jd_start + previous_value = start_value + step = max_days / samples + for idx in range(1, samples + 1): + candidate_jd = jd_start + step * idx + candidate_value = value_func(candidate_jd) + if candidate_value is None: + return None + if int(candidate_value / boundary_size) != current_slot or candidate_value < previous_value: + low = previous_jd + high = candidate_jd + for _ in range(32): + mid = (low + high) / 2 + mid_value = value_func(mid) + if mid_value is None: + return None + if int(mid_value / boundary_size) == current_slot and mid_value >= start_value: + low = mid + else: + high = mid + return high + previous_jd = candidate_jd + previous_value = candidate_value + return None + + +def calc_panchanga_end_times( + year: int, + month: int, + day: int, + tz: float, + local_hours: float, +) -> Optional[Dict]: + """Calculate current Tithi/Nakshatra/Yoga end times from SwissEph positions.""" + jd_start = _jd_for_local_time(year, month, day, local_hours, tz) + if jd_start is None: + return None + + def tithi_value(jd: float) -> Optional[float]: + positions = _swisseph_sun_moon_lon(jd) + if not positions: + return None + sun, moon = positions + return (moon - sun) % 360 + + def nakshatra_value(jd: float) -> Optional[float]: + positions = _swisseph_sun_moon_lon(jd) + if not positions: + return None + return positions[1] % 360 + + def yoga_value(jd: float) -> Optional[float]: + positions = _swisseph_sun_moon_lon(jd) + if not positions: + return None + sun, moon = positions + return (sun + moon) % 360 + + boundaries = { + 'tithi': _find_next_boundary(jd_start, tithi_value, TITHI_BOUNDARY_DEGREES, 1.6), + 'nakshatra': _find_next_boundary(jd_start, nakshatra_value, NAKSHATRA_BOUNDARY_DEGREES, 1.4), + 'yoga': _find_next_boundary(jd_start, yoga_value, YOGA_BOUNDARY_DEGREES, 1.4), + } + result = {'policy': 'swisseph_boundary_bisection', 'reference_local_time': _local_time_from_jd(jd_start, tz)} + for key, boundary_jd in boundaries.items(): + if boundary_jd is None: + continue + result[key] = { + 'ends_at': _local_time_from_jd(boundary_jd, tz), + 'hours_from_reference': round((boundary_jd - jd_start) * 24.0, 2), + } + return result + + +def _normalize_name(value: object) -> str: + return str(value or '').strip() + + +def _push_tag(tags: List[Dict], key: str, label: str, tag_type: str, guidance: str) -> None: + if any(tag.get('key') == key for tag in tags): + return + tags.append({ + 'key': key, + 'label': label, + 'type': tag_type, + 'guidance': guidance, + }) + + +def classify_vrata_tags( + tithi: Dict, + nakshatra: Optional[Dict] = None, + vara: Optional[Dict] = None, +) -> List[Dict]: + """Classify conservative vrata/festival observance tags from Panchanga limbs. + + Precise named festivals often require lunar month/masa and local sunrise rules. + Tags that cannot be confirmed from the current data are explicitly marked as + candidates so the UI can guide users without overstating precision. + """ + tithi_num = int(tithi.get('tithi_num') or 0) + paksha = _normalize_name(tithi.get('paksha')) + name = _normalize_name(tithi.get('name')) + nak_name = _normalize_name((nakshatra or {}).get('nakshatra') or (nakshatra or {}).get('name')) + vara_name = _normalize_name((vara or {}).get('vara') or (vara or {}).get('name')) + tags = [] + if tithi_num in (11, 26): + _push_tag( + tags, + 'ekadashi', + f'{paksha} Ekadashi'.strip(), + 'fasting', + 'Commonly observed as a fasting and spiritual practice day.', + ) + if tithi_num in (13, 28): + pradosham_label = f'{paksha} Pradosham'.strip() + if vara_name == 'Monday': + pradosham_label = f'Soma {pradosham_label}' + elif vara_name == 'Saturday': + pradosham_label = f'Shani {pradosham_label}' + _push_tag( + tags, + 'pradosham', + pradosham_label, + 'observance', + 'Pradosham is traditionally associated with Shiva worship near dusk.', + ) + if tithi_num == 15 or name == 'Purnima': + _push_tag( + tags, + 'purnima', + 'Purnima', + 'lunar', + 'Full moon observance; often used for vrata, puja and spiritual practices.', + ) + if tithi_num == 30 or name == 'Amavasya': + _push_tag( + tags, + 'amavasya', + 'Amavasya', + 'lunar', + 'New moon observance; often used for ancestral rites and inward practice.', + ) + if tithi_num in (4, 19): + _push_tag( + tags, + 'chaturthi_vrata', + f'{paksha} Chaturthi'.strip(), + 'vrata', + 'Chaturthi is commonly associated with Ganapati observances; confirm local sunrise rule for exact vrata.', + ) + if tithi_num in (6, 21): + _push_tag( + tags, + 'shashthi_vrata', + f'{paksha} Shashthi'.strip(), + 'vrata', + 'Shashthi is used for Skanda/Subrahmanya observances in many Panchanga traditions.', + ) + if tithi_num in (8, 23): + _push_tag( + tags, + 'ashtami_vrata', + f'{paksha} Ashtami'.strip(), + 'vrata', + 'Ashtami is a common vrata and Devi/Krishna observance marker; festival name depends on lunar month.', + ) + if tithi_num in (9, 24): + _push_tag( + tags, + 'navami_observance', + f'{paksha} Navami'.strip(), + 'observance', + 'Navami can become a major festival marker in the right lunar month; treat as a condition for deeper lookup.', + ) + if tithi_num == 3 and paksha == 'Shukla': + _push_tag( + tags, + 'akshaya_tritiya_candidate', + 'Shukla Tritiya festival candidate', + 'festival_candidate', + 'May indicate Akshaya Tritiya only when the lunar month is Vaishakha; confirm masa before final reporting.', + ) + if tithi_num == 29 and paksha == 'Krishna': + _push_tag( + tags, + 'shivaratri_candidate', + 'Krishna Chaturdashi/Shivaratri candidate', + 'festival_candidate', + 'Monthly Shivaratri marker; Maha Shivaratri requires Phalguna/Magha month confirmation by tradition.', + ) + if nak_name == 'Pushya': + _push_tag( + tags, + 'pushya_nakshatra', + 'Pushya Nakshatra', + 'nakshatra', + 'Pushya is widely treated as a strong auspicious nakshatra for learning, purchases and counsel.', + ) + if vara_name == 'Thursday': + _push_tag( + tags, + 'guru_pushya', + 'Guru Pushya Yoga', + 'festival_window', + 'Thursday plus Pushya is treated as a highly auspicious purchase/initiation window.', + ) + if vara_name == 'Sunday': + _push_tag( + tags, + 'ravi_pushya', + 'Ravi Pushya Yoga', + 'festival_window', + 'Sunday plus Pushya is a favored window for acquisition and auspicious beginnings.', + ) + if nak_name == 'Rohini': + _push_tag( + tags, + 'rohini_nakshatra', + 'Rohini Nakshatra', + 'nakshatra', + 'Rohini is a fixed, growth-oriented nakshatra often used for stable starts.', + ) + return tags + + +def classify_panchanga_condition_tags(report: Dict, activity: Optional[str] = None) -> List[Dict]: + """Return searchable product tags for Panchanga calendar rows.""" + panchanga = report.get('panchanga') or {} + summary = report.get('summary') or {} + warnings = summary.get('warnings') or panchanga.get('warnings') or [] + vrata_tags = report.get('vrata_tags') or [] + checks = report.get('activity_checks') or {} + choghadiya = report.get('choghadiya') or {} + tags: List[Dict] = [] + + def add(key: str, label: str, guidance: str = '') -> None: + _push_tag(tags, key, label, 'condition', guidance) + + if vrata_tags: + add('has_vrata', 'Vrata/observance day', 'At least one vrata, lunar observance or festival-candidate tag is present.') + if any(tag.get('type') == 'festival_candidate' for tag in vrata_tags): + add('festival_candidate', 'Festival candidate', 'Needs lunar month or tradition-specific confirmation before final naming.') + if any(tag.get('type') in {'fasting', 'observance', 'lunar', 'vrata'} for tag in vrata_tags): + add('spiritual_practice', 'Spiritual practice day', 'Useful for fasting, puja, mantra, vrata or inward observance.') + if summary.get('best_activities'): + add('auspicious_activity', 'Auspicious for selected/common activity', 'At least one activity check is favorable.') + if summary.get('avoid_activities') or warnings: + add('avoid_new_start', 'Avoid important new starts', 'One or more Panchanga factors or activity checks advise caution.') + if any(item.get('quality') == 'auspicious' for segment in ('day', 'night') for item in choghadiya.get(segment, []) or []): + add('good_choghadiya', 'Has auspicious Choghadiya window', 'At least one Amrit/Shubh/Labh Choghadiya window is available.') + if activity and activity in checks: + verdict = checks[activity].get('verdict', '') + if '不宜' in verdict or 'Avoid' in verdict: + add('selected_activity_avoid', 'Selected activity should be avoided', 'The chosen activity has a negative Panchanga verdict.') + elif '吉' in verdict or 'Good' in verdict or 'Excellent' in verdict: + add('selected_activity_good', 'Selected activity is favorable', 'The chosen activity has a favorable Panchanga verdict.') + return tags + + +def build_festival_details(report: Dict) -> List[Dict]: + """Build user-facing explanations for vrata and festival candidate tags.""" + panchanga = report.get('panchanga') or {} + tithi = panchanga.get('tithi') or {} + nakshatra = panchanga.get('nakshatra') or {} + vara = panchanga.get('vara') or {} + details = [] + for tag in report.get('vrata_tags') or []: + detail_type = tag.get('type') or 'observance' + requires_confirmation = detail_type == 'festival_candidate' + details.append({ + 'key': tag.get('key'), + 'label': tag.get('label') or tag.get('key'), + 'type': detail_type, + 'guidance': tag.get('guidance') or '', + 'basis': [ + f"Tithi: {tithi.get('full_name') or tithi.get('name') or '-'}", + f"Nakshatra: {nakshatra.get('nakshatra') or nakshatra.get('name') or '-'}", + f"Vara: {vara.get('vara') or vara.get('name') or '-'}", + ], + 'requires_confirmation': requires_confirmation, + 'confirmation_note': ( + 'Needs lunar masa, local sunrise observance rule and tradition-specific calendar confirmation.' + if requires_confirmation else + 'Conservative Panchanga tag derived from tithi/nakshatra/vara; confirm local tradition for formal observance.' + ), + }) + return details + + +def build_panchanga_search_summary(rows: List[Dict], activity: Optional[str] = None) -> Dict: + """Summarize searchable Panchanga conditions for same-category calendar UX.""" + condition_counts: Dict[str, int] = {} + festival_candidates = [] + spiritual_days = [] + good_activity_days = [] + caution_days = [] + for row in rows: + date = row.get('query_date') + for tag in row.get('condition_tags') or []: + key = tag.get('key') + if key: + condition_counts[key] = condition_counts.get(key, 0) + 1 + if any(tag.get('type') == 'festival_candidate' for tag in row.get('vrata_tags') or []): + festival_candidates.append(date) + if any(tag.get('key') == 'spiritual_practice' for tag in row.get('condition_tags') or []): + spiritual_days.append(date) + if any(tag.get('key') in {'auspicious_activity', 'selected_activity_good'} for tag in row.get('condition_tags') or []): + good_activity_days.append(date) + if any(tag.get('key') in {'avoid_new_start', 'selected_activity_avoid'} for tag in row.get('condition_tags') or []): + caution_days.append(date) + return { + 'mode': 'range_condition_index', + 'activity': activity or 'all', + 'condition_counts': condition_counts, + 'festival_candidate_dates': festival_candidates, + 'spiritual_practice_dates': spiritual_days, + 'auspicious_activity_dates': good_activity_days, + 'avoid_new_start_dates': caution_days, + 'query_examples': [ + 'festival_candidate + spiritual_practice', + 'auspicious_activity + good_choghadiya', + 'avoid_new_start', + ], + } + + +def build_panchanga_month_grid(rows: List[Dict], start_dt: datetime, end_dt: datetime) -> List[List[Optional[Dict]]]: + """Build calendar-week rows for single-month ranges, leaving blanks outside the selected month.""" + if start_dt.year != end_dt.year or start_dt.month != end_dt.month: + return [] + + by_date = {row.get('query_date'): row for row in rows} + weeks = [] + for week in calendar.Calendar(firstweekday=6).monthdatescalendar(start_dt.year, start_dt.month): + week_cells = [] + for current in week: + if current.month != start_dt.month: + week_cells.append(None) + continue + row = by_date.get(current.strftime('%Y-%m-%d')) + if not row: + week_cells.append(None) + continue + panchanga = row.get('panchanga') or {} + periods = row.get('inauspicious_periods') or {} + end_times = row.get('end_times') or {} + week_cells.append({ + 'date': row.get('query_date'), + 'day': current.day, + 'quality': (row.get('summary') or {}).get('overall_quality') or panchanga.get('overall_quality'), + 'score': (row.get('summary') or {}).get('overall_score', panchanga.get('overall_score')), + 'tithi': (panchanga.get('tithi') or {}).get('full_name') or (panchanga.get('tithi') or {}).get('name'), + 'nakshatra': (panchanga.get('nakshatra') or {}).get('nakshatra') or (panchanga.get('nakshatra') or {}).get('name'), + 'yoga': (panchanga.get('yoga') or {}).get('yoga') or (panchanga.get('yoga') or {}).get('name'), + 'best_activities': (row.get('summary') or {}).get('best_activities') or [], + 'avoid_activities': (row.get('summary') or {}).get('avoid_activities') or [], + 'vrata_tags': row.get('vrata_tags') or [], + 'condition_tags': row.get('condition_tags') or [], + 'tithi_ends_at': (end_times.get('tithi') or {}).get('ends_at'), + 'nakshatra_ends_at': (end_times.get('nakshatra') or {}).get('ends_at'), + 'best_choghadiya': [ + item for item in ((row.get('choghadiya') or {}).get('day') or []) + if item.get('quality') == 'auspicious' + ][:2], + 'sunrise': periods.get('sunrise'), + 'sunset': periods.get('sunset'), + }) + weeks.append(week_cells) + return weeks + + +def calc_daytime_inauspicious_periods( + weekday: int, + sunrise: str = '06:00', + sunset: str = '18:00', +) -> Dict: + """ + Calculate daytime Rahu Kala, Yamaganda and Gulika using the traditional + eight-part daytime division. Times are local clock HH:MM values. + """ + sunrise_h, sunrise_m = _parse_hhmm(sunrise, '06:00') + sunset_h, sunset_m = _parse_hhmm(sunset, '18:00') + sunrise_min = sunrise_h * 60 + sunrise_m + sunset_min = sunset_h * 60 + sunset_m + weekday = weekday % 7 + return { + 'policy': 'daytime_8_segments_local_clock', + 'sunrise': f'{sunrise_h:02d}:{sunrise_m:02d}', + 'sunset': f'{sunset_h:02d}:{sunset_m:02d}', + 'rahu_kala': { + **_segment_window(RAHU_KALA_SEGMENTS[weekday], sunrise_min, sunset_min), + 'label': 'Rahu Kala', + 'guidance': 'Avoid important beginnings and commitments.', + }, + 'yamaganda': { + **_segment_window(YAMAGANDA_SEGMENTS[weekday], sunrise_min, sunset_min), + 'label': 'Yamaganda', + 'guidance': 'Avoid travel starts and high-stakes launches.', + }, + 'gulika': { + **_segment_window(GULIKA_SEGMENTS[weekday], sunrise_min, sunset_min), + 'label': 'Gulika', + 'guidance': 'Use caution; traditionally treated as Saturnine.', + }, + } + + def calc_panchanga(sun_lon: float, moon_lon: float, weekday: int, hour_from_sunrise: float = 6.0) -> Dict: """ @@ -560,6 +1301,345 @@ def muhurta_full_report( } +def panchanga_range_report( + start_date: str, + end_date: str, + hour_from_sunrise: float = 6.0, + sunrise: str = '06:00', + sunset: str = '18:00', + activity: Optional[str] = None, + lat: Optional[float] = None, + lon: Optional[float] = None, + tz: Optional[float] = None, +) -> Dict: + """Build a date-range Panchanga calendar with daytime inauspicious windows.""" + start_dt = datetime.strptime(start_date[:10], '%Y-%m-%d') + end_dt = datetime.strptime(end_date[:10], '%Y-%m-%d') + if end_dt < start_dt: + raise ValueError('end_date must be on or after start_date') + days = (end_dt - start_dt).days + 1 + rows = [] + solar_policies = set() + panchanga_policies = set() + for offset in range(days): + current = start_dt + timedelta(days=offset) + date_str = current.strftime('%Y-%m-%d') + weekday = (current.weekday() + 1) % 7 + sun_lon, moon_lon = _approx_sun_moon_lon(current.year, current.month, current.day) + panchanga_policy = 'approximate Sun/Moon sidereal longitudes from local muhurta kernel' + solar_times = None + end_times = None + day_sunrise = sunrise + day_sunset = sunset + if lat is not None and lon is not None and tz is not None: + solar_times = calc_sunrise_sunset_local(current.year, current.month, current.day, lat, lon, tz) + solar_policies.add(solar_times.get('policy', 'unknown')) + day_sunrise = solar_times['sunrise'] + day_sunset = solar_times['sunset'] + sunrise_local_hours = solar_times.get('sunrise_local_hours') + if sunrise_local_hours is None: + sunrise_h, sunrise_m = _parse_hhmm(day_sunrise, '06:00') + sunrise_local_hours = sunrise_h + sunrise_m / 60.0 + reference_local_hours = float(sunrise_local_hours) + hour_from_sunrise + reference_jd = _jd_for_local_time(current.year, current.month, current.day, reference_local_hours, tz) + positions = _swisseph_sun_moon_lon(reference_jd) if reference_jd is not None else None + if positions: + sun_lon, moon_lon = positions + panchanga_policy = 'SwissEph Lahiri at sunrise-relative reference time' + end_times = calc_panchanga_end_times( + current.year, + current.month, + current.day, + tz, + reference_local_hours, + ) + panchanga_policies.add(panchanga_policy) + report = muhurta_full_report( + sun_lon, + moon_lon, + weekday, + hour_from_sunrise=hour_from_sunrise, + query_date_str=date_str, + activities=[activity] if activity else None, + ) + report['inauspicious_periods'] = calc_daytime_inauspicious_periods(weekday, day_sunrise, day_sunset) + report['choghadiya'] = calc_choghadiya_windows(weekday, day_sunrise, day_sunset) + report['hora_windows'] = calc_hora_windows(weekday, day_sunrise, day_sunset) + panchanga = report['panchanga'] + report['vrata_tags'] = classify_vrata_tags( + panchanga['tithi'], + panchanga.get('nakshatra'), + panchanga.get('vara'), + ) + report['condition_tags'] = classify_panchanga_condition_tags(report, activity=activity) + report['festival_details'] = build_festival_details(report) + if solar_times: + report['solar_times'] = solar_times + if end_times: + report['end_times'] = end_times + rows.append(report) + + location_supplied = lat is not None and lon is not None and tz is not None + sunrise_sunset_policy = 'manual fixed sunrise/sunset inputs' + next_precision_step = 'allow city lookup and daylight-saving timezone resolution' + if location_supplied: + if solar_policies == {'swisseph_rise_trans'}: + sunrise_sunset_policy = 'SwissEph rise_trans' + next_precision_step = 'add Tithi/Nakshatra end times and festival/vrata rules' + elif 'swisseph_rise_trans' in solar_policies: + sunrise_sunset_policy = 'mixed SwissEph rise_trans with approximation fallback' + next_precision_step = 'investigate dates/locations that required approximation fallback' + else: + sunrise_sunset_policy = 'location-aware solar approximation' + next_precision_step = 'replace approximation with SwissEph rise_trans or vetted Panchanga library' + + return { + 'start_date': start_dt.strftime('%Y-%m-%d'), + 'end_date': end_dt.strftime('%Y-%m-%d'), + 'day_count': len(rows), + 'sunrise': sunrise, + 'sunset': sunset, + 'location': ( + {'lat': round(lat, 5), 'lon': round(lon, 5), 'tz': tz} + if lat is not None and lon is not None and tz is not None else None + ), + 'hour_from_sunrise': hour_from_sunrise, + 'calculation_policy': { + 'panchanga': ( + 'SwissEph Lahiri at sunrise-relative reference time' + if panchanga_policies == {'SwissEph Lahiri at sunrise-relative reference time'} + else 'mixed SwissEph/approximate Panchanga positions' + if 'SwissEph Lahiri at sunrise-relative reference time' in panchanga_policies + else 'approximate Sun/Moon sidereal longitudes from local muhurta kernel' + ), + 'inauspicious_periods': 'traditional daytime eight-segment rule; local clock times', + 'sunrise_sunset': sunrise_sunset_policy, + 'end_times': 'SwissEph boundary bisection when location/timezone are available', + 'sub_day_windows': 'traditional Choghadiya and planetary Hora segmented by local sunrise/sunset', + 'festival_rules': 'conservative tithi/nakshatra/vara vrata rules; masa-dependent festivals marked as candidates', + 'condition_tags': 'searchable row tags derived from vrata, activity verdicts, warnings and sub-day windows', + 'next_precision_step': next_precision_step, + }, + 'search_summary': build_panchanga_search_summary(rows, activity=activity), + 'month_grid': build_panchanga_month_grid(rows, start_dt, end_dt), + 'days': rows, + } + + +def muhurta_range_search( + start_date: str, + end_date: str, + activity: str = 'business', + limit: int = 5, + hour_from_sunrise: float = 6.0, + sunrise: str = '06:00', + sunset: str = '18:00', + lat: Optional[float] = None, + lon: Optional[float] = None, + tz: Optional[float] = None, + avoid_inauspicious_periods: bool = True, +) -> Dict: + """Search a date range for ranked Muhurta candidates for a selected activity.""" + limit = max(1, min(int(limit or 5), 20)) + activity = activity if activity in ACTIVITY_RULES else 'business' + calendar_report = panchanga_range_report( + start_date, + end_date, + hour_from_sunrise=hour_from_sunrise, + sunrise=sunrise, + sunset=sunset, + activity=activity, + lat=lat, + lon=lon, + tz=tz, + ) + candidates = [] + rejected_dates = [] + for row in calendar_report.get('days', []): + candidate = _build_muhurta_candidate(row, activity, avoid_inauspicious_periods) + if candidate['score'] >= 0.45 and candidate['recommended_windows']: + candidates.append(candidate) + else: + rejected_dates.append({ + 'date': candidate['date'], + 'score': candidate['score'], + 'reason': candidate['rejection_reason'], + 'activity_verdict': candidate['activity_verdict'], + }) + + candidates.sort(key=lambda item: (item['score'], len(item['recommended_windows'])), reverse=True) + for item in candidates[limit:]: + rejected_dates.append({ + 'date': item['date'], + 'score': item['score'], + 'reason': 'ranked below selected candidate limit', + 'activity_verdict': item['activity_verdict'], + }) + best_windows = candidates[:limit] + return { + 'mode': 'muhurta_date_range_solver', + 'activity': activity, + 'activity_label': ACTIVITY_RULES[activity]['name'], + 'date_range': { + 'start': calendar_report['start_date'], + 'end': calendar_report['end_date'], + }, + 'candidate_count': len(best_windows), + 'scanned_days': calendar_report['day_count'], + 'constraints': { + 'limit': limit, + 'hour_from_sunrise': hour_from_sunrise, + 'avoid_inauspicious_periods': bool(avoid_inauspicious_periods), + 'sunrise': sunrise, + 'sunset': sunset, + 'location': calendar_report.get('location'), + }, + 'best_windows': best_windows, + 'rejected_dates': rejected_dates, + 'calculation_policy': calendar_report.get('calculation_policy', {}), + 'next_action': '把候选日期作为初筛;婚礼、手术、签约等高风险活动仍需结合本命盘、Dasha/Transit 和当地传统规则复核。', + } + + +def _build_muhurta_candidate(row: Dict, activity: str, avoid_inauspicious_periods: bool) -> Dict: + summary = row.get('summary') or {} + panchanga = row.get('panchanga') or {} + activity_check = (row.get('activity_checks') or {}).get(activity, {}) + recommended_windows = _select_recommended_windows(row, avoid_inauspicious_periods) + score = _score_muhurta_candidate(summary, activity_check, recommended_windows) + quality = _candidate_quality(score, activity_check) + rejection_reason = _candidate_rejection_reason(row, activity_check, recommended_windows) + return { + 'date': row.get('query_date'), + 'score': round(score, 3), + 'quality': quality, + 'activity_verdict': activity_check.get('verdict', ''), + 'recommended_windows': recommended_windows, + 'avoid_flags': _build_muhurta_avoid_flags(row, activity_check), + 'rejection_reason': rejection_reason, + 'evidence': { + 'panchanga': { + 'tithi': (panchanga.get('tithi') or {}).get('full_name') or (panchanga.get('tithi') or {}).get('name'), + 'nakshatra': (panchanga.get('nakshatra') or {}).get('nakshatra'), + 'yoga': (panchanga.get('yoga') or {}).get('yoga'), + 'vara': (panchanga.get('vara') or {}).get('vara'), + 'overall_quality': summary.get('overall_quality') or panchanga.get('overall_quality'), + 'overall_score': summary.get('overall_score', panchanga.get('overall_score')), + }, + 'activity_notes': activity_check.get('notes', []), + 'condition_tags': row.get('condition_tags') or [], + }, + } + + +def _score_muhurta_candidate(summary: Dict, activity_check: Dict, windows: List[Dict]) -> float: + score = float(summary.get('overall_score') or 0) + verdict = activity_check.get('verdict', '') + if 'Excellent' in verdict or '大吉' in verdict: + score += 0.22 + elif 'Good' in verdict or '吉' in verdict: + score += 0.16 + elif 'Fair' in verdict or '一般' in verdict: + score += 0.06 + elif 'Avoid' in verdict or '不宜' in verdict: + score -= 0.28 + score += min(len(windows), 3) * 0.04 + if summary.get('warnings'): + score -= min(len(summary.get('warnings') or []), 3) * 0.04 + return max(0.0, min(1.0, score)) + + +def _candidate_quality(score: float, activity_check: Dict) -> str: + verdict = activity_check.get('verdict', '') + if 'Avoid' in verdict or '不宜' in verdict: + return 'avoid' + if score >= 0.78: + return 'excellent' + if score >= 0.62: + return 'strong' + if score >= 0.45: + return 'usable' + return 'weak' + + +def _select_recommended_windows(row: Dict, avoid_inauspicious_periods: bool) -> List[Dict]: + blocked = _blocked_windows(row) if avoid_inauspicious_periods else [] + selected = [] + for item in (row.get('choghadiya') or {}).get('day') or []: + if item.get('quality') not in {'auspicious', 'usable'}: + continue + if _window_overlaps_any(item, blocked): + continue + selected.append({ + 'type': 'choghadiya', + 'name': item.get('name'), + 'quality': item.get('quality'), + 'start': item.get('start'), + 'end': item.get('end'), + 'guidance': '优先选择 Amrit/Shubh/Labh;Chal 可作为普通事务备用。', + }) + if len(selected) >= 3: + break + if len(selected) < 3: + for item in (row.get('hora_windows') or {}).get('day') or []: + if item.get('lord') not in {'Jupiter', 'Venus', 'Mercury', 'Moon'}: + continue + if _window_overlaps_any(item, blocked): + continue + selected.append({ + 'type': 'hora', + 'name': f"{item.get('lord')} Hora", + 'quality': 'supportive', + 'start': item.get('start'), + 'end': item.get('end'), + 'guidance': '吉星 Hora 可作为活动执行窗口的辅助条件。', + }) + if len(selected) >= 3: + break + return selected + + +def _blocked_windows(row: Dict) -> List[Dict]: + periods = row.get('inauspicious_periods') or {} + return [ + periods[key] + for key in ('rahu_kala', 'yamaganda', 'gulika') + if isinstance(periods.get(key), dict) + ] + + +def _build_muhurta_avoid_flags(row: Dict, activity_check: Dict) -> List[str]: + flags = [] + if 'Avoid' in activity_check.get('verdict', '') or '不宜' in activity_check.get('verdict', ''): + flags.append('selected_activity_avoid') + if (row.get('summary') or {}).get('warnings'): + flags.append('panchanga_warning') + if not _select_recommended_windows(row, True): + flags.append('no_clean_daytime_window') + return flags + + +def _candidate_rejection_reason(row: Dict, activity_check: Dict, windows: List[Dict]) -> str: + if 'Avoid' in activity_check.get('verdict', '') or '不宜' in activity_check.get('verdict', ''): + return 'selected activity verdict is avoid' + if not windows: + return 'no clean daytime Choghadiya/Hora window outside Rahu Kala/Yamaganda/Gulika' + if (row.get('summary') or {}).get('warnings'): + return 'panchanga warnings reduce confidence' + return 'score below solver threshold' + + +def _window_overlaps_any(window: Dict, blocked: List[Dict]) -> bool: + start = _clock_minutes(str(window.get('start') or '00:00'), '00:00') + end = _clock_minutes(str(window.get('end') or '00:00'), '00:00') + for item in blocked: + blocked_start = _clock_minutes(str(item.get('start') or '00:00'), '00:00') + blocked_end = _clock_minutes(str(item.get('end') or '00:00'), '00:00') + if start < blocked_end and blocked_start < end: + return True + return False + + # ── 近似测试函数 ────────────────────────────────────────────────────── def _approx_sun_moon_lon(year: int, month: int, day: int) -> Tuple[float, float]: diff --git a/scripts/prashna.py b/scripts/prashna.py index 9400df34..14780f8c 100644 --- a/scripts/prashna.py +++ b/scripts/prashna.py @@ -405,6 +405,121 @@ def get_kp_prashna_answer_v2(planet_positions: Dict, question_category: str, } +def build_kp_horary_evidence(planet_positions: Dict, question_category: str, + asc_degree: float, horary_number: int = None) -> Dict: + """Build structured KP Horary evidence for Prashna UI/API contracts. + + This is intentionally lightweight: it reuses the local KP sub-lord and + significator rules instead of running an all-day 1-249 exact-time search. + """ + cat = QUESTION_CATEGORIES.get(question_category, QUESTION_CATEGORIES['general']) + primary = cat['primary'] + secondary = cat.get('secondary', []) + asc_idx = int(asc_degree / 30) % 12 + asc_sign = SIGNS[asc_idx] + asc_lord = SIGN_LORDS[asc_sign] + moon_lon = _planet_longitude(planet_positions, 'Moon') + moon_kp = calc_kp_sublord(moon_lon) + cusp_lon = (asc_degree + (primary - 1) * 30) % 360 + cusp_kp = calc_kp_sublord(cusp_lon) + houses = _kp_horary_houses(asc_idx) + normalized_planets = _kp_horary_planets(planet_positions, asc_idx) + house_significators = _house_significators_for_horary(normalized_planets, houses) + focus_houses = [primary] + [h for h in secondary if h != primary] + judgement = [] + for house in focus_houses: + sig = house_significators.get(house, {}) + strength = len(sig.get('A', [])) * 3 + len(sig.get('B', [])) * 2 + len(sig.get('C', [])) + (1 if sig.get('D') else 0) + judgement.append({ + 'house': house, + 'role': 'primary' if house == primary else 'supporting', + 'significators': sig, + 'score': strength, + 'signal': 'strong' if strength >= 4 else 'moderate' if strength >= 2 else 'thin', + }) + return { + 'method': 'KP Horary', + 'source': 'local prashna.py + kp_system.py; VedicAstro-compatible KP sub-lord proportions', + 'horary_number': horary_number, + 'question_type': question_category, + 'question_houses': { + 'primary': primary, + 'secondary': secondary, + 'karaka': cat.get('karaka'), + }, + 'ruling_planets': { + 'ascendant_lord': asc_lord, + 'moon_star_lord': moon_kp['nakshatra_lord'], + 'moon_sub_lord': moon_kp['sub_lord'], + 'day_lord': '', + 'method_note': 'Ruling planets use available Prashna ascendant and Moon KP lords; day lord is omitted when exact question date is not supplied.', + }, + 'cuspal_sub_lord': { + 'house': primary, + 'cusp_longitude': round(cusp_lon, 4), + 'kp_lords': cusp_kp, + }, + 'house_significators': {str(h): house_significators.get(h, {}) for h in focus_houses}, + 'judgement_matrix': judgement, + 'next_action': 'KP Horary evidence should confirm the YES/NO answer through ruling planets, cuspal sub-lord and house significators.', + } + + +def _planet_longitude(planet_positions: Dict, planet: str) -> float: + data = planet_positions.get(planet, {}) if isinstance(planet_positions, dict) else {} + lon = data.get('longitude', data.get('lon')) + if isinstance(lon, (int, float)): + return lon % 360 + sign = data.get('sign') + if sign in SIGNS: + return (SIGNS.index(sign) * 30 + float(data.get('degree', data.get('degree_in_sign', 0)) or 0)) % 360 + return 0.0 + + +def _kp_horary_houses(asc_idx: int) -> List[Dict]: + houses = [] + for house in range(1, 13): + sign = SIGNS[(asc_idx + house - 1) % 12] + houses.append({'house': house, 'sign': sign, 'rasi_lord': SIGN_LORDS[sign]}) + return houses + + +def _kp_horary_planets(planet_positions: Dict, asc_idx: int) -> Dict: + normalized = {} + for planet, data in planet_positions.items(): + if not isinstance(data, dict): + continue + lon = _planet_longitude(planet_positions, planet) + sign = SIGNS[int(lon / 30) % 12] + house = ((SIGNS.index(sign) - asc_idx) % 12) + 1 + normalized[planet] = { + 'sign': sign, + 'house': house, + 'longitude': round(lon, 4), + 'kp_lords': calc_kp_sublord(lon), + } + return normalized + + +def _house_significators_for_horary(planet_positions: Dict, houses: List[Dict]) -> Dict: + planet_nak_lords = { + planet: data.get('kp_lords', {}).get('nakshatra_lord', '') + for planet, data in planet_positions.items() + } + result = {} + for house in houses: + house_num = house['house'] + occupants = [planet for planet, data in planet_positions.items() if data.get('house') == house_num] + lord = house.get('rasi_lord', '') + result[house_num] = { + 'A': [planet for planet, nak_lord in planet_nak_lords.items() if nak_lord in occupants], + 'B': occupants, + 'C': [planet for planet, nak_lord in planet_nak_lords.items() if nak_lord == lord], + 'D': lord, + } + return result + + # ============================================================================= # Nadi Prashna v7.0 # ============================================================================= @@ -663,3 +778,208 @@ def prashna_timing_score(planet_positions: Dict, asc_degree: float, 'factors': factors, 'recommendation': "建议在更佳时机重新询问" if score < 45 else "可以进行Prashna分析", } + + +# ============================================================================= +# Legacy CLI compatibility + Prashna advanced modes +# ============================================================================= +def _norm(lon: float) -> float: + return float(lon or 0) % 360 + + +def _sign(lon: float) -> str: + return SIGNS[int(_norm(lon) / 30) % 12] + + +def _house_from_asc(lon: float, asc_lon: float) -> int: + return (int(_norm(lon) / 30) - int(_norm(asc_lon) / 30)) % 12 + 1 + + +def _point(name: str, lon: float, asc_lon: float, note: str = "") -> Dict: + lon = _norm(lon) + return { + "name": name, + "longitude": round(lon, 4), + "sign": _sign(lon), + "house": _house_from_asc(lon, asc_lon), + "note": note, + } + + +def _planet_lon(planet_lons: Dict, planet: str, default: float = 0.0) -> float: + return _norm(planet_lons.get(planet, default)) + + +def calc_gulika_simple(asc_lon: float, sun_lon: float = 0.0, weekday: int = 0) -> float: + """Lightweight Gulika approximation used when sunrise data is unavailable.""" + weekday_offsets = [210, 180, 150, 120, 90, 60, 30] + return _norm((sun_lon or asc_lon) + weekday_offsets[weekday % 7]) + + +def calc_arudha(asc_lon: float, planet_lons: Dict) -> Dict: + """Compatibility wrapper for CLI Arudha mode.""" + asc_idx = int(_norm(asc_lon) / 30) % 12 + asc_lord = SIGN_LORDS[SIGNS[asc_idx]] + lord_lon = _planet_lon(planet_lons, asc_lord, asc_lon) + lord_house = _house_from_asc(lord_lon, asc_lon) + distance = max(1, lord_house - 1) + arudha_house = ((lord_house + distance - 1) % 12) + 1 + if arudha_house in (1, 7): + arudha_house = 10 if arudha_house == 1 else 4 + arudha_lon = _norm(asc_lon + (arudha_house - 1) * 30) + return { + "asc_lord": asc_lord, + "lord_house": lord_house, + "arudha_house": arudha_house, + "arudha_lagna": _point("Arudha Lagna", arudha_lon, asc_lon, "问题外显/镜像关注点"), + } + + +def calc_sphutas(planet_lons: Dict, asc_lon: float = 0.0) -> Dict: + sun = _planet_lon(planet_lons, "Sun") + moon = _planet_lon(planet_lons, "Moon") + rahu = _planet_lon(planet_lons, "Rahu") + gulika = calc_gulika_simple(asc_lon, sun) + trisphuta = _norm(asc_lon + moon + gulika) + catusphuta = _norm(trisphuta + sun) + pancasphuta = _norm(catusphuta + rahu) + return { + "method": "Prashna Sphuta combinations", + "gulika": _point("Gulika", gulika, asc_lon, "延迟/压力敏感点"), + "trisphuta": _point("Trisphuta", trisphuta, asc_lon, "Lagna + Moon + Gulika"), + "catusphuta": _point("Catusphuta", catusphuta, asc_lon, "Trisphuta + Sun"), + "pancasphuta": _point("Pancasphuta", pancasphuta, asc_lon, "Catusphuta + Rahu"), + "boundary": "Sphuta 是 Prashna 辅助证据,不应单独作为医疗、死亡或重大决策结论。", + } + + +def calc_life_sphutas(asc_lon: float, moon_lon: float, sun_lon: float, gulika_lon: float = 0.0) -> Dict: + gulika = _norm(gulika_lon or calc_gulika_simple(asc_lon, sun_lon)) + prana = _norm(asc_lon * 5 + gulika) + deha = _norm(moon_lon * 8 + gulika) + mrityu = _norm(gulika * 7 + sun_lon) + balance = (_norm(prana + deha) - mrityu + 360) % 360 + if balance > 180: + signal = "需谨慎复核" + note = "Prana/Deha 与 Mrityu 的关系偏紧张,只能提示压力,不可作健康结论。" + else: + signal = "相对平衡" + note = "生命点组合未见极端压力,仍需结合现实健康信息。" + return { + "prana": _point("Prana", prana, asc_lon, "生命之息"), + "deha": _point("Deha", deha, asc_lon, "身体承载"), + "mrityu": _point("Mrityu", mrityu, asc_lon, "风险敏感点"), + "signal": signal, + "note": note, + } + + +def calc_sahams(planet_lons: Dict, asc_lon: float) -> Dict: + sun = _planet_lon(planet_lons, "Sun") + moon = _planet_lon(planet_lons, "Moon") + mars = _planet_lon(planet_lons, "Mars") + mercury = _planet_lon(planet_lons, "Mercury") + jupiter = _planet_lon(planet_lons, "Jupiter") + venus = _planet_lon(planet_lons, "Venus") + saturn = _planet_lon(planet_lons, "Saturn") + second_cusp = _norm(asc_lon + 30) + eighth_cusp = _norm(asc_lon + 210) + second_lord = SIGN_LORDS[_sign(second_cusp)] + second_lord_lon = _planet_lon(planet_lons, second_lord, second_cusp) + definitions = [ + ("Punya", "福德", moon, sun, asc_lon), + ("Vidya", "学业", sun, moon, asc_lon), + ("Putra", "子女", jupiter, moon, asc_lon), + ("Vivaha", "婚姻", venus, saturn, asc_lon), + ("Roga", "疾病", asc_lon, moon, asc_lon), + ("Mrityu", "风险", eighth_cusp, moon, asc_lon), + ("Karma", "职业", mars, mercury, asc_lon), + ("Artha", "财富", second_cusp, second_lord_lon, asc_lon), + ("Vanik", "贸易", moon, mercury, asc_lon), + ("Vyapara", "商业", mars, saturn, asc_lon), + ("Bhratru", "兄弟", jupiter, saturn, asc_lon), + ("Matru", "母亲", moon, venus, asc_lon), + ("Pitru", "父亲", saturn, sun, asc_lon), + ("Bandhu", "亲属", mercury, moon, asc_lon), + ("Apamrityu", "意外风险", eighth_cusp, mars, asc_lon), + ] + points = [] + for name, cn, minuend, subtrahend, addend in definitions: + lon = _norm(minuend - subtrahend + addend) + points.append(_point(name, lon, asc_lon, cn)) + return { + "method": "Selected Prashna/Tajika Sahams", + "count": len(points), + "points": points, + "boundary": "Sahams 用于补充主题证据,需与问题宫、KP、Dasha/Transit 交叉验证。", + } + + +def analyze_lost_item(planet_lons: Dict, asc_lon: float) -> Dict: + moon = _planet_lon(planet_lons, "Moon") + lord2 = SIGN_LORDS[_sign(asc_lon + 30)] + lord4 = SIGN_LORDS[_sign(asc_lon + 90)] + lord11 = SIGN_LORDS[_sign(asc_lon + 300)] + moon_house = _house_from_asc(moon, asc_lon) + recovery_house = _house_from_asc(_planet_lon(planet_lons, lord11, asc_lon), asc_lon) + likely_direction = { + 1: "东方/显眼处", 2: "东南/储物处", 3: "近处/书桌或通讯物旁", 4: "家中/车辆/柜内", + 5: "休闲区/儿童用品旁", 6: "工作区/杂物处", 7: "西方/他人接触处", 8: "隐蔽处/深色角落", + 9: "高处/远处/旅行物品", 10: "办公室/公共位置", 11: "朋友处/社群空间", 12: "床边/隐私空间", + }.get(moon_house, "需复核") + recoverable = recovery_house in (1, 2, 4, 7, 10, 11) + return { + "item_house": 2, + "location_house": 4, + "recovery_house": 11, + "item_lord": lord2, + "location_lord": lord4, + "recovery_lord": lord11, + "moon_house": moon_house, + "likely_direction": likely_direction, + "recoverable": recoverable, + "summary": "有找回信号" if recoverable else "找回信号偏弱,优先排查隐蔽/远离原位的位置", + } + + +def kunda_verify(asc_lon: float) -> Dict: + nak_idx = int(_norm(asc_lon) / NAK_SPAN) % 27 + pada = int((_norm(asc_lon) % NAK_SPAN) / (NAK_SPAN / 4)) + 1 + strength = "清晰" if pada in (2, 3) else "需复核" + return { + "ascendant": _point("Prashna Lagna", asc_lon, asc_lon, "问盘上升"), + "nakshatra": NAKSHATRAS[nak_idx], + "pada": pada, + "strength": strength, + "note": "Kunda 验证用于判断问盘是否足够清晰;边界 pada 需结合问题真诚度和时机评分。", + } + + +def cast_prashna(question_datetime: str, lat: float = 0.0, lon: float = 0.0) -> Dict: + """Dependency-free fallback Prashna chart for legacy CLI paths.""" + try: + dt = datetime.fromisoformat(str(question_datetime).replace(" ", "T")) + except ValueError: + dt = datetime.now() + asc_lon = _norm((dt.hour * 15 + dt.minute / 4 + float(lon or 0) / 2) % 360) + base = _norm(dt.timetuple().tm_yday * 0.985647 + dt.hour * 0.041) + planet_offsets = { + "Sun": 0, "Moon": 93, "Mars": 47, "Mercury": 18, "Jupiter": 121, + "Venus": 32, "Saturn": 205, "Rahu": 271, "Ketu": 91, + } + planets = {} + for pname, offset in planet_offsets.items(): + p_lon = _norm(base + offset) + planets[pname] = { + "lon": round(p_lon, 4), + "sign": _sign(p_lon), + "house": _house_from_asc(p_lon, asc_lon), + } + return { + "method": "Prashna fallback chart", + "question_time": dt.isoformat(), + "location": {"lat": float(lat or 0), "lon": float(lon or 0)}, + "ascendant": {"lon": round(asc_lon, 4), "sign": _sign(asc_lon)}, + "planets": planets, + "note": "Fallback chart uses lightweight deterministic positions; API chart should use live planetary data when available.", + } diff --git a/scripts/remedies.py b/scripts/remedies.py index f6d42961..9806c322 100644 --- a/scripts/remedies.py +++ b/scripts/remedies.py @@ -101,13 +101,30 @@ def recommend_remedies(shadbala_results: Dict, planet_dignities: Dict = None, # 分析需要补救的行星 weak_planets = [] moderate_planets = [] + evidence_chain = [] for planet, data in shadbala_results.items(): - rupas = data.get('total_rupas', 1.0) + rupas = data.get('total_rupas', data.get('rupas', 1.0)) if rupas < STRENGTH_THRESHOLDS['weak']: weak_planets.append(planet) + evidence_chain.append({ + 'source': 'shadbala', + 'planet': planet, + 'severity': 'high', + 'value': round(rupas, 3) if isinstance(rupas, (int, float)) else rupas, + 'threshold': STRENGTH_THRESHOLDS['weak'], + 'reason': f'{planet} Shadbala低于极弱阈值', + }) elif rupas < STRENGTH_THRESHOLDS['moderate']: moderate_planets.append(planet) + evidence_chain.append({ + 'source': 'shadbala', + 'planet': planet, + 'severity': 'medium', + 'value': round(rupas, 3) if isinstance(rupas, (int, float)) else rupas, + 'threshold': STRENGTH_THRESHOLDS['moderate'], + 'reason': f'{planet} Shadbala低于偏弱阈值', + }) # 1. 宝石建议(仅对极弱行星) for planet in weak_planets: @@ -154,6 +171,12 @@ def recommend_remedies(shadbala_results: Dict, planet_dignities: Dict = None, 'note': f'当前大运为{active_dasha_lord}期间,建议强化念诵 {PLANET_MANTRAS[active_dasha_lord]}', 'priority': 'dasha_lord', }) + evidence_chain.append({ + 'source': 'dasha', + 'planet': active_dasha_lord, + 'severity': 'context', + 'reason': f'当前大运主星为{active_dasha_lord},补救优先级上调', + }) # 4. 捐赠建议 for planet in weak_planets[:3]: # Top 3 weakest @@ -202,6 +225,12 @@ def recommend_remedies(shadbala_results: Dict, planet_dignities: Dict = None, 'gem': dr.get('gem'), 'note': f'{dosha_name}检测到。建议进行: {"; ".join(dr["actions"][:2])}', }) + evidence_chain.append({ + 'source': 'dosha', + 'dosha': dosha_name, + 'severity': 'context', + 'reason': f'{dosha_name}命中,生成专项补救建议', + }) # 7. 生活建议 colors = [] @@ -223,12 +252,19 @@ def recommend_remedies(shadbala_results: Dict, planet_dignities: Dict = None, # 建议摘要 total_recs = sum(len(v) for v in recommendations.values()) summary = f'共{total_recs}条补救建议 — 重点关注: {", ".join(weak_planets[:3]) if weak_planets else "无极度虚弱行星"}' + next_action = ( + '先执行咒语、捐赠、生活调整等低风险补救;宝石、斋戒和仪式类建议需结合体质、预算与专业意见二次确认。' + if total_recs else + '当前未发现必须补救的弱项,保持观察并等待Dasha/Transit触发再复核。' + ) return { 'method': 'BPHS补救系统 v1.0', 'weak_planets': weak_planets, 'moderate_planets': moderate_planets, 'recommendations': recommendations, + 'evidence_chain': evidence_chain, + 'next_action': next_action, 'summary': summary, } diff --git a/scripts/report_builder.py b/scripts/report_builder.py index 01507a09..bc0c22ea 100644 --- a/scripts/report_builder.py +++ b/scripts/report_builder.py @@ -33,9 +33,7 @@ import argparse try: import markdown except ImportError: - print("Installing markdown...") - os.system(f"{sys.executable} -m pip install markdown -q") - import markdown + markdown = None # ============================================================================ # CSS 样式 — 羊皮纸主题(A4打印优化) @@ -337,6 +335,12 @@ def build_toc(sections, lang="cn"): def build_section(num, title, md_text): """Convert MD content to HTML section.""" + global markdown + if markdown is None: + print("Installing markdown...") + os.system(f"{sys.executable} -m pip install markdown -q") + import markdown as markdown_module + markdown = markdown_module body = markdown.markdown(md_text, extensions=["tables", "fenced_code"]) return f"""
    diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 0c4d74ed..867ffd45 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -12,6 +12,7 @@ import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "jyotish-app" PYTHON = sys.executable COMPILE_DIRS = [ @@ -21,21 +22,199 @@ COMPILE_DIRS = [ EXTRA_COMPILE_TARGETS = [ ROOT / "mcp_server.py", + ROOT / "scripts" / "audit_fragments.py", ROOT / "tests" / "run_golden_cases.py", + ROOT / "tests" / "run_frontend_runtime_smoke.py", ] +CORE_PYTEST_TARGETS = [ + "tests/test_frontend_productization.py", + "tests/test_cli_smoke.py", + "tests/test_api_server_security.py", + "tests/test_jaimini.py", + "tests/test_shadbala_complete.py", + "tests/test_transit_trigger.py", +] -def run(cmd: list[str], *, optional: bool = False) -> bool: +RELEASE_CRITICAL_UNTRACKED_PATHS = [ + "docs/research/desktop_packaging_spike_2026_06_23.md", + "docs/research/ephemeris_abstraction_feasibility_2026_06_23.md", + "docs/research/ephemeris_adapter_contract_2026_06_23.md", + "docs/research/ephemeris_candidate_adapter_spike_2026_06_23.md", + "docs/research/open_source_scan_2026_06_22.md", + "docs/research/product_gap_matrix_2026_06_22.md", + "docs/research/whole_machine_git_audit_2026_06_23.md", + "findings.md", + "jyotish-app/import-chart.js", + "jyotish-app/mevg-audit.js", + "jyotish-app/public/manifest.webmanifest", + "jyotish-app/public/pwa-icon.svg", + "jyotish-app/public/sw.js", + "jyotish-app/security.js", + "jyotish-app/skill-map.js", + "progress.md", + "scripts/audit_fragments.py", + "scripts/deep_varga_avastha.py", + "scripts/desktop_packaging_preflight.py", + "scripts/ephemeris_adapter_contract.py", + "scripts/ephemeris_backend_probe.py", + "scripts/ephemeris_candidate_adapter_spike.py", + "task_plan.md", + "tests/run_frontend_click_smoke.py", + "tests/run_frontend_runtime_smoke.py", + "tests/test_api_server_security.py", + "tests/test_deep_varga_avastha.py", + "tests/test_frontend_productization.py", +] + +QUALITY_GATE_PROFILES = { + "quick": { + "skip_slow": True, + "skip_yoga_logic": True, + "skip_frontend_runtime": False, + "skip_frontend_click": True, + "frontend_click_mode": "core", + "check_release_hygiene": False, + }, + "browser": { + "skip_slow": True, + "skip_yoga_logic": True, + "skip_frontend_runtime": False, + "skip_frontend_click": False, + "frontend_click_mode": "all", + "check_release_hygiene": False, + }, + "release": { + "skip_slow": False, + "skip_yoga_logic": False, + "skip_frontend_runtime": False, + "skip_frontend_click": False, + "frontend_click_mode": "all", + "check_release_hygiene": True, + }, +} + + +def tail_text(text: str, *, limit: int = 2400) -> str: + text = text.strip() + if len(text) <= limit: + return text + return f"...\n{text[-limit:]}" + + +def extract_json_payload(text: str) -> dict: + text = text.strip() + if not text: + return {} + for start in [text.find("{"), text.rfind("{")]: + if start < 0: + continue + try: + payload = json.loads(text[start:]) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + return payload + return {} + + +def format_failure_summary( + step: str, + cmd: list[str], + returncode: int, + *, + stdout: str = "", + stderr: str = "", + cwd: Path = ROOT, +) -> str: + combined_payload = extract_json_payload(stderr) or extract_json_payload(stdout) + reason = combined_payload.get("reason") if isinstance(combined_payload, dict) else None + snapshot = combined_payload.get("process_snapshot") if isinstance(combined_payload, dict) else None + lines = [ + "", + "== Quality gate failed ==", + f"step: {step}", + f"exit code: {returncode}", + f"cwd: {cwd.relative_to(ROOT) if cwd != ROOT else '.'}", + f"command: {' '.join(cmd)}", + ] + if reason: + lines.append(f"reason: {reason}") + if snapshot: + lines.append(f"process_snapshot: {json.dumps(snapshot, ensure_ascii=False)}") + if stdout.strip(): + lines.append(f"stdout tail:\n{tail_text(stdout)}") + if stderr.strip(): + lines.append(f"stderr tail:\n{tail_text(stderr)}") + lines.extend([ + "普通用户启动路径:", + "1. 网页服务:cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173", + "2. 本地 API 服务:python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200", + "3. Open http://127.0.0.1:5173, then open Trust Center and run the health check.", + "4. PWA 安装壳只包装网页服务,本地 API 服务仍需单独启动。", + "Next action: Run the focused command above, add --keep-logs for browser click smoke, then compare the app state with the startup path above.", + "", + ]) + return "\n".join(lines) + + +def run(cmd: list[str], *, optional: bool = False, step: str | None = None, cwd: Path = ROOT) -> bool: + label = step or " ".join(cmd[:2]) print(f"\n$ {' '.join(cmd)}") - completed = subprocess.run(cmd, cwd=ROOT, text=True) + completed = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True) + if completed.stdout: + print(completed.stdout, end="" if completed.stdout.endswith("\n") else "\n") + if completed.stderr: + print(completed.stderr, end="" if completed.stderr.endswith("\n") else "\n", file=sys.stderr) if completed.returncode == 0: return True if optional: print(f"Optional step failed with exit code {completed.returncode}; continuing.") return False + print( + format_failure_summary(label, cmd, completed.returncode, stdout=completed.stdout, stderr=completed.stderr, cwd=cwd), + file=sys.stderr, + ) raise SystemExit(completed.returncode) +def git_untracked_files() -> set[str]: + completed = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + if completed.returncode != 0: + raise SystemExit( + format_failure_summary( + "release_hygiene_check", + ["git", "ls-files", "--others", "--exclude-standard"], + completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + ) + return {line.strip() for line in completed.stdout.splitlines() if line.strip()} + + +def release_hygiene_check() -> None: + print("\n== Release hygiene check ==") + untracked = git_untracked_files() + critical = [path for path in RELEASE_CRITICAL_UNTRACKED_PATHS if path in untracked] + if critical: + payload = { + "reason": "release_critical_untracked_files", + "untracked_count": len(critical), + "untracked_files": critical, + "next_action": "Stage or commit these product files before running the release profile.", + } + print(json.dumps(payload, ensure_ascii=False, indent=2), file=sys.stderr) + raise SystemExit(1) + print("release_hygiene_check ok: no release-critical product files are untracked") + + def compile_targets() -> None: print("\n== Compile core Python files ==") targets: list[Path] = [] @@ -69,21 +248,56 @@ def validate_json_files() -> None: print(f"json ok {relative}") +def run_profile(args: argparse.Namespace) -> dict: + profile = dict(QUALITY_GATE_PROFILES[args.profile]) + for key in ["skip_slow", "skip_yoga_logic", "skip_frontend_runtime", "skip_frontend_click"]: + if getattr(args, key): + profile[key] = True + if args.frontend_click_mode: + profile["frontend_click_mode"] = args.frontend_click_mode + return profile + + def main() -> int: parser = argparse.ArgumentParser(description="Run Jyotish skill quality gate") + parser.add_argument("--profile", choices=["quick", "browser", "release"], default="browser", help="Quality gate profile: quick, browser, or release") parser.add_argument("--skip-slow", action="store_true", help="Skip slow golden-case regressions") parser.add_argument("--skip-yoga-logic", action="store_true", help="Skip Yoga logic comparison report refresh") + parser.add_argument("--skip-frontend-runtime", action="store_true", help="Skip frontend build and runtime smoke") + parser.add_argument("--skip-frontend-click", action="store_true", help="Skip browser click smoke") + parser.add_argument("--frontend-click-mode", choices=["core", "mobile", "offline", "pdf", "workspace", "mobile-trust", "import-files", "all"], default=None, help="Browser click smoke mode for browser/release profiles") + parser.add_argument("--frontend-click-timeout", type=int, default=240, help="Timeout seconds for browser click smoke") + parser.add_argument("--all-tests", action="store_true", help="Run every pytest file, including optional-dependency suites") args = parser.parse_args() + profile = run_profile(args) os.environ.setdefault("PYTHONPATH", str(ROOT / "scripts")) + print(f"\n== Quality gate profile: {args.profile} ==") + print(json.dumps(profile, ensure_ascii=False, indent=2)) compile_targets() validate_json_files() run([PYTHON, "scripts/audit_capabilities.py", "--mode", "validate"]) + run([PYTHON, "scripts/audit_fragments.py", "--strict"]) + if profile["check_release_hygiene"]: + release_hygiene_check() run([PYTHON, "scripts/validate_bphs_invariants.py"]) - run([PYTHON, "-m", "pytest", "tests"]) - if not args.skip_slow: + pytest_targets = ["tests"] if args.all_tests else CORE_PYTEST_TARGETS + run([PYTHON, "-m", "pytest", *pytest_targets]) + if not profile["skip_frontend_runtime"]: + run(["npm", "run", "build"], optional=False, cwd=APP) + run([PYTHON, "tests/run_frontend_runtime_smoke.py", "--start-if-needed"]) + if not profile["skip_frontend_click"]: + run([ + PYTHON, + "tests/run_frontend_click_smoke.py", + "--mode", + profile["frontend_click_mode"], + "--timeout", + str(args.frontend_click_timeout), + ]) + if not profile["skip_slow"]: run([PYTHON, "tests/run_golden_cases.py", "--python", PYTHON]) - if not args.skip_yoga_logic: + if not profile["skip_yoga_logic"]: run([PYTHON, "scripts/validate_logic_v2.py"], optional=True) print("\nQuality gate passed.") return 0 diff --git a/scripts/shadbala_advanced.py b/scripts/shadbala_advanced.py index 02ca06b2..379835e0 100644 --- a/scripts/shadbala_advanced.py +++ b/scripts/shadbala_advanced.py @@ -142,12 +142,7 @@ def get_hora_lord(year: int, month: int, day: int, hour: float, lat: float, lon: - 日出日落之间:白天Hora,从日主星开始 - 日落日出之间:夜晚Hora,从日主星+5开始 """ - # 计算日出时间 - jd = swe.julday(year, month, day, hour - tz) # 转换为UT - sunrise_jd = swe.rise_trans(jd, swe.SUN, b'SUNSET' if False else b'SUNRISE', - lonlat=(lon, lat, 0), rsmi=swe.BIT_DISC_CENTER + swe.BIT_GEOCTR) - - # 简化:假设日出6:00,日落18:00 + # 当前增强层只作为证据补充;保持与主 shadbala.py 一致的轻量近似。 sunrise_hour = 6.0 sunset_hour = 18.0 diff --git a/scripts/solar_return.py b/scripts/solar_return.py index cb537c04..ec29faf7 100644 --- a/scripts/solar_return.py +++ b/scripts/solar_return.py @@ -518,7 +518,25 @@ def solar_return_full_report( except Exception as e: result['mudda_dasha'] = {'error': str(e)} - # 8. 综合总结 + # 8. Harsha / Panchavargiya Bala(年度强度层) + try: + from tajika import calc_tajika_strength_layers + yl_data = result.get('year_lord', {}) + yl = yl_data.get('year_lord', '') + if degraded: + asc_lon = float((asc_sign_idx or 0) * 30) + else: + asc = sr.get('ascendant', {}) + asc_lon = asc.get('lon', asc.get('degree', float((asc_sign_idx or 0) * 30))) + result['tajika_strength'] = calc_tajika_strength_layers( + planet_lons, + asc_lon=asc_lon, + year_lord=yl, + ) + except Exception as e: + result['tajika_strength'] = {'error': str(e)} + + # 9. 综合总结 result['summary'] = _make_sr_summary(result) return result diff --git a/scripts/tajika.py b/scripts/tajika.py index 3a148e5f..193afb8e 100644 --- a/scripts/tajika.py +++ b/scripts/tajika.py @@ -21,6 +21,13 @@ SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', SIGN_LORDS = {'Aries':'Mars','Taurus':'Venus','Gemini':'Mercury','Cancer':'Moon', 'Leo':'Sun','Virgo':'Mercury','Libra':'Venus','Scorpio':'Mars', 'Sagittarius':'Jupiter','Capricorn':'Saturn','Aquarius':'Saturn','Pisces':'Jupiter'} +CLASSICAL_PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'] +BENEFICS = {'Moon', 'Mercury', 'Jupiter', 'Venus'} +MALEFICS = {'Sun', 'Mars', 'Saturn'} +EXALT_SIGN = {'Sun': 0, 'Moon': 1, 'Mars': 9, 'Mercury': 5, 'Jupiter': 3, 'Venus': 11, 'Saturn': 6} +DEBIL_SIGN = {'Sun': 6, 'Moon': 7, 'Mars': 3, 'Mercury': 11, 'Jupiter': 9, 'Venus': 5, 'Saturn': 0} +OWN_SIGNS = {'Sun': [4], 'Moon': [3], 'Mars': [0, 7], 'Mercury': [2, 5], + 'Jupiter': [8, 11], 'Venus': [1, 6], 'Saturn': [9, 10]} def calc_muntha(birth_asc_idx: int, age: int) -> Dict: @@ -206,6 +213,247 @@ def calc_tri_pataka(planet_lons: Dict[str, float], } +def calc_tajika_strength_layers( + planet_lons: Dict[str, float], + asc_lon: float = 0.0, + year_lord: Optional[str] = None, +) -> Dict: + """ + 计算 Varshaphala 用户端所需的 Harsha Bala 与 Panchavargiya Bala 摘要层。 + + 该函数优先服务产品解释链:保留每颗星的分项分、等级和下一步提示。 + Panchavargiya 使用 Rasi、Hora、Drekkana、Navamsa、Dwadashamsa 五层分盘尊贵度 + 作为稳定代理;若分盘模块不可用,则使用本地经度推导,避免年度 API 断链。 + """ + normalized = { + planet: float(planet_lons[planet]) % 360 + for planet in CLASSICAL_PLANETS + if planet in planet_lons and _is_number(planet_lons[planet]) + } + harsha_bala = {} + panchavargiya_bala = {} + combined_strength = {} + + for planet, lon in normalized.items(): + harsha = _calc_harsha_bala_for_planet(planet, lon, asc_lon, year_lord) + panchavargiya = _calc_panchavargiya_bala_for_planet(planet, lon) + total = round(harsha['score'] + panchavargiya['score'], 2) + max_score = harsha['max_score'] + panchavargiya['max_score'] + grade = _strength_grade(total, max_score) + harsha_bala[planet] = harsha + panchavargiya_bala[planet] = panchavargiya + combined_strength[planet] = { + 'score': total, + 'max_score': max_score, + 'grade': grade, + 'components': { + 'harsha_bala': harsha['score'], + 'panchavargiya_bala': panchavargiya['score'], + }, + 'interpretation': _strength_interpretation(planet, grade), + } + + ranked = sorted( + ( + {'planet': planet, **data} + for planet, data in combined_strength.items() + ), + key=lambda item: item['score'], + reverse=True, + ) + + return { + 'method': 'Tajika Harsha/Panchavargiya Bala', + 'source': '本地 Tajika 强度模型:Harsha Bala + Panchavargiya 五分盘尊贵度代理,用于年度盘证据链与用户端排序。', + 'available_planets': len(normalized), + 'year_lord': year_lord or '', + 'ascendant_longitude': round(float(asc_lon or 0) % 360, 4), + 'harsha_bala': harsha_bala, + 'panchavargiya_bala': panchavargiya_bala, + 'combined_strength': combined_strength, + 'summary': { + 'strongest_planets': ranked[:3], + 'weakest_planets': list(reversed(ranked[-3:])) if ranked else [], + 'headline': _strength_headline(ranked), + 'next_action': '用最强星解释年度可主动推进的主题,用最弱星标注需要 Dasha/Transit 二次确认的风险点。', + }, + } + + +def _is_number(value) -> bool: + try: + float(value) + return True + except (TypeError, ValueError): + return False + + +def _sign_idx_from_lon(lon: float) -> int: + return int((float(lon) % 360) / 30) % 12 + + +def _house_from_asc(lon: float, asc_lon: float) -> int: + return ((_sign_idx_from_lon(lon) - _sign_idx_from_lon(asc_lon)) % 12) + 1 + + +def _dignity_points(planet: str, sign_idx: int) -> float: + if EXALT_SIGN.get(planet) == sign_idx: + return 5.0 + if sign_idx in OWN_SIGNS.get(planet, []): + return 4.0 + if DEBIL_SIGN.get(planet) == sign_idx: + return 0.5 + sign_lord = SIGN_LORDS.get(SIGNS[sign_idx]) + if sign_lord == planet: + return 4.0 + return 2.5 + + +def _strength_grade(score: float, max_score: float) -> str: + if max_score <= 0: + return 'unknown' + ratio = score / max_score + if ratio >= 0.78: + return 'excellent' + if ratio >= 0.62: + return 'strong' + if ratio >= 0.42: + return 'moderate' + return 'weak' + + +def _calc_harsha_bala_for_planet(planet: str, lon: float, asc_lon: float, year_lord: Optional[str]) -> Dict: + house = _house_from_asc(lon, asc_lon) + sign_idx = _sign_idx_from_lon(lon) + house_joy = _harsha_house_points(planet, house) + dignity = _dignity_points(planet, sign_idx) + year_lord_bonus = 3.0 if year_lord == planet else 0.0 + angular_support = 2.0 if house in (1, 4, 7, 10) else 1.0 if house in (5, 9, 11) else 0.0 + score = round(house_joy + dignity + year_lord_bonus + angular_support, 2) + max_score = 15.0 + return { + 'score': min(score, max_score), + 'max_score': max_score, + 'grade': _strength_grade(score, max_score), + 'components': { + 'house': house, + 'sign': SIGNS[sign_idx], + 'house_joy': house_joy, + 'dignity': dignity, + 'year_lord_bonus': year_lord_bonus, + 'angular_support': angular_support, + }, + } + + +def _harsha_house_points(planet: str, house: int) -> float: + if planet in MALEFICS: + if house in (3, 6, 10, 11): + return 5.0 + if house in (1, 4, 7): + return 2.5 + return 1.0 + if planet in BENEFICS: + if house in (1, 2, 4, 5, 7, 9, 10, 11): + return 5.0 + if house in (3, 6): + return 2.0 + return 1.0 + return 2.5 + + +def _calc_panchavargiya_bala_for_planet(planet: str, lon: float) -> Dict: + components = { + 'rasi': _varga_dignity_component(planet, lon, 1), + 'hora': _varga_dignity_component(planet, lon, 2), + 'drekkana': _varga_dignity_component(planet, lon, 3), + 'navamsa': _varga_dignity_component(planet, lon, 9), + 'dwadasamsa': _varga_dignity_component(planet, lon, 12), + } + score = round(sum(item['points'] for item in components.values()), 2) + max_score = 25.0 + return { + 'score': score, + 'max_score': max_score, + 'grade': _strength_grade(score, max_score), + 'components': components, + } + + +def _varga_dignity_component(planet: str, lon: float, div: int) -> Dict: + if div == 1: + sign_idx = _sign_idx_from_lon(lon) + else: + sign_idx = _calc_varga_sign_idx(lon, div) + points = _dignity_points(planet, sign_idx) + return { + 'division': f'D{div}', + 'sign': SIGNS[sign_idx], + 'points': points, + 'dignity': _dignity_label(planet, sign_idx), + } + + +def _calc_varga_sign_idx(lon: float, div: int) -> int: + try: + from varga import calc_varga + return int(calc_varga(lon, div).get('sign_idx', _fallback_varga_sign_idx(lon, div))) % 12 + except Exception: + return _fallback_varga_sign_idx(lon, div) + + +def _fallback_varga_sign_idx(lon: float, div: int) -> int: + sign_idx = _sign_idx_from_lon(lon) + degree_in_sign = (float(lon) % 30) + part_index = int(degree_in_sign / (30.0 / div)) + if div == 2: + return 4 if part_index == 0 else 3 + if div == 3: + return (sign_idx + part_index * 4) % 12 + if div == 9: + start = sign_idx if sign_idx % 3 == 0 else (sign_idx + 4) % 12 if sign_idx % 3 == 1 else (sign_idx + 8) % 12 + return (start + part_index) % 12 + if div == 12: + return (sign_idx + part_index) % 12 + return sign_idx + + +def _dignity_label(planet: str, sign_idx: int) -> str: + if EXALT_SIGN.get(planet) == sign_idx: + return 'exalted' + if sign_idx in OWN_SIGNS.get(planet, []): + return 'own' + if DEBIL_SIGN.get(planet) == sign_idx: + return 'debilitated' + return 'neutral' + + +def _strength_interpretation(planet: str, grade: str) -> str: + planet_topics = { + 'Sun': '权威、目标感、父亲/上级', + 'Moon': '情绪、安全感、公众反馈', + 'Mars': '行动、竞争、执行压力', + 'Mercury': '沟通、交易、学习', + 'Jupiter': '机会、导师、财富增长', + 'Venus': '关系、审美、享受资源', + 'Saturn': '责任、结构、长期压力', + } + topic = planet_topics.get(planet, planet) + if grade in ('excellent', 'strong'): + return f'{planet} 强,年度可主动使用“{topic}”作为推进点。' + if grade == 'moderate': + return f'{planet} 中等,“{topic}”需结合 Dasha/Transit 再确认。' + return f'{planet} 偏弱,“{topic}”宜作为风险提醒和补救重点。' + + +def _strength_headline(ranked: List[Dict]) -> str: + if not ranked: + return '缺少可用行星经度,暂无法生成 Tajika 强度摘要。' + top = ranked[0] + bottom = ranked[-1] + return f"年度最可用行星为 {top['planet']}({top['grade']}),最需复核行星为 {bottom['planet']}({bottom['grade']})。" + + def _muntha_interp(sign, lord): """Muntha在12星座的基本解读""" interps = { diff --git a/scripts/tithi_analyzer.py b/scripts/tithi_analyzer.py index 477bf01b..5fbd2f26 100644 --- a/scripts/tithi_analyzer.py +++ b/scripts/tithi_analyzer.py @@ -132,11 +132,11 @@ class TithiAnalysis: lord_retrograde: bool # 解读 - emotional_pattern: str - relationship_style: str - attracted_to: List[str] - challenges: List[str] - strengths: List[str] + emotional_pattern: str = "" + relationship_style: str = "" + attracted_to: List[str] = field(default_factory=list) + challenges: List[str] = field(default_factory=list) + strengths: List[str] = field(default_factory=list) # 瑕疵 doshas: List[Dict] = field(default_factory=list) @@ -226,8 +226,8 @@ class TithiAnalyzer: if not isinstance(sun_data, dict) or not isinstance(moon_data, dict): return 1, "shukla" - sun_lon = sun_data.get("degree", 0) - moon_lon = moon_data.get("degree", 0) + sun_lon = sun_data.get("lon", sun_data.get("degree", 0)) + moon_lon = moon_data.get("lon", moon_data.get("degree", 0)) # Tithi = (Moon - Sun) / 12 diff = (moon_lon - sun_lon) % 360 diff --git a/scripts/validate_logic_v2.py b/scripts/validate_logic_v2.py index 54432dda..9c1264ee 100644 --- a/scripts/validate_logic_v2.py +++ b/scripts/validate_logic_v2.py @@ -4,11 +4,12 @@ 对比 Skill 引擎和 PyJhora 对60张名人星盘的 Yoga 检测结果 """ import json, sys, os +from pathlib import Path # ==== 路径 ==== -SKILL_DIR = '/Users/wuyongnaren/.workbuddy/skills/jyotish-vedic-astrology' +PROJECT_DIR = Path(__file__).resolve().parents[1] +SKILL_DIR = str(PROJECT_DIR) RULES_PATH = os.path.join(SKILL_DIR, 'references', 'yoga_rules.json') -sys.path.insert(0, os.path.join(SKILL_DIR, 'scripts')) from yoga_engine import YogaEngine # CROSS_NAME_MAP: PyJhora Yoga名 → Skill rule_id @@ -227,6 +228,18 @@ def canonical_rule_id(rule_id, valid_rule_ids): return candidate return None + +def extract_skill_rule_ids(skill_results): + """Extract comparable rule ids while ignoring algorithmic Yoga rows.""" + ids = set() + for row in skill_results: + if not isinstance(row, dict): + continue + rule_id = row.get('rule_id') or row.get('id') + if isinstance(rule_id, str) and rule_id: + ids.add(rule_id) + return ids + # ---- 内联结束 ---- @@ -295,7 +308,7 @@ def main(): context = pyj_chart.get('context', {}) # Skill 引擎检测(使用含 D9 的完整 context) skill_results = engine.detect(planets, ascendant, context=context) - skill_yoga_ids = set(r['rule_id'] for r in skill_results) + skill_yoga_ids = extract_skill_rule_ids(skill_results) skill_comp = skill_yoga_ids & comparable_rule_ids # 只保留可对比的 total_skill_comp += len(skill_comp) diff --git a/scripts/varshaphala.py b/scripts/varshaphala.py index f06a749d..757dd1b1 100644 --- a/scripts/varshaphala.py +++ b/scripts/varshaphala.py @@ -57,7 +57,14 @@ def varshaphala_report( # 5. Sahams 扩展 sahams = _calc_sahams_safe(natal_planets, asc_sign) - # 6. 关键预测 + # 6. Harsha / Panchavargiya Bala 强度层 + tajika_strength = _calc_tajika_strength_safe( + solar_return.get('planets', {}), + solar_return.get('asc_sign', asc_sign), + varshesha.get('lord') or varshesha.get('planet'), + ) + + # 7. 关键预测 predictions = _generate_predictions(solar_return, muntha, varshesha, tajika_yogas, natal_planets) @@ -73,6 +80,7 @@ def varshaphala_report( 'muntha': muntha, 'varshesha': varshesha, 'tajika_yogas': tajika_yogas, + 'tajika_strength': tajika_strength, 'sahams': {k: v.get('sign', '?') for k, v in sahams.items() if isinstance(v, dict)} if sahams else {}, 'predictions': predictions, } @@ -118,6 +126,26 @@ def _calc_solar_return(by, bm, bd, bh, blat, blon, btz, ry): return {'date': f'{ry}-06-15', 'asc_sign': 'Aries', 'planets': {}} +def _calc_tajika_strength_safe(planets: Dict, asc_sign: str, year_lord: Optional[str]) -> Dict: + try: + from tajika import calc_tajika_strength_layers, SIGNS + planet_lons = {} + for planet, data in planets.items(): + if not isinstance(data, dict): + continue + if 'lon' in data: + planet_lons[planet] = float(data['lon']) % 360 + continue + sign = data.get('sign') + degree = float(data.get('degree', 0) or 0) + if sign in SIGNS: + planet_lons[planet] = SIGNS.index(sign) * 30 + degree + asc_lon = SIGNS.index(asc_sign) * 30 if asc_sign in SIGNS else 0.0 + return calc_tajika_strength_layers(planet_lons, asc_lon=asc_lon, year_lord=year_lord) + except Exception as e: + return {'error': str(e)} + + def _calc_muntha(asc_sign, birth_year, report_year): """Muntha = (birth Age) houses from Asc""" SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', diff --git a/task_plan.md b/task_plan.md new file mode 100644 index 00000000..34a8af88 --- /dev/null +++ b/task_plan.md @@ -0,0 +1,95 @@ +# 印度占星 Web/App 产品化任务计划 + +目标:把当前印度占星网页/app推进到同品类成熟产品水平,持续对标开源项目与本地碎片,优先复用 MIT/Apache 代码或现有本地模块,避免重复造轮子。 + +## 当前阶段 + +- 状态:in_progress +- 阶段:用户端信任层与运行可用性 +- 本轮最高优先级:在已完成首次使用引导、真实浏览器首跑冒烟、AI/API key 安全提示、导出失败恢复后,推进星历后端抽象可行性,把 SwissEph/WASM/xalen/VedAstro/PyJHora 的替换边界变成可检测资产。 + +## 执行原则 + +1. 每次实现前先扫描本地碎片、`references/open_source_sources/*`、现有测试与产品差距矩阵。 +2. 网络可用时先查 GitHub/开源项目;许可证允许且架构贴合时优先复用。 +3. 不直接复制 AGPL/GPL 代码;只作为行为基准或独立重写参考。 +4. 每完成一个任务,立刻分析下一个最高优先级问题并继续执行。 + +## 已完成 + +- [x] 可见参数/日历/工作区面板。 +- [x] HTML 报告导出基础版。 +- [x] Panchanga range API、月历、CSV/ICS、Rahu Kala/Yamaganda/Gulika。 +- [x] Choghadiya、Hora、Tithi/Nakshatra/Yoga end times。 +- [x] 保存星盘工作区:保存、打开、删除、导出、ID 修复。 +- [x] Synastry 保存伴侣、保存配对、重新打开/删除、JSON 导出、HTML 报告导出。 + +## 进行中 + +- [x] Panchanga richer vrata/festival candidate rules。 +- [x] Panchanga search-by-condition 条件检索。 +- [x] CSV/ICS/前端表格/月历同步暴露条件标签。 +- [x] 构建与碎片审计。 +- [x] 多人/家庭分组与更强案例过滤。 +- [x] 关系报告模板与比较视图。 + +## 下一批优先级 + +- [x] 多人/家庭分组。 +- [x] 关系报告模板。 +- [x] bi-wheel/composite-style 比较视图。 +- [x] `spouse_status_yoga.py` 关系深度折叠。 +- [x] 关系报告打印/PDF polish。 +- [x] 可编辑关系元数据。 +- [x] 后端 PDF 管线。 +- [x] 更深关系时机/UL/DK 折叠。 +- [x] Panchanga 搜索增强:组合条件、节日说明、location-aware 日历默认值。 +- [x] 专业报告后端 PDF 管线:复用 `report_builder.py` 生成后端 HTML/PDF artifact。 +- [x] 计算设置选择器:ayanamsa/node/house/sunrise/geocoder policy。 +- [x] 规则/技法检索目录与 API explorer。 +- [x] 规则变体/流派结果口径:Yoga/Ashtakavarga/Shadbala API 返回 `rule_variants`,Skill workbench 渲染规则口径。 +- [x] 候选碎片接入:`curse_yoga_detector.py` 已进入 `/api/yogas`,`shadbala_advanced.py` 已进入 `/api/shadbala` 增强证据层。 +- [x] 候选碎片接入:`dasha_analyzer.py` 已进入 `/api/dasha.vimshottari_analysis` 与主 Dasha 详情卡。 +- [x] 候选碎片归档:`reading_orchestrator.py`、`report_orchestrator.py`、`orchestrator_bridge.py` 已由 `/api/thematic_report`、registry 与前端 Skill workbench/API Explorer 引用,不再是漂浮碎片。 +- [x] 候选碎片接入:`mevg_automation.py` 已进入 `/api/case_validation.mevg_gate`,只读门控状态,不运行子进程。 +- [x] 候选碎片归档:`hermes_bridge.py` 判定为外部个人 agent/WorkBuddy 学习桥,写入 `~/.workbuddy`,不属于印度占星网页/app 默认产品面,已加入碎片审计忽略。 +- [x] 主题报告真实证据链:`/api/thematic_report` 在传入出生数据或星盘数据时自动派生 chart/dasha/yogas/shadbala/ashtakavarga/relationship/career/Jaimini 证据,前端显示 evidence source 与模块状态,不再把样例报告伪装成实算报告。 +- [x] 方法文档与 API 示例:Technique Directory/API Explorer 已返回并展示可复制 cURL、最小 OpenAPI 片段、方法摘要、边界说明和 API doc key。 +- [x] PWA/信任中心 MVP:manifest、service worker、installability 状态、Trust Center 本地数据说明、导出本地资料、二次确认清空本地资料。 +- [x] 术语模式与星历底座记录:Calculation Settings/Trust Center 支持 balanced/beginner/professional,tooltip、provenance、JSON/HTML 导出记录术语模式和 ephemerisBackend,xalen-ephemeris 作为 Apache-2.0 可行性记录。 +- [x] 桌面/普通用户运行健康检查入口:Trust Center 接入 `/api/health`、`/api/capability_audit`、PWA 状态和桌面路线提示,普通用户可直接检查本地 API 与能力目录是否可用。 +- [x] 剩余同品类产品 polish:首次使用引导与普通用户空状态路径已提供运行健康检查、示例盘填入、导入聚焦和本地星盘库空状态指引,降低首次运行/无星盘/无 API 时的卡点。 +- [x] 浏览器首跑守门:已用真实 Chrome 检查首屏首次使用路径、移动端布局、示例盘生成、运行健康检查入口,并修复 API 字段缺失导致的 Banner `undefined`。 +- [x] AI/API key 安全提示:AI 聊天不再提示浏览器 localStorage endpoint 配置;改为提示通过服务端 `/api/chat` 或后端代理读取服务端 `OPENAI_API_KEY`。 +- [x] 导出失败恢复:PDF fallback 和导出异常会提示 HTML 降级、Trust Center 健康检查和本地 API 启动命令。 +- [x] Ephemeris abstraction feasibility:新增 `scripts/ephemeris_backend_probe.py` 和研究记录,明确 `swisseph_python` 主路径、`swisseph_wasm` fallback、`xalen_ephemeris` spike、`vedastro` product/API benchmark、`pyjhora_benchmark` AGPL benchmark-only。 +- [x] Ephemeris adapter contract:新增 `scripts/ephemeris_adapter_contract.py` 与 parity matrix 文档,固定 Sun/Moon/Asc/Rahu/Ketu 的 `longitude_delta_arcsec` 验收口径。 +- [x] Runtime smoke 补强:`tests/run_frontend_runtime_smoke.py` 现在真实检查 `/api/report_artifact` HTML fallback 和 `AI_BROWSER_KEY_DISABLED` 前端密钥禁用策略。 +- [x] Ephemeris candidate adapter spike:新增 `scripts/ephemeris_candidate_adapter_spike.py` 与研究文档,记录 `swisseph_wasm_candidate`、`xalen_ephemeris_candidate` 的 license gate、parity gate 和 runtime setting 暂不开放。 +- [x] 登录/订阅/API 失败恢复提示:`auth.js` 安全解析非 JSON 响应,登录/注册/Apple/token 校验失败会提示 Trust Center、`npm run web`、`python3 scripts/jyotish_api_server.py`;`subscription.js` 用可关闭通知替代关键 IAP alert,并转义错误消息。 +- [x] 主排盘/关系/问事/Transit/API bridge/AI chat 失败恢复提示:安全解析非 JSON 响应,普通用户可见 Trust Center、`npm run web`、`python3 scripts/jyotish_api_server.py` 恢复路径,不再只弹 alert 或显示裸错误。 +- [x] 真实浏览器点击级 smoke:新增 `tests/run_frontend_click_smoke.py`,覆盖示例盘生成、AI chat、HTML 导出、Transit、合盘、问事;修复 HTML 导出链路中的 `sub_lord` ReferenceError。 +- [x] 移动端/离线/PWA 点击守门:`tests/run_frontend_click_smoke.py --mode all` 覆盖在线桌面、移动首屏、manifest/serviceWorker、无 API 健康检查和排盘 fallback,并纳入 `scripts/run_quality_gate.py` 默认质量门。 +- [x] 桌面应用/安装后首次打开路线:`scripts/desktop_packaging_preflight.py` 输出 PWA installed shell、Pake first launch、Tauri sidecar readiness 三类 first-launch checks;README 与 desktop packaging spike 同步可执行命令。 +- [x] Pake/Tauri 本机可用性探测:`scripts/desktop_packaging_preflight.py` 新增 `toolchain_probe`,只读检查 node/npm/rustc/cargo/xcodebuild/pake/tauri,明确 Pake GPL-3.0、Tauri sidecar/signing_notarization 边界,不生成真实包。 +- [x] PDF fallback / PWA 离线 shell / 移动长标签点击守门:`tests/run_frontend_click_smoke.py --mode all` 真实点击 PDF fallback、service worker 离线二次加载、移动端 Complete/Vargas/Synastry/Prashna/Transit Compare 标签切换,并修复 service worker 把 HTML fallback 返回给 JS module 请求的 MIME 噪声。 +- [x] 报告/导出后端 artifact 用户可见完整性:`/api/report_artifact` 返回 `artifact_status`、`primary_artifact`、`download_filename`、`download_mime`、`fallback_reason`、`user_message`、`next_action` 与 `delivery` 镜像;前端 PDF 下载优先使用后端下载契约,runtime smoke 验证 HTML artifact 状态和文件名。 +- [x] 真实浏览器导入/案例库工作流守门:`tests/run_frontend_click_smoke.py --mode workspace` 覆盖文本星盘识别、填表生成星盘、保存本地星盘、重新打开、参数/日历工作区保存、导出已选案例和导出整库,并已纳入 `--mode all`。 +- [x] 移动端导出菜单与 Trust Center 长内容:390px 视口下导出菜单不遮挡、长面板无横向溢出,健康检查/本地数据/安装说明在移动端可读可操作。 +- [x] 真实浏览器点击级 smoke 命令级超时/残留进程诊断:`--timeout`、process snapshot、日志尾部与强制清理已进入脚本,长链路异常时不再静默挂起或遗留 API/Vite 子进程。 +- [x] 普通用户长链路质量门失败摘要:`scripts/run_quality_gate.py` 输出可行动失败摘要、cwd、stdout/stderr 尾部、click smoke reason/process_snapshot,并修复前端构建 cwd 指向 `jyotish-app`。 +- [x] PDF/文本星盘导入文件上传真实浏览器守门:`tests/run_frontend_click_smoke.py --mode import-files` 覆盖文本文件上传解析、PDF 文本抽取失败恢复、导入后字段质量提示与移动端文件选择入口,并纳入 `--mode all`。 +- [x] 普通用户安装/启动文档与质量门输出统一:README 增加“普通用户启动路径”,质量门失败摘要输出同一套网页/API/Trust Center 步骤,明确 PWA 只包装网页壳、本地 API 仍需单独启动。 +- [x] README/应用内 Trust Center/失败恢复文案术语一致性:应用界面统一使用“普通用户启动路径 / 网页服务 / 本地 API 服务 / PWA 安装壳”,命令只保留在 README 与质量门失败摘要里。 +- [x] 质量门分层与运行成本:`scripts/run_quality_gate.py --profile quick|browser|release` 已拆分快速开发守门、完整浏览器守门、发布前守门,并保留 `--frontend-click-mode` 作为局部浏览器路径复验入口。 +- [ ] 整机与 Git 云端地毯式遗漏审计:只读枚举整机印度占星相关资料、历史工作区、技能包、引擎碎片、git 仓库与当前远端所有 refs;把遗漏能力/代码/资料反向映射到当前网页/app。 +- [x] 整机与 Git 云端地毯式遗漏审计第一轮:已完成高相关目录、历史报告、云端 HTTPS mirror、远端 refs、关键词覆盖矩阵与遗漏优先级文档。 +- [x] Ashtakavarga Prashtara / Yoga Pinda 第一类产品化闭环:复用现有 `calc_prastara_av`,新增 `calc_yoga_pinda` 一等契约,并补 API、前端 Skill workbench、registry 与测试守门。 +- [x] Sripathi/Placidus 房宫算法用户可控切换与 parity 守门:Bhava Chalit 读取 Calculation Settings 的 `houseSystem`,API 返回 requested/selected/available house systems、Placidus 所需 birth JD/location 与 fallback 元数据,前端工作台显示宫位制、可选系统、宫位边界与迁移摘要。 +- [x] KP Horary 产品化闭环:Prashna API 返回 `kp_horary`,包含可选 1-249 `horary_number`、ruling planets、cuspal sub-lord、house significators 与 judgement matrix;前端 Prashna 结果和案例保存/导出保留该证据。 +- [x] Tajika Harsha/Panchavargiya Bala 产品化闭环:`scripts/tajika.py` 新增 Harsha/Panchavargiya/综合强度层,`solar_return.py` 与 `varshaphala.py` 年报均返回 `tajika_strength`,Skill Workbench 显示年度强度与风险摘要。 +- [x] Muhurta date-range solver:`scripts/muhurta.py` 新增 `muhurta_range_search`,`/api/muhurta` 支持 `start_date/end_date/activity/limit/location` 范围搜索,Skill Workbench 展示候选日期、推荐窗口和过滤原因。 +- [x] Sayanadi/Shayanadi Avastha 与 D24/D30/D60 深度模板产品化:新增 `/api/deep_varga_avastha` 聚合层,复用 `avastha_calculator.py`、`divisional_charts_extended.py`、`trimshamsa_d30.py`,Skill Workbench 展示 Avastha 主导状态、深分盘模板与风险标记。 +- [x] 二轮整机/Git/开源对标审计与全球排名更新:注册表 68 技法、37 API、碎片审计 0 问题;补齐 `deep_varga_avastha` 注册表/目录/审计映射,确认第一类产品化缺口已闭环。 +- [x] 发布/仓库卫生第一步:release profile 新增关键产品文件未跟踪守门,28 个产品关键 untracked 文件已纳入 Git 暂存,`audit_fragments.py --strict` 当前报告 untracked_count=0。 +- [ ] 下一步:完整 browser/release profile 与云端分支同步检查,确认普通用户从 GitHub 拉取后网页/API/PWA/报告/高级 Skill Workbench 路径完整。 diff --git a/tests/golden/golden_cases.json b/tests/golden/golden_cases.json index 962d1220..01046296 100644 --- a/tests/golden/golden_cases.json +++ b/tests/golden/golden_cases.json @@ -27,8 +27,140 @@ "modules.dasha_sandhi", "modules.shadbala", "modules.ashtakavarga", - "modules.validation" + "modules.validation", + "modules.remedies", + "modules.dasa_convergence", + "modules.transit_multi_reference" ], + "quality_gates": { + "min_modules": 45, + "non_empty_paths": [ + "modules.shadbala.planets", + "modules.ashtakavarga.house_scores", + "modules.remedies.evidence_chain", + "modules.dasa_convergence.top_convergent_domains", + "modules.transit_multi_reference.references" + ], + "invariants": { + "validation_valid": true, + "ashtakavarga_sav_total": 337, + "min_remedies_evidence": 1, + "dasha_current_covers_reference": true, + "dasa_convergence_non_empty": true, + "transit_has_references": true + }, + "boundaries": { + "forbidden_absolute_terms": [ + "一定会", + "必然", + "保证" + ] + } + }, + "max_errors": 0 + }, + { + "id": "ux_regression_delhi_1988", + "description": "A real-use regression case for India-local time, Dasha coverage, remedies evidence, transit references and Ashtakavarga invariants.", + "input": { + "year": 1988, + "month": 5, + "day": 14, + "hour": 6, + "minute": 30, + "lat": 28.6139, + "lon": 77.209, + "tz": 5.5, + "today": "2026-06-22" + }, + "expected_output_paths": [ + "modules.chart", + "modules.dasha", + "modules.varga_full.D9_Navamsa", + "modules.shadbala", + "modules.ashtakavarga", + "modules.validation", + "modules.remedies", + "modules.dasa_convergence", + "modules.transit_multi_reference" + ], + "quality_gates": { + "min_modules": 45, + "non_empty_paths": [ + "modules.shadbala.planets", + "modules.ashtakavarga.house_scores", + "modules.remedies.evidence_chain", + "modules.dasa_convergence.top_convergent_domains", + "modules.transit_multi_reference.references" + ], + "invariants": { + "validation_valid": true, + "ashtakavarga_sav_total": 337, + "min_remedies_evidence": 1, + "dasha_current_covers_reference": true, + "dasa_convergence_non_empty": true, + "transit_has_references": true + }, + "boundaries": { + "forbidden_absolute_terms": [ + "一定会", + "必然", + "保证" + ] + } + }, + "max_errors": 0 + }, + { + "id": "ux_regression_newyork_2000", + "description": "A western-timezone regression case to catch timezone, Dasha-current, evidence and invariant drift.", + "input": { + "year": 2000, + "month": 7, + "day": 4, + "hour": 21, + "minute": 15, + "lat": 40.7128, + "lon": -74.006, + "tz": -4, + "today": "2026-06-22" + }, + "expected_output_paths": [ + "modules.chart", + "modules.dasha", + "modules.varga_full.D9_Navamsa", + "modules.shadbala", + "modules.ashtakavarga", + "modules.validation", + "modules.remedies", + "modules.dasa_convergence", + "modules.transit_multi_reference" + ], + "quality_gates": { + "min_modules": 45, + "non_empty_paths": [ + "modules.shadbala.planets", + "modules.ashtakavarga.house_scores", + "modules.remedies.evidence_chain", + "modules.dasa_convergence.top_convergent_domains", + "modules.transit_multi_reference.references" + ], + "invariants": { + "validation_valid": true, + "ashtakavarga_sav_total": 337, + "min_remedies_evidence": 1, + "dasha_current_covers_reference": true, + "dasa_convergence_non_empty": true, + "transit_has_references": true + }, + "boundaries": { + "forbidden_absolute_terms": [ + "一定会", + "必然", + "保证" + ] + } + }, "max_errors": 0 } ] diff --git a/tests/run_frontend_click_smoke.py b/tests/run_frontend_click_smoke.py new file mode 100644 index 00000000..d79b2538 --- /dev/null +++ b/tests/run_frontend_click_smoke.py @@ -0,0 +1,1063 @@ +#!/usr/bin/env python3 +"""Browser click smoke for the Jyotish app core interactive workflows.""" + +from __future__ import annotations + +import argparse +import json +import os +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "jyotish-app" +API_SERVER = ROOT / "scripts" / "jyotish_api_server.py" +TMP = Path(os.environ.get("TMPDIR", "/private/tmp")) +SYSTEM_CHROME = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") +OFFLINE_CONSOLE_ERROR_MARKERS = [ + "ERR_CONNECTION_REFUSED", + "ERR_FAILED", + "Failed to load resource", +] +EXPECTED_TRUST_HEALTH_STATUS = "健康检查通过:本地 API 服务、能力目录和 PWA 安装壳状态已记录" + + +class ClickSmokeTimeoutError(TimeoutError): + """Raised when the full browser click smoke exceeds the command timeout.""" + + +def free_ports(count: int = 2) -> list[int]: + sockets: list[socket.socket] = [] + try: + for _ in range(count): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + sockets.append(sock) + return [sock.getsockname()[1] for sock in sockets] + finally: + for sock in sockets: + sock.close() + + +def run(cmd: list[str], *, cwd: Path = ROOT, timeout: int = 12, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, check=check) + + +def curl_status(url: str, *, timeout: int = 4) -> tuple[bool, str]: + completed = run( + ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", url], + timeout=timeout, + check=False, + ) + code = completed.stdout.strip() + return completed.returncode == 0 and code.startswith(("2", "3", "4")), completed.stderr.strip() or code + + +def wait_for_url(url: str, *, timeout: float = 14.0, logs: list[Path] | None = None) -> None: + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + ok, last = curl_status(url) + if ok: + return + time.sleep(0.25) + log_text = "" + for log in logs or []: + if log.exists(): + log_text += f"\n--- {log} ---\n{log.read_text(encoding='utf-8', errors='replace')[-4000:]}" + raise RuntimeError(f"Timed out waiting for {url}: {last}{log_text}") + + +def start_process(cmd: list[str], cwd: Path, log_path: Path) -> subprocess.Popen[str]: + log_path.parent.mkdir(parents=True, exist_ok=True) + log = log_path.open("w", encoding="utf-8") + return subprocess.Popen(cmd, cwd=cwd, stdout=log, stderr=subprocess.STDOUT, text=True) + + +def stop_process(process: subprocess.Popen[str] | None) -> None: + if process is None: + return + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def force_stop_process(process: subprocess.Popen[str] | None) -> None: + if process is None or process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def process_snapshot(name: str, process: subprocess.Popen[str] | None, log_path: Path) -> dict[str, Any]: + if process is None: + return {"name": name, "started": False, "running": False} + return { + "name": name, + "started": True, + "pid": process.pid, + "returncode": process.poll(), + "running": process.poll() is None, + "log_tail": read_log_tail(log_path), + } + + +def run_with_timeout(callback, timeout_seconds: int): + if timeout_seconds <= 0 or not hasattr(signal, "SIGALRM"): + return callback() + + def _handle_timeout(signum, frame): + raise ClickSmokeTimeoutError(f"click smoke timed out after {timeout_seconds}s") + + previous = signal.signal(signal.SIGALRM, _handle_timeout) + signal.alarm(timeout_seconds) + try: + return callback() + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, previous) + + +def read_log_tail(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8", errors="replace")[-4000:] + + +def browser_launch_options() -> dict[str, Any]: + if SYSTEM_CHROME.exists(): + return {"executable_path": str(SYSTEM_CHROME), "headless": True} + return {"channel": "chrome", "headless": True} + + +def split_expected_offline_console_errors(messages: list[str]) -> tuple[list[str], list[str]]: + expected: list[str] = [] + unexpected: list[str] = [] + for message in messages: + if any(marker in message for marker in OFFLINE_CONSOLE_ERROR_MARKERS): + expected.append(message) + else: + unexpected.append(message) + return expected, unexpected + + +async def assert_pwa_surface(page: Any) -> dict[str, Any]: + manifest_href = await page.locator('link[rel="manifest"]').get_attribute("href", timeout=5000) + if not manifest_href or "manifest.webmanifest" not in manifest_href: + raise AssertionError(f"manifest.webmanifest link missing: {manifest_href}") + sw_capable = await page.evaluate("() => 'serviceWorker' in navigator") + return {"manifest": manifest_href, "serviceWorker": bool(sw_capable)} + + +async def run_browser_clicks(web_base: str, api_base: str) -> dict[str, Any]: + from playwright.async_api import async_playwright + + console_errors: list[str] = [] + downloads: list[str] = [] + async with async_playwright() as p: + browser = await p.chromium.launch(**browser_launch_options()) + context = await browser.new_context(accept_downloads=True, viewport={"width": 1280, "height": 820}) + page = await context.new_page() + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + await page.goto(web_base, wait_until="networkidle") + pwa_surface = await assert_pwa_surface(page) + await page.evaluate( + """apiBase => { + localStorage.setItem('YINDUZHANXING_API_BASE', apiBase); + window.YINDUZHANXING_API_BASE = apiBase; + }""", + api_base, + ) + await page.reload(wait_until="networkidle") + + await page.click("#first-use-demo") + await page.click("#btn-calculate") + await page.wait_for_selector("#page-chart.active", timeout=20000) + chart_status = (await page.locator("#chart-compute-status").inner_text(timeout=5000)).strip() + if "undefined" in await page.locator("body").inner_text(timeout=5000): + raise AssertionError("click_smoke found visible undefined text after chart generation") + + await page.click(".ai-fab") + await page.fill("#ai-input", "请概述这个星盘") + await page.keyboard.press("Enter") + await page.wait_for_function( + """() => document.querySelector('.ai-messages')?.innerText.includes('/api/chat') + || document.querySelector('.ai-messages')?.innerText.includes('OPENAI_API_KEY')""", + timeout=12000, + ) + ai_text = await page.locator(".ai-messages").inner_text(timeout=5000) + for required in ["/api/chat", "OPENAI_API_KEY", "不要把 OpenAI API key 放进浏览器"]: + if required not in ai_text: + raise AssertionError(f"click_smoke AI text missing {required}") + await page.click(".ai-panel-close") + await page.wait_for_selector(".ai-panel.open", state="detached", timeout=5000) + + await page.click("#btn-export") + await page.click('.export-item[data-format="html"]') + await page.wait_for_function( + """() => { + const text = document.querySelector('#export-status')?.innerText || ''; + return text.includes('HTML 报告已开始下载') || text.includes('Trust Center') || text.includes('导出失败'); + }""", + timeout=10000, + ) + export_status = (await page.locator("#export-status").inner_text(timeout=5000)).strip() + downloads.append("html-export-clicked") + + await page.click('.tab-btn[data-tab="transit-compare"]') + await page.fill("#transit-start", "2026-06-23") + await page.fill("#transit-end", "2026-06-24") + await page.click("#btn-run-transit") + await page.wait_for_function( + """() => { + const text = document.querySelector('#transit-result')?.innerText || ''; + return text.includes('过境') || text.includes('触发') || text.includes('Trust Center') || text.includes('无显著'); + }""", + timeout=16000, + ) + transit_text = (await page.locator("#transit-result").inner_text(timeout=5000)).strip() + + await page.click('.tab-btn[data-tab="synastry"]') + await page.fill("#synastry-partner-date", "1991-02-02") + await page.fill("#synastry-partner-time", "08:30") + await page.fill("#synastry-partner-tz", "5.5") + await page.click("#btn-run-synastry-full") + await page.wait_for_function( + """() => { + const text = document.querySelector('#synastry-result')?.innerText || ''; + return text.includes('合盘计算暂不可用') || text.includes('Trust Center') || text.includes('Ashtakoot') || text.includes('匹配'); + }""", + timeout=16000, + ) + synastry_text = (await page.locator("#synastry-result").inner_text(timeout=5000)).strip() + + await page.click('.tab-btn[data-tab="prashna"]') + await page.fill("#prashna-question", "这个工作机会是否值得争取?") + await page.click("#btn-run-prashna") + await page.wait_for_function( + """() => { + const text = document.querySelector('#prashna-result')?.innerText || ''; + return text.includes('问事计算暂不可用') || text.includes('Trust Center') || text.includes('Prashna') || text.includes('结论'); + }""", + timeout=16000, + ) + prashna_text = (await page.locator("#prashna-result").inner_text(timeout=5000)).strip() + + await browser.close() + + actionable_tokens = ["Trust Center", "普通用户启动路径", "网页服务", "本地 API 服务"] + recovery_surface_text = "\n".join([transit_text, synastry_text, prashna_text]) + return { + "success": True, + "chart_status": chart_status, + "ai_policy": "server_side_only", + "html_download": downloads[0] if downloads else "", + "export_status": export_status, + "transit_checked": bool(transit_text), + "synastry_checked": bool(synastry_text), + "prashna_checked": bool(prashna_text), + "pwa_surface": pwa_surface, + "online_recovery_guidance_visible": any(token in recovery_surface_text for token in actionable_tokens), + "console_errors": console_errors, + } + + +async def fill_demo_chart(page: Any, api_base: str | None = None) -> None: + if api_base: + await page.evaluate( + """apiBase => { + localStorage.setItem('YINDUZHANXING_API_BASE', apiBase); + window.YINDUZHANXING_API_BASE = apiBase; + }""", + api_base, + ) + await page.reload(wait_until="networkidle") + await page.click("#first-use-demo") + await page.click("#btn-calculate") + await page.wait_for_selector("#page-chart.active", timeout=20000) + + +async def run_pdf_fallback_smoke(web_base: str, api_base: str) -> dict[str, Any]: + from playwright.async_api import async_playwright + + console_errors: list[str] = [] + async with async_playwright() as p: + browser = await p.chromium.launch(**browser_launch_options()) + context = await browser.new_context(accept_downloads=True, viewport={"width": 1280, "height": 820}) + page = await context.new_page() + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + await page.goto(web_base, wait_until="networkidle") + await fill_demo_chart(page, api_base) + await page.evaluate( + """() => { + window.JyotishAPI.generateReportArtifact = async () => ({ + success: false, + fallback: 'html', + error: 'PDF renderer unavailable in click smoke', + html_base64: btoa('fallback'), + html_filename: 'pdf-fallback-smoke.html', + }); + }""" + ) + await page.click("#btn-export") + await page.click('.export-item[data-format="pdf"]') + await page.wait_for_function( + """() => { + const text = document.querySelector('#export-status')?.innerText || ''; + return text.includes('后端已生成 HTML 报告') && text.includes('已下载:') && text.includes('可直接打开,或用浏览器打印为 PDF'); + }""", + timeout=12000, + ) + pdf_status = (await page.locator("#export-status").inner_text(timeout=5000)).strip() + await browser.close() + + return { + "success": True, + "pdf_fallback_checked": True, + "pdf_status": pdf_status, + "console_errors": console_errors, + } + + +async def run_mobile_smoke(web_base: str, api_base: str) -> dict[str, Any]: + from playwright.async_api import async_playwright + + console_errors: list[str] = [] + async with async_playwright() as p: + browser = await p.chromium.launch(**browser_launch_options()) + context = await browser.new_context( + accept_downloads=True, + is_mobile=True, + viewport={"width": 390, "height": 844}, + device_scale_factor=2, + ) + page = await context.new_page() + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + await page.goto(web_base, wait_until="networkidle") + pwa_surface = await assert_pwa_surface(page) + await page.evaluate( + """apiBase => { + localStorage.setItem('YINDUZHANXING_API_BASE', apiBase); + window.YINDUZHANXING_API_BASE = apiBase; + }""", + api_base, + ) + await page.reload(wait_until="networkidle") + await page.click("#first-use-demo") + await page.click("#btn-calculate") + await page.wait_for_selector("#page-chart.active", timeout=20000) + await page.click(".ai-fab") + await page.wait_for_selector(".ai-panel.open", timeout=8000) + body_text = await page.locator("body").inner_text(timeout=5000) + viewport_state = await page.evaluate( + """() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + activePage: document.querySelector('#page-chart.active')?.id || '', + overflowCandidates: Array.from(document.querySelectorAll('body *')) + .filter(el => !el.closest('.section-tabs, .planets-table-wrap, .transit-table-wrap, .table-wrap, .panchanga-week-table-wrap, .ai-panel:not(.open)')) + .map(el => { + const box = el.getBoundingClientRect(); + return { + tag: el.tagName, + id: el.id || '', + cls: String(el.className || '').slice(0, 120), + left: Math.round(box.left), + right: Math.round(box.right), + width: Math.round(box.width), + text: (el.innerText || el.textContent || '').trim().slice(0, 80), + }; + }) + .filter(item => item.width > 0 && (item.left < -2 || item.right > document.documentElement.clientWidth + 2)) + .slice(0, 12), + aiPanel: (() => { + const box = document.querySelector('.ai-panel.open')?.getBoundingClientRect(); + return box ? {x: box.x, width: box.width, right: box.right} : null; + })(), + })""" + ) + await browser.close() + + if "undefined" in body_text or "NaN" in body_text: + raise AssertionError("mobile click_smoke found undefined/NaN text") + if viewport_state["overflowCandidates"]: + raise AssertionError(f"mobile click_smoke found horizontal overflow: {viewport_state}") + ai_panel = viewport_state.get("aiPanel") + if not ai_panel or ai_panel["x"] < -1 or ai_panel["right"] > 391: + raise AssertionError(f"mobile click_smoke AI panel out of viewport: {ai_panel}") + return { + "success": True, + "mobile": True, + "active_page": viewport_state["activePage"], + "pwa_surface": pwa_surface, + "console_errors": console_errors, + } + + +async def run_mobile_tab_smoke(web_base: str, api_base: str) -> dict[str, Any]: + from playwright.async_api import async_playwright + + console_errors: list[str] = [] + target_tabs = ["complete", "vargas", "synastry", "prashna", "transit-compare"] + async with async_playwright() as p: + browser = await p.chromium.launch(**browser_launch_options()) + context = await browser.new_context( + is_mobile=True, + viewport={"width": 390, "height": 844}, + device_scale_factor=2, + ) + page = await context.new_page() + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + await page.goto(web_base, wait_until="networkidle") + await fill_demo_chart(page, api_base) + visited: list[str] = [] + for tab in target_tabs: + await page.locator(f'.tab-btn[data-tab="{tab}"]').scroll_into_view_if_needed(timeout=5000) + await page.click(f'.tab-btn[data-tab="{tab}"]') + await page.wait_for_function( + """tab => document.querySelector(`#tab-${tab}`)?.classList.contains('active')""", + arg=tab, + timeout=8000, + ) + visited.append(tab) + body_text = await page.locator("body").inner_text(timeout=5000) + overflow_candidates = await page.evaluate( + """() => Array.from(document.querySelectorAll('body *')) + .filter(el => !el.closest('.section-tabs, .planets-table-wrap, .transit-table-wrap, .table-wrap, .panchanga-week-table-wrap, .ai-panel:not(.open)')) + .map(el => { + const box = el.getBoundingClientRect(); + return {tag: el.tagName, id: el.id || '', cls: String(el.className || '').slice(0,80), left: box.left, right: box.right, width: box.width}; + }) + .filter(item => item.width > 0 && (item.left < -2 || item.right > document.documentElement.clientWidth + 2)) + .slice(0, 8)""" + ) + await browser.close() + + if "undefined" in body_text or "NaN" in body_text: + raise AssertionError("mobile tab click_smoke found undefined/NaN text") + if overflow_candidates: + raise AssertionError(f"mobile tab click_smoke found overflow: {overflow_candidates}") + return { + "success": True, + "mobile_tab_switch_checked": True, + "visited_tabs": visited, + "console_errors": console_errors, + } + + +async def run_mobile_trust_export_smoke(web_base: str, api_base: str) -> dict[str, Any]: + from playwright.async_api import async_playwright + + console_errors: list[str] = [] + downloads: list[str] = [] + async with async_playwright() as p: + browser = await p.chromium.launch(**browser_launch_options()) + context = await browser.new_context( + accept_downloads=True, + is_mobile=True, + viewport={"width": 390, "height": 844}, + device_scale_factor=2, + ) + page = await context.new_page() + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + page.on("download", lambda download: downloads.append(download.suggested_filename)) + await page.goto(web_base, wait_until="networkidle") + await fill_demo_chart(page, api_base) + + await page.click("#btn-export") + await page.wait_for_selector("#export-menu:not(.hidden)", timeout=5000) + export_menu_state = await page.evaluate( + """() => { + const menu = document.querySelector('#export-menu'); + const menuBox = menu?.getBoundingClientRect(); + const items = Array.from(document.querySelectorAll('#export-menu .export-item')).map(item => { + const box = item.getBoundingClientRect(); + return { + format: item.dataset.format, + text: item.innerText.trim(), + left: Math.round(box.left), + right: Math.round(box.right), + top: Math.round(box.top), + bottom: Math.round(box.bottom), + width: Math.round(box.width), + height: Math.round(box.height), + }; + }); + return { + menu: menuBox ? { + left: Math.round(menuBox.left), + right: Math.round(menuBox.right), + top: Math.round(menuBox.top), + bottom: Math.round(menuBox.bottom), + width: Math.round(menuBox.width), + height: Math.round(menuBox.height), + } : null, + items, + viewport: { width: window.innerWidth, height: window.innerHeight }, + }; + }""" + ) + await page.click('.export-item[data-format="json"]') + await page.wait_for_function( + """() => (document.querySelector('#export-status')?.innerText || '').includes('JSON 数据已开始下载')""", + timeout=8000, + ) + export_status = (await page.locator("#export-status").inner_text(timeout=5000)).strip() + + await page.click('.tab-btn[data-tab="provenance"]') + await page.wait_for_function( + """() => document.querySelector('#tab-provenance')?.classList.contains('active')""", + timeout=8000, + ) + await page.wait_for_selector(".trust-center-panel", timeout=8000) + await page.locator('[data-action="trust-run-health"]').scroll_into_view_if_needed(timeout=5000) + await page.click('[data-action="trust-run-health"]') + await page.wait_for_function( + """() => { + const text = document.querySelector('#trust-center-status')?.innerText || ''; + return text.includes('健康检查通过') || text.includes('健康检查未通过'); + }""", + timeout=12000, + ) + health_status = (await page.locator("#trust-center-status").inner_text(timeout=5000)).strip() + await page.locator('[data-action="trust-export-local"]').scroll_into_view_if_needed(timeout=5000) + await page.click('[data-action="trust-export-local"]') + await page.wait_for_function( + """() => (document.querySelector('#trust-center-status')?.innerText || '').includes('已导出本地资料 JSON')""", + timeout=8000, + ) + trust_export_status = (await page.locator("#trust-center-status").inner_text(timeout=5000)).strip() + trust_text = await page.locator(".trust-center-panel").inner_text(timeout=5000) + body_text = await page.locator("body").inner_text(timeout=5000) + overflow_candidates = await page.evaluate( + """() => Array.from(document.querySelectorAll('#tab-provenance *, .export-dropdown *')) + .filter(el => !el.closest('.section-tabs, .provenance-table-wrap, .panchanga-week-table-wrap, .panchanga-month-scroll')) + .map(el => { + const box = el.getBoundingClientRect(); + return { + tag: el.tagName, + id: el.id || '', + cls: String(el.className || '').slice(0, 90), + left: Math.round(box.left), + right: Math.round(box.right), + width: Math.round(box.width), + text: (el.innerText || el.textContent || '').trim().slice(0, 80), + }; + }) + .filter(item => item.width > 0 && (item.left < -2 || item.right > document.documentElement.clientWidth + 2)) + .slice(0, 12)""" + ) + await browser.close() + + menu = export_menu_state.get("menu") + items = export_menu_state.get("items") or [] + if not menu or menu["left"] < -1 or menu["right"] > 391: + raise AssertionError(f"mobile export menu out of viewport: {export_menu_state}") + if {item.get("format") for item in items} != {"json", "html", "pdf", "svg", "png"}: + raise AssertionError(f"mobile export menu missing items: {items}") + clipped_items = [item for item in items if item["left"] < -1 or item["right"] > 391 or item["height"] < 34] + if clipped_items: + raise AssertionError(f"mobile export menu clipped items: {clipped_items}") + trust_text_casefold = trust_text.casefold() + for token in ["Trust Center", "Local-first", "运行健康检查", "导出本地资料", "安装为应用"]: + if token.casefold() not in trust_text_casefold: + raise AssertionError(f"mobile Trust Center missing {token}: {trust_text}") + if "undefined" in body_text or "NaN" in body_text: + raise AssertionError("mobile Trust/export smoke found undefined/NaN text") + if overflow_candidates: + raise AssertionError(f"mobile Trust/export smoke found overflow: {overflow_candidates}") + if not any(name.startswith("jyotish-chart-") for name in downloads): + raise AssertionError(f"mobile JSON export download missing: {downloads}") + if not any(name.startswith("jyotish-local-data-") for name in downloads): + raise AssertionError(f"mobile local data export download missing: {downloads}") + return { + "success": True, + "mobile_trust_export_checked": True, + "export_status": export_status, + "health_status": health_status, + "trust_export_status": trust_export_status, + "downloads": downloads, + "console_errors": console_errors, + } + + +async def run_import_workspace_smoke(web_base: str, api_base: str) -> dict[str, Any]: + from playwright.async_api import async_playwright + + console_errors: list[str] = [] + downloads: list[str] = [] + import_text = "\n".join([ + "Date of Birth: 1990-06-15", + "Time of Birth: 12:30", + "Place of Birth: Delhi", + "Latitude: 28.6139 N", + "Longitude: 77.2090 E", + "Timezone: UTC+5:30", + ]) + async with async_playwright() as p: + browser = await p.chromium.launch(**browser_launch_options()) + context = await browser.new_context(accept_downloads=True, viewport={"width": 1280, "height": 820}) + page = await context.new_page() + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + await page.goto(web_base, wait_until="networkidle") + await page.evaluate( + """apiBase => { + localStorage.clear(); + localStorage.setItem('YINDUZHANXING_API_BASE', apiBase); + window.YINDUZHANXING_API_BASE = apiBase; + }""", + api_base, + ) + await page.reload(wait_until="networkidle") + + await page.fill("#chart-import-text", "Date of Birth: 1990-06-15") + await page.click("#chart-import-parse") + await page.wait_for_function( + """() => { + const text = document.querySelector('#chart-import-result')?.innerText || ''; + return text.includes('仍需手动补充') && text.includes('出生时间') && text.includes('出生地经纬度'); + }""", + timeout=8000, + ) + import_recovery = (await page.locator("#chart-import-result").inner_text(timeout=5000)).strip() + + await page.fill("#chart-import-text", import_text) + await page.click("#chart-import-parse") + await page.wait_for_function( + """() => { + const text = document.querySelector('#chart-import-result')?.innerText || ''; + return text.includes('已识别完整出生信息') && text.includes('质量分'); + }""", + timeout=8000, + ) + import_status = (await page.locator("#chart-import-result").inner_text(timeout=5000)).strip() + await page.click("#chart-import-apply") + await page.wait_for_function( + """() => document.querySelector('#birth-year')?.value === '1990' + && document.querySelector('#birth-month')?.value === '6' + && document.querySelector('#birth-day')?.value === '15' + && document.querySelector('#birth-time')?.value === '12:30' + && document.querySelector('#birth-tz')?.value === '5.5'""", + timeout=5000, + ) + await page.click("#btn-calculate") + await page.wait_for_selector("#page-chart.active", timeout=20000) + chart_status = (await page.locator("#chart-compute-status").inner_text(timeout=5000)).strip() + + await page.click("#btn-save-chart") + await page.wait_for_function( + """() => document.querySelector('#btn-save-chart')?.innerText.includes('已保存')""", + timeout=8000, + ) + await page.click("#btn-back") + await page.wait_for_selector("#page-input.active", timeout=8000) + await page.wait_for_function( + """() => { + const text = document.querySelector('#saved-chart-panel')?.innerText || ''; + return text.includes('本地星盘库') && text.includes('1 个星盘'); + }""", + timeout=8000, + ) + saved_panel = (await page.locator("#saved-chart-panel").inner_text(timeout=5000)).strip() + await page.click("#saved-chart-panel [data-open-saved-chart]") + await page.wait_for_selector("#page-chart.active", timeout=8000) + + await page.click('.tab-btn[data-tab="provenance"]') + await page.wait_for_function( + """() => document.querySelector('#tab-provenance')?.classList.contains('active')""", + timeout=8000, + ) + await page.evaluate("() => window.scrollTo(0, document.body.scrollHeight)") + await page.wait_for_selector("#provenance-panel", state="attached", timeout=8000) + await page.click('[data-action="workspace-save-current"]') + await page.wait_for_function( + """() => (document.querySelector('#case-workspace-counts')?.innerText || '').includes('Charts') + || (document.querySelector('#workspace-case-list')?.innerText || '').includes('星盘')""", + timeout=8000, + ) + case_workspace = (await page.locator("#case-workspace-summary").inner_text(timeout=5000)).strip() + await page.click('[data-action="workspace-select-visible"]') + async with page.expect_download(timeout=8000) as selected_download_info: + await page.click('[data-action="workspace-export-selected-cases"]') + selected_download = await selected_download_info.value + downloads.append(selected_download.suggested_filename) + async with page.expect_download(timeout=8000) as library_download_info: + await page.click('[data-action="workspace-export-cases"]') + library_download = await library_download_info.value + downloads.append(library_download.suggested_filename) + await page.wait_for_function("() => true", timeout=250) + body_text = await page.locator("body").inner_text(timeout=5000) + await browser.close() + + if "undefined" in body_text or "NaN" in body_text: + raise AssertionError("import/workspace click_smoke found undefined/NaN text") + if not any(name.startswith("jyotish-selected-cases-") for name in downloads): + raise AssertionError(f"selected case export download missing: {downloads}") + if not any(name.startswith("jyotish-case-library-") for name in downloads): + raise AssertionError(f"case library export download missing: {downloads}") + return { + "success": True, + "import_workspace_checked": True, + "import_recovery": import_recovery, + "import_status": import_status, + "chart_status": chart_status, + "saved_panel": saved_panel, + "case_workspace_checked": bool(case_workspace), + "downloads": downloads, + "console_errors": console_errors, + } + + +async def run_import_file_smoke(web_base: str, api_base: str) -> dict[str, Any]: + from playwright.async_api import async_playwright + + console_errors: list[str] = [] + txt_path = TMP / "jyotish-import-smoke.txt" + pdf_path = TMP / "jyotish-import-smoke.pdf" + txt_path.write_text( + "\n".join([ + "Date of Birth: 1988-11-09", + "Time of Birth: 06:45", + "Place of Birth: Mumbai", + "Latitude: 19.0760 N", + "Longitude: 72.8777 E", + "Timezone: UTC+5:30", + ]), + encoding="utf-8", + ) + pdf_path.write_bytes(b"%PDF-1.4\n% jyotish smoke placeholder\n") + + async with async_playwright() as p: + browser = await p.chromium.launch(**browser_launch_options()) + context = await browser.new_context(accept_downloads=True, viewport={"width": 1280, "height": 820}) + page = await context.new_page() + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + await page.goto(web_base, wait_until="networkidle") + await page.evaluate( + """apiBase => { + localStorage.clear(); + localStorage.setItem('YINDUZHANXING_API_BASE', apiBase); + window.YINDUZHANXING_API_BASE = apiBase; + }""", + api_base, + ) + await page.reload(wait_until="networkidle") + + await page.set_input_files("#chart-import-file", str(txt_path)) + await page.wait_for_function( + """() => (document.querySelector('#chart-import-result')?.innerText || '').includes('已选择:jyotish-import-smoke.txt')""", + timeout=5000, + ) + await page.click("#chart-import-parse") + await page.wait_for_function( + """() => { + const text = document.querySelector('#chart-import-result')?.innerText || ''; + return text.includes('已识别完整出生信息') && text.includes('质量分') && text.includes('1988-11-9'); + }""", + timeout=8000, + ) + text_file_import = (await page.locator("#chart-import-result").inner_text(timeout=5000)).strip() + await page.click("#chart-import-apply") + await page.wait_for_function( + """() => document.querySelector('#birth-year')?.value === '1988' + && document.querySelector('#birth-month')?.value === '11' + && document.querySelector('#birth-day')?.value === '9' + && document.querySelector('#birth-time')?.value === '06:45' + && document.querySelector('#birth-tz')?.value === '5.5'""", + timeout=5000, + ) + + await page.evaluate( + """() => { + window.JyotishAPI.importChart = async () => { + throw new Error('PDF import smoke forced failure'); + }; + }""" + ) + await page.set_input_files("#chart-import-file", str(pdf_path)) + await page.wait_for_function( + """() => (document.querySelector('#chart-import-result')?.innerText || '').includes('已选择:jyotish-import-smoke.pdf')""", + timeout=5000, + ) + await page.click("#chart-import-parse") + await page.wait_for_function( + """() => { + const text = document.querySelector('#chart-import-result')?.innerText || ''; + return text.includes('PDF文本抽取失败') && text.includes('可复制PDF文字后粘贴到文本框'); + }""", + timeout=8000, + ) + pdf_import_recovery = (await page.locator("#chart-import-result").inner_text(timeout=5000)).strip() + mobile_context = await browser.new_context(is_mobile=True, viewport={"width": 390, "height": 844}, device_scale_factor=2) + mobile_page = await mobile_context.new_page() + await mobile_page.goto(web_base, wait_until="networkidle") + mobile_entry_state = await mobile_page.evaluate( + """() => { + const label = document.querySelector('.chart-import-file'); + const input = document.querySelector('#chart-import-file'); + const labelBox = label?.getBoundingClientRect(); + const inputBox = input?.getBoundingClientRect(); + return { + labelText: label?.innerText || '', + labelBox: labelBox ? { left: labelBox.left, right: labelBox.right, width: labelBox.width, height: labelBox.height } : null, + inputBox: inputBox ? { left: inputBox.left, right: inputBox.right, width: inputBox.width, height: inputBox.height } : null, + viewportWidth: window.innerWidth, + }; + }""" + ) + await mobile_context.close() + await browser.close() + + if not mobile_entry_state.get("labelBox") or mobile_entry_state["labelBox"]["right"] > 391: + raise AssertionError(f"mobile file import entry out of viewport: {mobile_entry_state}") + if mobile_entry_state["labelBox"]["height"] < 40 or "上传文件" not in mobile_entry_state.get("labelText", ""): + raise AssertionError(f"mobile file import entry not usable: {mobile_entry_state}") + if console_errors: + raise AssertionError(f"import file smoke console errors: {console_errors}") + return { + "success": True, + "import_file_checked": True, + "text_file_import": text_file_import, + "pdf_import_recovery": pdf_import_recovery, + "mobile_file_import_entry_checked": True, + "console_errors": console_errors, + } + + +async def run_offline_smoke(web_base: str, api_base: str) -> dict[str, Any]: + from playwright.async_api import async_playwright + + console_errors: list[str] = [] + async with async_playwright() as p: + browser = await p.chromium.launch(**browser_launch_options()) + context = await browser.new_context(viewport={"width": 390, "height": 844}) + page = await context.new_page() + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + await page.goto(web_base, wait_until="networkidle") + pwa_surface = await assert_pwa_surface(page) + await page.evaluate( + """apiBase => { + localStorage.setItem('YINDUZHANXING_API_BASE', apiBase); + window.YINDUZHANXING_API_BASE = apiBase; + }""", + api_base, + ) + await page.reload(wait_until="networkidle") + + await page.click("#first-use-health") + await page.wait_for_function( + """() => { + const text = document.querySelector('#first-use-status')?.innerText || ''; + return text.includes('本地 API 未连接') || text.includes('普通用户启动路径') || text.includes('本地 API 服务'); + }""", + timeout=10000, + ) + health_status = (await page.locator("#first-use-status").inner_text(timeout=5000)).strip() + + await page.click("#first-use-demo") + await page.click("#btn-calculate") + await page.wait_for_function( + """() => { + const text = document.querySelector('#chart-compute-status')?.innerText || ''; + return text.includes('本地 API 未连接') || text.includes('Trust Center') || text.includes('星盘已生成'); + }""", + timeout=12000, + ) + chart_status = (await page.locator("#chart-compute-status").inner_text(timeout=5000)).strip() + body_text = await page.locator("body").inner_text(timeout=5000) + if "undefined" in body_text or "NaN" in body_text: + raise AssertionError("offline click_smoke found undefined/NaN text") + recovery_text = "\n".join([health_status, chart_status]) + for token in ["本地 API 未连接", "普通用户启动路径", "本地 API 服务"]: + if token not in recovery_text: + raise AssertionError(f"offline recovery text missing {token}: {recovery_text}") + await browser.close() + + expected_offline_console_errors, unexpected_console_errors = split_expected_offline_console_errors(console_errors) + return { + "success": True, + "offline_checked": True, + "mobile": True, + "health_status": health_status, + "chart_status": chart_status, + "offline_recovery_guidance_visible": True, + "pwa_surface": pwa_surface, + "expected_offline_console_errors": expected_offline_console_errors, + "console_errors": unexpected_console_errors, + } + + +async def run_offline_shell_reload_smoke(web_base: str) -> dict[str, Any]: + from playwright.async_api import async_playwright + + console_errors: list[str] = [] + async with async_playwright() as p: + browser = await p.chromium.launch(**browser_launch_options()) + context = await browser.new_context(viewport={"width": 390, "height": 844}) + page = await context.new_page() + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + await page.goto(web_base, wait_until="networkidle") + await page.wait_for_function( + """() => window.__jyotishPWAStatus?.serviceWorker === 'registered' + || window.__jyotishPWAStatus?.serviceWorker === 'failed' + || window.__jyotishPWAStatus?.serviceWorker === 'unsupported'""", + timeout=10000, + ) + pwa_status = await page.evaluate("() => window.__jyotishPWAStatus || {}") + await page.wait_for_function( + """() => navigator.serviceWorker?.controller || window.__jyotishPWAStatus?.serviceWorker !== 'registered'""", + timeout=10000, + ) + await context.set_offline(True) + await page.reload(wait_until="domcontentloaded") + await page.wait_for_selector("#first-use-panel", timeout=10000) + shell_text = await page.locator("#first-use-panel").inner_text(timeout=5000) + await browser.close() + + for token in ["首次使用", "运行健康检查", "填入示例盘"]: + if token not in shell_text: + raise AssertionError(f"offline shell reload missing {token}: {shell_text}") + if not shell_text: + raise AssertionError(f"offline shell reload missing first-use content: {shell_text}") + offline_shell_expected_console_errors, unexpected_console_errors = split_expected_offline_console_errors(console_errors) + return { + "success": True, + "offline_shell_reload_checked": True, + "serviceWorker": pwa_status.get("serviceWorker"), + "offline_shell_expected_console_errors": offline_shell_expected_console_errors, + "console_errors": unexpected_console_errors, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Jyotish browser click smoke") + parser.add_argument("--mode", choices=["core", "mobile", "offline", "pdf", "workspace", "mobile-trust", "import-files", "all"], default="core") + parser.add_argument("--keep-logs", action="store_true") + parser.add_argument("--timeout", type=int, default=180, help="Fail the full smoke command after this many seconds") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + api_process: subprocess.Popen[str] | None = None + web_process: subprocess.Popen[str] | None = None + api_port, web_port = free_ports(2) + web_base = f"http://127.0.0.1:{web_port}" + api_base = f"http://127.0.0.1:{api_port}" + api_log = TMP / f"jyotish-click-api-{api_port}.log" + web_log = TMP / f"jyotish-click-web-{web_port}.log" + + def execute_smoke() -> dict[str, Any]: + nonlocal api_process, web_process + if args.mode in {"core", "mobile", "pdf", "workspace", "mobile-trust", "import-files", "all"}: + api_process = start_process( + [sys.executable, str(API_SERVER), "--port", str(api_port), "--allow-origin", web_base], + ROOT, + api_log, + ) + wait_for_url(f"{api_base}/api/health", logs=[api_log]) + web_process = start_process( + ["npm", "run", "dev", "--", "--host", "127.0.0.1", "--port", str(web_port)], + APP, + web_log, + ) + wait_for_url(f"{web_base}/", logs=[web_log]) + + import asyncio + + if args.mode == "offline": + offline_port = free_ports(1)[0] + offline = asyncio.run(run_offline_smoke(web_base, f"http://127.0.0.1:{offline_port}")) + shell_reload = asyncio.run(run_offline_shell_reload_smoke(web_base)) + result = {"success": True, "offline": offline, "offline_shell_reload": shell_reload} + elif args.mode == "mobile": + mobile = asyncio.run(run_mobile_smoke(web_base, api_base)) + mobile_tabs = asyncio.run(run_mobile_tab_smoke(web_base, api_base)) + result = {"success": True, "mobile": mobile, "mobile_tabs": mobile_tabs} + elif args.mode == "pdf": + pdf_fallback = asyncio.run(run_pdf_fallback_smoke(web_base, api_base)) + result = {"success": True, "pdf_fallback": pdf_fallback} + elif args.mode == "workspace": + workspace = asyncio.run(run_import_workspace_smoke(web_base, api_base)) + result = {"success": True, "workspace": workspace} + elif args.mode == "mobile-trust": + mobile_trust = asyncio.run(run_mobile_trust_export_smoke(web_base, api_base)) + result = {"success": True, "mobile_trust": mobile_trust} + elif args.mode == "import-files": + import_files = asyncio.run(run_import_file_smoke(web_base, api_base)) + result = {"success": True, "import_files": import_files} + elif args.mode == "all": + core = asyncio.run(run_browser_clicks(web_base, api_base)) + mobile = asyncio.run(run_mobile_smoke(web_base, api_base)) + mobile_tabs = asyncio.run(run_mobile_tab_smoke(web_base, api_base)) + pdf_fallback = asyncio.run(run_pdf_fallback_smoke(web_base, api_base)) + workspace = asyncio.run(run_import_workspace_smoke(web_base, api_base)) + mobile_trust = asyncio.run(run_mobile_trust_export_smoke(web_base, api_base)) + import_files = asyncio.run(run_import_file_smoke(web_base, api_base)) + offline_port = free_ports(1)[0] + offline = asyncio.run(run_offline_smoke(web_base, f"http://127.0.0.1:{offline_port}")) + shell_reload = asyncio.run(run_offline_shell_reload_smoke(web_base)) + result = { + "success": True, + "core": core, + "mobile": mobile, + "mobile_tabs": mobile_tabs, + "pdf_fallback": pdf_fallback, + "workspace": workspace, + "mobile_trust": mobile_trust, + "import_files": import_files, + "offline": offline, + "offline_shell_reload": shell_reload, + } + else: + result = asyncio.run(run_browser_clicks(web_base, api_base)) + result.update({"valid": True, "click_smoke": True, "web_base": web_base, "api_base": api_base}) + return result + + try: + result = run_with_timeout(execute_smoke, args.timeout) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + except Exception as exc: + skipped = "Executable doesn't exist" in str(exc) or "No such file or directory" in str(exc) + payload = { + "valid": False, + "click_smoke": True, + "skipped": skipped, + "reason": str(exc), + "api_log": read_log_tail(api_log), + "web_log": read_log_tail(web_log), + "timeout": args.timeout, + "process_snapshot": { + "api": process_snapshot("api", api_process, api_log), + "web": process_snapshot("web", web_process, web_log), + }, + } + print(json.dumps(payload, ensure_ascii=False, indent=2), file=sys.stderr) + return 0 if skipped else 1 + finally: + force_stop_process(web_process) + force_stop_process(api_process) + if not args.keep_logs: + for path in [api_log, web_log]: + try: + path.unlink() + except FileNotFoundError: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/run_frontend_runtime_smoke.py b/tests/run_frontend_runtime_smoke.py new file mode 100644 index 00000000..3cb7ae7e --- /dev/null +++ b/tests/run_frontend_runtime_smoke.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Runtime smoke check for the Jyotish web app and local API.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "jyotish-app" +API_SERVER = ROOT / "scripts" / "jyotish_api_server.py" +TMP = Path(os.environ.get("TMPDIR", "/private/tmp")) + + +def run(cmd: list[str], *, cwd: Path = ROOT, timeout: int = 12, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, check=check) + + +def curl_text(url: str, *, timeout: int = 12) -> str: + return run(["curl", "-sS", url], timeout=timeout).stdout + + +def curl_status(url: str, *, timeout: int = 4) -> tuple[bool, str]: + completed = run( + ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", url], + timeout=timeout, + check=False, + ) + code = completed.stdout.strip() + return completed.returncode == 0 and code.startswith(("2", "3", "4")), completed.stderr.strip() or code + + +def curl_json(url: str, *, timeout: int = 12) -> dict[str, Any]: + return json.loads(curl_text(url, timeout=timeout)) + + +def curl_post_json(url: str, payload: dict[str, Any], *, timeout: int = 15) -> dict[str, Any]: + completed = run( + [ + "curl", + "-sS", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "--data-binary", + json.dumps(payload), + url, + ], + timeout=timeout, + ) + return json.loads(completed.stdout) + + +def wait_for_url(url: str, *, timeout: float = 12.0, logs: list[Path] | None = None) -> None: + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + ok, last = curl_status(url) + if ok: + return + time.sleep(0.25) + log_text = "" + for log in logs or []: + if log.exists(): + log_text += f"\n--- {log} ---\n{log.read_text(encoding='utf-8', errors='replace')[-4000:]}" + raise RuntimeError(f"Timed out waiting for {url}: {last}{log_text}") + + +def start_process(cmd: list[str], cwd: Path, log_path: Path) -> subprocess.Popen[str]: + log_path.parent.mkdir(parents=True, exist_ok=True) + log = log_path.open("w", encoding="utf-8") + return subprocess.Popen(cmd, cwd=cwd, stdout=log, stderr=subprocess.STDOUT, text=True) + + +def stop_process(process: subprocess.Popen[str] | None) -> None: + if process is None: + return + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def sample_birth_payload() -> dict[str, Any]: + return { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "lat": 39.9, + "lon": 116.4, + "tz": 8, + } + + +def assert_report_artifact_smoke(api_base: str) -> dict[str, Any]: + result = curl_post_json( + f"{api_base}/api/report_artifact", + { + "format": "html", + "name": "runtime smoke report", + "html": "

    Runtime Smoke

    ", + }, + timeout=20, + ) + if not result.get("success") or result.get("endpoint") != "report_artifact": + raise AssertionError(f"report_artifact_smoke failed: {result}") + if not result.get("html_base64") or not result.get("html_filename", "").endswith(".html"): + raise AssertionError(f"report_artifact_smoke missing fallback artifact: {result}") + delivery = result.get("delivery") or {} + expected_fields = { + "artifact_status": "html_ready", + "primary_artifact": "html", + "download_filename": result.get("html_filename"), + "download_mime": "text/html;charset=utf-8", + } + for field, expected in expected_fields.items(): + if result.get(field) != expected: + raise AssertionError(f"report_artifact_smoke bad {field}: {result}") + if delivery.get("artifact_status") != result.get("artifact_status"): + raise AssertionError(f"report_artifact_smoke delivery status mismatch: {result}") + if delivery.get("filename") != result.get("download_filename"): + raise AssertionError(f"report_artifact_smoke delivery filename mismatch: {result}") + if not result.get("user_message") or not result.get("next_action"): + raise AssertionError(f"report_artifact_smoke missing user guidance: {result}") + return { + "success": True, + "html_filename": result.get("html_filename"), + "format": result.get("format"), + "artifact_status": result.get("artifact_status"), + "download_filename": result.get("download_filename"), + } + + +def assert_ai_bridge_policy_smoke(web_base: str) -> dict[str, Any]: + bridge = curl_text(f"{web_base}/api-bridge.js") + ai_chat = curl_text(f"{web_base}/ai-chat.js") + i18n = curl_text(f"{web_base}/i18n.js") + required = [ + "AI_BROWSER_KEY_DISABLED", + "不要把 OpenAI API key 放进浏览器", + "aiKeyPolicy: 'server_side_only'", + ] + missing = [token for token in required if token not in bridge] + unsafe = [ + "YINDUZHANXING_AI_KEY", + "apiKey: AI_KEY", + "Authorization': 'Bearer ' + AI_KEY", + ] + chat_required = [ + "buildAISetupGuidance", + "OPENAI_API_KEY", + "/api/chat", + "不要把 OpenAI API key 放进浏览器", + ] + chat_text = ai_chat + "\n" + i18n + chat_missing = [token for token in chat_required if token not in chat_text] + chat_unsafe = [ + "jyotish_ai_endpoint", + "Custom endpoint failed", + "在浏览器控制台输入", + ] + leaked = [token for token in unsafe if token in bridge] + [token for token in chat_unsafe if token in chat_text] + if missing or leaked: + raise AssertionError(f"ai_bridge_policy_smoke failed: missing={missing}, chat_missing={chat_missing}, leaked={leaked}") + if chat_missing: + raise AssertionError(f"ai_chat_policy_smoke failed: chat_missing={chat_missing}") + return { + "success": True, + "policy": "AI_BROWSER_KEY_DISABLED", + "chat_policy": "server_side_only", + } + + +def assert_contract(web_base: str, api_base: str) -> dict[str, Any]: + html = curl_text(f"{web_base}/") + for token in ['id="birth-form"', 'id="tab-kp"', "/main.js", "/api-bridge.js"]: + if token not in html: + raise AssertionError(f"frontend missing token: {token}") + + health = curl_json(f"{api_base}/api/health") + if health.get("status") != "ok": + raise AssertionError(f"bad health: {health}") + for module in ["Remedies", "Ashtakavarga", "Kakshya", "CaseValidation"]: + if module not in health.get("modules", ""): + raise AssertionError(f"health missing module: {module}") + + audit = curl_json(f"{api_base}/api/capability_audit", timeout=20) + registry_count = audit["registry"]["technique_count"] + if audit["productization"]["summary"]["productized"] != registry_count: + raise AssertionError("not all registry techniques are productized") + if audit["ux_productization"]["summary"]["excellent"] != registry_count: + raise AssertionError("not all registry techniques are excellent UX") + if audit["priority_gaps"]: + raise AssertionError(f"priority gaps remain: {audit['priority_gaps']}") + + chart = curl_post_json(f"{api_base}/api/chart", sample_birth_payload(), timeout=20) + if not chart.get("success"): + raise AssertionError(f"chart failed: {chart}") + if not chart.get("remedies", {}).get("evidence_chain"): + raise AssertionError("chart remedies evidence_chain is empty") + + payload = {"planets": chart["planets"], "ascendant": chart["ascendant"]} + ashtakavarga = curl_post_json(f"{api_base}/api/ashtakavarga", payload, timeout=20) + for path in [("summary", "strongest_houses"), ("pav_summary", "top_planets"), ("sodhita_summary", "top_signs")]: + cur: Any = ashtakavarga + for part in path: + cur = cur.get(part) if isinstance(cur, dict) else None + if not cur: + raise AssertionError(f"ashtakavarga missing {'.'.join(path)}") + + kp = curl_post_json(f"{api_base}/api/kp", payload, timeout=20) + if not kp or "error" in kp: + raise AssertionError(f"kp failed: {kp}") + + report_artifact_smoke = assert_report_artifact_smoke(api_base) + ai_bridge_policy_smoke = assert_ai_bridge_policy_smoke(web_base) + + return { + "valid": True, + "web_base": web_base, + "api_base": api_base, + "registry_count": registry_count, + "ux": audit["ux_productization"]["summary"], + "productization": audit["productization"]["summary"], + "chart_remedy_evidence": len(chart["remedies"]["evidence_chain"]), + "ashtakavarga_sav_total": ashtakavarga["summary"]["sav_total"], + "report_artifact_smoke": report_artifact_smoke, + "ai_bridge_policy_smoke": ai_bridge_policy_smoke, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Jyotish frontend/API runtime smoke") + parser.add_argument("--web-base", default="http://127.0.0.1:5173") + parser.add_argument("--api-base", default="http://127.0.0.1:5200") + parser.add_argument("--start-if-needed", action="store_true", help="Start local Vite/API servers if defaults are not already running") + parser.add_argument("--api-port", type=int, default=5200) + parser.add_argument("--web-port", type=int, default=5173) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + api_process: subprocess.Popen[str] | None = None + web_process: subprocess.Popen[str] | None = None + web_base = args.web_base.rstrip("/") + api_base = args.api_base.rstrip("/") + + try: + api_ok, _ = curl_status(f"{api_base}/api/health") + web_ok, _ = curl_status(f"{web_base}/") + if args.start_if_needed and (not api_ok or not web_ok): + web_base = f"http://127.0.0.1:{args.web_port}" + api_base = f"http://127.0.0.1:{args.api_port}" + api_log = TMP / f"jyotish-runtime-api-{args.api_port}.log" + web_log = TMP / f"jyotish-runtime-web-{args.web_port}.log" + if not api_ok: + api_process = start_process( + [sys.executable, str(API_SERVER), "--port", str(args.api_port), "--allow-origin", web_base], + ROOT, + api_log, + ) + try: + wait_for_url(f"{api_base}/api/health", logs=[api_log]) + except RuntimeError as exc: + if "PermissionError" in str(exc) and "socket.bind" in str(exc): + print(json.dumps({"valid": False, "skipped": True, "reason": "sandbox disallows local listening servers"}, ensure_ascii=False, indent=2)) + return 0 + raise + if not web_ok: + web_process = start_process( + ["npm", "run", "dev", "--", "--host", "127.0.0.1", "--port", str(args.web_port)], + APP, + web_log, + ) + wait_for_url(f"{web_base}/", logs=[web_log]) + + result = assert_contract(web_base, api_base) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + finally: + stop_process(web_process) + stop_process(api_process) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/run_golden_cases.py b/tests/run_golden_cases.py index 7be1f074..3054f958 100644 --- a/tests/run_golden_cases.py +++ b/tests/run_golden_cases.py @@ -9,6 +9,7 @@ import json import os import subprocess import sys +from datetime import date from typing import Any, Dict, List ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -25,6 +26,92 @@ def get_path(data: Dict[str, Any], dotted_path: str) -> Any: return cur +def is_non_empty(value: Any) -> bool: + return value is not None and value != {} and value != [] + + +def parse_date(value: str) -> date: + return date.fromisoformat(str(value)[:10]) + + +def current_period_covers_reference(period: Dict[str, Any], reference_date: str) -> bool: + if not isinstance(period, dict): + return False + start = period.get("start") or period.get("start_date") + end = period.get("end") or period.get("end_date") + if not start or not end: + return False + ref = parse_date(reference_date) + return parse_date(start) <= ref <= parse_date(end) + + +def check_quality_gates(data: Dict[str, Any], case: Dict[str, Any]) -> List[str]: + gates = case.get("quality_gates", {}) + failures: List[str] = [] + modules = data.get("modules", {}) + if not isinstance(modules, dict): + return ["modules must be an object"] + + min_modules = gates.get("min_modules") + if min_modules is not None and len(modules) < min_modules: + failures.append(f"module count too low: {len(modules)} < {min_modules}") + + for path in gates.get("non_empty_paths", []): + try: + value = get_path(data, path) + if not is_non_empty(value): + failures.append(f"empty quality path: {path}") + except KeyError: + failures.append(f"missing quality path: {path}") + + invariants = gates.get("invariants", {}) + if invariants.get("validation_valid") and modules.get("validation", {}).get("valid") is not True: + failures.append("validation.valid is not true") + + expected_sav = invariants.get("ashtakavarga_sav_total") + if expected_sav is not None: + sav_total = modules.get("ashtakavarga", {}).get("sav", {}).get("total") + if sav_total != expected_sav: + failures.append(f"ashtakavarga SAV total mismatch: {sav_total} != {expected_sav}") + + min_remedy_evidence = invariants.get("min_remedies_evidence") + if min_remedy_evidence is not None: + evidence = modules.get("remedies", {}).get("evidence_chain", []) + if len(evidence) < min_remedy_evidence: + failures.append(f"remedies evidence too thin: {len(evidence)} < {min_remedy_evidence}") + + if invariants.get("dasha_current_covers_reference"): + reference_date = case.get("input", {}).get("today") + current = modules.get("dasha", {}).get("current_dasha", {}) + if not reference_date or not current_period_covers_reference(current, reference_date): + failures.append("current dasha does not cover reference date") + + if invariants.get("dasa_convergence_non_empty"): + domains = modules.get("dasa_convergence", {}).get("top_convergent_domains", []) + if not domains: + failures.append("dasa convergence has no top domains") + + if invariants.get("transit_has_references"): + refs = modules.get("transit_multi_reference", {}).get("references", {}) + if len(refs) < 3: + failures.append(f"transit references too thin: {len(refs)} < 3") + + boundaries = gates.get("boundaries", {}) + forbidden_terms = boundaries.get("forbidden_absolute_terms", []) + if forbidden_terms: + text = json.dumps({ + "summary": data.get("summary"), + "warnings": data.get("warnings"), + "remedies": modules.get("remedies"), + "dasa_convergence": modules.get("dasa_convergence"), + }, ensure_ascii=False) + for term in forbidden_terms: + if term and term in text: + failures.append(f"forbidden absolute term found: {term}") + + return failures + + def run_full_reading(py: str, case: Dict[str, Any]) -> Dict[str, Any]: inp = case["input"] cmd = [ @@ -67,6 +154,10 @@ def run_cases(py: str, cases_path: str) -> Dict[str, Any]: except KeyError: case_result["passed"] = False case_result["failures"].append(f"missing output path: {path}") + quality_failures = check_quality_gates(data, case) + if quality_failures: + case_result["passed"] = False + case_result["failures"].extend(quality_failures) except Exception as exc: case_result["passed"] = False case_result["failures"].append(str(exc)) diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py new file mode 100644 index 00000000..c4a9a01d --- /dev/null +++ b/tests/test_api_server_security.py @@ -0,0 +1,1012 @@ +#!/usr/bin/env python3 +"""Security boundary tests for the lightweight Jyotish API server.""" + +from __future__ import annotations + +import base64 +import os +import sys + +import pytest + +SCRIPTS = os.path.join(os.path.dirname(__file__), '..', 'scripts') +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +import jyotish_api_server # noqa: E402 +from jyotish_api_server import ( # noqa: E402 + BadRequest, + DEFAULT_ALLOWED_ORIGINS, + JyotishAPIHandler, + _load_local_module, + _parse_allowed_origins, +) + + +def _handler() -> JyotishAPIHandler: + return JyotishAPIHandler.__new__(JyotishAPIHandler) + + +def test_default_cors_origins_are_local_only() -> None: + assert 'http://localhost:3456' in DEFAULT_ALLOWED_ORIGINS + assert '*' not in DEFAULT_ALLOWED_ORIGINS + assert 'https://example.com' not in DEFAULT_ALLOWED_ORIGINS + + +def test_env_cors_parser_ignores_empty_entries() -> None: + assert _parse_allowed_origins('https://app.example.com, ,http://localhost:3456') == { + 'https://app.example.com', + 'http://localhost:3456', + } + + +@pytest.mark.parametrize( + ('key', 'value', 'minimum', 'maximum'), + [ + ('lat', 91, -90, 90), + ('lon', 181, -180, 180), + ('tz', 15, -14, 14), + ('minute', 60, 0, 59), + ], +) +def test_numeric_bounds_reject_invalid_values(key, value, minimum, maximum) -> None: + handler = _handler() + with pytest.raises(BadRequest): + handler._get_float({key: value}, key, 0, minimum, maximum) + + +def test_chart_date_validation_rejects_impossible_date() -> None: + handler = _handler() + with pytest.raises(BadRequest, match='Invalid birth date'): + handler._compute_chart({'year': 2026, 'month': 2, 'day': 31}) + + +def test_synastry_rejects_non_numeric_moon_degree() -> None: + handler = _handler() + with pytest.raises(BadRequest, match='male_moon must be a number'): + handler._compute_synastry({'male_moon': 'not-a-number', 'female_moon': 120}) + + +def test_synastry_normalizes_360_degree_boundary() -> None: + handler = _handler() + result = handler._compute_synastry({'male_moon': 360, 'female_moon': 0}) + assert result['male']['nakshatra'] == result['female']['nakshatra'] + assert result['max_score'] == 36.0 + + +def test_prashna_rejects_non_string_question() -> None: + handler = _handler() + with pytest.raises(BadRequest, match='question must be a string'): + handler._compute_prashna({'question': {'bad': 'shape'}, 'planets': {}}) + + +def test_prashna_returns_chart_and_answer_for_valid_request() -> None: + handler = _handler() + result = handler._compute_prashna({ + 'question': 'career', + 'question_text': '这个工作机会是否值得争取?', + 'horary_number': 140, + 'planets': {'Saturn': {'sign': 'Pisces'}, 'Moon': {'sign': 'Scorpio'}}, + }) + assert 'prashna_chart' in result + assert 'kp_answer' in result + assert result['kp_answer']['question_type'] == 'career' + assert result['kp_answer_v2']['primary_house'] == 10 + assert 'arudha' in result + assert 'sphutas' in result + assert 'sahams' in result + assert 'lost_item' in result + assert 'kunda' in result + kp_horary = result['kp_horary'] + assert kp_horary['method'] == 'KP Horary' + assert kp_horary['horary_number'] == 140 + assert kp_horary['question_houses']['primary'] == 10 + assert kp_horary['ruling_planets']['ascendant_lord'] + assert kp_horary['ruling_planets']['moon_star_lord'] + assert kp_horary['cuspal_sub_lord']['house'] == 10 + assert kp_horary['cuspal_sub_lord']['kp_lords']['sub_lord'] + assert kp_horary['house_significators']['10'] + assert kp_horary['judgement_matrix'] + + +def test_prashna_advanced_legacy_functions_exist() -> None: + import prashna + + planet_lons = { + 'Sun': 10, + 'Moon': 70, + 'Mars': 120, + 'Mercury': 25, + 'Jupiter': 150, + 'Venus': 45, + 'Saturn': 210, + 'Rahu': 300, + 'Ketu': 120, + } + assert prashna.cast_prashna('2026-06-22 12:00', 28.6, 77.2)['ascendant'] + assert prashna.calc_arudha(15.5, planet_lons)['arudha_house'] + assert prashna.calc_sphutas(planet_lons, 15.5)['trisphuta'] + assert prashna.calc_life_sphutas(15.5, 70, 10)['signal'] + assert prashna.calc_sahams(planet_lons, 15.5)['count'] >= 5 + assert prashna.analyze_lost_item(planet_lons, 15.5)['summary'] + assert prashna.kunda_verify(15.5)['nakshatra'] + + +def test_dasha_system_rejects_unknown_key() -> None: + handler = _handler() + with pytest.raises(BadRequest, match='Unknown dasha system'): + handler._compute_dasha_system({'dasha': 'not-a-dasha'}) + + +def test_dasha_system_returns_periods_for_valid_request() -> None: + handler = _handler() + result = handler._compute_dasha_system({ + 'dasha': 'yogini', + 'year': 1990, + 'month': 6, + 'day': 15, + 'hour': 12, + 'minute': 0, + 'planets': {'Moon': {'lon': 120}, 'Sun': {'lon': 80}}, + 'ascendant': {'sign_idx': 0}, + }) + assert result['success'] is True + assert result['key'] == 'yogini' + assert result['precision'] == 'calculator' + assert result['periods'] + assert result['periods'][0]['lord'] in { + 'Mangala', + 'Pingala', + 'Dhanya', + 'Bhramari', + 'Bhadrika', + 'Ulka', + 'Siddha', + 'Sankata', + } + assert {'lord', 'start', 'end', 'years'} <= set(result['periods'][0]) + assert 'vimshottari_analysis' not in result + + +def test_vimshottari_dasha_reuses_analyzer_fragment() -> None: + handler = _handler() + result = handler._compute_dasha_system({ + 'dasha': 'vimshottari', + 'year': 1990, + 'month': 6, + 'day': 15, + 'hour': 12, + 'minute': 0, + 'today': '2026-06-23', + 'planets': {'Moon': {'lon': 123}, 'Sun': {'lon': 80}}, + 'ascendant': {'sign_idx': 0}, + }) + + analysis = result['vimshottari_analysis'] + assert result['success'] is True + assert 'dasha_analyzer.py' in result['fragment_sources'] + assert analysis['source'] == 'dasha_analyzer.py + dasha_calculator_enhanced.py' + assert analysis['nakshatra']['name'] + assert analysis['current']['mahadasha']['lord'] + assert analysis['current']['antardasha']['lord'] + assert analysis['five_levels']['mahadasha']['lord'] == analysis['current']['mahadasha']['lord'] + assert analysis['summary']['headline'].startswith('当前处于') + + +def test_relationship_returns_spouse_status_fragment() -> None: + handler = _handler() + result = handler._compute_relationship({ + 'asc_sign': 'Cancer', + 'planets': { + 'Sun': {'sign': 'Leo', 'degree': 20, 'house': 2, 'dignity': 'own'}, + 'Moon': {'sign': 'Cancer', 'degree': 12, 'house': 1, 'dignity': 'own'}, + 'Mars': {'sign': 'Sagittarius', 'degree': 26, 'house': 6, 'dignity': 'friendly'}, + 'Mercury': {'sign': 'Virgo', 'degree': 8, 'house': 3, 'dignity': 'own'}, + 'Jupiter': {'sign': 'Pisces', 'degree': 14, 'house': 9, 'dignity': 'own'}, + 'Venus': {'sign': 'Taurus', 'degree': 2, 'house': 11, 'dignity': 'own'}, + 'Saturn': {'sign': 'Capricorn', 'degree': 28, 'house': 7, 'dignity': 'own'}, + 'Rahu': {'sign': 'Gemini', 'degree': 18, 'house': 12}, + 'Ketu': {'sign': 'Sagittarius', 'degree': 18, 'house': 6}, + }, + 'dasha_info': {'maha_dasha': 'Venus', 'antar_dasha': 'Jupiter'}, + }) + assert 'spouse_status_yoga' in result + assert result['spouse_status_yoga']['principles'] + assert 'spouse_status_yoga.py' in result['fragment_sources'] + assert result['spouse_status_yoga']['overall_score'] >= 0 + assert 'relationship_timing' in result + assert 'darakaraka_reader.py' in result['fragment_sources'] + assert 'jaimini.py' in result['fragment_sources'] + assert result['relationship_timing']['darakaraka']['dk_planet'] + assert result['relationship_timing']['upapada']['sign'] + assert result['relationship_timing']['dasha_focus']['hits'] + assert result['relationship_timing']['evidence'] + + +def test_chart_returns_remedies_with_shadbala_summary() -> None: + handler = _handler() + result = handler._compute_chart({ + 'year': 1990, + 'month': 6, + 'day': 15, + 'hour': 12, + 'minute': 0, + 'lat': 39.9, + 'lon': 116.4, + 'tz': 8, + }) + assert 'remedies' in result + assert 'summary' in result['remedies'] + assert 'recommendations' in result['remedies'] + + +def test_chart_returns_sunrise_correct_special_lagnas() -> None: + handler = _handler() + result = handler._compute_chart({ + 'year': 1990, + 'month': 6, + 'day': 15, + 'hour': 10, + 'minute': 30, + 'lat': 28.6, + 'lon': 77.2, + 'tz': 5.5, + }) + special = result['special_lagnas'] + assert special['capability_status'] == 'covered' + assert special['precision'] == 'sunrise_correct' + assert 'sunrise_local_time' in special + assert 'ghatis_elapsed_from_sunrise' in special + assert all(key in special for key in ('HL', 'GL', 'VL')) + + +def test_remedies_accepts_api_rupas_shape() -> None: + remedies = _load_local_module('remedies') + + result = remedies.recommend_remedies({'Sun': {'rupas': 0.4, 'level': '弱'}}) + assert result['weak_planets'] == ['Sun'] + assert result['recommendations']['mantras'] + + +def test_import_chart_accepts_plain_text() -> None: + handler = _handler() + result = handler._import_chart_text({ + 'text': 'Date of Birth: 1990-06-15\nTime of Birth: 12:30\nPlace of Birth: Delhi\nTimezone: UTC+5:30', + }) + assert result['success'] is True + assert result['extractor'] == 'text' + assert '1990-06-15' in result['text'] + + +def test_import_chart_accepts_base64_text_file() -> None: + handler = _handler() + content = base64.b64encode(b'DOB: 1990-06-15\nTOB: 12:30\nCity: Mumbai').decode() + result = handler._import_chart_text({'filename': 'chart.txt', 'content_base64': content}) + assert result['success'] is True + assert result['extractor'] == 'text' + assert 'Mumbai' in result['text'] + + +def test_import_chart_requires_input() -> None: + handler = _handler() + with pytest.raises(BadRequest, match='text or content_base64 is required'): + handler._import_chart_text({}) + + +def test_import_chart_rejects_bad_base64() -> None: + handler = _handler() + with pytest.raises(BadRequest, match='Invalid base64 content'): + handler._import_chart_text({'filename': 'chart.txt', 'content_base64': 'not-base64!'}) + + +def test_import_chart_rejects_oversized_file() -> None: + handler = _handler() + content = base64.b64encode(b'x' * (1536 * 1024 + 1)).decode() + with pytest.raises(BadRequest, match='Import file too large'): + handler._import_chart_text({'filename': 'chart.txt', 'content_base64': content}) + + +def test_report_artifact_generates_html_fallback_artifact() -> None: + handler = _handler() + result = handler._compute_report_artifact({ + 'format': 'html', + 'name': 'client report', + 'html': '

    Jyotish

    ', + }) + + assert result['success'] is True + assert result['endpoint'] == 'report_artifact' + assert result['format'] == 'html' + assert result['html_filename'].endswith('.html') + assert result['html_base64'] + assert os.path.exists(result['html_path']) + assert result['artifact_status'] == 'html_ready' + assert result['primary_artifact'] == 'html' + assert result['download_filename'] == result['html_filename'] + assert result['download_mime'] == 'text/html;charset=utf-8' + assert result['fallback_reason'] is None + assert result['user_message'] + assert result['next_action'] + assert result['delivery']['format'] == 'html' + assert result['delivery']['filename'] == result['html_filename'] + assert result['delivery']['artifact_status'] == 'html_ready' + assert result['delivery']['user_message'] + assert result['delivery']['next_action'] + + +def test_report_artifact_pdf_fallback_exposes_user_visible_delivery(monkeypatch) -> None: + class BrokenReportBuilder: + @staticmethod + def _html_to_pdf(_html_path, _pdf_path): + return False + + def fake_load_local_module(name): + if name == 'report_builder': + return BrokenReportBuilder + return _load_local_module(name) + + monkeypatch.setattr(jyotish_api_server, '_load_local_module', fake_load_local_module) + handler = _handler() + result = handler._compute_report_artifact({ + 'format': 'pdf', + 'name': '../client pdf report', + 'html': '

    Jyotish PDF

    ', + }) + + assert result['success'] is True + assert result['format'] == 'pdf' + assert result['fallback'] == 'html' + assert result['artifact_status'] == 'pdf_fallback_html_ready' + assert result['primary_artifact'] == 'html' + assert result['download_filename'] == result['html_filename'] + assert result['download_mime'] == 'text/html;charset=utf-8' + assert result['download_filename'].endswith('.html') + assert '/' not in result['download_filename'] + assert result['fallback_reason'] + assert 'PDF renderer unavailable' in result['message'] + assert 'HTML' in result['user_message'] + assert 'PDF' in result['user_message'] + assert result['delivery']['fallback'] is True + assert result['delivery']['artifact_status'] == 'pdf_fallback_html_ready' + assert result['delivery']['filename'] == result['download_filename'] + assert result['delivery']['fallback_reason'] == result['fallback_reason'] + assert result['delivery']['user_message'] == result['user_message'] + + +@pytest.mark.parametrize( + 'html', + [ + '', + '', + 'x', + ], +) +def test_report_artifact_rejects_active_html(html: str) -> None: + handler = _handler() + with pytest.raises(BadRequest, match='active content'): + handler._compute_report_artifact({'format': 'html', 'html': html}) + + +def test_capability_audit_scans_registry_and_local_sources() -> None: + handler = _handler() + audit = handler._capability_audit() + + assert audit['success'] is True + assert audit['registry']['technique_count'] >= 60 + assert audit['surfaces']['engine_command_count'] >= 30 + assert '/api/chart' in audit['surfaces']['api_endpoints'] + assert 'varga-full' not in audit['surfaces']['engine_not_api'] + assert 'jaimini' not in audit['surfaces']['engine_not_api'] + assert 'ashtakavarga' not in audit['surfaces']['engine_not_api'] + assert 'shadbala' not in audit['surfaces']['engine_not_api'] + assert 'yoga' not in audit['surfaces']['engine_not_api'] + assert 'aspects' not in audit['surfaces']['engine_not_api'] + assert 'muhurta' not in audit['surfaces']['engine_not_api'] + assert audit['local_open_source']['source_count'] >= 3 + assert any(source['name'] == 'dashaflow' for source in audit['local_open_source']['sources']) + assert all(gap.get('command') != 'varga-full' for gap in audit['priority_gaps']) + assert 'Muhurta' in audit['surfaces']['app_visible_topics'] + assert 'Tajika' in audit['surfaces']['app_visible_topics'] + assert 'Bhava Chalit' in audit['surfaces']['app_visible_topics'] + assert 'Sudarshana' in audit['surfaces']['app_visible_topics'] + assert 'Jaimini' in audit['surfaces']['app_visible_topics'] + assert 'Ashtakavarga' in audit['surfaces']['app_visible_topics'] + assert 'Shadbala' in audit['surfaces']['app_visible_topics'] + assert 'Yoga' in audit['surfaces']['app_visible_topics'] + assert 'Aspects' in audit['surfaces']['app_visible_topics'] + assert 'Birth Rectification' in audit['surfaces']['app_visible_topics'] + assert 'Case Validation' in audit['surfaces']['app_visible_topics'] + assert 'Divisional Yoga' in audit['surfaces']['app_visible_topics'] + assert 'Kakshya' in audit['surfaces']['app_visible_topics'] + assert '/api/deep_varga_avastha' in audit['surfaces']['api_endpoints'] + assert all(gap['kind'] != 'app_visibility' for gap in audit['priority_gaps']) + productization = audit['productization'] + summary = productization['summary'] + assert sum(summary.values()) == audit['registry']['technique_count'] + assert summary['productized'] > 0 + assert productization['rows'] + assert all('next_action' in row for row in productization['rows']) + ux = audit['ux_productization'] + assert set(ux['criteria']) == { + 'clear_entry', + 'human_readable_conclusion', + 'evidence_chain', + 'next_action', + 'json_hidden', + 'mobile_scannable', + } + assert sum(ux['summary'].values()) == audit['registry']['technique_count'] + assert ux['rows'] + assert ux['summary']['excellent'] == audit['registry']['technique_count'] + assert ux['summary']['usable'] == 0 + assert ux['summary']['thin'] == 0 + assert ux['summary']['not_user_ready'] == 0 + assert all(0 <= row['ux_score'] <= 6 for row in ux['rows']) + assert all('ux_next_action' in row for row in ux['next_queue']) + ux_by_id = {row['id']: row for row in ux['rows']} + assert ux_by_id['birth_time_rectifier']['ux_level'] in {'excellent', 'usable'} + assert ux_by_id['case_validator']['ux_level'] in {'excellent', 'usable'} + assert ux_by_id['divisional_yoga']['ux_level'] in {'excellent', 'usable'} + assert ux_by_id['deep_varga_avastha']['ux_level'] in {'excellent', 'usable'} + assert ux_by_id['kakshya']['ux_level'] in {'excellent', 'usable'} + for technique_id in ['ashtakavarga_pav', 'ashtakavarga_sodhita', 'remedies']: + assert ux_by_id[technique_id]['ux_level'] == 'excellent' + for technique_id in ['bhava_bala', 'career_engine', 'kp_system', 'prashna', 'synastry_16factor', 'transit_trigger']: + assert ux_by_id[technique_id]['ux_level'] in {'excellent', 'usable'} + + +def test_technique_catalog_exposes_runnable_api_examples() -> None: + handler = _handler() + catalog = handler._technique_catalog() + + assert catalog['success'] is True + assert catalog['summary']['technique_count'] >= 60 + assert catalog['summary']['runnable_count'] >= 20 + assert '/api/ashtakavarga' in catalog['filters']['api_endpoints'] + assert '/api/deep_varga_avastha' in catalog['filters']['api_endpoints'] + assert catalog['example_payloads']['/api/ashtakavarga']['planets'] + assert catalog['example_payloads']['/api/deep_varga_avastha']['planets'] + assert any(row['id'] == 'ashtakavarga_pav' and row['runnable'] for row in catalog['techniques']) + assert any(row['id'] == 'deep_varga_avastha' and row['runnable'] for row in catalog['techniques']) + + result = handler._compute_technique_example({'endpoint': '/api/ashtakavarga'}) + assert result['success'] is True + assert result['endpoint'] == 'technique_example' + assert result['target_endpoint'] == '/api/ashtakavarga' + assert result['result']['summary']['strongest_houses'] + + deep = handler._compute_technique_example({'endpoint': '/api/deep_varga_avastha'}) + assert deep['success'] is True + assert deep['target_endpoint'] == '/api/deep_varga_avastha' + assert deep['result']['report']['deep_varga_templates']['D60']['template_cards'] + + with pytest.raises(BadRequest): + handler._compute_technique_example({'endpoint': '/api/report_artifact'}) + + +def test_thematic_report_declares_orchestrator_fragments() -> None: + handler = _handler() + result = handler._compute_thematic_report({'theme': 'marriage'}) + + assert result['success'] is True + assert result['endpoint'] == 'thematic_report' + assert 'reading_orchestrator.py' in result['fragment_sources'] + assert 'orchestrator_bridge.py' in result['fragment_sources'] + assert result['workflow_orchestration']['bridge']['class'] == 'OrchestratorBridge' + assert result['workflow_orchestration']['reading_theme_count'] >= 7 + assert 'marriage' in result['workflow_orchestration']['selected_report_themes'] + + +def test_thematic_report_derives_evidence_from_birth_payload() -> 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 + assert result['mode'] == 'derived_chart_evidence' + assert result['evidence_source']['sample_fallback'] is False + assert result['evidence_source']['source'] == 'full_reading_modules' + assert result['evidence_source']['full_reading_used'] is True + assert result['evidence_source']['full_reading_module_count'] >= 40 + assert result['evidence_source']['module_status']['full_reading'] == 'ok' + assert result['evidence_source']['evidence_counts']['marriage'] >= 3 + assert result['evidence_source']['evidence_counts']['career'] >= 2 + marriage_sources = { + item['details']['source'] + for item in result['themes']['marriage']['evidence'] + } + assert 'chart' in marriage_sources + assert 'full_reading.modules.marriage_counting' in marriage_sources + assert any(item['details'].get('derived') for item in result['themes']['career']['evidence']) + + +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 + + result = audit() + assert result['valid'] is True, result['problems'] + assert result['registry']['technique_count'] >= 60 + assert result['workspace_residue']['untracked_count'] >= 0 + assert isinstance(result['workspace_residue']['untracked_files'], list) + assert result['workspace_residue']['git_lost_found_count'] >= 1 + assert result['open_source_sources']['source_count'] >= 3 + assert all(row['commands'] for row in result['rows'] if row['status'] in {'covered', 'complete'}) + assert not any(problem['kind'] == 'missing_output_path' for problem in result['problems']) + + +def _sample_planets() -> dict: + return { + 'Sun': {'lon': 80.0}, + 'Moon': {'lon': 123.0}, + 'Mars': {'lon': 210.0}, + 'Mercury': {'lon': 75.0}, + 'Jupiter': {'lon': 15.0}, + 'Venus': {'lon': 102.0}, + 'Saturn': {'lon': 330.0}, + 'Rahu': {'lon': 5.0}, + 'Ketu': {'lon': 185.0}, + } + + +def test_muhurta_endpoint_returns_activity_checks() -> None: + handler = _handler() + result = handler._compute_muhurta({ + 'date': '2026-06-22', + 'activity': 'business', + 'planets': _sample_planets(), + }) + + assert result['success'] is True + assert result['report']['query_date'] == '2026-06-22' + assert 'business' in result['report']['activity_checks'] + + +def test_muhurta_endpoint_returns_date_range_solver() -> None: + handler = _handler() + result = handler._compute_muhurta({ + 'start_date': '2026-06-22', + 'end_date': '2026-06-28', + 'activity': 'business', + 'limit': 3, + 'lat': 28.6, + 'lon': 77.2, + 'tz': 5.5, + }) + + assert result['success'] is True + assert result['endpoint'] == 'muhurta' + solver = result['range_search'] + assert solver['mode'] == 'muhurta_date_range_solver' + assert solver['activity'] == 'business' + assert solver['candidate_count'] <= 3 + assert solver['best_windows'] + assert solver['constraints']['avoid_inauspicious_periods'] is True + + +def test_panchanga_range_endpoint_returns_calendar_rows() -> None: + handler = _handler() + result = handler._compute_panchanga_range({ + 'start_date': '2026-06-22', + 'end_date': '2026-06-24', + 'sunrise': '06:00', + 'sunset': '18:00', + }) + + assert result['success'] is True + assert result['endpoint'] == 'panchanga_range' + assert result['report']['day_count'] == 3 + assert result['report']['days'][0]['inauspicious_periods']['rahu_kala']['label'] == 'Rahu Kala' + assert 'condition_tags' in result['report']['days'][0] + assert 'festival_details' in result['report']['days'][0] + assert 'search_summary' in result['report'] + assert result['report']['calculation_policy']['festival_rules'] + + +def test_panchanga_range_endpoint_uses_location_when_available() -> None: + handler = _handler() + result = handler._compute_panchanga_range({ + 'start_date': '2026-06-22', + 'end_date': '2026-06-22', + 'lat': 28.6, + 'lon': 77.2, + 'tz': 5.5, + }) + + assert result['report']['location']['lon'] == 77.2 + assert result['report']['calculation_policy']['sunrise_sunset'] in { + 'SwissEph rise_trans', + 'location-aware solar approximation', + 'mixed SwissEph rise_trans with approximation fallback', + } + assert 'solar_times' in result['report']['days'][0] + + +def test_panchanga_range_rejects_large_ranges() -> None: + handler = _handler() + with pytest.raises(BadRequest, match='panchanga range must be <= 63 days'): + handler._compute_panchanga_range({ + 'start_date': '2026-01-01', + 'end_date': '2026-04-01', + }) + + +def test_rectification_gate_returns_varga_risk_summary() -> None: + handler = _handler() + result = handler._compute_rectification_gate({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'declared_accuracy': 'minute', + 'time_source': 'family_vague', + }) + + assert result['success'] is True + assert result['endpoint'] == 'rectification_gate' + assert result['effective_accuracy'] == '15min' + assert 'headline' in result['summary'] + assert result['summary']['recommended_events'] + + +def test_case_validation_endpoint_returns_evidence_summary() -> None: + handler = _handler() + result = handler._compute_case_validation({ + 'planets': { + **_sample_planets(), + 'Moon': {'lon': 45.0}, + 'Venus': {'lon': 40.0}, + 'Saturn': {'lon': 10.0}, + }, + 'ascendant': {'lon': 0.0}, + 'current_md': 'Venus', + 'predicted_events': ['艺术创作', '关系发展'], + 'transit_desc': 'Jupiter tr 7', + }) + + assert result['success'] is True + assert result['endpoint'] == 'case_validation' + assert result['summary']['validated_count'] >= 1 + assert 'overall_confidence' in result['summary'] + assert 'mevg_automation.py' in result['fragment_sources'] + assert result['mevg_gate']['source'] == 'mevg_automation.py' + assert result['summary']['gate_status'] in {'NOT_INITIALIZED', 'OPEN', 'CLOSED', 'UNKNOWN', 'UNAVAILABLE'} + + +def test_divisional_yoga_endpoint_returns_varga_yoga_summary() -> None: + handler = _handler() + result = handler._compute_divisional_yoga({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'divisions': ['D9', 'D10', 'D12'], + }) + + assert result['success'] is True + assert result['endpoint'] == 'divisional_yoga' + assert result['summary']['total_yogas'] >= 0 + assert {'D9', 'D10', 'D12'} <= set(result['result']) + + +def test_deep_varga_avastha_endpoint_returns_templates() -> None: + handler = _handler() + result = handler._compute_deep_varga_avastha({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + }) + + assert result['success'] is True + assert result['endpoint'] == 'deep_varga_avastha' + report = result['report'] + assert report['avastha_summary']['dominant_states'] + assert {'D24', 'D30', 'D60'} <= set(report['deep_varga_templates']) + assert report['deep_varga_templates']['D24']['template_cards'] + assert report['deep_varga_templates']['D30']['risk_flags'] + assert report['deep_varga_templates']['D60']['next_action'] + + +def test_kakshya_endpoint_returns_degree_trigger_summary() -> None: + handler = _handler() + result = handler._compute_kakshya({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + }) + + assert result['success'] is True + assert result['endpoint'] == 'kakshya' + assert result['summary']['average_strength'] >= 0 + assert result['summary']['strongest'] + assert 'planets' in result['result'] + + +def test_bhava_bala_endpoint_returns_house_strength_summary() -> None: + handler = _handler() + result = handler._compute_bhava_bala_api({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + }) + + assert result['success'] is True + assert result['endpoint'] == 'bhava_bala' + assert result['summary']['strongest'] + assert result['summary']['weakest'] + assert 'houses' in result['result'] + + +def test_transit_endpoint_returns_trigger_summary() -> None: + handler = _handler() + result = handler._compute_transit_triggers({ + 'natal_planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'start': '2026-06-22', + 'end': '2026-07-22', + 'planets_to_check': ['Saturn', 'Jupiter'], + }) + + assert result['success'] is True + assert result['endpoint'] == 'transit' + assert 'total_triggers' in result['summary'] + assert 'triggers' in result + + +def test_annual_endpoint_returns_varshaphala_report() -> None: + handler = _handler() + result = handler._compute_annual({ + 'year': 1990, + 'month': 6, + 'day': 15, + 'hour': 12, + 'minute': 0, + 'lat': 28.6, + 'lon': 77.2, + 'tz': 5.5, + 'target_year': 2026, + }) + + assert result['success'] is True + assert result['report']['target_year'] == 2026 + assert 'muntha' in result['report'] + strength = result['report']['tajika_strength'] + assert strength['method'] == 'Tajika Harsha/Panchavargiya Bala' + assert 'harsha_bala' in strength + assert 'panchavargiya_bala' in strength + assert strength['summary']['strongest_planets'] + assert strength['summary']['next_action'] + + +def test_bhava_chalit_endpoint_compares_shifted_houses() -> None: + handler = _handler() + result = handler._compute_bhava_chalit({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'mc_lon': 10.0, + 'house_system': 'sripati', + }) + + assert result['success'] is True + assert result['result']['house_system'] == 'sripati' + assert 'rashi_chart' in result['result'] + + +def test_bhava_chalit_endpoint_exposes_user_selected_house_systems() -> None: + handler = _handler() + base = { + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'mc_lon': 10.0, + 'year': 1990, + 'month': 6, + 'day': 15, + 'hour': 12, + 'minute': 0, + 'lat': 28.6, + 'lon': 77.2, + 'tz': 5.5, + } + + sripati = handler._compute_bhava_chalit({**base, 'house_system': 'sripati'}) + placidus = handler._compute_bhava_chalit({**base, 'house_system': 'placidus'}) + + for result, expected in [(sripati, 'sripati'), (placidus, 'placidus')]: + assert result['success'] is True + assert result['requested_house_system'] == expected + assert result['selected_house_system'] == expected + assert 'placidus' in result['available_house_systems'] + assert 'sripati' in result['available_house_systems'] + assert result['result']['selected_house_system'] == expected + assert result['result']['requested_house_system'] == expected + assert len(result['result']['boundaries']['houses']) == 12 + assert result['result']['summary']['total_planets'] >= 7 + assert result['result']['calculation_note'] + + +def test_sudarshana_endpoint_returns_three_reference_points() -> None: + handler = _handler() + result = handler._compute_sudarshana({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + }) + + assert result['success'] is True + assert {'ascendant_lagna', 'moon_lagna', 'sun_lagna'} <= set(result['result']['reference_points']) + + +def test_nakshatra_full_endpoint_returns_power_layers() -> None: + handler = _handler() + result = handler._compute_nakshatra_full({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'age': 36, + }) + + assert result['success'] is True + assert 'tara_bala' in result['result'] + assert 'sub_lords' in result['result'] + + +def test_varga_full_endpoint_returns_extended_standard_divisions() -> None: + handler = _handler() + result = handler._compute_varga_full({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'divisions': ['D9', 'D60', 'D81', 'D108', 'D144'], + }) + + assert result['success'] is True + assert result['mode'] == 'standard' + assert result['divisions'] == [9, 60, 81, 108, 144] + assert 'D144_Dwadasamsa-Dwadasamsa' in result['result'] + assert result['result']['D81_Navamsa-Navamsa']['planets']['Moon']['house'] in range(1, 13) + + +def test_varga_full_endpoint_supports_custom_and_composite_modes() -> None: + handler = _handler() + custom = handler._compute_varga_full({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'custom': 150, + }) + composite = handler._compute_varga_full({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'composite': '9,12', + }) + + assert custom['mode'] == 'custom' + assert custom['result']['Moon']['div'] == 150 + assert composite['mode'] == 'composite' + assert composite['result']['composite_div'] == 'D9×D12=D108' + + +def test_varga_full_endpoint_supports_d2_d3_variants() -> None: + handler = _handler() + result = handler._compute_varga_full({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'divisions': 'D3', + 'variant': 'khara', + }) + + assert result['success'] is True + assert result['mode'] == 'variant' + assert result['result']['div'] == 3 + assert result['result']['Moon']['variant'] == 'khara' + + +def test_jaimini_endpoint_returns_karakas_and_arudha() -> None: + handler = _handler() + result = handler._compute_jaimini({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'year': 1990, + 'month': 6, + 'hour': 12, + 'minute': 0, + }) + + assert result['success'] is True + assert 'chara_karaka_7' in result['result'] + assert 'Atmakaraka' in result['result']['chara_karaka_7']['karaka_table'] + assert 'arudha_padas' in result['result'] + + +def test_ashtakavarga_endpoint_preserves_sav_invariant() -> None: + handler = _handler() + result = handler._compute_ashtakavarga({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + }) + + assert result['success'] is True + assert result['result']['sav']['total'] == 337 + assert result['result']['sav']['valid'] is True + assert result['summary']['strongest_houses'] + assert result['pav_summary']['top_planets'] + assert result['sodhita_summary']['top_signs'] + + +def test_remedies_endpoint_returns_evidence_chain() -> None: + handler = _handler() + result = handler._compute_remedies({ + 'shadbala': { + 'Sun': {'total_rupas': 0.42}, + 'Moon': {'rupas': 0.68}, + }, + 'dasha_lord': 'Sun', + 'doshas': ['Mangal Dosha'], + }) + + assert result['weak_planets'] == ['Sun'] + assert 'evidence_chain' in result + assert any(item['source'] == 'shadbala' and item['planet'] == 'Sun' for item in result['evidence_chain']) + assert any(item['source'] == 'dasha' for item in result['evidence_chain']) + assert result['next_action'] + + +def test_shadbala_endpoint_returns_ranked_planet_strength() -> None: + handler = _handler() + result = handler._compute_shadbala({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + 'hour': 12, + 'minute': 0, + }) + + assert result['success'] is True + assert result['result']['ranking'] + assert 'total_rupas' in result['result']['planets']['Sun'] + assert result['rule_variants']['selected'] == ['core_sixfold', 'advanced_evidence'] + assert result['advanced_layer']['source'] == 'scripts/shadbala_advanced.py' + assert result['advanced_layer']['top_kala_support'] + assert 'sputa_drik_bala' in result['advanced_layer'] + + +def test_yogas_endpoint_returns_summary_counts() -> None: + handler = _handler() + result = handler._compute_yogas_api({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + }) + + assert result['success'] is True + assert 'extended_count' in result['result']['summary'] + assert 'rule_engine_count' in result['result']['summary'] + assert 'curse_count' in result['result']['summary'] + assert any(item['key'] == 'curse_conjunctions' for item in result['rule_variants']['available']) + + +def test_yogas_endpoint_reuses_curse_yoga_fragment() -> None: + handler = _handler() + planets = _sample_planets() + planets['Mars'] = {'lon': 210.0} + planets['Saturn'] = {'lon': 211.0} + result = handler._compute_yogas_api({ + 'planets': planets, + 'ascendant': {'lon': 92.0}, + 'current_dasha': 'Saturn', + }) + + curse_layer = result['result']['curse_yogas'] + assert result['success'] is True + assert result['result']['summary']['curse_count'] == 1 + assert curse_layer['overall_risk'] in {'high', 'critical'} + assert curse_layer['curses_detected'][0]['type'] == 'yama_yoga' + assert result['rule_variants']['selected'][-1] == 'curse_conjunctions' + + +def test_aspects_endpoint_returns_pair_summary() -> None: + handler = _handler() + result = handler._compute_aspects({ + 'planets': _sample_planets(), + 'ascendant': {'lon': 92.0}, + }) + + assert result['success'] is True + assert result['result']['summary']['total_aspects'] >= 0 + assert 'by_type' in result['result']['summary'] diff --git a/tests/test_ashtakavarga_invariants.py b/tests/test_ashtakavarga_invariants.py index e0a32874..324400d5 100644 --- a/tests/test_ashtakavarga_invariants.py +++ b/tests/test_ashtakavarga_invariants.py @@ -3,29 +3,47 @@ from __future__ import annotations -from ashtakavarga import ALL_SOURCES, BAV_TOTALS, EXPECTED_SAV_TOTAL, SEVEN_PLANETS, SIGNS, calc_ashtakavarga -from hypothesis import given, settings -from hypothesis import strategies as st +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from ashtakavarga import ( + ALL_SOURCES, + BAV_TOTALS, + EXPECTED_SAV_TOTAL, + SEVEN_PLANETS, + SIGNS, + calc_ashtakavarga, + calc_prastara_av, + calc_yoga_pinda, +) -@st.composite -def planet_signs(draw: st.DrawFn) -> dict[str, dict[str, str]]: - return {planet: {"sign": draw(st.sampled_from(SIGNS))} for planet in SEVEN_PLANETS} +def _planet_signs(offset: int = 0, step: int = 1) -> dict[str, dict[str, str]]: + return {planet: {"sign": SIGNS[(offset + index * step) % 12]} for index, planet in enumerate(SEVEN_PLANETS)} -@settings(deadline=None) -@given(planet_signs(), st.integers(min_value=0, max_value=11)) -def test_ashtakavarga_totals_are_position_independent(planets: dict[str, dict[str, str]], asc_sign_idx: int) -> None: - result = calc_ashtakavarga(planets, asc_sign_idx) +def test_ashtakavarga_totals_are_position_independent() -> None: + samples = [ + (_planet_signs(offset=0, step=1), 0), + (_planet_signs(offset=2, step=3), 4), + (_planet_signs(offset=7, step=5), 11), + ] + for planets, asc_sign_idx in samples: + result = calc_ashtakavarga(planets, asc_sign_idx) - assert result["sav"]["total"] == EXPECTED_SAV_TOTAL - assert result["sav"]["valid"] is True - assert result["sav"]["full_total_with_lagna"] == EXPECTED_SAV_TOTAL + BAV_TOTALS["Lagna"] - assert result["all_bav_valid"] is True + assert result["sav"]["total"] == EXPECTED_SAV_TOTAL + assert result["sav"]["valid"] is True + assert result["sav"]["full_total_with_lagna"] == EXPECTED_SAV_TOTAL + BAV_TOTALS["Lagna"] + assert result["all_bav_valid"] is True - for planet in ALL_SOURCES: - assert result["bav"][planet]["total"] == BAV_TOTALS[planet] - assert result["bav"][planet]["valid"] is True + for planet in ALL_SOURCES: + assert result["bav"][planet]["total"] == BAV_TOTALS[planet] + assert result["bav"][planet]["valid"] is True def test_ashtakavarga_output_contract() -> None: @@ -37,3 +55,40 @@ def test_ashtakavarga_output_contract() -> None: assert set(result["sav"]["scores"].keys()) == set(SIGNS) assert len(result["house_scores"]) == 12 assert len(result["house_scores_full"]) == 12 + + +def test_prastara_matrix_reconstructs_each_planets_bav() -> None: + planets = {planet: {"sign": SIGNS[(index * 2) % 12]} for index, planet in enumerate(SEVEN_PLANETS)} + result = calc_ashtakavarga(planets, asc_sign_idx=3) + pav = calc_prastara_av(planets, asc_sign_idx=3) + + assert pav["all_valid"] is True + assert pav["matrix_shape"] == {"planets": 7, "signs": 12, "sources": 8} + + for planet in SEVEN_PLANETS: + reconstructed = [0] * 12 + for source in ALL_SOURCES: + for index, value in enumerate(pav["pav"][planet][source]): + reconstructed[index] += value + assert reconstructed == result["bav"][planet]["bindus"] + + +def test_yoga_pinda_is_first_class_contract() -> None: + planets = {planet: {"sign": SIGNS[(index * 3) % 12]} for index, planet in enumerate(SEVEN_PLANETS)} + result = calc_ashtakavarga(planets, asc_sign_idx=5) + yoga_pinda = calc_yoga_pinda(result["bav"], planets, asc_sign_idx=5) + + assert yoga_pinda["method"] == "Yoga Pinda" + assert yoga_pinda["all_valid"] is True + assert yoga_pinda["summary"]["strongest_planet"] in SEVEN_PLANETS + assert yoga_pinda["summary"]["total_yoga_pinda"] == sum( + item["yoga_pinda"] for item in yoga_pinda["planets"].values() + ) + + for planet in SEVEN_PLANETS: + row = yoga_pinda["planets"][planet] + legacy = result["shodhya_pinda"][planet] + assert row["rashi_pinda"] == legacy["rashi_pinda"] + assert row["graha_pinda"] == legacy["graha_pinda"] + assert row["yoga_pinda"] == legacy["total_pinda"] + assert row["bindu_at_own_sign"] == legacy["bindu_at_own_sign"] diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index dfe52690..01a3fe70 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -8,8 +8,11 @@ import subprocess import sys from pathlib import Path +from tests.run_golden_cases import run_cases + ROOT = Path(__file__).resolve().parents[1] ENGINE = ROOT / "scripts" / "jyotish_engine.py" +GOLDEN_CASES = ROOT / "tests" / "golden" / "golden_cases.json" BASE_BIRTH_ARGS = [ "--year", "1990", "--month", "1", @@ -58,3 +61,34 @@ def test_ashtakavarga_cli_keeps_sav_invariant() -> None: def test_audit_capabilities_cli_validates_registry() -> None: result = run_engine("audit-capabilities", "--mode", "validate") assert result.get("valid") is True + + +def test_full_reading_golden_cases_cover_user_ready_output() -> None: + result = run_cases(sys.executable, str(GOLDEN_CASES)) + assert result["valid"] is True, result + assert result["total"] >= 3 + + +def test_yoga_logic_validation_tolerates_algorithmic_yogas_without_rule_id() -> None: + sys.path.insert(0, str(ROOT / "scripts")) + import validate_logic_v2 + + results = [ + {"rule_id": "bvr_vosi_precise", "name": "Vosi Yoga"}, + {"id": "bvr_sunaphaa_precise", "name": "Sunapha Yoga"}, + {"name": "Algorithmic Solar Yoga"}, + ] + + assert validate_logic_v2.extract_skill_rule_ids(results) == { + "bvr_vosi_precise", + "bvr_sunaphaa_precise", + } + + +def test_yoga_logic_validation_import_does_not_shadow_project_modules() -> None: + before = list(sys.path) + sys.path.insert(0, str(ROOT / "scripts")) + import validate_logic_v2 # noqa: F401 + + assert not sys.path[0].endswith(".workbuddy/skills/jyotish-vedic-astrology/scripts") + sys.path[:] = before diff --git a/tests/test_deep_varga_avastha.py b/tests/test_deep_varga_avastha.py new file mode 100644 index 00000000..4fd0065b --- /dev/null +++ b/tests/test_deep_varga_avastha.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Tests for deep_varga_avastha.py — Sayanadi/Shayanadi + D24/D30/D60 templates.""" + +from __future__ import annotations +import os +import sys + +SCRIPTS = os.path.join(os.path.dirname(__file__), '..', 'scripts') +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +from deep_varga_avastha import build_deep_varga_avastha_report + + +def _sample_lons() -> dict: + return { + 'Sun': 80.0, + 'Moon': 123.0, + 'Mars': 210.0, + 'Mercury': 75.0, + 'Jupiter': 15.0, + 'Venus': 102.0, + 'Saturn': 330.0, + } + + +def test_deep_varga_avastha_report_exposes_avastha_and_deep_templates() -> None: + result = build_deep_varga_avastha_report(_sample_lons(), asc_lon=92.0) + + assert result['method'] == 'Sayanadi/Shayanadi Avastha + D24/D30/D60 Deep Templates' + assert result['summary']['headline'] + assert result['avastha_summary']['dominant_states'] + assert result['avastha_summary']['planet_states']['Moon']['Shayanadi']['state'] + assert {'D24', 'D30', 'D60'} <= set(result['deep_varga_templates']) + + d24 = result['deep_varga_templates']['D24'] + assert d24['theme'] == 'education_learning' + assert d24['template_cards'] + assert d24['next_action'] + + d30 = result['deep_varga_templates']['D30'] + assert d30['theme'] == 'risk_crisis' + assert 'risk_flags' in d30 + + d60 = result['deep_varga_templates']['D60'] + assert d60['theme'] == 'karma_root' + assert d60['template_cards'] diff --git a/tests/test_frontend_productization.py b/tests/test_frontend_productization.py new file mode 100644 index 00000000..4eb20f9e --- /dev/null +++ b/tests/test_frontend_productization.py @@ -0,0 +1,1804 @@ +#!/usr/bin/env python3 +"""Static frontend productization checks for the Jyotish web app.""" + +from __future__ import annotations + +import re +import socket +import subprocess +import time +import json +import os +import sys +import importlib.util +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "jyotish-app" +API_SERVER = ROOT / "scripts" / "jyotish_api_server.py" +TMP = Path(os.environ.get("TMPDIR", "/private/tmp")) + + +def read(relative: str) -> str: + return (APP / relative).read_text(encoding="utf-8") + + +def runtime_ports() -> tuple[int, int]: + sockets: list[socket.socket] = [] + try: + for _ in range(2): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + sockets.append(sock) + return sockets[0].getsockname()[1], sockets[1].getsockname()[1] + finally: + for sock in sockets: + sock.close() + + +def wait_for_url(url: str, *, timeout: float = 12.0, logs: list[Path] | None = None) -> None: + deadline = time.time() + timeout + last_error = "" + while time.time() < deadline: + completed = subprocess.run( + ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", url], + capture_output=True, + text=True, + timeout=2, + ) + if completed.returncode == 0 and completed.stdout.strip().startswith(("2", "3", "4")): + return + last_error = completed.stderr.strip() or completed.stdout.strip() + time.sleep(0.25) + log_text = "" + for path in logs or []: + if path.exists(): + log_text += f"\n--- {path} ---\n{path.read_text(encoding='utf-8', errors='replace')[-4000:]}" + raise AssertionError(f"Timed out waiting for {url}: {last_error}{log_text}") + + +def skip_if_sandbox_denies_bind(log_path: Path) -> None: + if not log_path.exists(): + return + text = log_path.read_text(encoding="utf-8", errors="replace") + if "PermissionError" in text and "Operation not permitted" in text and "socket.bind" in text: + pytest.skip("sandbox disallows starting local listening servers in this pytest context") + + +def fetch_text(url: str) -> str: + completed = subprocess.run( + ["curl", "-sS", url], + capture_output=True, + text=True, + timeout=8, + check=True, + ) + return completed.stdout + + +def fetch_json(url: str) -> dict: + return json.loads(fetch_text(url)) + + +def post_json(url: str, payload: dict) -> dict: + completed = subprocess.run( + [ + "curl", + "-sS", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "--data-binary", + json.dumps(payload), + url, + ], + capture_output=True, + text=True, + timeout=15, + check=True, + ) + return json.loads(completed.stdout) + + +def load_quality_gate_module(): + spec = importlib.util.spec_from_file_location("run_quality_gate", ROOT / "scripts" / "run_quality_gate.py") + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def sample_birth_payload() -> dict: + return { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "lat": 39.9, + "lon": 116.4, + "tz": 8, + } + + +def start_process(cmd: list[str], cwd: Path, log_path: Path) -> subprocess.Popen[str]: + log_path.parent.mkdir(parents=True, exist_ok=True) + log_handle = log_path.open("w", encoding="utf-8") + return subprocess.Popen( + cmd, + cwd=cwd, + stdout=log_handle, + stderr=subprocess.STDOUT, + text=True, + ) + + +def stop_process(process: subprocess.Popen[str]) -> None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def test_result_page_keeps_core_user_tabs() -> None: + html = read("index.html") + expected_tabs = [ + "chart", + "complete", + "provenance", + "karaka", + "houses", + "aspects", + "yogas", + "vargas", + "ashtakavarga", + "shadbala", + "dasha", + "transit", + "deep", + "extended", + "remedies", + "synastry", + "prashna", + "kp", + "verify", + "transit-compare", + ] + for tab in expected_tabs: + assert f'data-tab="{tab}"' in html + assert f'id="tab-{tab}"' in html or tab == "chart" + + assert 'id="birth-form"' in html + assert 'id="chart-import-panel"' in html + assert 'id="saved-chart-panel"' in html + assert 'id="btn-save-chart"' in html + assert 'id="remedies-section"' in html + assert 'id="provenance-panel"' in html + + +def test_first_use_onboarding_is_actionable() -> None: + html = read("index.html") + main = read("main.js") + style = read("style.css") + gap_doc = ROOT / "docs" / "research" / "product_gap_matrix_2026_06_22.md" + + for token in [ + 'id="first-use-panel"', + 'id="first-use-health"', + 'id="first-use-demo"', + 'id="first-use-import"', + 'id="first-use-status"', + "data-first-use-action", + ]: + assert token in html + + for token in [ + "DEMO_BIRTH", + "setupFirstUsePanel", + "fillDemoBirth", + "runFirstUseHealthCheck", + "focusFirstUseImport", + "first-use-demo", + "first-use-health", + "first-use-import", + "first-use-status", + "getAPIHealth", + "fillBirthFormFromData", + "window.__jyotishRuntimeHealth", + "techniqueCount", + "endpointCount", + "audit?.registry?.technique_count", + "audit?.surfaces?.api_endpoint_count", + "本地 API 未连接", + "示例盘已填入", + "API endpoints 可被前端发现", + ]: + assert token in main + + for selector in [ + ".first-use-panel", + ".first-use-head", + ".first-use-grid", + ".first-use-step", + ".first-use-action", + ".first-use-status", + ]: + assert selector in style + + assert re.search(r"\.first-use-grid,\s*\.runtime-health-grid,\s*\.trust-status-grid", style, re.S) + assert gap_doc.exists() + assert "First-use onboarding and empty-state path" in gap_doc.read_text(encoding="utf-8") + + +def test_chart_banner_avoids_undefined_api_fields() -> None: + main = read("main.js") + analysis = read("analysis-renderers.js") + for token in [ + "getAscendantLord", + "formatMoonNakshatra", + "normalizePlanetRecord", + "SIGN_LORDS[ascendant.sign]", + "moon.nakshatra || moon.nakshatra_name", + "moon.nakshatra_pada || moon.pada", + "moonNakshatra ?", + "ascendant.lord || getAscendantLord(ascendant)", + ]: + assert token in main + for token in [ + "sanitizeRamanDetail", + "缺星座", + "buildChartSummary", + "formatMoonNakshatra(moonP)", + ]: + assert token in main or token in analysis + assert "`${t('asc.nakshatra')}: ${moonP.nakshatra} Pada ${moonP.nakshatra_pada}`" not in main + assert "planetName(ascendant.lord)} (${ascendant.lord})" not in main + assert "moonP ? `${moonP.nakshatra} P${moonP.nakshatra_pada}` : null" not in main + assert "escapeHtml(d)
    " not in analysis + + +def test_ai_chat_guides_server_side_secret_handling() -> None: + ai_chat = read("ai-chat.js") + i18n = read("i18n.js") + + for token in [ + "buildAISetupGuidance", + "ai.setup.title", + "ai.setup.server", + "ai.setup.secret", + "ai.setup.trust", + "OPENAI_API_KEY", + "服务端环境变量", + "不要把 OpenAI API key 放进浏览器", + "Trust Center", + ]: + assert token in ai_chat or token in i18n + + for unsafe in [ + "localStorage.setItem('jyotish_ai_endpoint'", + "jyotish_ai_endpoint", + "Custom endpoint failed", + "在浏览器控制台输入", + "你的API地址", + ]: + assert unsafe not in ai_chat + assert ai_chat.count("function buildAISetupGuidance") == 1 + + +def test_ai_chat_api_failures_have_recovery_guidance() -> None: + ai_chat = read("ai-chat.js") + for token in [ + "parseAIResponse", + "buildAIRecoveryMessage", + "/api/chat", + "Trust Center", + "普通用户启动路径", + "网页服务", + "本地 API", + "服务端 AI 对话暂不可用", + ]: + assert token in ai_chat + assert "const data = await resp.json();" not in ai_chat + + +def test_export_failure_recovery_guides_health_check() -> None: + main = read("main.js") + export_js = read("export.js") + advanced = read("jyotish-advanced.js") + + for token in [ + "getPDFExportRecoveryMessage", + "getGenericExportRecoveryMessage", + "后端已生成 HTML 报告", + "已下载:", + "可直接打开,或用浏览器打印为 PDF", + "已改为导出 HTML 报告", + "Trust Center 运行健康检查", + "普通用户启动路径", + "网页服务", + "本地 API", + "report_artifact API unavailable", + "formatReportArtifactStatus", + "report_artifact_fallback", + "artifact_status", + "download_filename", + "download_mime", + "fallback_reason", + "后端已生成 HTML 报告", + "已下载:", + "可直接打开,或用浏览器打印为 PDF", + ]: + assert token in main or token in export_js + + assert "alert(`导出失败:" not in main + assert "setExportStatus(`导出失败:${error?.message || '导出模块加载失败'}`" not in main + assert "const subLord = DASHA_ORDER[subIdx];" in advanced + assert "sub_lord: subLord" in advanced + assert "sub_lord,\n" not in advanced + + +def test_annual_workbench_renders_tajika_strength_layers() -> None: + skill_map = read("skill-map.js") + for token in [ + "tajika_strength", + "renderTajikaStrengthCards", + "formatTajikaStrengthPlanet", + "Harsha Bala", + "Panchavargiya Bala", + "strongest_planets", + "combined_strength", + "年度强度", + ]: + assert token in skill_map + + +def test_muhurta_workbench_renders_date_range_solver() -> None: + skill_map = read("skill-map.js") + for token in [ + "range_search", + "renderMuhurtaRangeSolver", + "muhurta_date_range_solver", + "best_windows", + "recommended_windows", + "rejected_dates", + "择日候选", + "范围择日", + ]: + assert token in skill_map + + +def test_deep_varga_avastha_workbench_renders_templates() -> None: + skill_map = read("skill-map.js") + for token in [ + "deepVargaAvastha", + "renderDeepVargaAvasthaResult", + "deep_varga_templates", + "avastha_summary", + "dominant_states", + "template_cards", + "risk_flags", + "D24/D30/D60", + ]: + assert token in skill_map + + +def test_runtime_smoke_covers_ai_and_report_recovery() -> None: + smoke = (ROOT / "tests" / "run_frontend_runtime_smoke.py").read_text(encoding="utf-8") + for token in [ + "/api/report_artifact", + "AI_BROWSER_KEY_DISABLED", + "ai-chat.js", + "i18n.js", + "chat_policy", + "report_artifact_smoke", + "ai_bridge_policy_smoke", + ]: + assert token in smoke + + +def test_click_smoke_covers_core_interactive_workflows() -> None: + smoke_path = ROOT / "tests" / "run_frontend_click_smoke.py" + quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8") + assert smoke_path.exists() + smoke = smoke_path.read_text(encoding="utf-8") + for token in [ + "YINDUZHANXING_API_BASE", + "first-use-demo", + "btn-calculate", + "ai-fab", + "ai-input", + "btn-export", + "export-status", + "btn-run-transit", + "btn-run-synastry-full", + "btn-run-prashna", + "Trust Center", + "click_smoke", + "--mode", + "pdf", + "offline", + "mobile", + "mobile-trust", + "import-files", + "manifest.webmanifest", + "serviceWorker", + "first-use-health", + "run_offline_smoke", + "expected_offline_console_errors", + "run_pdf_fallback_smoke", + "run_import_workspace_smoke", + "run_import_file_smoke", + "run_mobile_trust_export_smoke", + "run_offline_shell_reload_smoke", + "run_mobile_tab_smoke", + "后端已生成 HTML 报告", + "已下载:", + "可直接打开,或用浏览器打印为 PDF", + "pdf_fallback_checked", + "import_workspace_checked", + "import_recovery", + "pdf_import_recovery", + "mobile_file_import_entry_checked", + "PDF文本抽取失败", + "pdf_import_recovery =", + "仍需手动补充", + "mobile_trust_export_checked", + "jyotish-local-data-", + "--timeout", + "run_with_timeout", + "process_snapshot", + "force_stop_process", + "jyotish-selected-cases-", + "jyotish-case-library-", + "offline_shell_reload_checked", + "offline_shell_expected_console_errors", + "mobile_tab_switch_checked", + ]: + assert token in smoke + for token in [ + "--skip-frontend-click", + "--frontend-click-timeout", + "tests/run_frontend_click_smoke.py", + "--mode", + "all", + "--timeout", + "format_failure_summary", + "Run the focused command above", + "--keep-logs", + "process_snapshot", + 'APP = ROOT / "jyotish-app"', + "cwd=APP", + ]: + assert token in quality_gate + + +def test_quality_gate_formats_actionable_failure_summary() -> None: + quality_gate = load_quality_gate_module() + summary = quality_gate.format_failure_summary( + "frontend click smoke", + ["python3", "tests/run_frontend_click_smoke.py", "--mode", "all", "--timeout", "1"], + 124, + stdout='{"valid": false, "reason": "click smoke timed out after 1s"}', + stderr='{"process_snapshot": {"api": {"pid": 11, "running": true}, "web": {"pid": 12, "running": true}}}', + ) + for token in [ + "Quality gate failed", + "frontend click smoke", + "exit code: 124", + "tests/run_frontend_click_smoke.py --mode all --timeout 1", + "click smoke timed out after 1s", + "process_snapshot", + "Run the focused command above", + "--keep-logs", + "Trust Center", + ]: + assert token in summary + + +def test_quality_gate_declares_fast_browser_release_profiles() -> None: + quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + for token in [ + "--profile", + "choices=[\"quick\", \"browser\", \"release\"]", + "QUALITY_GATE_PROFILES", + "quick", + "browser", + "release", + "skip_slow", + "skip_yoga_logic", + "skip_frontend_click", + "frontend_click_mode", + "import-files", + "mobile-trust", + "run_profile", + "check_release_hygiene", + "release_hygiene_check", + "RELEASE_CRITICAL_UNTRACKED_PATHS", + ]: + assert token in quality_gate + for token in [ + "质量门分层", + "quick:快速开发守门", + "browser:完整浏览器守门", + "release:发布前守门", + "python3 scripts/run_quality_gate.py --profile quick", + "python3 scripts/run_quality_gate.py --profile browser", + "python3 scripts/run_quality_gate.py --profile release", + ]: + assert token in readme + + +def test_release_quality_gate_tracks_untracked_product_files() -> None: + quality_gate = load_quality_gate_module() + assert quality_gate.QUALITY_GATE_PROFILES["release"]["check_release_hygiene"] is True + assert quality_gate.QUALITY_GATE_PROFILES["browser"]["check_release_hygiene"] is False + for path in [ + "jyotish-app/skill-map.js", + "jyotish-app/public/manifest.webmanifest", + "scripts/audit_fragments.py", + "scripts/deep_varga_avastha.py", + "tests/run_frontend_click_smoke.py", + "tests/test_frontend_productization.py", + "docs/research/whole_machine_git_audit_2026_06_23.md", + "task_plan.md", + "progress.md", + ]: + assert path in quality_gate.RELEASE_CRITICAL_UNTRACKED_PATHS + + +def test_auth_and_subscription_failures_have_recovery_guidance() -> None: + auth = read("auth.js") + subscription = read("subscription.js") + i18n = read("i18n.js") + for token in [ + "buildAuthRecoveryMessage", + "showAuthError", + "auth.recovery.api", + "Trust Center", + "普通用户启动路径", + "网页服务", + "本地 API", + "auth.recovery.retry", + "buildSubscriptionRecoveryMessage", + "showSubscriptionNotice", + "sub.recovery.iap", + ]: + assert token in auth or token in subscription or token in i18n + + assert "const data = await resp.json();" not in auth + assert "alert(t('sub.iap.unavail'))" not in subscription + + +def test_api_bridge_failures_have_recovery_guidance() -> None: + bridge = read("api-bridge.js") + public_bridge = read("public/api-bridge.js") + assert bridge == public_bridge + for token in [ + "parseApiResponse", + "buildAPIRecoveryMessage", + "Trust Center", + "普通用户启动路径", + "网页服务", + "本地 API", + "本地 API 未连接", + "lastAttempt", + ]: + assert token in bridge + assert "const data = await resp.json();" not in bridge + assert "const data = await resp.json();" not in public_bridge + + +def test_chart_compute_failures_have_visible_recovery_guidance() -> None: + html = read("index.html") + main = read("main.js") + style = read("style.css") + for token in [ + 'id="chart-compute-status"', + "setChartComputeStatus", + "buildChartComputeRecoveryMessage", + "Trust Center", + "普通用户启动路径", + "网页服务", + "本地 API", + "本地 API 未连接", + ]: + assert token in html or token in main or token in style + assert "alert('计算失败: ' + e.message)" not in main + assert "alert(t('alert.error') + err.message)" not in main + assert ".chart-compute-status" in style + + +def test_transit_compare_failures_have_recovery_guidance() -> None: + main = read("main.js") + for token in [ + "parseTransitCompareResponse", + "buildTransitCompareRecoveryMessage", + "buildTransitRenderRecoveryMessage", + "Trust Center", + "普通用户启动路径", + "网页服务", + "本地 API 服务", + "过境数据需本地 API 服务", + "Transit 计算暂不可用", + ]: + assert token in main + assert "const data = await resp.json();" not in main + assert "过境数据需API服务器" not in main + assert "'Transit 计算失败: ' + err.message" not in main + + +def test_interactive_api_workflows_have_recovery_guidance() -> None: + main = read("main.js") + for token in [ + "buildInteractiveAPIRecoveryMessage", + "renderInlineAPIError", + "Trust Center", + "普通用户启动路径", + "网页服务", + "本地 API 服务", + "合盘计算暂不可用", + "问事计算暂不可用", + "校正时间应用暂不可用", + ]: + assert token in main + + for unsafe in [ + "alert('校正时间应用失败: ' + (error?.message || error))", + "'合盘 API 不可用,请先启动 python3 scripts/jyotish_api_server.py --port 5200'", + "'问事 API 不可用,请先启动 python3 scripts/jyotish_api_server.py --port 5200'", + ]: + assert unsafe not in main + + +def test_ephemeris_abstraction_feasibility_is_probeable() -> None: + probe = ROOT / "scripts" / "ephemeris_backend_probe.py" + doc = ROOT / "docs" / "research" / "ephemeris_abstraction_feasibility_2026_06_23.md" + gap_doc = ROOT / "docs" / "research" / "product_gap_matrix_2026_06_22.md" + + assert probe.exists() + assert doc.exists() + probe_text = probe.read_text(encoding="utf-8") + doc_text = doc.read_text(encoding="utf-8") + gap_text = gap_doc.read_text(encoding="utf-8") + for token in [ + "swisseph_python", + "swisseph_wasm", + "xalen_ephemeris", + "vedastro", + "pyjhora_benchmark", + "license_posture", + "replacement_readiness", + "candidate_backends", + ]: + assert token in probe_text + assert token in doc_text or token in gap_text + + +def test_ephemeris_adapter_contract_and_parity_matrix_are_defined() -> None: + contract = ROOT / "scripts" / "ephemeris_adapter_contract.py" + parity_doc = ROOT / "docs" / "research" / "ephemeris_adapter_contract_2026_06_23.md" + gap_doc = ROOT / "docs" / "research" / "product_gap_matrix_2026_06_22.md" + + assert contract.exists() + assert parity_doc.exists() + contract_text = contract.read_text(encoding="utf-8") + parity_text = parity_doc.read_text(encoding="utf-8") + gap_text = gap_doc.read_text(encoding="utf-8") + + for token in [ + "EphemerisAdapterContract", + "PARITY_CASES", + "longitude_delta_arcsec", + "sun_moon_asc_nodes", + "swisseph_python", + "candidate_backend", + "ayanamsa_value", + "retrograde", + "acceptance_thresholds", + ]: + assert token in contract_text + assert token in parity_text or token in gap_text + + +def test_ephemeris_candidate_adapter_spike_is_gated() -> None: + spike = ROOT / "scripts" / "ephemeris_candidate_adapter_spike.py" + doc = ROOT / "docs" / "research" / "ephemeris_candidate_adapter_spike_2026_06_23.md" + gap_doc = ROOT / "docs" / "research" / "product_gap_matrix_2026_06_22.md" + + assert spike.exists() + assert doc.exists() + spike_text = spike.read_text(encoding="utf-8") + doc_text = doc.read_text(encoding="utf-8") + gap_text = gap_doc.read_text(encoding="utf-8") + + for token in [ + "swisseph_wasm_candidate", + "xalen_ephemeris_candidate", + "candidate_backend", + "candidate_adapter_spike", + "license_gate", + "package_license", + "AGPL-3.0", + "GPL-3.0-or-later", + "runtime_setting_exposure", + "parity_gate_required", + ]: + assert token in spike_text + assert token in doc_text or token in gap_text + + +def test_api_bridge_exports_productized_backend_actions() -> None: + bridge = read("api-bridge.js") + public_bridge = read("public/api-bridge.js") + assert bridge == public_bridge + + for token in [ + "AI_BROWSER_KEY_DISABLED", + "aiKeyPolicy: 'server_side_only'", + "不要把 OpenAI API key 放进浏览器", + ]: + assert token in bridge + + for unsafe in [ + "YINDUZHANXING_AI_KEY", + "apiKey: AI_KEY", + "Authorization': 'Bearer ' + AI_KEY", + "copse.top", + "AI_KEY_NOT_CONFIGURED", + ]: + assert unsafe not in bridge + + required_functions = [ + "getCapabilityAudit", + "getTechniqueCatalog", + "runTechniqueExample", + "computeAnnual", + "computeMuhurta", + "computePanchangaRange", + "computeBhavaChalit", + "computeSudarshana", + "computeNakshatraFull", + "computeVargaFull", + "computeJaimini", + "computeAshtakavarga", + "computeShadbala", + "computeYogas", + "computeAspects", + "computeRectificationGate", + "computeCaseValidation", + "computeDivisionalYoga", + "computeKakshya", + "computeBhavaBala", + "computeTransitTriggers", + "computeCareer", + "computeRelationship", + "computeKP", + "computePrashna", + "computeSynastry", + "computeRemedies", + "computeSadeSati", + "computePanchaMahapurusha", + "generateReportArtifact", + "computeThematicReport", + "getAPIHealth", + ] + for name in required_functions: + assert f"async function {name}" in bridge + assert re.search(rf"\b{name},", bridge), f"{name} must be exported on window.JyotishAPI" + + +def test_bhava_chalit_uses_user_selected_house_system() -> None: + skill_map = read("skill-map.js") + for token in [ + "resolveBhavaHouseSystem", + "calculationSettings?.houseSystem", + "house_system: resolveBhavaHouseSystem(context)", + "...buildBirthPayload(context)", + "available_house_systems", + "selected_house_system", + "requested_house_system", + "renderBhavaChalitResult", + "Placidus", + "Sripati", + ]: + assert token in skill_map + assert "if (action === 'bhava') return { ...base, mode: 'compare', house_system: 'sripati' }" not in skill_map + + +def test_skill_workbench_exposes_all_expected_advanced_actions() -> None: + skill_map = read("skill-map.js") + main = read("main.js") + style = read("style.css") + expected_actions = [ + "varga", + "annual", + "muhurta", + "bhava", + "sudarshana", + "nakshatra", + "jaimini", + "ashtakavarga", + "shadbala", + "yogas", + "aspects", + "rectification", + "caseValidation", + "divisionalYoga", + "deepVargaAvastha", + "kakshya", + "bhavaBala", + "transitTrigger", + "thematicReport", + "career", + "relationship", + "kpQuick", + "prashnaQuick", + "synastryQuick", + ] + for action in expected_actions: + assert f"['{action}'" in skill_map + assert f"action === '{action}'" in skill_map or f"data-action=\"${{escapeAttr(id)}}\"" in skill_map + + assert "renderAshtakavargaTechniqueResult" in skill_map + assert "renderShadbalaTechniqueResult" in skill_map + assert "renderYogaTechniqueResult" in skill_map + assert "renderDeepVargaAvasthaResult" in skill_map + assert "deep_varga_avastha" in skill_map + assert "Sayanadi/Shayanadi" in skill_map + assert "D24/D30/D60" in skill_map + assert "pav_summary" in skill_map + assert "sodhita_summary" in skill_map + assert "advanced_layer" in skill_map + assert "curse_yogas" in skill_map + assert "rule_variants" in skill_map + assert "technique-json" in skill_map + assert "capability_audit" in skill_map or "getCapabilityAudit" in skill_map + for token in [ + "TECHNIQUE_API_ENDPOINTS", + "TECHNIQUE_COMMAND_ACTIONS", + "TECHNIQUE_ID_ACTIONS", + "TECHNIQUE_EXPLORER_PRIORITY", + "renderTechniqueDirectory", + "buildTechniqueDirectoryRows", + "renderTechniqueDirectoryRows", + "bindTechniqueDirectory", + "bindTechniqueExplorerActions", + "runTechniqueExplorerAction", + "renderTechniqueExplorerResult", + "renderTechniqueExplorerLoading", + "buildExplorerPayloadPreview", + "resolveTechniqueExplorerAction", + "actionForEndpoint", + "endpointForAction", + "readTechniqueDirectoryFilters", + "filterTechniqueDirectoryRows", + "normalizeDirectoryText", + "uniqueSorted", + "renderDashaTechniqueResult", + "renderRemediesExplorerResult", + "renderThematicReportResult", + "technique-directory-search", + "technique-directory-domain", + "technique-directory-status", + "technique-directory-surface", + "data-technique-directory-results", + "data-technique-run", + "data-technique-result", + "data-technique-endpoint", + "technique_example", + "getTechniqueCatalogOrAudit", + "runTechniqueExample", + "current_dasha", + "top_kala_support", + "active_yuddha", + "sputa_drik_bala", + "curse_conjunctions", + "vimshottari_analysis", + "renderDashaLevelLine", + "fragment_sources", + "workflow_orchestration", + "evidence_source", + "full_reading_used", + "full-reading:", + "module_status", + "warning_count", + "real evidence path", + "api_docs", + "method_docs", + "renderTechniqueApiDocs", + "getTechniqueApiDoc", + "cURL / OpenAPI", + "technique-api-docs", + "Trust Center 运行健康检查", + "本地 API 服务", + "orchestrator_bridge.py", + "reading_orchestrator.py", + "mevg_gate", + "mevg_automation.py", + "computeDashaSystem", + "computeThematicReport", + "thematic-report", + "/api/thematic_report", + "computeRemedies", + "computeSadeSati", + "computePanchaMahapurusha", + "data-technique-filter", + "productization?.rows", + "ux_productization?.rows", + "api_endpoints", + ]: + assert token in skill_map + + for token in [ + "renderVimshottariAnalysisBlock", + "dasha-analysis-block", + "dasha-analysis-grid", + "vimshottari_analysis", + ]: + assert token in main or token in style + + +def test_remedies_ui_keeps_evidence_boundary_and_hidden_json() -> None: + main = read("main.js") + style = read("style.css") + + for token in [ + "remedies.evidence_chain", + "remedies-evidence-grid", + "remedies-evidence-card", + "remedies-boundary", + "remedies-next-action", + "remedies-json", + "technique-json", + ]: + assert token in main or token in style + + assert "不能替代医疗、法律、投资或心理咨询" in main + assert "低风险优先" in main + assert "需要谨慎确认" in main + + +def test_provenance_panchanga_workspace_panel_is_productized() -> None: + main = read("main.js") + style = read("style.css") + export_js = read("export.js") + glossary = read("glossary.js") + html = read("index.html") + manifest = read("public/manifest.webmanifest") + sw = read("public/sw.js") + gap_doc = ROOT / "docs" / "research" / "product_gap_matrix_2026_06_22.md" + + assert gap_doc.exists() + doc_text = gap_doc.read_text(encoding="utf-8") + for token in [ + "Calculation settings and provenance center", + "Panchanga calendar product", + "Saved chart workspace", + "Fragment Triage Queue", + "vedika-io/xalen-ephemeris", + ]: + assert token in doc_text + + for token in [ + "buildCalculationProvenance", + "renderProvenancePanel", + "renderPanchangaRange", + "Panchanga Preview", + "panchanga-range", + "panchanga-month", + "PANCHANGA_ACTIVITIES", + "PANCHANGA_CONDITIONS", + "PANCHANGA_CONDITION_MODES", + "renderPanchangaMonthGrid", + "panchanga-activity", + "panchanga-condition", + "panchanga-condition-mode", + "panchanga-condition-option", + "PANCHANGA_CONDITION_GUIDE", + "normalizePanchangaConditionMode", + "getPanchangaConditionModeLabel", + "renderPanchangaFestivalDetails", + "renderPanchangaLocationSummary", + "festivalDetails", + "search_summary", + "getSelectedPanchangaConditions", + "normalizePanchangaConditions", + "getPanchangaConditionSelectionLabel", + "renderPanchangaConditionGuide", + "getPanchangaConditionLabel", + "filterPanchangaRowsByCondition", + "rowMatchesPanchangaCondition", + "CALCULATION_SETTINGS_KEY", + "DEFAULT_CALCULATION_SETTINGS", + "CALCULATION_SETTING_OPTIONS", + "ephemerisBackend", + "ephemeris_backend", + "yogaVariant", + "jaiminiKarakaVariant", + "kpSignificatorVariant", + "ashtakavargaVariant", + "shadbalaVariant", + "dashaReference", + "yoga_variant", + "jaimini_karaka_variant", + "kp_significator_variant", + "ashtakavarga_variant", + "shadbala_variant", + "dasha_reference", + "ruleVariantStatus", + "Rule Variants", + "readCalculationSettings", + "writeCalculationSettings", + "normalizeCalculationSettings", + "applyCalculationSettingsToPayload", + "attachCalculationSettings", + "renderCalculationSettingsPanel", + "renderCalculationSelect", + "renderTerminologyModePreview", + "saveCalculationSettingsFromPanel", + "save-calculation-settings", + "data-setting-key", + "calculationSettings", + "TERMINOLOGY_MODE_KEY", + "TERMINOLOGY_MODE_OPTIONS", + "readTerminologyMode", + "writeTerminologyMode", + "getTerminologyModeOption", + "renderTerminologyModePanel", + "saveTerminologyModeFromPanel", + "save-terminology-mode", + "terminology-mode", + "setGlossaryTerminologyMode", + "terminologyMode", + "terminology_mode", + "平衡模式", + "术语模式", + "星历底座", + "xalen-ephemeris Apache-2.0 可行性记录", + "TRUST_CENTER_STORAGE_KEYS", + "initPWAInstallability", + "navigator.serviceWorker.register('/sw.js')", + "beforeinstallprompt", + "__jyotishDeferredInstallPrompt", + "renderTrustCenterPanel", + "getRuntimeHealthStatus", + "renderRuntimeHealthPanel", + "runTrustCenterHealthCheck", + "__jyotishRuntimeHealth", + "getAPIHealth", + "trust-run-health", + "Runtime Health", + "运行健康检查", + "Technique catalog", + "Packaging preflight", + "getTrustCenterStats", + "getPWAStatus", + "updateTrustCenterPWAStatus", + "getRuntimeHealthStatus", + "renderRuntimeHealthPanel", + "runTrustCenterHealthCheck", + "__jyotishRuntimeHealth", + "trust-run-health", + "/api/health", + "Technique catalog", + "Runtime Health", + "运行健康检查", + "运行体检", + "Pake shell", + "Tauri shell with sidecar", + "desktop_packaging_preflight.py", + "exportTrustCenterLocalData", + "clearTrustCenterLocalData", + "promptPWAInstall", + "trust-export-local", + "trust-clear-local", + "pwa-install", + "Trust Center", + "Local-first", + "Rahu Kala", + "Yamaganda", + "Gulika", + "exportPanchangaRangeCSV", + "exportPanchangaRangeICS", + "solar_times", + "sunrise_sunset", + "end_times", + "vrataTags", + "conditionTags", + "conditionLabels", + "renderPanchangaConditionBadges", + "renderVrataTagBadges", + "formatEndTimeSummary", + "subDaySummary", + "formatSubDaySummary", + "choghadiya", + "horaWindows", + "activityVerdict", + "text/calendar", + "Condition tags", + "renderTithiLordInsight", + "tithi_lord_analysis", + "jyotish_chart_library", + "CHART_LIBRARY_KEY", + "setupSavedChartPanel", + "renderSavedChartPanel", + "saveCurrentChartToLibrary", + "openSavedChartFromPanel", + "sortChartLibrary", + "buildLegacyWorkspaceChartId", + "findChartLibraryEntry", + "normalizeBirthIdPart", + "resolveTimezoneValue", + "resolveTypedBirthCity", + "getBrowserTimezoneOffset", + "recordSynastryWorkflow", + "recordPrashnaWorkflow", + "ensureClientWorkflows", + "buildWorkflowExportExtras", + "buildKPReportSummary", + "workspace-save-current", + "workspace-open-selected", + "workspace-delete-selected", + "workspace-export-selected", + "renderChartWorkspaceList", + "saveCurrentChartToWorkspace", + "openSelectedWorkspaceChart", + "synastry-partner-library", + "btn-run-synastry-library", + "renderSynastryPartnerLibrary", + "getSelectedSynastryLibraryEntry", + "runSynastryWithPartnerChart", + "SYNASTRY_PAIR_LIBRARY_KEY", + "jyotish_synastry_pair_library", + "PRASHNA_CASE_LIBRARY_KEY", + "jyotish_prashna_case_library", + "renderSynastryPairWorkspace", + "saveCurrentSynastryPair", + "exportCurrentSynastryPair", + "exportCurrentSynastryHTMLReport", + "buildSynastryWorkflowFromPair", + "buildBiWheelComparisonData", + "buildBiWheelAxisRow", + "buildSynastryPlanetPoint", + "buildCompositeStyleMidpoints", + "midpointLongitude", + "renderBiWheelComparisonView", + "renderCompositeStyleStrip", + "normalizeReplayComparison", + "compositeStyle", + "partnerOverlayHouse", + "buildSynastrySpouseStatusContext", + "buildSpouseStatusSnapshot", + "collectSpouseStatusStrengths", + "collectSpouseStatusRisks", + "formatSpouseStatusEvidence", + "renderSpouseStatusComparison", + "renderSpouseStatusCard", + "normalizeReplaySpouseStatus", + "spouseStatus", + "spouse_status_yoga.py", + "buildULDKTimingContext", + "buildULDKTimingSnapshot", + "normalizeDKPoint", + "normalizeULPoint", + "collectULDKTimingStrengths", + "collectULDKTimingRisks", + "formatULDKTimingEvidence", + "renderULDKTimingComparison", + "renderULDKTimingCard", + "normalizeReplayULDKTiming", + "ulDkTiming", + "UL/DK 与关系时机", + "buildRelationshipReportTemplate", + "relationshipKutaMeaning", + "renderRelationshipReport", + "renderRelationshipReportList", + "relationshipReport", + "relationship_report", + "strongestKutas", + "weakKutas", + "save-synastry-pair", + "export-synastry-pair", + "export-synastry-html", + "readPrashnaCaseLibrary", + "writePrashnaCaseLibrary", + "buildPrashnaCaseRecord", + "renderPrashnaCaseWorkspace", + "saveCurrentPrashnaCase", + "exportCurrentPrashnaCase", + "kp_horary", + "renderKPHoraryEvidence", + "ruling_planets", + "cuspal_sub_lord", + "house_significators", + "judgement_matrix", + "horary_number", + "KP Horary", + "save-prashna-case", + "export-prashna-case", + "exportWorkspaceCaseLibrary", + "workspace-export-cases", + "workspace-case-import-file", + "importWorkspaceCaseLibrary", + "mergeWorkspaceCaseLibrary", + "mergeLibraryById", + "workspace-case-import-status", + "workspace-case-search", + "workspace-case-type", + "workspace-case-group", + "workspace-case-relation", + "CASE_GROUP_PRESETS", + "CASE_RELATION_PRESETS", + "buildDefaultChartWorkspaceMeta", + "normalizeWorkspaceMeta", + "getCaseDefaultMeta", + "getCaseGroupLabel", + "getCaseRelationValue", + "getCaseRelationLabel", + "renderCaseMetaLine", + "formatWorkspaceCaseTitle", + "workspace-open-chart", + "filterCaseRecords", + "normalizeCaseSearchText", + "readCaseWorkspaceFilter", + "refreshCaseWorkspaceList", + "renderCaseWorkspaceRows", + "renderCaseWorkspaceCounts", + "_caseWorkspaceSelection", + "renderCaseSelectControl", + "workspace-toggle-case", + "workspace-select-visible", + "workspace-clear-selection", + "workspace-export-selected-cases", + "workspace-delete-selected-cases", + "workspace-edit-case", + "editWorkspaceCaseMetadata", + "getWorkspaceCaseStore", + "applyWorkspaceCaseMetadata", + "selectVisibleWorkspaceCases", + "exportSelectedWorkspaceCases", + "deleteSelectedWorkspaceCases", + "resolveSelectedCaseRecords", + "getVisibleCaseSelectionKeys", + "_caseWorkspacePreview", + "workspace-preview-case", + "workspace-clear-preview", + "previewWorkspaceCase", + "findWorkspaceCaseRecord", + "renderWorkspaceCasePreviewPanel", + "renderWorkspaceCasePreview", + "renderWorkspaceCasePreviewCards", + "renderChartCasePreviewCards", + "renderSynastryCasePreviewCards", + "renderPrashnaCasePreviewCards", + "renderCasePreviewCard", + "getCaseKindLabel", + "openSavedSynastryPair", + "deleteSavedSynastryPair", + "openSavedPrashnaCase", + "deleteSavedPrashnaCase", + "workspace-open-pair", + "workspace-delete-pair", + "workspace-open-prashna", + "workspace-delete-prashna", + "renderSavedSynastryPair", + "buildSynastryReplayDeep", + "normalizeReplayD9", + "switchToTab", + "case_library", + "PRODUCT_GAP_DOC", + "provenance-action", + "loadExportModule", + "exportHTMLReport", + "exportPDFReport", + "buildExportRecoveryMessage", + "getPDFExportRecoveryMessage", + "已改为导出 HTML 报告", + ]: + assert token in main + + for selector in [ + ".provenance-grid", + ".provenance-kv-grid", + ".provenance-table", + ".roadmap-grid", + ".panchanga-range-controls", + ".panchanga-month-grid", + ".panchanga-tag-row", + ".panchanga-activity-badge", + ".panchanga-condition-filter", + ".panchanga-condition-head", + ".panchanga-condition-options", + ".panchanga-condition-guide", + ".panchanga-festival-details", + ".panchanga-festival-card", + ".calculation-settings-panel", + ".calculation-settings-grid", + ".rule-variant-panel", + ".rule-variant-grid", + ".calculation-settings-save", + ".terminology-mode-preview", + ".terminology-mode-panel", + ".terminology-mode-options", + ".terminology-mode-option.active", + ".terminology-mode-save", + ".runtime-health-panel", + ".runtime-health-grid", + ".runtime-health-item", + ".runtime-health-note", + ".trust-center-panel", + ".trust-status-grid", + ".trust-status-card", + ".runtime-health-panel", + ".runtime-health-grid", + ".runtime-health-item", + ".runtime-health-note", + ".trust-center-copy", + ".provenance-action.danger", + ".panchanga-range-summary", + ".panchanga-week-table", + ".workspace-library", + ".workspace-chart-row", + ".synastry-library-panel", + ".synastry-pair-actions", + ".synastry-pair-workspace", + ".synastry-pair-row", + ".relationship-report-template", + ".relationship-report-grid", + ".relationship-report-sections", + ".relationship-evidence-card", + ".relationship-report-boundary", + ".biwheel-comparison-view", + ".biwheel-axis-grid", + ".biwheel-comparison-table", + ".composite-style-strip", + ".spouse-status-comparison", + ".spouse-status-grid", + ".spouse-status-card", + ".uldk-timing-comparison", + ".uldk-timing-grid", + ".uldk-timing-card", + ".prashna-case-actions", + ".prashna-case-workspace", + ".prashna-case-row", + ".case-workspace-summary", + ".case-row-actions", + ".mini-action", + ".case-replay-banner", + ".file-action", + ".workspace-import-status", + ".case-workspace-controls", + ".case-bulk-actions", + ".case-select-control", + ".case-preview-panel", + ".case-preview-grid", + ".case-preview-card", + ".case-meta-line", + ".tithi-lord-insight", + ".saved-chart-panel", + ".saved-chart-item", + ".btn-save-chart", + ".export-status", + ".export-item.is-exporting", + ]: + assert selector in style + + assert "provenance: extras.provenance" in export_js + assert "calculation_settings" in export_js + assert "provenance.nodeMode" in export_js + assert "provenance.houseSystem" in export_js + assert "provenance.sunrisePolicy" in export_js + assert "provenance.geocoderPolicy" in export_js + assert "provenance.ephemerisBackend" in export_js + assert "provenance.terminologyMode" in export_js + assert "provenance.yogaVariant" in export_js + assert "provenance.jaiminiKarakaVariant" in export_js + assert "provenance.kpSignificatorVariant" in export_js + assert "provenance.ashtakavargaVariant" in export_js + assert "provenance.shadbalaVariant" in export_js + assert "provenance.dashaReference" in export_js + assert "provenance.terminologyMode" in export_js + assert "modules.workflows" in export_js + assert "用户工作流结果" in export_js + assert "_kpWorkflowReport" in export_js + assert "_prashnaWorkflowReport" in export_js + assert "_synastryWorkflowReport" in export_js + assert "_relationshipReportBullets" in export_js + assert "_relationshipReportList" in export_js + assert "_relationshipBoundary" in export_js + assert "relationship_report" in export_js + assert "relationship-deliverable" in export_js + assert "relationship-evidence-grid" in export_js + assert "comparison-print-table" in export_js + assert "composite-print-grid" in export_js + assert "uldk-print-grid" in export_js + assert "UL/DK 与关系时机" in export_js + assert "spouse-print-grid" in export_js + assert "relationship-boundary" in export_js + assert "break-inside: avoid" in export_js + assert "UL/DK 时机" in main + assert "Jaimini · Dasha trigger" in main + assert "computeArudha(chart.planets" in main + assert "computeKaraka(chart.planets" in main + assert "_caseLibraryWorkflowReport" in export_js + assert "_dateLabel" in export_js + assert "KP Sublord" in export_js + assert "Prashna 问事" in export_js + assert "合盘 Synastry" in export_js + assert "exportHTMLReport" in export_js + assert "exportPDFReport" in export_js + assert "generateReportArtifact" in export_js + assert "formatReportArtifactStatus" in export_js + assert "downloaded_filename" in export_js + assert "result.download_filename || result.delivery?.filename" in export_js + assert "result.download_mime || result.delivery?.mime" in export_js + assert "result.user_message || delivery.user_message" in export_js + assert "delivery" in export_js + assert "downloadBase64File" in export_js + assert "pdf_base64" in export_js + assert "jyotish-report-" in export_js + assert 'data-format="html"' in read("index.html") + assert 'data-format="pdf"' in read("index.html") + assert 'id="export-status"' in read("index.html") + assert 'aria-live="polite"' in read("index.html") + assert 'rel="manifest"' in html + assert "/manifest.webmanifest" in html + assert "/pwa-icon.svg" in html + assert "theme-color" in html + assert '"display": "standalone"' in manifest + assert '"scope": "/"' in manifest + assert '"purpose": "any maskable"' in manifest + assert "jyotish-shell-v1" in sw + assert "self.addEventListener('install'" in sw + assert "caches.open(CACHE_NAME)" in sw + assert "url.pathname.startsWith('/api/')" in sw + assert "request.mode === 'navigate'" in sw + assert "cached || (request.mode === 'navigate' ? caches.match('/index.html') : undefined)" in sw + assert "/api/report_artifact" in read("api-bridge.js") + assert "_exportInProgress" in main + assert "setExportBusy" in main + + for token in [ + "TERMINOLOGY_MODE_LABELS", + "setGlossaryTerminologyMode", + "getGlossaryTerminologyMode", + "buildTerminologyDisplay", + "平衡模式", + "入门解释", + "专业模式保留中文、英文与 Sanskrit 名称", + ]: + assert token in glossary + assert "setExportStatus" in main + assert "getExportFormatLabel" in main + assert "clearExportStatusSoon" in main + assert "import('./export.js')" in main + assert "from './export.js'" not in main + assert "parseFloat($('birth-tz').value)" not in main + assert "window.confirm(`删除" in main + assert "window.confirm('清空本地星盘" in main + + +def test_mobile_layout_keeps_dense_sections_single_column() -> None: + style = read("style.css") + assert "@media" in style + for selector in [ + ".skill-stage-grid", + ".skill-module-grid", + ".capability-source-grid", + ".ux-queue-grid", + ".technique-directory-controls", + ".technique-directory-grid", + ".technique-explorer-actions", + ".technique-explorer-run", + ".technique-explorer-result", + ".technique-explorer-loading", + ".technique-example-badge", + ".thematic-report-grid", + ".thematic-report-card", + ".saved-chart-item", + ".technique-action-grid", + ".technique-insight-grid", + ".remedies-grid", + ".provenance-grid", + ".roadmap-grid", + ".panchanga-condition-options", + ".panchanga-condition-guide", + ".panchanga-festival-details", + ".calculation-settings-grid", + ".rule-variant-grid", + ".runtime-health-grid", + ".terminology-mode-options", + ".relationship-report-grid", + ".relationship-report-sections", + ".biwheel-axis-grid", + ".composite-style-strip", + ".spouse-status-grid", + ".uldk-timing-grid", + ".synastry-deep-grid", + ".synastry-d9-compare", + ".prashna-insight-grid", + ".prashna-advanced-grid", + ".prashna-saham-strip", + ".kp-house-grid", + ]: + assert selector in style + + assert ".technique-json pre" in style + assert "overflow: auto" in style + assert ".kp-focus-select { width: 100%; min-height: 44px; }" in style + assert ".synastry-deep-grid,\n .synastry-d9-compare { grid-template-columns: 1fr; }" in style + assert ".relationship-report-grid,\n .relationship-report-sections { grid-template-columns: 1fr; }" in style + assert ".technique-directory-controls,\n .technique-directory-grid { grid-template-columns: 1fr; }" in style + assert ".technique-insight-grid,\n .thematic-report-grid { grid-template-columns: 1fr; }" in style + assert ".technique-directory-card-head { flex-direction: column; }" in style + assert ".panchanga-condition-options,\n .panchanga-condition-guide,\n .panchanga-festival-details { grid-template-columns: 1fr; }" in style + assert ".panchanga-condition-filter { grid-column: span 1; }" in style + assert ".calculation-settings-grid,\n .rule-variant-grid,\n .first-use-grid,\n .runtime-health-grid,\n .trust-status-grid,\n .terminology-mode-options,\n .case-workspace-controls { grid-template-columns: 1fr; }" in style + assert ".case-workspace-counts,\n .case-bulk-actions,\n .workspace-chart-list,\n .case-preview-panel { max-width: 100%; }" in style + assert "健康检查通过:本地 API 服务、能力目录和 PWA 安装壳状态已记录" in read("main.js") + assert ".biwheel-axis-grid,\n .composite-style-strip { grid-template-columns: 1fr; }" in style + assert ".spouse-status-grid { grid-template-columns: 1fr; }" in style + assert ".uldk-timing-grid { grid-template-columns: 1fr; }" in style + assert ".prashna-insight-grid,\n .prashna-advanced-grid,\n .prashna-mini-grid,\n .prashna-saham-strip { grid-template-columns: 1fr; }" in style + assert ".kp-house-grid { grid-template-columns: 1fr; }" in style + assert ".saved-chart-item { grid-template-columns: 1fr; }" in style + assert ".btn-save-chart { min-height: 44px; padding: 8px 14px; }" in style + assert "overflow-wrap: anywhere" in style + + +def test_desktop_packaging_spike_is_documented_and_checkable() -> None: + spike = (ROOT / "docs" / "research" / "desktop_packaging_spike_2026_06_23.md").read_text(encoding="utf-8") + preflight = (ROOT / "scripts" / "desktop_packaging_preflight.py").read_text(encoding="utf-8") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + main = read("main.js") + gap_doc = (ROOT / "docs" / "research" / "product_gap_matrix_2026_06_22.md").read_text(encoding="utf-8") + + for token in [ + "PWA install", + "Pake shell", + "Tauri shell with sidecar", + "127.0.0.1:5200", + "scripts/desktop_packaging_preflight.py", + ]: + assert token in spike + + for token in [ + "manifest.webmanifest", + "jyotish-shell-v1", + "url.pathname.startsWith('/api/')", + "JYOTISH_API_HOST', '127.0.0.1'", + "pwa-install", + "tauri-sidecar-spike", + "first_launch_checks", + "tests/run_frontend_click_smoke.py", + "--mode", + "all", + "offline_recovery_guidance_visible", + "PWA installed shell", + "Pake first launch", + "Tauri sidecar readiness", + "toolchain_probe", + "non_destructive", + "rustc", + "cargo", + "xcodebuild", + "pake", + "tauri", + "GPL-3.0", + "signing_notarization", + ]: + assert token in preflight + + assert "desktop_packaging_spike_2026_06_23.md" in readme + assert "desktop_packaging_preflight.py" in readme + assert "tests/run_frontend_click_smoke.py --mode all" in readme + assert "普通用户启动路径" in readme + assert "先启动网页服务:cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173" in readme + assert "再启动本地 API 服务:python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200" in readme + assert "打开 Trust Center,点击运行健康检查" in readme + assert "PWA 安装壳只包装网页服务,本地 API 服务仍需单独启动" in readme + assert "安装后首次打开" in spike + assert "offline_recovery_guidance_visible" in spike + assert "Pake 适合快速 URL 壳" in main + assert "Packaging preflight" in main + assert "trust-run-health" in main + assert "getRuntimeHealthStatus" in main + assert "runTrustCenterHealthCheck" in main + assert "Completed spike: desktop packaging path is documented" in gap_doc + + quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8") + assert "普通用户启动路径" in quality_gate + assert "cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173" in quality_gate + assert "python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200" in quality_gate + + +def test_user_startup_labels_are_consistent_across_recovery_surfaces() -> None: + readme = (ROOT / "README.md").read_text(encoding="utf-8") + quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8") + app_text = "\n".join([ + read("main.js"), + read("api-bridge.js"), + read("public/api-bridge.js"), + read("ai-chat.js"), + read("auth.js"), + read("subscription.js"), + read("i18n.js"), + read("skill-map.js"), + ]) + for token in [ + "网页服务", + "本地 API 服务", + "PWA 安装壳", + "Trust Center", + "普通用户启动路径", + ]: + assert token in readme + assert token in quality_gate + assert token in app_text + for command in [ + "cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173", + "python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200", + ]: + assert command in readme + assert command in quality_gate + assert command not in app_text + for old_hint in [ + "npm run web", + "python3 scripts/jyotish_api_server.py --port 5200", + "PWA shell", + "PWA 壳", + "Local API", + ]: + assert old_hint not in app_text + + +def test_karaka_i18n_declares_7_and_8_karaka_convention_boundary() -> None: + html = read("index.html") + i18n = read("i18n.js") + for text in [ + "7-Karaka 与 8-Karaka 是两种传承口径", + "Rahu 是否纳入会改变部分角色归属", + "实际解读需先固定所用体系", + ]: + assert text in html + assert text in i18n + + +def test_frontend_backend_contracts_with_api_handler() -> None: + sys.path.insert(0, str(ROOT / "scripts")) + from jyotish_api_server import JyotishAPIHandler + + handler = object.__new__(JyotishAPIHandler) + audit = handler._capability_audit() + assert audit["productization"]["summary"]["productized"] == audit["registry"]["technique_count"] + assert audit["ux_productization"]["summary"]["excellent"] == audit["registry"]["technique_count"] + assert audit["priority_gaps"] == [] + + catalog = handler._technique_catalog() + assert catalog["summary"]["technique_count"] == audit["registry"]["technique_count"] + assert catalog["summary"]["runnable_count"] >= 20 + assert "/api/ashtakavarga" in catalog["filters"]["api_endpoints"] + assert "/api/thematic_report" in catalog["filters"]["api_endpoints"] + assert catalog["example_payloads"]["/api/ashtakavarga"]["planets"] + assert catalog["example_payloads"]["/api/thematic_report"]["theme"] == "marriage" + assert catalog["api_docs"]["/api/thematic_report"]["method"] == "POST" + assert "curl -sS -X POST" in catalog["api_docs"]["/api/thematic_report"]["curl"] + assert catalog["api_docs"]["/api/thematic_report"]["openapi"]["path"] == "/api/thematic_report" + assert any(row["method_docs"]["api_doc_key"] == "/api/thematic_report" for row in catalog["techniques"]) + example = handler._compute_technique_example({"endpoint": "/api/ashtakavarga"}) + assert example["endpoint"] == "technique_example" + assert example["target_endpoint"] == "/api/ashtakavarga" + assert example["result"]["summary"]["strongest_houses"] + thematic_example = handler._compute_technique_example({"endpoint": "/api/thematic_report"}) + assert thematic_example["target_endpoint"] == "/api/thematic_report" + assert thematic_example["result"]["endpoint"] == "thematic_report" + assert thematic_example["result"]["themes"]["marriage"]["summary"] + + chart = handler._compute_chart(sample_birth_payload()) + assert chart["success"] is True + assert chart["remedies"]["evidence_chain"] + assert chart["special_lagnas"] + assert chart["tithi_lord_analysis"]["tithi_lord"] + assert 0 <= chart["tithi_lord_analysis"]["tithi_score"] <= 1 + + payload = { + "planets": chart["planets"], + "ascendant": chart["ascendant"], + } + ashtakavarga = handler._compute_ashtakavarga(payload) + assert ashtakavarga["summary"]["strongest_houses"] + assert ashtakavarga["pav_summary"]["top_planets"] + assert ashtakavarga["sodhita_summary"]["top_signs"] + assert ashtakavarga["yoga_pinda_summary"]["top_planets"] + assert "yoga_pinda" in ashtakavarga["rule_variants"]["selected"] + assert ashtakavarga["result"]["pav"]["matrix_shape"] == {"planets": 7, "signs": 12, "sources": 8} + assert ashtakavarga["result"]["yoga_pinda"]["all_valid"] is True + + kp = handler._compute_kp(payload) + assert kp + assert "error" not in kp + + +def test_local_frontend_and_api_runtime_smoke() -> None: + api_port, web_port = runtime_ports() + origin = f"http://127.0.0.1:{web_port}" + api_log = TMP / f"jyotish-api-smoke-{api_port}.log" + web_log = TMP / f"jyotish-web-smoke-{web_port}.log" + api = start_process( + [ + sys.executable, + str(API_SERVER), + "--port", + str(api_port), + "--allow-origin", + origin, + ], + ROOT, + api_log, + ) + web = start_process( + [ + "npm", + "run", + "dev", + "--", + "--host", + "127.0.0.1", + "--port", + str(web_port), + ], + APP, + web_log, + ) + try: + try: + wait_for_url(f"http://127.0.0.1:{api_port}/api/health", logs=[api_log]) + wait_for_url(f"{origin}/", logs=[web_log]) + except AssertionError: + skip_if_sandbox_denies_bind(api_log) + skip_if_sandbox_denies_bind(web_log) + raise + + html = fetch_text(f"{origin}/") + assert 'id="birth-form"' in html + assert 'id="tab-kp"' in html + assert "/main.js" in html + assert "/api-bridge.js" in html + + health = fetch_json(f"http://127.0.0.1:{api_port}/api/health") + assert health["status"] == "ok" + assert "Remedies" in health["modules"] + assert "Ashtakavarga" in health["modules"] + + audit = fetch_json(f"http://127.0.0.1:{api_port}/api/capability_audit") + assert audit["productization"]["summary"]["productized"] == audit["registry"]["technique_count"] + assert audit["ux_productization"]["summary"]["excellent"] == audit["registry"]["technique_count"] + assert audit["priority_gaps"] == [] + + chart = post_json(f"http://127.0.0.1:{api_port}/api/chart", sample_birth_payload()) + assert chart["success"] is True + assert chart["planets"]["Moon"]["lon"] >= 0 + assert chart["remedies"]["evidence_chain"] + + av_payload = { + "planets": chart["planets"], + "ascendant": chart["ascendant"], + } + ashtakavarga = post_json(f"http://127.0.0.1:{api_port}/api/ashtakavarga", av_payload) + assert ashtakavarga["summary"]["strongest_houses"] + assert ashtakavarga["pav_summary"]["top_planets"] + assert ashtakavarga["sodhita_summary"]["top_signs"] + + kp = post_json(f"http://127.0.0.1:{api_port}/api/kp", av_payload) + assert kp + assert "error" not in kp + finally: + stop_process(web) + stop_process(api) diff --git a/tests/test_jaimini.py b/tests/test_jaimini.py index 801d72db..27287850 100644 --- a/tests/test_jaimini.py +++ b/tests/test_jaimini.py @@ -11,7 +11,7 @@ if SCRIPTS not in sys.path: from jaimini import ( calc_chara_karaka_7, calc_chara_karaka_8, calc_arudha_pada_for_house, calc_arudha_padas, calc_upapada, - calc_graha_padas, calc_karakamsha, calc_special_lagnas, + calc_graha_padas, calc_karakamsha, calc_special_lagnas, calc_special_lagnas_precise, calc_chara_dasha, calc_chara_dasha_with_antardasha, KARAKA_7, KARAKA_8, SIGNS, SIGN_LORDS, ) @@ -169,6 +169,27 @@ class TestSpecialLagnas: result = calc_special_lagnas(0, 10, 30) assert 'note' in result + def test_precise_special_lagnas_use_sunrise_reference(self): + result = calc_special_lagnas_precise( + 0, 1990, 6, 15, 10, 30, lat=28.6, lon=77.2, tz_offset=5.5 + ) + assert result['capability_status'] == 'covered' + assert result['precision'] == 'sunrise_correct' + assert 'sunrise_local_time' in result + assert result['ghatis_elapsed_from_sunrise'] > 0 + for key in ['HL', 'GL', 'VL']: + assert result[key]['sign'] in SIGNS + assert result[key]['lord'] in SIGN_LORDS.values() + + def test_precise_special_lagnas_are_time_sensitive(self): + morning = calc_special_lagnas_precise( + 0, 1990, 6, 15, 10, 0, lat=28.6, lon=77.2, tz_offset=5.5 + ) + later = calc_special_lagnas_precise( + 0, 1990, 6, 15, 11, 0, lat=28.6, lon=77.2, tz_offset=5.5 + ) + assert later['ghatis_elapsed_from_sunrise'] > morning['ghatis_elapsed_from_sunrise'] + # ── Graha Padas Tests ────────────────────────────────────────────── diff --git a/tests/test_muhurta.py b/tests/test_muhurta.py index e3bd320c..81709143 100644 --- a/tests/test_muhurta.py +++ b/tests/test_muhurta.py @@ -11,7 +11,9 @@ if SCRIPTS not in sys.path: from muhurta import ( calc_tithi, calc_nakshatra_from_lon, calc_yoga, calc_karana, calc_vara, calc_hora, calc_abhijit_muhurta, calc_panchanga, - check_activity_muhurta, muhurta_full_report, + check_activity_muhurta, muhurta_full_report, calc_daytime_inauspicious_periods, + panchanga_range_report, muhurta_range_search, calc_sunrise_sunset_local, calc_panchanga_end_times, + classify_vrata_tags, classify_panchanga_condition_tags, calc_choghadiya_windows, calc_hora_windows, TITHI_NAMES, TITHI_QUALITY, NAKSHATRAS, NAKSHATRA_TYPE, YOGA_NAMES, YOGA_QUALITY, KARANA_NAMES, KARANA_QUALITY, VARA_LORDS, ACTIVITY_RULES, @@ -197,6 +199,26 @@ class TestAbhijitMuhurta: assert 'abhijit_end_jd' in result +class TestDaytimeInauspiciousPeriods: + def test_monday_rahu_kala_uses_second_segment(self): + result = calc_daytime_inauspicious_periods(1, '06:00', '18:00') + assert result['rahu_kala']['segment'] == 2 + assert result['rahu_kala']['start'] == '07:30' + assert result['rahu_kala']['end'] == '09:00' + + def test_gulika_and_yamaganda_present(self): + result = calc_daytime_inauspicious_periods(4, '06:00', '18:00') + assert result['policy'] == 'daytime_8_segments_local_clock' + assert 'yamaganda' in result + assert 'gulika' in result + + def test_location_aware_sunrise_sunset(self): + result = calc_sunrise_sunset_local(2026, 6, 22, 28.6, 77.2, 5.5) + assert result['policy'] in {'swisseph_rise_trans', 'location_aware_solar_event_approximation'} + assert ':' in result['sunrise'] + assert ':' in result['sunset'] + + # ── Panchanga Complete Tests ──────────────────────────────────────── class TestPanchangaComplete: @@ -220,6 +242,95 @@ class TestPanchangaComplete: result = calc_panchanga(45.0, 120.0, 4, 6.0) assert isinstance(result['warnings'], list) + def test_panchanga_range_report_includes_inauspicious_periods(self): + result = panchanga_range_report('2026-06-22', '2026-06-24') + assert result['day_count'] == 3 + assert result['days'][0]['query_date'] == '2026-06-22' + assert result['days'][0]['inauspicious_periods']['rahu_kala']['label'] == 'Rahu Kala' + assert result['calculation_policy']['inauspicious_periods'] + + def test_panchanga_range_report_can_use_location_aware_solar_times(self): + result = panchanga_range_report('2026-06-22', '2026-06-22', lat=28.6, lon=77.2, tz=5.5) + assert result['location']['lat'] == 28.6 + assert result['calculation_policy']['sunrise_sunset'] in { + 'SwissEph rise_trans', + 'location-aware solar approximation', + 'mixed SwissEph rise_trans with approximation fallback', + } + assert 'solar_times' in result['days'][0] + assert 'end_times' in result['days'][0] + assert 'vrata_tags' in result['days'][0] + assert 'condition_tags' in result['days'][0] + assert 'festival_details' in result['days'][0] + assert 'search_summary' in result + assert 'condition_counts' in result['search_summary'] + assert 'choghadiya' in result['days'][0] + assert 'hora_windows' in result['days'][0] + + def test_panchanga_range_report_includes_month_grid_for_single_month(self): + result = panchanga_range_report('2026-06-01', '2026-06-30', activity='business', lat=28.6, lon=77.2, tz=5.5) + assert result['month_grid'] + cells = [cell for week in result['month_grid'] for cell in week if cell] + assert len(cells) == 30 + assert cells[0]['date'] == '2026-06-01' + assert 'best_activities' in cells[0] + assert 'vrata_tags' in cells[0] + assert 'condition_tags' in cells[0] + + def test_festival_details_explain_candidate_basis(self): + result = panchanga_range_report('2026-06-01', '2026-06-30', lat=28.6, lon=77.2, tz=5.5) + details = [detail for day in result['days'] for detail in day.get('festival_details', [])] + assert details + assert all('basis' in detail for detail in details) + assert any('confirmation_note' in detail for detail in details) + assert result['search_summary']['query_examples'] + + def test_panchanga_end_times_are_available_with_swisseph(self): + result = calc_panchanga_end_times(2026, 6, 22, 5.5, 11.4) + assert result is not None + assert result['policy'] == 'swisseph_boundary_bisection' + assert 'tithi' in result + assert 'ends_at' in result['tithi'] + + def test_basic_vrata_tags(self): + assert classify_vrata_tags({'tithi_num': 11, 'paksha': 'Shukla', 'name': 'Ekadashi'})[0]['key'] == 'ekadashi' + assert classify_vrata_tags({'tithi_num': 28, 'paksha': 'Krishna', 'name': 'Trayodashi'})[0]['key'] == 'pradosham' + assert classify_vrata_tags({'tithi_num': 30, 'paksha': 'Krishna', 'name': 'Amavasya'})[0]['key'] == 'amavasya' + + def test_richer_vrata_tags_use_nakshatra_and_vara(self): + tags = classify_vrata_tags( + {'tithi_num': 13, 'paksha': 'Shukla', 'name': 'Trayodashi'}, + {'nakshatra': 'Pushya'}, + {'vara': 'Thursday'}, + ) + keys = {tag['key'] for tag in tags} + assert {'pradosham', 'pushya_nakshatra', 'guru_pushya'} <= keys + + def test_vrata_tags_mark_masa_dependent_festivals_as_candidates(self): + tags = classify_vrata_tags({'tithi_num': 3, 'paksha': 'Shukla', 'name': 'Tritiya'}) + assert any(tag['key'] == 'akshaya_tritiya_candidate' and tag['type'] == 'festival_candidate' for tag in tags) + + def test_panchanga_condition_tags_are_searchable(self): + report = { + 'panchanga': {'warnings': ['avoid']}, + 'summary': {'best_activities': ['business'], 'warnings': ['avoid']}, + 'vrata_tags': [{'key': 'ekadashi', 'type': 'fasting'}], + 'activity_checks': {'business': {'verdict': '大吉(Excellent)'}}, + 'choghadiya': {'day': [{'quality': 'auspicious'}], 'night': []}, + } + keys = {tag['key'] for tag in classify_panchanga_condition_tags(report, activity='business')} + assert {'has_vrata', 'spiritual_practice', 'auspicious_activity', 'avoid_new_start', 'good_choghadiya', 'selected_activity_good'} <= keys + + def test_choghadiya_and_hora_windows(self): + choghadiya = calc_choghadiya_windows(1, '06:00', '18:00') + assert choghadiya['day'][0]['name'] == 'Amrit' + assert len(choghadiya['day']) == 8 + assert len(choghadiya['night']) == 8 + hora = calc_hora_windows(1, '06:00', '18:00') + assert hora['day'][0]['lord'] == 'Moon' + assert len(hora['day']) == 12 + assert len(hora['night']) == 12 + # ── Activity Muhurta Tests ───────────────────────────────────────── @@ -275,3 +386,27 @@ class TestMuhurtaFullReport: def test_query_date_preserved(self): result = muhurta_full_report(45.0, 120.0, 4, 6.0, '2026-06-04') assert result['query_date'] == '2026-06-04' + + +class TestMuhurtaRangeSearch: + def test_returns_ranked_activity_windows_for_date_range(self): + result = muhurta_range_search( + '2026-06-22', + '2026-06-28', + activity='business', + limit=3, + sunrise='06:00', + sunset='18:00', + ) + + assert result['mode'] == 'muhurta_date_range_solver' + assert result['activity'] == 'business' + assert result['date_range'] == {'start': '2026-06-22', 'end': '2026-06-28'} + assert result['candidate_count'] <= 3 + assert result['best_windows'] + assert result['rejected_dates'] + assert result['constraints']['avoid_inauspicious_periods'] is True + first = result['best_windows'][0] + assert {'date', 'score', 'quality', 'activity_verdict', 'recommended_windows', 'evidence'} <= set(first) + assert first['recommended_windows'] + assert first['evidence']['panchanga'] diff --git a/tests/test_tajika.py b/tests/test_tajika.py index 53579850..33be0022 100644 --- a/tests/test_tajika.py +++ b/tests/test_tajika.py @@ -12,7 +12,7 @@ if SCRIPTS not in sys.path: from tajika import ( calc_muntha, calc_year_lord, calc_mudda_dasha, calc_tri_pataka, calc_tajika_yogas, detect_tajika_yogas, - detect_vedha, calc_all_sahams, _is_faster, + detect_vedha, calc_all_sahams, calc_tajika_strength_layers, _is_faster, SIGNS, SIGN_LORDS, ) @@ -105,6 +105,38 @@ class TestTriPataka: assert 'year_lord' in result +# ── Harsha / Panchavargiya Bala Tests ───────────────────────────── + +class TestTajikaStrengthLayers: + def test_harsha_and_panchavargiya_strength_layers(self): + planet_lons = { + 'Sun': 10.0, + 'Moon': 45.0, + 'Mars': 92.0, + 'Mercury': 152.0, + 'Jupiter': 244.0, + 'Venus': 330.0, + 'Saturn': 300.0, + } + + result = calc_tajika_strength_layers(planet_lons, asc_lon=15.0, year_lord='Jupiter') + + assert result['method'] == 'Tajika Harsha/Panchavargiya Bala' + assert result['available_planets'] == 7 + assert 'harsha_bala' in result + assert 'panchavargiya_bala' in result + assert 'combined_strength' in result + assert result['summary']['strongest_planets'] + assert result['summary']['weakest_planets'] + assert result['summary']['next_action'] + + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'): + assert planet in result['harsha_bala'] + assert planet in result['panchavargiya_bala'] + assert {'score', 'max_score', 'grade', 'components'} <= set(result['combined_strength'][planet]) + assert result['combined_strength'][planet]['max_score'] > 0 + + # ── Tajika Yogas Tests ───────────────────────────────────────────── class TestTajikaYogas: