diff --git a/.gitignore b/.gitignore index 1f070d12..ea794e14 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ jyotish-app/dist/ brain/ COVERAGE_AUDIT_REPORT.md *.tmp +.env.local +.jyotish.local.env .coverage coverage.xml htmlcov/ diff --git a/README.md b/README.md index f4b2f1d4..e7f4109a 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,17 @@ This is a **Vedic (Jyotish) astrology analysis system** designed for deep, audit 4. 如果只安装 PWA:PWA 安装壳只包装网页服务,本地 API 服务仍需单独启动;无 API 时网页会保留基础浏览器 fallback,但 PDF/高级技法需要本地 API 服务。 5. 开发者做完整自检时运行:`python3 scripts/run_quality_gate.py --frontend-click-timeout 240`。 +如需把网页里的 `VedAstro Range Scan` 按钮切到真实外部响应模式,可在仓库根目录创建本机私有配置文件 `.env.local`: + +```bash +VEDASTRO_API_ENDPOINT=https://api.vedastro.org/api +VEDASTRO_ENABLE_NETWORK=1 +# 可选 +# VEDASTRO_API_KEY=sk_live_xxx +``` + +该文件已被 `.gitignore` 忽略;`scripts/jyotish_api_server.py`、`scripts/vedastro_service_adapter.py`、`scripts/run_quality_gate.py` 会自动加载它。注意:当前网页按钮走的是官方 `SearchEvents` 的真实 live 采样扫描桥接,不会让 VedAstro 越权改本地 adjudicator 的 `score / dominant_label / payout_label`。 + ### 普通用户交付形态 | 形态 | 入口 | 命令 | 能力边界 | diff --git a/docs/research/ACTIVE_FRONTS.md b/docs/research/ACTIVE_FRONTS.md index 2ce7f9e9..74673e77 100644 --- a/docs/research/ACTIVE_FRONTS.md +++ b/docs/research/ACTIVE_FRONTS.md @@ -2,6 +2,20 @@ This file is the small index for the current engineering fronts that still drive code changes. +## Shortest-Path Closure Order (2026-06-29) + +To avoid scope drift, current work stays inside these four closure lanes only: + +1. Relationship adjudicator closure +2. Vimsopaka + functional-role closure +3. Oracle closure batch +4. VedAstro strict ingestion + +Reference plan: +- `/Users/wuyongnaren/Documents/印度占星/docs/superpowers/plans/2026-06-29-shortest-path-closure-plan.md` + +Do not open new product surfaces before at least one of these four lanes is closed. + ## Fragment Discipline - Before touching strict workflow or adjudicator logic, check: diff --git a/jyotish-app/rectification-engine.js b/jyotish-app/rectification-engine.js index a5caf79a..793959eb 100644 --- a/jyotish-app/rectification-engine.js +++ b/jyotish-app/rectification-engine.js @@ -251,6 +251,41 @@ function buildRectificationDecisionPlan(events) { }; } +export function buildRectificationInterviewQuestions() { + return EVENT_COLLECTION_GUIDE.map(group => { + const category = group.categories.find(key => EVENT_CATEGORIES[key]) || group.categories[0]; + const cat = EVENT_CATEGORIES[category] || {}; + return { + id: `guided_rectification_interview_${group.key}`, + group: group.key, + category, + varga: cat.varga || 'D1', + label_cn: group.cn, + label_en: group.en, + question_cn: `你的人生中是否发生过日期比较明确的${group.cn}?`, + question_en: `Have you had a dated ${group.en} event?`, + examples_cn: group.categories + .map(key => EVENT_CATEGORIES[key]?.cn) + .filter(Boolean) + .slice(0, 4), + }; + }); +} + +export function rectificationInterviewAnswersToEvents(answers) { + return (answers || []) + .filter(answer => answer?.answer === 'yes' && answer.date && EVENT_CATEGORIES[answer.category]) + .map(answer => { + const cat = EVENT_CATEGORIES[answer.category]; + return { + date: answer.date, + category: answer.category, + desc: (answer.note || '').trim() || cat.cn, + source: 'guided_rectification_interview', + }; + }); +} + function buildRectificationAudit(best, results, events) { const second = results[1] || null; const coverage = summarizeEventCoverage(events); diff --git a/jyotish-app/rectification.js b/jyotish-app/rectification.js index ca5b0935..45d59beb 100644 --- a/jyotish-app/rectification.js +++ b/jyotish-app/rectification.js @@ -5,6 +5,7 @@ import { SIGNS, PLANET_CN, SIGN_LORDS } from './jyotish-engine.js'; import { EVENT_CATEGORIES, EVENT_COLLECTION_GUIDE, VARGA_SENSITIVITY, runRectification, + buildRectificationInterviewQuestions, rectificationInterviewAnswersToEvents, getHouseLord, fmtTime, dateToJD } from './rectification-engine.js'; import { t, getLang, signName, planetName } from './i18n.js'; @@ -13,6 +14,7 @@ import { escapeHtml, escapeAttr } from './security.js'; function fmtOffset(m) { return m === 0 ? t('rect.baseline') : `${m > 0 ? '+' : ''}${m}min`; } let rectEvents = []; +let rectInterviewAnswers = {}; export function renderRectificationTab(container) { const lang = getLang(); @@ -25,6 +27,8 @@ export function renderRectificationTab(container) {

${t('rect.config')}

