From c2bc58ce1ed3b2230f2801ae5c6020706ee1fb38 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Sun, 12 Jul 2026 12:51:08 +0800 Subject: [PATCH] add guided skill and web consultation surfaces --- SKILL.md | 16 ++++ mcp_server.py | 21 ++++- scripts/jyotish_api_server.py | 75 ++++++++++++++- scripts/skill_experience.py | 135 +++++++++++++++++++++++++++ tests/test_api_async_job_contract.py | 59 ++++++++++++ tests/test_skill_experience.py | 82 ++++++++++++++++ web/evidence_packet.html | 26 ++++++ web/rectification.html | 13 +++ 8 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 scripts/skill_experience.py create mode 100644 tests/test_api_async_job_contract.py create mode 100644 tests/test_skill_experience.py create mode 100644 web/evidence_packet.html create mode 100644 web/rectification.html diff --git a/SKILL.md b/SKILL.md index ee44677b..25c92818 100644 --- a/SKILL.md +++ b/SKILL.md @@ -78,6 +78,22 @@ python3 scripts/user_invocation_acceptance_check.py 该命令必须返回 `"status": "pass"`,并显式列出 VedAstro / PyJHora-JHora / jyotishganit 的可用、partial 或 blocked 状态;否则不得声称云端 Git 仓库调用已可高质量使用。 +### 首次调用与降级合同 + +普通用户不必先理解 API、MCP、分盘或校时方法。Skill/MCP 首次调用必须先使用 +`skill_onboarding`:缺出生字段时只收集日期、时间、经纬度;出生时间有误差时返回 +`rectification` 的选择题问卷;时间明确时进入 `direct_chart`。不得要求用户先提交长篇 +人生事件表。 + +安装或运行异常时调用 `skill_doctor`。它只报告本地资产与外部适配器 readiness,不得把 +adapter available 解释为已完成 VedAstro、PyJHora/JHora 或 jyotishganit raw-oracle 校验。 + +每个工作流结果必须包含 `execution_status`: + +- `official_verified`:仅此状态可说 VedAstro 官方 raw evidence 已被使用; +- `official_blocked`:官方请求失败、额度/网络/超时受阻; +- `local_fallback`:本地计算继续可用,但不能称为官方云端闭环。 + **强制工作流**(完整规范 → `references/ai-reading-workflow-prompt.md` v5.1.0): 0. **阶段负一**:问题类型路由(事业/婚恋/财务/应期/历史验证/综合解盘)→ 必须先读 `references/strict-workflow-router.md`,按对应 strict checklist 执行;用户不需要主动点名高级技法。 diff --git a/mcp_server.py b/mcp_server.py index c26d56db..4989f062 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -45,6 +45,7 @@ from mcp.server.fastmcp import FastMCP from functional_benefics import derive_functional_benefic_malefic from vedastro_priority import official_snapshot_evidence from unified_consultation_orchestrator import UnifiedConsultationOrchestrator +from skill_experience import build_skill_doctor, build_skill_onboarding, summarize_execution_status load_local_env(SCRIPT_DIR) @@ -718,7 +719,7 @@ def _execute_mcp_consultation_workflow( from jyotish_api_server import JyotishAPIHandler, execute_consultation_workflow handler = JyotishAPIHandler.__new__(JyotishAPIHandler) - return execute_consultation_workflow( + result = execute_consultation_workflow( handler, body={ "question": question, @@ -740,6 +741,9 @@ def _execute_mcp_consultation_workflow( }, surface="skill_mcp", ) + if isinstance(result, dict): + result["execution_status"] = summarize_execution_status(result) + return result def _safe_get(data: Dict[str, Any], *path: str) -> Any: @@ -4353,6 +4357,21 @@ def life_event_graph( } +# ============================================================================ +# Skill experience tools + +@mcp.tool() +def skill_onboarding(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Return the minimal next input or active rectification question set.""" + return build_skill_onboarding(payload) + + +@mcp.tool() +def skill_doctor() -> Dict[str, Any]: + """Check local Skill assets and external adapter readiness.""" + return build_skill_doctor() + + # ============================================================================ # Resources # ============================================================================ diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index ed23e3e1..ac3645d2 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -32,6 +32,18 @@ try: from scripts.unified_consultation_orchestrator import UnifiedConsultationOrchestrator except ModuleNotFoundError: # pragma: no cover - script execution path from unified_consultation_orchestrator import UnifiedConsultationOrchestrator +try: + from scripts.skill_experience import ( + build_rectification_questionnaire, + score_rectification_answers, + summarize_execution_status, + ) +except ModuleNotFoundError: # pragma: no cover - script execution path + from skill_experience import ( + build_rectification_questionnaire, + score_rectification_answers, + summarize_execution_status, + ) try: from scripts.western_oracle_adapter import build_packet_from_oracle_payload except ModuleNotFoundError: # pragma: no cover - script execution path @@ -54,6 +66,22 @@ _ASYNC_JOB_EXECUTOR = ThreadPoolExecutor( _ASYNC_JOB_CAPACITY = threading.BoundedSemaphore(_ASYNC_JOB_WORKERS + _ASYNC_JOB_QUEUE_SIZE) +def build_evidence_packet_view(job_record: dict | None) -> dict: + """Public, token-protected job view. Excludes prompt internals and raw input.""" + job_record = job_record or {} + result = job_record.get('result') + result = result if isinstance(result, dict) else {} + return { + 'scope': 'evidence_packet_view', + 'job_id': job_record.get('job_id'), + 'status': job_record.get('status', 'unknown'), + 'execution_status': summarize_execution_status(result), + 'machine_evidence_packet': result.get('machine_evidence_packet') or {}, + 'technique_audit': result.get('technique_audit') or result.get('technique_audit_table') or [], + 'warnings': result.get('warnings') or [], + } + + def _submit_background_job(callback): if not _ASYNC_JOB_CAPACITY.acquire(blocking=False): raise JobQueueFull('Async job queue is full') @@ -900,6 +928,17 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): def _error_json(self, message, status=500, error_code='ERR_INTERNAL'): self._json({'success': False, 'error': message, 'error_code': error_code}, status) + def _html(self, content, status=200): + encoded = content.encode('utf-8') + self.send_response(status) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self._send_cors_headers() + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('Cache-Control', 'no-store') + self.send_header('Content-Length', str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + def _send_cors_headers(self): origin = self.headers.get('Origin') allowed = getattr(self.server, 'allowed_origins', DEFAULT_ALLOWED_ORIGINS) @@ -979,7 +1018,19 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): path = urlparse(self.path).path try: self._enforce_request_security() - if path == '/api/health': + if path == '/evidence': + page = Path(REPO_ROOT) / 'web' / 'evidence_packet.html' + if not page.is_file(): + self._error_json('Evidence Packet page unavailable', 404, 'ERR_NOT_FOUND') + else: + self._html(page.read_text(encoding='utf-8')) + elif path == '/rectification': + page = Path(REPO_ROOT) / 'web' / 'rectification.html' + if not page.is_file(): + self._error_json('Rectification page unavailable', 404, 'ERR_NOT_FOUND') + else: + self._html(page.read_text(encoding='utf-8')) + elif path == '/api/health': swisseph_available = False swisseph_version = None try: @@ -1030,6 +1081,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): self._error_json('Not found', 404, 'ERR_NOT_FOUND') else: self._json(result) + elif path.startswith('/api/evidence_packet/chart/'): + job_id = path.rsplit('/', 1)[-1] + result = self._get_chart_job(job_id) + if result is None: + self._error_json('Not found', 404, 'ERR_NOT_FOUND') + else: + self._json(build_evidence_packet_view(result)) + elif path.startswith('/api/evidence_packet/high_rigor_workflow/'): + job_id = path.rsplit('/', 1)[-1] + result = self._get_high_rigor_job(job_id) + if result is None: + self._error_json('Not found', 404, 'ERR_NOT_FOUND') + else: + self._json(build_evidence_packet_view(result)) elif path == '/api/real_case_revalidation': self._json(self._real_case_revalidation()) else: @@ -1146,6 +1211,14 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): elif path == '/api/aspects': result = self._compute_aspects(body) self._json(result) + elif path == '/api/rectification/questionnaire': + self._json(build_rectification_questionnaire(body)) + elif path == '/api/rectification/answers': + questionnaire = body.get('questionnaire') + answers = body.get('answers') + if not isinstance(questionnaire, dict) or not isinstance(answers, dict): + raise BadRequest('questionnaire and answers must be JSON objects') + self._json(score_rectification_answers(questionnaire, answers)) elif path == '/api/rectification_gate': result = self._compute_rectification_gate(body) self._json(result) diff --git a/scripts/skill_experience.py b/scripts/skill_experience.py new file mode 100644 index 00000000..06f67834 --- /dev/null +++ b/scripts/skill_experience.py @@ -0,0 +1,135 @@ +"""Stable user-facing contracts shared by Skill and MCP entry points.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from scripts.active_rectification_questions import build_questionnaire, score_answers +from scripts.diagnose_external_engine_adapters import build_report as adapter_report + + +ROOT = Path(__file__).resolve().parents[1] +_REQUIRED_BIRTH_FIELDS = ("year", "month", "day", "hour", "minute", "lat", "lon") + + +def _missing_birth_fields(payload: dict[str, Any]) -> list[str]: + return [field for field in _REQUIRED_BIRTH_FIELDS if payload.get(field) is None] + + +def build_skill_onboarding(payload: dict[str, Any] | None = None) -> dict[str, Any]: + """Return the next minimal user action; never infer missing birth inputs.""" + payload = payload or {} + missing = _missing_birth_fields(payload) + if missing: + return { + "scope": "skill_onboarding", + "status": "needs_birth_data", + "entry_mode": "pending", + "missing_fields": missing, + "next_action": "collect_birth_data", + "input_template": { + "year": "YYYY", "month": "MM", "day": "DD", + "hour": "0-23", "minute": "0-59", "lat": "decimal", "lon": "decimal", + "time_uncertainty_minutes": "optional; use when birth time is approximate", + "question": "optional; career, relationship, wealth, health, general", + }, + } + + uncertainty = int(payload.get("time_uncertainty_minutes") or 0) + if uncertainty > 0: + birth_time = ( + f"{int(payload['year']):04d}-{int(payload['month']):02d}-{int(payload['day']):02d} " + f"{int(payload['hour']):02d}:{int(payload['minute']):02d}" + ) + questionnaire = build_questionnaire(birth_time, uncertainty_minutes=uncertainty) + first_question = questionnaire.get("questions", [{}])[0] + return { + "scope": "skill_onboarding", + "status": "ready", + "entry_mode": "rectification", + "next_action": "run_rectification_questionnaire", + "first_question": first_question, + "questionnaire": questionnaire, + } + + return { + "scope": "skill_onboarding", + "status": "ready", + "entry_mode": "direct_chart", + "next_action": "run_consultation_workflow", + "question": str(payload.get("question") or ""), + } + + +def build_rectification_questionnaire(payload: dict[str, Any]) -> dict[str, Any]: + """Build the active-choice questionnaire from a minimal approximate time.""" + required = ("year", "month", "day", "hour", "minute") + missing = [field for field in required if payload.get(field) is None] + if missing: + raise ValueError(f"missing rectification fields: {', '.join(missing)}") + birth_time = ( + f"{int(payload['year']):04d}-{int(payload['month']):02d}-{int(payload['day']):02d} " + f"{int(payload['hour']):02d}:{int(payload['minute']):02d}" + ) + uncertainty = max(int(payload.get("time_uncertainty_minutes") or 30), 1) + step = max(int(payload.get("step_minutes") or 1), 1) + return build_questionnaire(birth_time, uncertainty_minutes=uncertainty, step_minutes=step) + + +def score_rectification_answers(questionnaire: dict[str, Any], answers: dict[str, str]) -> dict[str, Any]: + """Score user choices; preserves the boundary against false minute precision.""" + return score_answers(questionnaire, answers or {}) + + +def build_skill_doctor() -> dict[str, Any]: + """Expose readiness, not an unsupported promise that all engines are usable.""" + assets = { + "skill_instructions": (ROOT / "SKILL.md").is_file(), + "mcp_server": (ROOT / "mcp_server.py").is_file(), + "native_engine": (ROOT / "scripts" / "jyotish_engine.py").is_file(), + "unified_orchestrator": (ROOT / "scripts" / "unified_consultation_orchestrator.py").is_file(), + } + adapters = adapter_report() + adapter_status = adapters.get("status", "blocked") + return { + "scope": "skill_doctor", + "status": "ready" if all(assets.values()) and adapter_status == "ready" else "degraded", + "core_assets": assets, + "external_engine_adapters": adapters, + "boundary": "Readiness only. An available adapter is not external raw-oracle verification.", + } + + +def _vedastro_status(result: dict[str, Any]) -> str: + engines = result.get("external_engine_cross_validation") + if isinstance(engines, dict): + engines = engines.get("engines") + vedastro = engines.get("VedAstro") if isinstance(engines, dict) else None + if isinstance(vedastro, dict): + return str(vedastro.get("status") or "") + return "" + + +def summarize_execution_status(result: dict[str, Any] | None) -> dict[str, Any]: + """Normalize official/local evidence state for every conversational surface.""" + result = result or {} + fallback_reason = str(result.get("fallback_reason") or "") + vedastro = _vedastro_status(result) + raw_status = str(result.get("official_evidence_status") or "") + if raw_status == "official_verified" or vedastro == "official_verified": + official, source = "official_verified", "official_raw" + elif fallback_reason or vedastro in {"local_fallback", "official_blocked", "blocked"}: + official, source = "official_blocked", "local_fallback" + else: + official, source = "official_not_requested", "local_or_unverified" + return { + "scope": "execution_status", + "official_evidence_status": official, + "calculation_source": source, + "fallback_reason": fallback_reason or None, + "allowed_claims": ["official_verified", "official_blocked", "local_fallback"], + "claim_boundary": ( + "Only official_verified permits claims that VedAstro official raw evidence was used." + ), + } diff --git a/tests/test_api_async_job_contract.py b/tests/test_api_async_job_contract.py new file mode 100644 index 00000000..4013344d --- /dev/null +++ b/tests/test_api_async_job_contract.py @@ -0,0 +1,59 @@ +import json +from pathlib import Path + +from scripts import jyotish_api_server as api + + +def test_evidence_packet_view_exposes_only_auditable_result_sections(): + packet = api.build_evidence_packet_view({ + "job_id": "job_1", + "status": "completed", + "result": { + "fallback_reason": "VedAstro official snapshot blocked: timeout", + "machine_evidence_packet": {"status": "draft", "metadata": {"capture_id": "x"}}, + "technique_audit": [{"technique": "D9", "status": "used"}], + "ai_prompt_pack": {"prompt_zh": "internal prompt"}, + }, + }) + + assert packet["job_id"] == "job_1" + assert packet["execution_status"]["official_evidence_status"] == "official_blocked" + assert packet["machine_evidence_packet"]["metadata"]["capture_id"] == "x" + assert "ai_prompt_pack" not in packet + + +def test_async_job_route_extracts_id_before_loading(monkeypatch, tmp_path): + record = {"job_id": "chart_abc", "status": "completed", "result": {}} + monkeypatch.setattr(api, "_load_async_job_record", lambda *args, **kwargs: record) + + handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler) + handler.path = "/api/chart/jobs/chart_abc" + handler.headers = {"Origin": ""} + handler._enforce_request_security = lambda: None + captured = {} + handler._json = lambda data, status=200: captured.update(data=data, status=status) + handler._error_json = lambda message, status=500, error_code="ERR_INTERNAL": captured.update(error=error_code, status=status) + handler._job_access_token = lambda: "token" + + handler.do_GET() + + assert captured["status"] == 200 + assert captured["data"]["job_id"] == "chart_abc" + + +def test_evidence_packet_page_is_present_and_does_not_embed_birth_data(): + page = Path(api.REPO_ROOT) / "web" / "evidence_packet.html" + source = page.read_text(encoding="utf-8") + + assert "Evidence Packet" in source + assert "access token" in source + assert "birth" not in source.lower() + + +def test_rectification_page_uses_choice_questionnaire_contract(): + page = Path(api.REPO_ROOT) / "web" / "rectification.html" + source = page.read_text(encoding="utf-8") + + assert "/api/rectification/questionnaire" in source + assert "/api/rectification/answers" in source + assert "候选簇排序" in source diff --git a/tests/test_skill_experience.py b/tests/test_skill_experience.py new file mode 100644 index 00000000..7406995e --- /dev/null +++ b/tests/test_skill_experience.py @@ -0,0 +1,82 @@ +from scripts.skill_experience import ( + build_rectification_questionnaire, + build_skill_doctor, + build_skill_onboarding, + score_rectification_answers, + summarize_execution_status, +) + + +def test_onboarding_requests_only_missing_birth_fields(): + packet = build_skill_onboarding({"year": 1993, "month": 4, "day": 17}) + + assert packet["status"] == "needs_birth_data" + assert packet["entry_mode"] == "pending" + assert packet["missing_fields"] == ["hour", "minute", "lat", "lon"] + assert packet["next_action"] == "collect_birth_data" + + +def test_onboarding_selects_rectification_for_uncertain_time(): + packet = build_skill_onboarding({ + "year": 1993, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.68, + "lon": 114.35, + "time_uncertainty_minutes": 20, + }) + + assert packet["status"] == "ready" + assert packet["entry_mode"] == "rectification" + assert packet["next_action"] == "run_rectification_questionnaire" + assert packet["first_question"] + + +def test_execution_status_makes_official_fallback_machine_readable(): + status = summarize_execution_status({ + "fallback_reason": "VedAstro official snapshot blocked: official_snapshot_budget_exhausted", + "external_engine_cross_validation": { + "engines": {"VedAstro": {"status": "local_fallback"}} + }, + }) + + assert status["official_evidence_status"] == "official_blocked" + assert status["calculation_source"] == "local_fallback" + assert status["fallback_reason"] == "VedAstro official snapshot blocked: official_snapshot_budget_exhausted" + assert "official_verified" in status["allowed_claims"] + + +def test_doctor_has_machine_readable_core_and_adapter_state(): + packet = build_skill_doctor() + + assert packet["scope"] == "skill_doctor" + assert "core_assets" in packet + assert "external_engine_adapters" in packet + assert packet["status"] in {"ready", "degraded"} + + +def test_mcp_exposes_skill_experience_tools(): + import mcp_server + + onboarding = mcp_server.skill_onboarding({}) + doctor = mcp_server.skill_doctor() + + assert onboarding["scope"] == "skill_onboarding" + assert doctor["scope"] == "skill_doctor" + + +def test_rectification_contract_generates_and_scores_choice_answers(): + questionnaire = build_rectification_questionnaire({ + "year": 1993, "month": 4, "day": 17, "hour": 14, "minute": 49, + "time_uncertainty_minutes": 20, + }) + scored = score_rectification_answers(questionnaire, { + "education_environment_shift": "A", + "health_crisis_or_low_period": "C", + }) + + assert questionnaire["scope"] == "active_birth_time_rectification_questionnaire" + assert scored["scope"] == "active_birth_time_rectification_scoring" + assert scored["candidate_cluster_rankings"] diff --git a/web/evidence_packet.html b/web/evidence_packet.html new file mode 100644 index 00000000..7b0a1104 --- /dev/null +++ b/web/evidence_packet.html @@ -0,0 +1,26 @@ + + + + +Jyotish Evidence Packet + +
+

Evidence Packet

+

仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。

+
+

运行状态

等待加载
+

Technique Audit

-
+

Machine Evidence Packet

-
+

Warnings

-
+
+ + diff --git a/web/rectification.html b/web/rectification.html new file mode 100644 index 00000000..a5c0036b --- /dev/null +++ b/web/rectification.html @@ -0,0 +1,13 @@ + + +主动问询式生时校正 + +

主动问询式生时校正

先扫描候选时间,再回答选择题。结果只缩小候选簇,不宣称已经精确到分钟。

+

+