Files
Jyotisha/scripts/technique_source_adapters.py
T
ClaudeandClaude Opus 5.5 6a57faacdb feat(consult): run the astrologer's upstream skill in full (BUG-1696, BUG-1697, BUG-1698)
- root SKILL.md is her upstream file (body byte-identical to the 5fb8379c mirror; description quoted so YAML parses and Mastra can load the skill)
- chat binds the whole SKILL.md plus references/product_runtime_contract.md (safety-only) instead of a ~477-character excerpt
- every turn carries her full strict-workflow-router (synced to upstream) and the domain's event-judgment file; product lists follow as an extra layer
- import her 10 sub-skills, family router, interpretation status contract, technique ontology and the 54 files her method names
- tests/test_upstream_skill_parity.py (in the quick gate) fails whenever her skill and ours drift
- 18 frontend guards that locked the excerpt design updated with 原值/新值/原因 notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-07 14:02:26 +08:00

209 lines
10 KiB
Python

#!/usr/bin/env python3
"""Refresh named-technique candidates from public catalogues and local book index."""
from __future__ import annotations
import argparse
import json
import re
import urllib.request
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
REF = ROOT / "references"
PYJHORA_DASHA_RAW = "https://raw.githubusercontent.com/naturalstupid/PyJHora/main/src/jhora/horoscope/dhasa/README.md"
JHORA_FEATURES = "https://vedicastrologer.org/jh/features.htm"
def fetch_text(url: str) -> str:
request = urllib.request.Request(url, headers={"User-Agent": "jyotish-technique-source-adapter/1.0"})
with urllib.request.urlopen(request, timeout=15) as response:
return response.read().decode("utf-8-sig", errors="ignore")
def slug(value: str) -> str:
value = value.lower().replace("dhasa", "dasha").replace("dasa", "dasha")
value = value.replace("prasna", "prashna").replace("sudharsana", "sudarsana")
value = value.replace("chathuraaseethi", "chaturashiti").replace("chaturaaseethi", "chaturashiti")
return re.sub(r"[^a-z0-9]+", "_", value).strip("_")
class _TextLines(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in {"br", "p", "div", "li", "h1", "h2", "h3", "h4", "tr"}:
self.parts.append("\n")
def handle_data(self, data: str) -> None:
self.parts.append(data)
def lines(self) -> list[str]:
return [" ".join(line.split()) for line in "".join(self.parts).splitlines() if " ".join(line.split())]
def parse_pyjhora_dashas(markdown: str) -> list[dict[str, Any]]:
rows = []
headings = list(re.finditer(r"^####\s+(.+)$", markdown, flags=re.MULTILINE))
for index, match in enumerate(headings):
heading = match.group(1)
if "jhora.horoscope.dhasa" not in heading and "dhasa" not in heading.lower():
continue
block_end = headings[index + 1].start() if index + 1 < len(headings) else len(markdown)
block = markdown[match.end():block_end]
clean = re.split(r"\s+jhora\.horoscope|:\s*jhora\.horoscope", heading, maxsplit=1)[0].strip(" :")
clean = clean.replace("Dhasa", "Dasha").replace("Dasa", "Dasha")
module_match = re.search(r"(jhora\.horoscope\.dhasa[\w.]+)", heading)
module = module_match.group(1) if module_match else ""
skill = "tajika-annual" if ".annual." in module.lower() or clean.lower().startswith(("mudda", "patyayini", "varsha ")) else ("jaimini-timing" if ".raasi." in module.lower() else "specialized-jyotish")
methods = []
parameters = []
for signature in re.findall(r"^#####\s+(.+)$", block, flags=re.MULTILINE):
signature = signature.replace(r"\_", "_").strip().rstrip(":")
methods.append(signature)
argument_match = re.search(r"\((.*)\)", signature)
if argument_match:
for argument in argument_match.group(1).split(","):
name = argument.strip().lstrip("*").split("=", 1)[0].strip()
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) and name not in parameters:
parameters.append(name)
for name in re.findall(r"@param\s+([A-Za-z_][A-Za-z0-9_]*)", block.replace(r"\_", "_")):
if name not in parameters:
parameters.append(name)
applicability_hints = []
for line in block.splitlines():
compact = " ".join(line.strip().strip('"').split())
if compact and re.search(r"\b(applicab(?:le|ility)|condition(?:al)?|only when|automatically determine)\b", compact, re.IGNORECASE):
applicability_hints.append(compact[:240])
rows.append({
"candidate_id": slug(clean),
"name": clean,
"family": "dasha",
"skill_family": skill,
"source_id": "pyjhora_dasha_raw",
"documented_module": module,
"documented_methods": methods,
"documented_parameters": parameters,
"applicability_hints": applicability_hints[:4],
})
return _dedupe(rows)
def parse_jhora_features(html: str) -> list[dict[str, Any]]:
parser = _TextLines()
parser.feed(html)
sections = {"Planets and mathematical points", "Chakras", "Annual Charts", "Krishnamoorthy Paddhati (KP)", "Panchanga"}
section = ""
rows = []
for line in parser.lines():
if line in sections:
section = line
continue
if line in {"Basic Calculations", "Strengths", "Dasas", "Transits", "Mundane", "Miscellaneous", "Learning Aids", "Conveniences"}:
section = ""
continue
if not line.startswith("-"):
continue
text = line.lstrip("- ")
if section == "Chakras" and "chakra" in text.lower():
name = re.split(r"\s+from\s+|\s*\(", text, maxsplit=1, flags=re.IGNORECASE)[0].strip()
rows.append({"candidate_id": slug(name), "name": name, "family": "chakra", "skill_family": "specialized-jyotish", "source_id": "jhora_features_live"})
elif section == "Annual Charts" and "pravesha" in text.lower():
for name in re.findall(r"(?:Tithi|Yoga|Nakshatra) Pravesha", text, flags=re.IGNORECASE):
proper = " ".join(word.capitalize() for word in name.split())
rows.append({"candidate_id": slug(proper), "name": proper, "family": "pravesha", "skill_family": "tajika-annual", "source_id": "jhora_features_live"})
elif section == "Planets and mathematical points" and "sphuta" in text.lower():
name = re.split(r"\s+of\s+", text, maxsplit=1, flags=re.IGNORECASE)[0].strip()
rows.append({"candidate_id": slug(name), "name": name, "family": "sphuta", "skill_family": "prashna-muhurta", "source_id": "jhora_features_live"})
elif section == "Krishnamoorthy Paddhati (KP)":
if "sub lords" in text.lower() and "cusp" in text.lower():
candidate_id, name = "kp_cusp_planet_sub_chain", "KP cusp and planet star/sub chain"
else:
candidate_id, name = "kp_significator_occupancy_lookup", "KP star/sub occupancy and significator lookup"
rows.append({"candidate_id": candidate_id, "name": name, "family": "kp", "skill_family": "kp-event", "source_id": "jhora_features_live"})
elif section == "Panchanga":
lower = text.lower()
if lower.startswith("show the panchanga"):
candidate_id, name = "daily_panchanga_with_end_times", "Daily Panchanga with limb end times"
elif "panchanga for a month" in lower:
candidate_id, name = "monthly_panchanga", "Monthly Panchanga"
elif "ephemeris for a month" in lower:
candidate_id, name = "monthly_ephemeris", "Monthly ephemeris"
else:
candidate_id, name = "monthly_exact_transit_events", "Monthly exact transit and aspect events"
rows.append({"candidate_id": candidate_id, "name": name, "family": "panchanga", "skill_family": "prashna-muhurta", "source_id": "jhora_features_live"})
return _dedupe(rows)
def parse_local_book_topics(data: dict[str, Any]) -> list[dict[str, Any]]:
return [{
"topic_id": topic_id,
"aliases": topic.get("aliases", []),
"source_role": topic.get("source_role"),
"priority_books": topic.get("priority_books", []),
"status": "local_topic_candidate",
} for topic_id, topic in data.get("topics", {}).items()]
def _dedupe(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
unique = {}
for row in rows:
unique.setdefault(row["candidate_id"], row)
return list(unique.values())
def _canonical_lookup() -> dict[str, str]:
catalog = json.loads((REF / "external_technique_catalog.json").read_text(encoding="utf-8"))
ontology = json.loads((REF / "technique_ontology.json").read_text(encoding="utf-8"))
lookup = {}
for row in catalog["techniques"]:
technique_id = row["canonical_id"]
for name in [technique_id, row["name"], *ontology.get("aliases", {}).get(technique_id, [])]:
lookup[slug(name)] = technique_id
for technique_id, names in ontology.get("aliases", {}).items():
lookup[slug(technique_id)] = technique_id
for name in names:
lookup[slug(name)] = technique_id
return lookup
def resolve_candidates(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
lookup = _canonical_lookup()
result = []
for row in rows:
candidate = row["candidate_id"]
canonical = lookup.get(candidate)
result.append({**row, "canonical_id": canonical, "resolution_status": "resolved" if canonical else "needs_canonicalization"})
return result
def refresh() -> tuple[dict[str, Any], dict[str, Any]]:
public_rows = resolve_candidates(parse_pyjhora_dashas(fetch_text(PYJHORA_DASHA_RAW)) + parse_jhora_features(fetch_text(JHORA_FEATURES)))
public = {
"schema": "jyotish.external_technique_discovery_snapshot.v1",
"sources": {"pyjhora_dasha_raw": PYJHORA_DASHA_RAW, "jhora_features_live": JHORA_FEATURES},
"summary": {"candidate_count": len(public_rows), "resolved_count": sum(row["resolution_status"] == "resolved" for row in public_rows), "unresolved_count": sum(row["resolution_status"] != "resolved" for row in public_rows)},
"candidates": public_rows,
}
books = parse_local_book_topics(json.loads((REF / "jyotish_book_topic_index.json").read_text(encoding="utf-8")))
local = {"schema": "jyotish.local_book_technique_candidates.v1", "summary": {"topic_count": len(books), "priority_book_links": sum(len(row["priority_books"]) for row in books)}, "topics": books}
return public, local
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--write", action="store_true")
args = parser.parse_args()
public, local = refresh()
if args.write:
(REF / "external_technique_discovery_snapshot.json").write_text(json.dumps(public, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
(REF / "local_book_technique_candidates.json").write_text(json.dumps(local, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"public": public["summary"], "local_books": local["summary"]}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()