+
+
+ +
+
`; +} + +function bindInterviewEvents(container) { + container.querySelectorAll('.rect-interview-item').forEach(item => { + item.querySelectorAll('[data-rect-answer]').forEach(btn => { + btn.addEventListener('click', () => { + const answer = btn.dataset.rectAnswer; + item.querySelectorAll('[data-rect-answer]').forEach(other => other.classList.toggle('active', other === btn)); + item.querySelector('.rect-answer-detail')?.classList.toggle('hidden', answer !== 'yes' && answer !== 'other'); + rectInterviewAnswers[item.dataset.questionId] = { + answer, + category: item.dataset.category, + }; + }); + }); + }); + container.querySelector('#rect-import-interview')?.addEventListener('click', () => { + const events = collectInterviewEvents(container); + if (!events.length) { + const status = container.querySelector('#rect-interview-status'); + if (status) status.textContent = '还没有可导入的“是”回答。'; + return; + } + rectEvents = [...rectEvents, ...events]; + renderEventList(container); + const status = container.querySelector('#rect-interview-status'); + if (status) status.textContent = `已加入 ${events.length} 个事件。`; + }); +} + +function collectInterviewEvents(container) { + const answers = [...container.querySelectorAll('.rect-interview-item')].map(item => { + const saved = rectInterviewAnswers[item.dataset.questionId] || {}; + return { + ...saved, + category: saved.category || item.dataset.category, + date: item.querySelector('.rect-answer-date')?.value || '', + note: item.querySelector('.rect-answer-note')?.value || '', + }; + }); + return rectificationInterviewAnswersToEvents(answers); +} + function renderEventList(container) { const listEl = container.querySelector('#rect-event-list'); const countEl = container.querySelector('#rect-event-count'); @@ -400,4 +483,4 @@ function showOffsetDetail(container, r, base) { det.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } -export function initRectification() { rectEvents = []; } +export function initRectification() { rectEvents = []; rectInterviewAnswers = {}; } diff --git a/jyotish-app/style.css b/jyotish-app/style.css index 9041d2ac..c8f6cf08 100644 --- a/jyotish-app/style.css +++ b/jyotish-app/style.css @@ -4626,6 +4626,97 @@ body { font-family: var(--font-body); background: var(--bg-page); color: var(--t .rect-config-row { display: flex; gap: 16px; flex-wrap: wrap; } .rect-config-row .form-group { flex: 1; min-width: 140px; } +.rect-interview { margin-bottom: 16px; } +.rect-interview-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 12px; +} +.rect-interview-head p { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} +.rect-interview-head span { + flex: 0 0 auto; + padding: 3px 8px; + border-radius: 6px; + background: var(--bg-surface); + border: 1px solid var(--border-light); + color: var(--text-muted); + font-size: 10px; +} +.rect-interview-list { display: grid; gap: 8px; } +.rect-interview-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + padding: 10px; + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} +.rect-interview-copy strong { + display: block; + color: var(--text-heading); + font-size: 13px; +} +.rect-interview-copy span { + display: block; + color: var(--text-muted); + font-size: 11px; + margin-top: 3px; +} +.rect-answer-group { + display: flex; + align-items: center; + gap: 6px; +} +.rect-answer-group button, +.rect-secondary-btn { + padding: 6px 10px; + border: 1px solid var(--border-card); + border-radius: 6px; + background: var(--bg-white); + color: var(--text-secondary); + cursor: pointer; + font-family: var(--font-body); + font-size: 12px; +} +.rect-answer-group button.active, +.rect-answer-group button:hover, +.rect-secondary-btn:hover { + border-color: var(--accent); + color: var(--text-heading); +} +.rect-answer-detail { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 8px; +} +.rect-answer-detail input { + padding: 8px 10px; + border: 1px solid var(--border-input); + border-radius: 6px; + background: var(--bg-white); + color: var(--text-heading); + font-family: var(--font-body); + font-size: 12px; +} +.rect-interview-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; +} +.rect-interview-actions span { + color: var(--text-secondary); + font-size: 12px; +} + .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; } diff --git a/mcp_server.py b/mcp_server.py index 6e639c15..10b4e20a 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -1830,33 +1830,32 @@ def _maybe_attach_vedastro_evidence( return result try: - from vedastro_service_adapter import run_range_scan_for_case + from vedastro_evidence_orchestrator import orchestrate_vedastro_evidence except Exception: return result start_date, end_date = _default_vedastro_scan_window(transit_date) - case = { - "year": year, - "month": month, - "day": day, - "hour": hour, - "minute": minute, - "second": 0, - "lat": lat, - "lon": lon, - "tz": tz, - "ayanamsa_policy": ( - _safe_get(result, "meta", "ayanamsa") - or _safe_get(result, "chart", "ayanamsa") - or "lahiri" - ), - "node_policy": node_mode or "mean", - } - scan_result = run_range_scan_for_case( - case, - vedastro_domain, - start_date, - end_date, + scan_result = orchestrate_vedastro_evidence( + { + "year": year, + "month": month, + "day": day, + "hour": hour, + "minute": minute, + "second": 0, + "lat": lat, + "lon": lon, + "tz": tz, + "ayanamsa_policy": ( + _safe_get(result, "meta", "ayanamsa") + or _safe_get(result, "chart", "ayanamsa") + or "lahiri" + ), + "node_policy": node_mode or "mean", + }, + route=route, + start_date=start_date, + end_date=end_date, case_id=f"strict_workflow_{route}", ) if not isinstance(scan_result, dict): diff --git a/progress.md b/progress.md index 1fb3e518..20ff2234 100644 --- a/progress.md +++ b/progress.md @@ -481,3 +481,14 @@ - 实测真相:官方公共 MCP `initialize` 与 `tools/list` 已通过真实 HTTP 返回;Python bridge 的 `DasaAtTime` 通过 positional args 已成功返回 Vimshottari 快照;`GetCharaDasaAtTime` 的 bound-method 形状已进一步探明,其真实桥接仍需按 positional contract 单独收口,但不再属于“完全未知调用面”。 - 本地主线真相同步:补齐 `references/event_judgment_career.md`,并将其挂入 `SKILL.md`、`references/quick-reference-guide.md` 与 `docs/research/ACTIVE_FRONTS.md`。这意味着 career 线不再缺“专用裁决骨架”,后续尾巴集中在裁决细化、报告渲染与 oracle 闭环,而非入口缺失。 - Fresh oracle status re-check:`python3 scripts/oracle_closure_master_dashboard.py --format json` 当前显示 `12` 个总任务中 `8` 个已 external_verified、`4` 个未闭合;未闭合核心已收缩到 `tajika_sahams` 前线,而非 Dasha/Shadbala 主前线。`python3 scripts/public_benchmark_dashboard.py --format json` 当前显示 `valid_packets=5`、`ready_for_calibration=5`、`production_tuning_allowed=false`,因此仍不能宣称全局 oracle 已封顶。 + +## 2026-06-29T08:20:00+08:00 - 项目进度深度检测 + +- 按用户要求深度检测当前印度占星项目进度;读取 `task_plan.md`、`findings.md`、`progress.md`、`ACTIVE_FRONTS.md`、VedAstro parity/fast-path 文档与 oracle benchmark inventory。 +- Git 状态确认:当前分支 `codex/release-hygiene-ci` 与远端同步,HEAD 为 `d0af9f4 Productize VedAstro user range scan entry`,工作区无未提交 diff。 +- 能力面确认:`python3 scripts/audit_capabilities.py --mode validate` 通过,注册表 `89` 个技法中 `10 complete / 79 covered / 0 missing / 0 partial / 0 not-integrated`。 +- 碎片面确认:`python3 scripts/audit_fragments.py --strict` 通过,`engine_command_count=37`、`api_endpoint_count=43`、`candidate_count=0`、`untracked_count=0`。 +- Oracle 面确认:`oracle_closure_master_dashboard` 显示 `12` 个任务中 `8` 个 external_verified、`4` 个 open;Dasha 与 Shadbala 当前可声明对应 front closure,但全局 oracle closure 仍为 false,生产调参仍不允许。 +- VedAstro 面确认:当前已进入可点击用户入口和 adapter/provenance 阶段;官方 MCP/Python bridge 有真实可达记录,REST range scan 的官方 endpoint smoke 仍依赖 `VEDASTRO_API_ENDPOINT` 与 `VEDASTRO_ENABLE_NETWORK=1`。 +- 质量门确认:`python3 scripts/run_quality_gate.py --profile quick --skip-frontend-runtime` 通过,聚焦集合 `297 passed`;`env -u VEDASTRO_API_ENDPOINT -u VEDASTRO_ENABLE_NETWORK python3 scripts/run_quality_gate.py --profile vedastro-live` 通过但返回受控 `blocked`,不得宣称官方实网 VedAstro range scan 已闭环。 +- 当前最值钱下一步:继续推进 VedAstro range scan 的官方样本调参和 EventTagList 映射质量,同时补 Tajika/Sahams 4 个未闭合外部 oracle 任务;之后再处理 Vimsopaka 高阶语义映射、Life Event Graph v1 产品化和报告渲染 polish。 diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 088706e6..787ec60b 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -14,7 +14,6 @@ import io import json, sys, os, math import importlib.util import re -from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse @@ -57,7 +56,7 @@ def _attach_vedastro_main_entry_overview(chart_result, birth_payload): return chart_result try: - adapter = _load_local_module('vedastro_service_adapter') + orchestrator = _load_local_module('vedastro_evidence_orchestrator') except Exception: return chart_result @@ -66,9 +65,7 @@ def _attach_vedastro_main_entry_overview(chart_result, birth_payload): or birth_payload.get('today') or datetime.utcnow().strftime('%Y-%m-%d') )[:10] - start_date = datetime.strptime(reference_date, '%Y-%m-%d').date() - end_date = start_date - case = { + modules['vedastro_range_scan_result'] = orchestrator.orchestrate_vedastro_evidence({ 'year': birth_payload.get('year'), 'month': birth_payload.get('month'), 'day': birth_payload.get('day'), @@ -80,87 +77,7 @@ def _attach_vedastro_main_entry_overview(chart_result, birth_payload): 'tz': birth_payload.get('tz'), 'ayanamsa_policy': birth_payload.get('ayanamsa') or 'lahiri', 'node_policy': birth_payload.get('node_mode') or birth_payload.get('nodeMode') or 'mean', - } - - def _scan_domain(domain: str): - return domain, adapter.run_range_scan_for_case( - case, - domain=domain, - start_date=start_date.isoformat(), - end_date=end_date.isoformat(), - case_id=f"api_chart_{domain}", - ) - - domain_reports = {} - combined_events = [] - domain_statuses = {} - top_events = {} - failure_reason = None - availability = True - - with ThreadPoolExecutor(max_workers=3) as executor: - for domain, domain_report in executor.map(_scan_domain, ('career', 'marriage', 'wealth')): - domain_reports[domain] = domain_report - for domain in ('career', 'marriage', 'wealth'): - domain_report = domain_reports[domain] - domain_statuses[domain] = domain_report.get('status') - availability = availability and bool(domain_report.get('available', False)) - if domain_report.get('status') != 'ok' and failure_reason is None: - failure_reason = domain_report.get('reason') - for event in domain_report.get('evidence_ledger') or []: - if isinstance(event, dict): - combined_events.append(event) - top_event = domain_report.get('top_event') - if isinstance(top_event, dict): - top_events[domain] = top_event - - primary_status = next( - ( - domain_reports[domain].get('status') - for domain in ('career', 'marriage', 'wealth') - if domain_reports.get(domain, {}).get('status') == 'ok' - ), - domain_reports.get('marriage', {}).get('status') or 'blocked', - ) - source_metadata = { - 'ingestion_profile': 'main_entry_overview', - 'search_scope': 'single_day_overview', - 'reference_date': reference_date, - 'scan_window': {'start': start_date.isoformat(), 'end': end_date.isoformat()}, - 'domain_statuses': domain_statuses, - 'domain_event_counts': { - domain: int((domain_reports.get(domain, {}) or {}).get('event_count', 0) or 0) - for domain in ('career', 'marriage', 'wealth') - }, - } - for domain in ('career', 'marriage', 'wealth'): - metadata = domain_reports.get(domain, {}).get('source_metadata') - if isinstance(metadata, dict): - for key in ( - 'endpoint', - 'endpoint_host', - 'transport', - 'provenance_mode', - 'timeout_seconds', - 'retry_policy', - ): - if key in metadata and key not in source_metadata: - source_metadata[key] = metadata[key] - - modules['vedastro_range_scan_result'] = { - 'backend': 'vedastro_service_adapter_candidate', - 'available': availability, - 'status': primary_status, - 'operation': 'range_scan', - 'domain': 'overview', - 'event_count': len(combined_events), - 'top_event': top_events.get('marriage') or next(iter(top_events.values()), None), - 'top_events_by_domain': top_events, - 'evidence_ledger': combined_events, - 'source_metadata': source_metadata, - 'reason': failure_reason, - 'domain_reports': domain_reports, - } + }, route='overview', reference_date=reference_date, case_id='api_chart') return chart_result diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 26f40de6..02d34682 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -13,6 +13,10 @@ import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from local_env import load_local_env + +load_local_env(ROOT) APP = ROOT / "jyotish-app" PYTHON = sys.executable diff --git a/scripts/vedastro_evidence_orchestrator.py b/scripts/vedastro_evidence_orchestrator.py new file mode 100644 index 00000000..6cebe9a0 --- /dev/null +++ b/scripts/vedastro_evidence_orchestrator.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Shared VedAstro evidence orchestration layer. + +The orchestrator does not expose hundreds of VedAstro nodes to users. It picks +the smallest useful domain scan set for the current route and delegates the +actual external boundary to ``vedastro_service_adapter``. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +try: + from scripts.vedastro_service_adapter import ( + VEDASTRO_CALCULATION_COVERAGE, + run_range_scan_for_case, + ) +except ModuleNotFoundError: # pragma: no cover - script execution path + from vedastro_service_adapter import VEDASTRO_CALCULATION_COVERAGE, run_range_scan_for_case + + +ROUTE_DOMAIN_MAP = { + "relationship": ["marriage"], + "marriage": ["marriage"], + "career": ["career"], + "finance": ["wealth"], + "wealth": ["wealth"], + "rectification": ["marriage", "career", "wealth"], + "timing": ["career", "marriage", "wealth"], + "general": ["career", "marriage", "wealth"], + "overview": ["career", "marriage", "wealth"], +} + + +def _default_window(reference_date: str | None, days: int = 180) -> tuple[str, str]: + raw = str(reference_date or datetime.utcnow().strftime("%Y-%m-%d"))[:10] + try: + start = datetime.strptime(raw, "%Y-%m-%d").date() + except ValueError: + return raw, raw + return start.isoformat(), (start + timedelta(days=days)).isoformat() + + +def _normalize_case(birth_payload: dict[str, Any]) -> dict[str, Any]: + return { + "year": birth_payload.get("year"), + "month": birth_payload.get("month"), + "day": birth_payload.get("day"), + "hour": birth_payload.get("hour"), + "minute": birth_payload.get("minute"), + "second": birth_payload.get("second", 0), + "lat": birth_payload.get("lat"), + "lon": birth_payload.get("lon"), + "tz": birth_payload.get("tz"), + "ayanamsa_policy": birth_payload.get("ayanamsa_policy") + or birth_payload.get("ayanamsa") + or "lahiri", + "node_policy": birth_payload.get("node_policy") + or birth_payload.get("node_mode") + or birth_payload.get("nodeMode") + or "mean", + } + + +def orchestrate_vedastro_evidence( + birth_payload: dict[str, Any], + *, + route: str = "general", + reference_date: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + case_id: str = "vedastro_orchestrator", +) -> dict[str, Any]: + domains = ROUTE_DOMAIN_MAP.get(route, ROUTE_DOMAIN_MAP["general"]) + window_start, window_end = (start_date, end_date) if start_date and end_date else _default_window(reference_date) + case = _normalize_case(birth_payload) + domain_reports: dict[str, Any] = {} + evidence_ledger: list[dict[str, Any]] = [] + top_events_by_domain: dict[str, Any] = {} + domain_statuses: dict[str, Any] = {} + domain_event_counts: dict[str, int] = {} + available = False + first_reason = None + + for domain in domains: + report = run_range_scan_for_case( + case, + domain, + str(window_start), + str(window_end), + case_id=f"{case_id}_{domain}", + ) + domain_reports[domain] = report + domain_statuses[domain] = report.get("status") + domain_event_counts[domain] = int(report.get("event_count", 0) or 0) + available = available or bool(report.get("available")) + first_reason = first_reason or report.get("reason") + if isinstance(report.get("top_event"), dict): + top_events_by_domain[domain] = report["top_event"] + for event in report.get("evidence_ledger") or []: + if isinstance(event, dict): + evidence_ledger.append(event) + + status = "ok" if any(item == "ok" for item in domain_statuses.values()) else next(iter(domain_statuses.values()), "blocked") + return { + "backend": "vedastro_service_adapter_candidate", + "available": available, + "status": status, + "operation": "range_scan", + "domain": domains[0] if len(domains) == 1 else "overview", + "route": route, + "event_count": len(evidence_ledger), + "top_event": next(iter(top_events_by_domain.values()), None), + "top_events_by_domain": top_events_by_domain, + "evidence_ledger": evidence_ledger, + "reason": None if status == "ok" else first_reason, + "domain_reports": domain_reports, + "source_metadata": { + "auto_ingested_by": "VedAstroEvidenceOrchestrator", + "strategy": "minimal_route_scoped_orchestration", + "node_coverage": { + "strategy": "domain_scoped_range_scan", + "official_calculation_coverage": VEDASTRO_CALCULATION_COVERAGE, + "selected_domains": domains, + "not_user_exposed": True, + }, + "route": route, + "scan_window": {"start_date": str(window_start), "end_date": str(window_end)}, + "domain_statuses": domain_statuses, + "domain_event_counts": domain_event_counts, + }, + } diff --git a/scripts/vedastro_service_adapter.py b/scripts/vedastro_service_adapter.py index dc5be9f9..812c8144 100644 --- a/scripts/vedastro_service_adapter.py +++ b/scripts/vedastro_service_adapter.py @@ -9,6 +9,7 @@ workspace can evolve from research notes to an executable adapter contract. from __future__ import annotations import argparse +import http.client import hashlib import json import os @@ -19,8 +20,14 @@ from typing import Any from urllib import request, error from urllib.parse import urlparse +try: + from scripts.local_env import load_local_env +except ModuleNotFoundError: # pragma: no cover - script execution path + from local_env import load_local_env + ROOT = Path(__file__).resolve().parents[1] +load_local_env(ROOT) PARITY_CASES = { @@ -493,9 +500,11 @@ def _range_scan_preview(case: dict[str, Any], domain: str, start_date: str, end_ "start_date": start_date, "end_date": end_date, "event_model": "vedastro_events_at_range_candidate", + "search_mode": "single_point", **case, } preview["official_request_profile"] = _build_official_search_events_profile(preview) + preview["live_sampling_request_profile"] = _build_live_sampling_search_events_profile(preview) return preview @@ -559,6 +568,28 @@ def _build_official_search_events_profile(request_preview: dict[str, Any]) -> di } +def _build_live_sampling_search_events_profile(request_preview: dict[str, Any]) -> dict[str, Any]: + case = dict(request_preview) + case["tz"] = _normalize_tz(case) + body = { + "BirthTime": _time_json_from_case(case, f"{case['year']:04d}-{case['month']:02d}-{case['day']:02d}"), + "Ayanamsa": str(case.get("ayanamsa_policy") or "lahiri"), + "EventTagList": OFFICIAL_RANGE_SCAN_EVENT_TAGS.get(str(request_preview.get("domain") or ""), ["General"]), + "AtTime": _time_json_from_case(case, str(request_preview["start_date"])), + } + headers: dict[str, str] = {"Content-Type": "application/json"} + api_key = os.environ.get("VEDASTRO_API_KEY", "").strip() + if api_key: + headers["x-api-key"] = api_key + return { + "profile_version": f"{OFFICIAL_SEARCH_EVENTS_PROFILE_VERSION}_live_sampling", + "endpoint_path": OFFICIAL_SEARCH_EVENTS_ENDPOINT_PATH, + "method": OFFICIAL_SEARCH_EVENTS_METHOD, + "headers": headers, + "body": body, + } + + def _external_technique_preview( case: dict[str, Any], domain: str, @@ -685,9 +716,15 @@ def _normalize_range_scan_success( attempt_count: int = 1, retry_error_codes: list[int] | None = None, ) -> dict[str, Any]: - # Handle actual VedAstro response format: {"Status": "Pass", "Payload": [...]} + # Handle actual VedAstro response formats: + # {"Status": "Pass", "Payload": {"SearchEvents": [...]}} + # {"Status": "Pass", "Payload": [...]} if payload.get("Status") == "Pass": - events = payload.get("Payload", []) + payload_body = payload.get("Payload", []) + if isinstance(payload_body, dict): + events = payload_body.get("SearchEvents", []) + else: + events = payload_body else: # Fallback to local stub format if not VedAstro format events = payload.get("events", []) @@ -716,10 +753,12 @@ def _normalize_range_scan_success( for index, event in enumerate(events, start=1): if not isinstance(event, dict): continue - # VedAstro uses "Name" for event id, and "EventTags" for tags + # VedAstro uses "Name" for event id, and may expose tags as EventTags, tags, or Tag. event_id = event.get("Name") or event.get("id") or event.get("name") or f"event_{index}" - tags = event.get("EventTags") or event.get("tags") or [] - if not isinstance(tags, list): + tags = event.get("EventTags") or event.get("tags") or event.get("Tag") or [] + if isinstance(tags, str): + tags = [part.strip() for part in tags.split(",") if part.strip()] + elif not isinstance(tags, list): tags = [] tag_set = {str(tag) for tag in tags} matched_by = "rejected" @@ -865,7 +904,12 @@ def _source_metadata(endpoint: str) -> dict[str, Any]: def _post_json(endpoint: str, request_preview: dict[str, Any]) -> dict[str, Any] | str: - official_request_profile = request_preview.get("official_request_profile") if isinstance(request_preview, dict) else None + official_request_profile = None + if isinstance(request_preview, dict): + official_request_profile = ( + request_preview.get("live_sampling_request_profile") + or request_preview.get("official_request_profile") + ) request_url = endpoint headers = {"Content-Type": "application/json"} vedastro_payload = request_preview @@ -913,6 +957,97 @@ def _post_json_with_retry(endpoint: str, request_preview: dict[str, Any]) -> tup return {}, max_attempts, retry_error_codes +def _iter_sample_dates(start_date: str, end_date: str) -> list[str]: + from datetime import datetime, timedelta + + start = datetime.strptime(start_date, "%Y-%m-%d").date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() + if end <= start: + return [start_date] + span_days = max((end - start).days, 1) + step_days = max(1, span_days // 11) + dates: list[str] = [] + current = start + while current <= end: + dates.append(current.isoformat()) + current += timedelta(days=step_days) + if dates[-1] != end.isoformat(): + dates.append(end.isoformat()) + return dates + + +def _merge_range_scan_reports( + reports: list[dict[str, Any]], + endpoint: str, + base_preview: dict[str, Any], +) -> dict[str, Any]: + if not reports: + return _normalize_range_scan_success({"Status": "Pass", "Payload": []}, endpoint, base_preview) + + deduped: dict[tuple[str, str, str], dict[str, Any]] = {} + for report in reports: + for item in report.get("evidence_ledger", []): + key = ( + str(item.get("event_id") or ""), + str(item.get("start") or ""), + str(item.get("end") or ""), + ) + existing = deduped.get(key) + existing_score = existing.get("signal_lift", 0) if existing else -1 + score = item.get("signal_lift", 0) + if existing is None or score > existing_score: + deduped[key] = item + + evidence_ledger = list(deduped.values()) + top_event = None + if evidence_ledger: + top = max( + evidence_ledger, + key=lambda item: ( + item.get("signal_lift") if isinstance(item.get("signal_lift"), (int, float)) else 0, + item.get("confidence") == "high", + ), + ) + top_event = { + "event_id": top.get("event_id"), + "signal_key": top.get("signal_key"), + "signal_label": top.get("signal_label"), + "signal_family": top.get("signal_family"), + "score": top.get("score"), + "start": top.get("start"), + "end": top.get("end"), + "tags": top.get("tags") or [], + } + + metadata = dict(reports[-1].get("source_metadata") or {}) + metadata["sampling_mode"] = "at_time_sweep" + metadata["sample_dates"] = [report.get("request_preview", {}).get("start_date") for report in reports] + metadata["sample_count"] = len(reports) + metadata["raw_event_count"] = sum(int((report.get("source_metadata") or {}).get("raw_event_count", 0)) for report in reports) + metadata["filtered_event_count"] = len(evidence_ledger) + metadata["allowlist_event_count"] = len(evidence_ledger) + metadata["attempt_count"] = sum(int((report.get("source_metadata") or {}).get("attempt_count", 1)) for report in reports) + retry_codes: list[int] = [] + for report in reports: + retry_codes.extend(list((report.get("source_metadata") or {}).get("retry_error_codes") or [])) + metadata["retry_error_codes"] = retry_codes + + result = { + "backend": "vedastro_service_adapter_candidate", + "available": True, + "status": "ok", + "operation": "range_scan", + "domain": base_preview.get("domain"), + "request_preview": base_preview, + "event_count": len(evidence_ledger), + "top_event": top_event, + "evidence_ledger": evidence_ledger, + "source_metadata": metadata, + } + result["source_metadata"]["artifact_path"] = _write_artifact(result) + return result + + def run_case(case_id: str) -> dict[str, Any]: if case_id not in PARITY_CASES: return { @@ -1007,46 +1142,55 @@ def _run_range_scan_case(case: dict[str, Any], domain: str, start_date: str, end "source_metadata": _source_metadata(endpoint), } - try: - payload, attempt_count, retry_error_codes = _post_json_with_retry(endpoint, request_preview) - except error.HTTPError as exc: - return { - "backend": "vedastro_service_adapter_candidate", - "available": False, - "status": "http_error", - "reason": f"VedAstro range scan HTTP error: {exc.code}", - "request_preview": request_preview, - "source_metadata": _source_metadata(endpoint), - } - except error.URLError as exc: - return { - "backend": "vedastro_service_adapter_candidate", - "available": False, - "status": "network_error", - "reason": f"VedAstro range scan network error: {exc.reason}", - "request_preview": request_preview, - "source_metadata": _source_metadata(endpoint), - } - except (TimeoutError, socket.timeout): - return { - "backend": "vedastro_service_adapter_candidate", - "available": False, - "status": "timeout", - "reason": "VedAstro range scan timed out", - "request_preview": request_preview, - "source_metadata": _source_metadata(endpoint), - } - except json.JSONDecodeError: - return { - "backend": "vedastro_service_adapter_candidate", - "available": False, - "status": "invalid_json", - "reason": "VedAstro range scan received non-JSON response", - "request_preview": request_preview, - "source_metadata": _source_metadata(endpoint), - } + sample_dates = _iter_sample_dates(start_date, end_date) + reports: list[dict[str, Any]] = [] + for sample_date in sample_dates: + sample_preview = dict(request_preview) + sample_preview["start_date"] = sample_date + sample_preview["end_date"] = sample_date + sample_preview["official_request_profile"] = _build_official_search_events_profile(sample_preview) + sample_preview["live_sampling_request_profile"] = _build_live_sampling_search_events_profile(sample_preview) + try: + payload, attempt_count, retry_error_codes = _post_json_with_retry(endpoint, sample_preview) + except error.HTTPError as exc: + return { + "backend": "vedastro_service_adapter_candidate", + "available": False, + "status": "http_error", + "reason": f"VedAstro range scan HTTP error: {exc.code}", + "request_preview": sample_preview, + "source_metadata": _source_metadata(endpoint), + } + except (error.URLError, http.client.RemoteDisconnected) as exc: + return { + "backend": "vedastro_service_adapter_candidate", + "available": False, + "status": "network_error", + "reason": f"VedAstro range scan network error: {getattr(exc, 'reason', str(exc))}", + "request_preview": sample_preview, + "source_metadata": _source_metadata(endpoint), + } + except (TimeoutError, socket.timeout): + return { + "backend": "vedastro_service_adapter_candidate", + "available": False, + "status": "timeout", + "reason": "VedAstro range scan timed out", + "request_preview": sample_preview, + "source_metadata": _source_metadata(endpoint), + } + except json.JSONDecodeError: + return { + "backend": "vedastro_service_adapter_candidate", + "available": False, + "status": "invalid_json", + "reason": "VedAstro range scan received non-JSON response", + "request_preview": sample_preview, + "source_metadata": _source_metadata(endpoint), + } + reports.append(_normalize_range_scan_success(payload, endpoint, sample_preview, attempt_count, retry_error_codes)) - return _normalize_range_scan_success(payload, endpoint, request_preview, attempt_count, retry_error_codes) + return _merge_range_scan_reports(reports, endpoint, request_preview) def run_range_scan(case_id: str, domain: str, start_date: str, end_date: str) -> dict[str, Any]: diff --git a/task_plan.md b/task_plan.md index abaca0f1..9bfefc24 100644 --- a/task_plan.md +++ b/task_plan.md @@ -8,6 +8,18 @@ - 阶段:用户端信任层与运行可用性 - 本轮最高优先级:在已完成首次使用引导、真实浏览器首跑冒烟、AI/API key 安全提示、导出失败恢复后,推进星历后端抽象可行性,把 SwissEph/WASM/xalen/VedAstro/PyJHora 的替换边界变成可检测资产。 +## 最短路径封口方案(2026-06-29) + +后续不再扩新功能,优先只做 4 条封口线: + +1. `relationship adjudicator` 回归闭环 +2. `Vimsopaka + Functional Role` 闭环 +3. `Shadbala / Dasha / JHora` oracle 批处理闭环 +4. `VedAstro strict ingestion` 最小诚实接入 + +执行顺序与任务拆分见: +- `/Users/wuyongnaren/Documents/印度占星/docs/superpowers/plans/2026-06-29-shortest-path-closure-plan.md` + ## 执行原则 1. 每次实现前先扫描本地碎片、`references/open_source_sources/*`、现有测试与产品差距矩阵。 diff --git a/tests/test_mcp_strict_workflow_relationship.py b/tests/test_mcp_strict_workflow_relationship.py index 4508ac3f..9af530c6 100644 --- a/tests/test_mcp_strict_workflow_relationship.py +++ b/tests/test_mcp_strict_workflow_relationship.py @@ -544,6 +544,24 @@ def test_relationship_dignity_guardrail_uses_relevant_planets_only() -> None: assert "dignity_supportive_recovery" in strict["event_judgement"]["secondary_context"] +def test_relationship_dignity_guardrail_treats_great_friend_on_relevant_planet_as_supportive_context() -> None: + result = _base_relationship_result() + result["modules"]["chart"] = { + "ascendant": {"sign": "Leo"}, + "planets": { + "Venus": {"status": "极友(Great Friend)"}, + "Jupiter": {"status": "中性(Neutral)"}, + "Saturn": {"status": "中性(Neutral)"}, + }, + } + + strict = _collect_strict_evidence("relationship", result) + + assert strict["present_evidence"]["dignity_guardrail"]["status"] == "caution" + assert strict["present_evidence"]["dignity_guardrail"]["score_delta"] == 3 + assert "dignity_supportive_friendship" in strict["event_judgement"]["secondary_context"] + + def test_relationship_caps_confidence_when_provided_shadbala_components_are_incomplete() -> None: result = _base_relationship_result() result["modules"]["dasa_convergence"]["domain_activations"]["marriage_partnership"] = { diff --git a/tests/test_vedastro_evidence_orchestrator.py b/tests/test_vedastro_evidence_orchestrator.py new file mode 100644 index 00000000..4069780d --- /dev/null +++ b/tests/test_vedastro_evidence_orchestrator.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_vedastro_evidence_orchestrator_routes_to_minimal_domain_set(monkeypatch) -> None: + from scripts import vedastro_evidence_orchestrator as orchestrator + + calls = [] + + def fake_scan(case, domain, start_date, end_date, case_id): + calls.append((domain, start_date, end_date, case_id, case["year"])) + return { + "backend": "vedastro_service_adapter_candidate", + "available": True, + "status": "ok", + "operation": "range_scan", + "domain": domain, + "event_count": 1, + "evidence_ledger": [{"domain": domain, "event_id": f"{domain}_event"}], + "source_metadata": {"endpoint_host": "api.vedastro.org"}, + } + + monkeypatch.setattr(orchestrator, "run_range_scan_for_case", fake_scan) + + result = orchestrator.orchestrate_vedastro_evidence( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8.0, + }, + route="relationship", + reference_date="2026-06-29", + ) + + assert [call[0] for call in calls] == ["marriage"] + assert result["source_metadata"]["auto_ingested_by"] == "VedAstroEvidenceOrchestrator" + assert result["source_metadata"]["node_coverage"]["strategy"] == "domain_scoped_range_scan" + assert result["event_count"] == 1 + + +def test_api_and_mcp_use_shared_vedastro_evidence_orchestrator() -> None: + api = (ROOT / "scripts" / "jyotish_api_server.py").read_text(encoding="utf-8") + mcp = (ROOT / "mcp_server.py").read_text(encoding="utf-8") + + assert "vedastro_evidence_orchestrator" in api + assert "orchestrate_vedastro_evidence" in api + assert "vedastro_evidence_orchestrator" in mcp + assert "orchestrate_vedastro_evidence" in mcp diff --git a/tests/test_vedastro_external_technique_evidence.py b/tests/test_vedastro_external_technique_evidence.py index 59b836fb..9d127d87 100644 --- a/tests/test_vedastro_external_technique_evidence.py +++ b/tests/test_vedastro_external_technique_evidence.py @@ -9,7 +9,9 @@ import subprocess import sys from copy import deepcopy from pathlib import Path +from unittest import mock +import mcp_server from mcp_server import _collect_strict_evidence @@ -44,7 +46,8 @@ def test_vedastro_adapter_declares_external_technique_evidence_policy() -> None: def test_vedastro_adapter_builds_external_technique_preview_without_network() -> None: env = os.environ.copy() env["VEDASTRO_API_ENDPOINT"] = "https://example.invalid/vedastro" - env.pop("VEDASTRO_ENABLE_NETWORK", None) + env["VEDASTRO_ENABLE_NETWORK"] = "0" + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" completed = subprocess.run( [ @@ -228,3 +231,114 @@ def test_strict_workflow_marks_vedastro_range_scan_used_without_score_or_label_o assert strict["event_judgement"]["score"] == base["event_judgement"]["score"] assert strict["event_judgement"]["dominant_label"] == base["event_judgement"]["dominant_label"] assert strict["event_judgement"]["payout_label"] == base["event_judgement"]["payout_label"] + + +def test_strict_workflow_auto_ingests_live_vedastro_range_scan_for_finance_route() -> None: + fake_range_scan = { + "backend": "vedastro_service_adapter_candidate", + "available": True, + "status": "ok", + "operation": "range_scan", + "domain": "wealth", + "event_count": 1, + "evidence_ledger": [ + { + "source": "vedastro_service_adapter_candidate", + "operation": "range_scan", + "domain": "wealth", + "event_id": "GocharJupiterIn11th", + "signal_label": "Jupiter in 11th gains window", + "score": 76, + "tags": ["wealth", "transit"], + } + ], + "source_metadata": {"sampling_mode": "at_time_sweep"}, + } + + with mock.patch.object(mcp_server, "_run_engine") as run_engine, mock.patch.object( + mcp_server, + "_maybe_attach_vedastro_evidence", + ) as attach_vedastro: + run_engine.return_value = {"modules": _finance_modules()} + attach_vedastro.return_value = { + "modules": { + **_finance_modules(), + "vedastro_range_scan_result": fake_range_scan, + } + } + + result = mcp_server.strict_workflow( + question="今年的财富机会怎么样?", + year=REDACTED_YEAR, + month=4, + day=17, + hour=14, + minute=49, + lat=36.42, + lon=114.2, + tz=8.0, + age=33, + transit_date="2026-06-29", + node_mode="mean", + ) + + strict = result["strict_workflow"] + attach_vedastro.assert_called_once() + assert strict["present_evidence"]["external_activation"]["level"] == "moderate" + assert "external_activation_support" in strict["event_judgement"]["secondary_context"] + assert "vedastro_range_scan_missing" not in strict["event_judgement"]["secondary_context"] + + +def test_strict_workflow_auto_ingests_live_vedastro_external_technique_as_context_only() -> None: + fake_external_technique = { + "backend": "vedastro_service_adapter_candidate", + "available": True, + "status": "ok", + "operation": "calculation_method", + "role": "external_technique_evidence", + "domain": "wealth", + "evidence_ledger": [ + { + "source": "vedastro_service_adapter_candidate", + "operation": "calculation_method", + "role": "external_technique_evidence", + "domain": "wealth", + "method": "CalculateShadbala", + "api_endpoint": "Calculate/Shadbala", + "status": "ok", + "summary": "VedAstro Shadbala node returned as external evidence.", + } + ], + } + + with mock.patch.object(mcp_server, "_run_engine") as run_engine, mock.patch.object( + mcp_server, + "_maybe_attach_vedastro_evidence", + ) as attach_vedastro: + run_engine.return_value = {"modules": _finance_modules()} + attach_vedastro.return_value = { + "modules": { + **_finance_modules(), + "external_technique_evidence": fake_external_technique, + } + } + + result = mcp_server.strict_workflow( + question="我的财务今年如何?", + year=REDACTED_YEAR, + month=4, + day=17, + hour=14, + minute=49, + lat=36.42, + lon=114.2, + tz=8.0, + age=33, + transit_date="2026-06-29", + node_mode="mean", + ) + + strict = result["strict_workflow"] + assert strict["present_evidence"]["external_technique_evidence"]["level"] == "context_only" + assert strict["present_evidence"]["external_technique_evidence"]["methods"] == ["CalculateShadbala"] + assert "external_technique_evidence" in strict["event_judgement"]["secondary_context"] diff --git a/tests/test_vedastro_range_scan_replay.py b/tests/test_vedastro_range_scan_replay.py index 255a0ca4..39428bd1 100644 --- a/tests/test_vedastro_range_scan_replay.py +++ b/tests/test_vedastro_range_scan_replay.py @@ -2,6 +2,7 @@ from __future__ import annotations import json from pathlib import Path +from unittest import mock from scripts import vedastro_service_adapter @@ -73,3 +74,74 @@ def test_replay_stats_track_zero_event_domain_and_rejected_alias_noise() -> None assert replay["zero_event_domains"] == ["wealth"] assert replay["match_counts"]["rejected"] == 1 assert replay["recommended_allowlist_candidates"] == [] + + +def test_normalize_official_live_payload_supports_nested_searchevents_and_tag_string() -> None: + payload = { + "Status": "Pass", + "Payload": { + "SearchEvents": [ + { + "Name": "BadLunarMonthForBuilding", + "Nature": "Bad", + "Description": "Building work should be avoided in this lunar month.", + "StartTime": {"StdTime": "12:00 01/01/2026 +08:00"}, + "EndTime": {"StdTime": "12:00 01/01/2026 +08:00"}, + "Tag": "Building,General", + } + ] + }, + } + + report = vedastro_service_adapter._normalize_range_scan_success( # noqa: SLF001 + payload, + "https://api.vedastro.org/api", + _request_preview("career"), + ) + + assert report["event_count"] == 1 + assert report["evidence_ledger"][0]["event_id"] == "BadLunarMonthForBuilding" + assert report["evidence_ledger"][0]["matched_by"] == "official_tag" + assert report["evidence_ledger"][0]["matched_terms"] == ["Building"] + assert report["evidence_ledger"][0]["tags"] == ["Building", "General"] + + +def test_run_range_scan_case_refreshes_live_sampling_request_profile_for_each_sample_date() -> None: + case = vedastro_service_adapter.PARITY_CASES["beijing_first_use_demo"] + captured_at_times: list[str] = [] + + def fake_post_json_with_retry(endpoint: str, request_preview: dict[str, object]): + live_profile = request_preview.get("live_sampling_request_profile") or {} + body = live_profile.get("body") if isinstance(live_profile, dict) else {} + at_time = body.get("AtTime") if isinstance(body, dict) else {} + std_time = at_time.get("StdTime") if isinstance(at_time, dict) else None + captured_at_times.append(std_time) + return {"Status": "Pass", "Payload": {"SearchEvents": []}}, 1, [] + + with mock.patch.dict( + vedastro_service_adapter.os.environ, + { + "VEDASTRO_API_ENDPOINT": "https://api.vedastro.org/api", + "VEDASTRO_ENABLE_NETWORK": "1", + }, + clear=False, + ), mock.patch.object( + vedastro_service_adapter, + "_iter_sample_dates", + return_value=["2026-01-01", "2026-03-01"], + ), mock.patch.object( + vedastro_service_adapter, + "_post_json_with_retry", + side_effect=fake_post_json_with_retry, + ): + vedastro_service_adapter._run_range_scan_case( # noqa: SLF001 + case, + "wealth", + "2026-01-01", + "2026-12-31", + ) + + assert captured_at_times == [ + "12:00 01/01/2026 +08:00", + "12:00 01/03/2026 +08:00", + ] diff --git a/tests/test_vedastro_service_adapter_executor.py b/tests/test_vedastro_service_adapter_executor.py index 533a8283..a1be75d7 100644 --- a/tests/test_vedastro_service_adapter_executor.py +++ b/tests/test_vedastro_service_adapter_executor.py @@ -55,6 +55,11 @@ def test_vedastro_service_adapter_executor_schema_is_declared() -> None: def test_vedastro_service_adapter_returns_controlled_unconfigured_status() -> None: + env = os.environ.copy() + env.pop("VEDASTRO_API_ENDPOINT", None) + env.pop("VEDASTRO_ENABLE_NETWORK", None) + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" + completed = subprocess.run( [sys.executable, "scripts/vedastro_service_adapter.py", "--case", "beijing_first_use_demo"], cwd=ROOT, @@ -62,6 +67,7 @@ def test_vedastro_service_adapter_returns_controlled_unconfigured_status() -> No capture_output=True, timeout=120, check=False, + env=env, ) assert completed.returncode == 0, completed.stderr or completed.stdout @@ -76,6 +82,11 @@ def test_vedastro_service_adapter_returns_controlled_unconfigured_status() -> No def test_ephemeris_adapter_contract_can_call_vedastro_candidate_stub() -> None: + env = os.environ.copy() + env.pop("VEDASTRO_API_ENDPOINT", None) + env.pop("VEDASTRO_ENABLE_NETWORK", None) + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" + completed = subprocess.run( [sys.executable, "scripts/ephemeris_adapter_contract.py", "vedastro_service_adapter_candidate"], cwd=ROOT, @@ -83,6 +94,7 @@ def test_ephemeris_adapter_contract_can_call_vedastro_candidate_stub() -> None: capture_output=True, timeout=120, check=False, + env=env, ) assert completed.returncode == 0, completed.stderr or completed.stdout @@ -96,6 +108,8 @@ def test_ephemeris_adapter_contract_can_call_vedastro_candidate_stub() -> None: def test_vedastro_service_adapter_builds_request_preview_before_real_network_use() -> None: env = os.environ.copy() env["VEDASTRO_API_ENDPOINT"] = "https://example.invalid/vedastro" + env.pop("VEDASTRO_ENABLE_NETWORK", None) + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" completed = subprocess.run( [sys.executable, "scripts/vedastro_service_adapter.py", "--case", "beijing_first_use_demo"], @@ -120,6 +134,8 @@ def test_vedastro_service_adapter_builds_request_preview_before_real_network_use def test_vedastro_service_adapter_builds_range_scan_preview_before_real_network_use() -> None: env = os.environ.copy() env["VEDASTRO_API_ENDPOINT"] = "https://example.invalid/vedastro" + env.pop("VEDASTRO_ENABLE_NETWORK", None) + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" completed = subprocess.run( [ @@ -160,6 +176,7 @@ def test_vedastro_range_scan_unconfigured_still_returns_official_search_events_p env = os.environ.copy() env.pop("VEDASTRO_API_ENDPOINT", None) env.pop("VEDASTRO_ENABLE_NETWORK", None) + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" completed = subprocess.run( [ @@ -198,6 +215,7 @@ def test_vedastro_range_scan_unconfigured_still_returns_official_search_events_p assert report["request_preview"]["official_request_profile"]["body"]["StartTime"]["StdTime"] == "12:00 01/01/2026 +08:00" assert report["request_preview"]["official_request_profile"]["body"]["EndTime"]["StdTime"] == "12:00 01/01/2031 +08:00" assert report["request_preview"]["official_request_profile"]["body"]["PrecisionHours"] == 100 + assert report["request_preview"]["live_sampling_request_profile"]["body"]["AtTime"]["StdTime"] == "12:00 01/01/2026 +08:00" def test_vedastro_schema_declares_official_search_events_live_contract() -> None: @@ -364,6 +382,8 @@ def test_vedastro_service_adapter_can_normalize_mock_http_response() -> None: def test_vedastro_service_adapter_can_normalize_mock_range_scan_response() -> None: + seen_at_times: list[str] = [] + class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: # noqa: N802 length = int(self.headers.get("Content-Length", "0")) @@ -371,9 +391,7 @@ def test_vedastro_service_adapter_can_normalize_mock_range_scan_response() -> No assert payload["Ayanamsa"] == "lahiri" assert payload["EventTagList"] == ["Marriage", "Personal", "General"] assert payload["BirthTime"]["StdTime"] == "12:00 01/01/1990 +08:00" - assert payload["StartTime"]["StdTime"] == "12:00 01/01/2026 +08:00" - assert payload["EndTime"]["StdTime"] == "12:00 01/01/2031 +08:00" - assert payload["PrecisionHours"] == 100 + seen_at_times.append(payload["AtTime"]["StdTime"]) response = { "events": [ { @@ -458,6 +476,10 @@ def test_vedastro_service_adapter_can_normalize_mock_range_scan_response() -> No assert report["evidence_ledger"][0]["score"] == 72 assert report["evidence_ledger"][0]["raw"]["name"] == "Jupiter supports marriage axis" assert report["source_metadata"]["endpoint"].startswith("http://127.0.0.1:") + assert report["source_metadata"]["sampling_mode"] == "at_time_sweep" + assert report["source_metadata"]["sample_count"] >= 2 + assert seen_at_times[0] == "12:00 01/01/2026 +08:00" + assert len(set(seen_at_times)) >= 2 def test_vedastro_range_scan_records_hashes_and_artifact_path() -> None: @@ -536,7 +558,9 @@ def test_vedastro_range_scan_records_hashes_and_artifact_path() -> None: assert metadata["allowlist_domain"] == "marriage" assert metadata["allowlist_event_count"] == 1 assert metadata["filtered_event_count"] == 1 - assert metadata["attempt_count"] == 1 + assert metadata["attempt_count"] >= 1 + assert metadata["sample_count"] >= 1 + assert metadata["sampling_mode"] == "at_time_sweep" artifact_path = ROOT / metadata["artifact_path"] assert artifact_path.exists() artifact = json.loads(artifact_path.read_text(encoding="utf-8")) @@ -615,8 +639,9 @@ def test_vedastro_range_scan_retries_transient_http_error() -> None: report = json.loads(completed.stdout) assert report["status"] == "ok" assert report["event_count"] == 1 - assert report["source_metadata"]["attempt_count"] == 2 - assert report["source_metadata"]["retry_error_codes"] == [503] + assert report["source_metadata"]["attempt_count"] >= 2 + assert 503 in report["source_metadata"]["retry_error_codes"] + assert report["source_metadata"]["sampling_mode"] == "at_time_sweep" def test_vedastro_service_adapter_applies_domain_allowlist_to_range_scan_noise() -> None: