add guided skill and web consultation surfaces

This commit is contained in:
732642856
2026-07-12 12:51:08 +08:00
parent a707f670de
commit c2bc58ce1e
8 changed files with 425 additions and 2 deletions
+16
View File
@@ -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 执行;用户不需要主动点名高级技法。
+20 -1
View File
@@ -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
# ============================================================================
+74 -1
View File
@@ -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)
+135
View File
@@ -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."
),
}
+59
View File
@@ -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
+82
View File
@@ -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"]
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jyotish Evidence Packet</title>
<style>
body{margin:0;background:#f5f7f8;color:#17212b;font:15px system-ui,-apple-system,"PingFang SC",sans-serif}
main{max-width:960px;margin:0 auto;padding:28px 18px 56px}.bar{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin:18px 0}
input,button{font:inherit;padding:10px;border:1px solid #b6c1c6;border-radius:4px}button{background:#006b6b;color:white;border-color:#006b6b;cursor:pointer}
section{background:white;border:1px solid #dce3e5;border-radius:6px;padding:18px;margin:14px 0}h1,h2{margin:0 0 10px}h1{font-size:24px}h2{font-size:17px}pre{white-space:pre-wrap;word-break:break-word;margin:0}.badge{display:inline-block;padding:3px 8px;border-radius:99px;background:#e1f2f0;color:#075d5a}
@media(max-width:640px){.bar{grid-template-columns:1fr}main{padding:20px 12px}}
</style>
<main>
<h1>Evidence Packet</h1>
<p>仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。</p>
<div class="bar"><input id="url" placeholder="/api/evidence_packet/high_rigor_workflow/{job_id}"><input id="token" placeholder="access token"><button id="load">加载</button></div>
<section><h2>运行状态</h2><div id="status">等待加载</div></section>
<section><h2>Technique Audit</h2><pre id="audit">-</pre></section>
<section><h2>Machine Evidence Packet</h2><pre id="packet">-</pre></section>
<section><h2>Warnings</h2><pre id="warnings">-</pre></section>
</main>
<script>
const show=(id,value)=>document.getElementById(id).textContent=JSON.stringify(value,null,2);
document.getElementById('load').onclick=async()=>{const url=document.getElementById('url').value.trim(),token=document.getElementById('token').value.trim();if(!url||!token)return alert('需要 job URL 与 access token');const r=await fetch(url,{headers:{Authorization:`Bearer ${token}`}});const d=await r.json();if(!r.ok){show('status',d);return}const s=d.execution_status||{};document.getElementById('status').innerHTML=`<span class="badge">${s.official_evidence_status||'unknown'}</span><pre>${JSON.stringify(s,null,2)}</pre>`;show('audit',d.technique_audit||[]);show('packet',d.machine_evidence_packet||{});show('warnings',d.warnings||[])};
</script>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>主动问询式生时校正</title>
<style>body{margin:0;background:#f5f7f8;color:#17212b;font:15px system-ui,-apple-system,"PingFang SC",sans-serif}main{max-width:860px;margin:auto;padding:28px 16px}form,.question,#result{background:#fff;border:1px solid #dce3e5;border-radius:6px;padding:16px;margin:12px 0}input,button{padding:9px;font:inherit;border:1px solid #b6c1c6;border-radius:4px}button{background:#006b6b;color:#fff;border-color:#006b6b}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.question label{display:block;padding:5px 0}pre{white-space:pre-wrap;word-break:break-word}@media(max-width:600px){.grid{grid-template-columns:1fr}}</style>
<main><h1>主动问询式生时校正</h1><p>先扫描候选时间,再回答选择题。结果只缩小候选簇,不宣称已经精确到分钟。</p>
<form id="birth"><div class="grid"><input name="year" placeholder="出生年" required><input name="month" placeholder="月" required><input name="day" placeholder="日" required><input name="hour" placeholder="时" required><input name="minute" placeholder="分" required><input name="time_uncertainty_minutes" value="30" placeholder="误差分钟"></div><p><button>生成第一轮问题</button></p></form><div id="questions"></div><div id="result"></div></main>
<script>
let questionnaire;
const asObject=f=>Object.fromEntries(new FormData(f).entries());
document.querySelector('#birth').onsubmit=async e=>{e.preventDefault();const p=asObject(e.target);for(const k of Object.keys(p))p[k]=Number(p[k]);const r=await fetch('/api/rectification/questionnaire',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});questionnaire=await r.json();render(questionnaire.questions||[])};
function render(qs){const root=document.querySelector('#questions');root.innerHTML=qs.map(q=>`<section class="question"><strong>${q.prompt}</strong>${q.options.map(o=>`<label><input type="radio" name="${q.id}" value="${o.key}"> ${o.key}. ${o.label}</label>`).join('')}</section>`).join('')+'<button id="score">提交本轮答案</button>';document.querySelector('#score').onclick=score}
async function score(){const answers={};document.querySelectorAll('#questions input:checked').forEach(e=>answers[e.name]=e.value);const r=await fetch('/api/rectification/answers',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({questionnaire,answers})});const d=await r.json();document.querySelector('#result').innerHTML=`<h2>候选簇排序</h2><pre>${JSON.stringify({candidate_cluster_rankings:d.candidate_cluster_rankings,next_round:d.next_round,boundary:d.boundary},null,2)}</pre>`}
</script></html>