Files
Jyotisha/scripts/chart_choice/service.py
T
ClaudeandClaude Opus 5.5 97afd1716a feat: chart choice replaces event-scored rectification page
Event scoring was measured at chance level on the 77 Rodden-AA holdout
cases (BUG-1091). The new flow finds the dividing line nearest the reported
time, describes how the flipped divisional charts read on either side, asks
up to five self-assessment questions, and saves the user's own choice
(provenance user_choice) with the domains to read in both versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXozbkyFNqbu8pVEtZ7W
2026-11-06 02:06:17 +08:00

215 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Stateless chart-choice service used by the API server.
Two calls, both recomputed from the same inputs so the browser never supplies
a minute the server did not derive:
* ``build_packet(body)`` -> narrowing question, "certain", or the choice packet
* ``decide_packet(body)`` -> the packet plus the decision for ``body["answers"]``
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Final
from scripts.chart_choice.boundaries import (
MAX_WINDOW_MINUTES,
ChartChoiceInputError,
ChartRequest,
chart_at,
find_lines,
nearest_line,
representative_minute,
segment_around,
)
from scripts.chart_choice.decision import decide, reported_is_neutral
from scripts.chart_choice.readings import DOMAIN, DOMAIN_ORDER, build_question, describe, varga_chart
ENGINE_VERSION: Final = "chart-choice-v1"
MAX_QUESTIONS: Final = 5
ALL_CHARTS: Final = ("D1", "D2", "D4", "D9", "D10", "D12", "D24")
def _parse_at(value: Any, field: str) -> datetime:
if not isinstance(value, str):
raise ChartChoiceInputError(f"{field} must be YYYY-MM-DDTHH:MM")
try:
return datetime.strptime(value[:16], "%Y-%m-%dT%H:%M")
except ValueError as exc:
raise ChartChoiceInputError(f"{field} must be YYYY-MM-DDTHH:MM") from exc
def _request(body: dict[str, Any]) -> tuple[ChartRequest, datetime, datetime, datetime, bool]:
try:
lat, lon, tz = float(body["lat"]), float(body["lon"]), float(body["tz"])
except (KeyError, TypeError, ValueError) as exc:
raise ChartChoiceInputError("lat, lon and tz are required") from exc
if not (-90 <= lat <= 90 and -180 <= lon <= 180 and -14 <= tz <= 14):
raise ChartChoiceInputError("lat, lon or tz out of range")
start = _parse_at(body.get("start_at"), "start_at")
end = _parse_at(body.get("end_at"), "end_at")
if end <= start:
raise ChartChoiceInputError("end_at must be after start_at")
if end - start > timedelta(hours=24):
raise ChartChoiceInputError("window longer than 24 hours")
reported_known = isinstance(body.get("reported_at"), str) and bool(body.get("reported_at"))
reported = _parse_at(body["reported_at"], "reported_at") if reported_known else start + (end - start) / 2
reported = reported.replace(second=0, microsecond=0)
if not start <= reported <= end:
reported_known = False
reported = (start + (end - start) / 2).replace(second=0, microsecond=0)
ayanamsa = str(body.get("ayanamsa") or "raman")
node_mode = str(body.get("node_mode") or "mean")
if node_mode not in ("mean", "true"):
raise ChartChoiceInputError("node_mode must be mean or true")
req = ChartRequest(birth_date=reported.date().isoformat(), lat=lat, lon=lon, tz=tz,
ayanamsa=ayanamsa, node_mode=node_mode)
return req, start, end, reported, reported_known
def _at(at: datetime) -> str:
return at.strftime("%Y-%m-%dT%H:%M")
def _at_s(at: datetime) -> str:
return at.strftime("%Y-%m-%dT%H:%M:%S")
def _hhmm(at: datetime) -> str:
return at.strftime("%H:%M")
def _sunrise_local_naive(date_iso: str, lat: float, lon: float, tz: float) -> datetime | None:
"""Real sunrise in local clock time; None for polar or unavailable cases (never invent 06:00)."""
try:
import saham_daynight
import swisseph as swe
noon = datetime.fromisoformat(f"{date_iso}T12:00:00")
row = saham_daynight.determine_daytime(noon, lat=lat, lon=lon, tz=tz)
year, month, day, ut_hours = swe.revjul(float(row["sunrise_jd_ut"]))
return datetime(int(year), int(month), int(day)) + timedelta(hours=float(ut_hours) + float(tz))
except Exception: # noqa: BLE001 - any failure means "no sunrise anchor", the halves fallback is used
return None
def _narrowing(req: ChartRequest, start: datetime, end: datetime) -> dict[str, Any]:
"""Split a wide window in two, at sunrise when sunrise sits well inside it."""
split = None
anchor = "half"
sunrise = _sunrise_local_naive(req.birth_date, req.lat, req.lon, req.tz)
if sunrise is not None:
sunrise = sunrise.replace(second=0, microsecond=0)
margin = timedelta(minutes=20)
if start + margin <= sunrise <= end - margin:
split, anchor = sunrise, "sunrise"
if split is None:
split = (start + (end - start) / 2).replace(second=0, microsecond=0)
if anchor == "sunrise":
prompt = f"你出生时,天已经亮了吗?(当天日出约 {_hhmm(split)})"
labels = ("天还没亮", "天已经亮了")
else:
prompt = "家里人说起你出生的时间,更靠近哪一段?"
labels = (f"{_hhmm(start)}–{_hhmm(split)}", f"{_hhmm(split)}–{_hhmm(end)}")
return {
"anchor": anchor,
"prompt": prompt,
"options": [
{"text": labels[0], "start_at": _at(start), "end_at": _at(split)},
{"text": labels[1], "start_at": _at(split), "end_at": _at(end)},
],
}
def _planets_and_asc(req: ChartRequest, at: datetime) -> tuple[dict[str, float], float]:
chart = chart_at(req, at)
planets = {
name: float(data["lon"]) for name, data in chart.get("planets", {}).items()
if isinstance(data, dict) and isinstance(data.get("lon"), int | float)
}
return planets, float(chart["ascendant"]["lon"])
def build_packet(body: dict[str, Any]) -> dict[str, Any]:
req, start, end, reported, reported_known = _request(body)
base = {
"engine_version": ENGINE_VERSION,
"window": {"start_at": _at(start), "end_at": _at(end)},
"reported_at": _at(reported),
"reported_known": reported_known,
"ayanamsa": req.ayanamsa,
"node_mode": req.node_mode,
}
if (end - start) > timedelta(minutes=MAX_WINDOW_MINUTES):
return {**base, "status": "needs_narrowing", "narrowing": _narrowing(req, start, end)}
lines = find_lines(req, start, end)
line = nearest_line(lines, reported)
if line is None:
return {**base, "status": "certain", "saved_at": _at(reported)}
before_seg, after_seg = segment_around(lines, line, start, end)
before_min = representative_minute(before_seg, reported)
after_min = representative_minute(after_seg, reported)
reported_side = "before" if reported < line.at else "after"
flipped = [key for key in DOMAIN_ORDER if key in line.flips]
unchanged = [key for key in DOMAIN_ORDER if key not in line.flips]
pb, ab = _planets_and_asc(req, before_min)
pa, aa = _planets_and_asc(req, after_min)
comparisons, questions = [], []
for key in flipped:
division = int(key[1:])
before = describe(key, varga_chart(pb, ab, division))
after = describe(key, varga_chart(pa, aa, division))
comparisons.append({"chart": key, "label": DOMAIN[key]["label"], "chart_label": DOMAIN[key]["chart"],
"before": before, "after": after})
if len(questions) < MAX_QUESTIONS and (q := build_question(before, after)):
questions.append(q)
return {
**base,
"status": "choose",
"line": {"at": _at_s(line.at), "degree": line.degree, "charts": flipped},
"other_lines_in_window": len(lines) - 1,
"reported_side": reported_side,
"reported_neutral": reported_is_neutral(reported, line.at, reported_known=reported_known),
"seconds_from_line": int(abs((reported - line.at).total_seconds())),
"sides": {
"before": {"from": _at_s(before_seg[0]), "to": _at_s(before_seg[1]), "saved_at": _at(before_min)},
"after": {"from": _at_s(after_seg[0]), "to": _at_s(after_seg[1]), "saved_at": _at(after_min)},
},
"comparisons": comparisons,
"unchanged": [{"chart": k, "label": DOMAIN[k]["label"]} for k in unchanged],
"questions": questions,
}
def decide_packet(body: dict[str, Any]) -> dict[str, Any]:
packet = build_packet(body)
if packet["status"] != "choose":
raise ChartChoiceInputError(f"no choice to make (status {packet['status']})")
raw = body.get("answers") or {}
if not isinstance(raw, dict):
raise ChartChoiceInputError("answers must be an object")
asked = {q["domain"] for q in packet["questions"]}
answers = {k: str(v) for k, v in raw.items() if k in asked and v in ("before", "after", "unsure")}
override = body.get("override_side")
if override in ("before", "after"):
decision = {"chosen_side": override, "rule": "user_override",
"votes": None, "both_versions_domains": []}
else:
decision = decide(answers, reported_side=packet["reported_side"], neutral=packet["reported_neutral"])
if body.get("read_both_all"):
decision["both_versions_domains"] = sorted(c["before"]["domain"] for c in packet["comparisons"])
side = decision["chosen_side"]
labels = {c["before"]["domain"]: c["label"] for c in packet["comparisons"]}
return {
**packet,
"answers": answers,
"decision": {
**decision,
"saved_at": packet["sides"][side]["saved_at"],
"both_versions": [{"domain": d, "label": labels.get(d, d)} for d in decision["both_versions_domains"]],
},
}