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
215 lines
9.1 KiB
Python
215 lines
9.1 KiB
Python
"""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"]],
|
||
},
|
||
}
|