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
This commit is contained in:
2026-11-06 02:06:17 +08:00
co-authored by Claude Opus 5.5
parent 945e86911a
commit 97afd1716a
20 changed files with 2121 additions and 3 deletions
+13
View File
@@ -0,0 +1,13 @@
"""Chart choice: pick which chart to read when the birth time sits near a dividing line.
This replaces the old event-scoring rectification flow (BUG-1091: event scoring
was measured at chance level on 77 Rodden-AA cases). Nothing here scores life
events. The engine only:
1. finds the minutes where the ascendant of D1/D2/D4/D9/D10/D12/D24 changes sign
(``boundaries``),
2. describes how the reading differs on either side of the line nearest the
reported time (``readings``),
3. turns the user's own self-assessment answers into a choice with fixed,
documented rules (``decision``).
"""
+157
View File
@@ -0,0 +1,157 @@
"""Exact dividing lines inside a birth-time window.
A "line" is the instant the ascendant crosses a degree where at least one of the
tracked divisional charts changes its ascendant sign. Several charts can flip at
the same degree (for example 15 degrees flips D2, D4, D10, D12 and D24 at once);
those are one line, one choice.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Final
_SCRIPTS = Path(__file__).resolve().parents[1]
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
import domain_calculation_service # noqa: E402
import varga # noqa: E402
TRACKED_DIVISIONS: Final[tuple[int, ...]] = (1, 2, 4, 9, 10, 12, 24)
MAX_WINDOW_MINUTES: Final = 60
# Lines closer than this to the edge of a segment are not used as a saved minute.
EDGE_GUARD_SECONDS: Final = 60
class ChartChoiceInputError(ValueError):
"""The request cannot be evaluated (bad date, window, or place)."""
@dataclass(frozen=True)
class ChartRequest:
birth_date: str # YYYY-MM-DD
lat: float
lon: float
tz: float
ayanamsa: str = "raman"
node_mode: str = "mean"
@dataclass
class Line:
at: datetime
degree: float # ascendant degree inside its sign where the line sits
flips: dict[str, tuple[int, int]] = field(default_factory=dict) # "D10": (before_sign, after_sign)
def chart_at(req: ChartRequest, at: datetime) -> dict[str, Any]:
return domain_calculation_service.compute_chart({
"year": at.year, "month": at.month, "day": at.day,
"hour": at.hour, "minute": at.minute, "second": at.second,
"lat": req.lat, "lon": req.lon, "tz": req.tz,
"ayanamsa": req.ayanamsa, "node_mode": req.node_mode,
})
def ascendant_longitude(req: ChartRequest, at: datetime) -> float:
return float(chart_at(req, at)["ascendant"]["lon"])
def varga_sign(longitude: float, division: int) -> int:
if division == 1:
return int(longitude // 30) % 12
return int(varga.calc_varga(longitude, division)["sign_idx"])
def signature(longitude: float) -> dict[str, int]:
return {f"D{d}": varga_sign(longitude, d) for d in TRACKED_DIVISIONS}
def _unwrap(prev: float, cur: float) -> float:
while cur < prev - 180:
cur += 360
return cur
def _crossing_degrees(lo: float, hi: float) -> list[float]:
"""Every tracked division boundary strictly inside (lo, hi], unwrapped degrees."""
found: set[float] = set()
for d in TRACKED_DIVISIONS:
part = 30.0 / d
k = int(lo // part) + 1
while k * part <= hi + 1e-12:
found.add(round(k * part, 9))
k += 1
return sorted(found)
def _bisect_time(req: ChartRequest, t0: datetime, a0: float, t1: datetime, degree: float) -> datetime:
"""Second-resolution time at which the (unwrapped) ascendant reaches ``degree``."""
lo, hi = t0, t1
while (hi - lo).total_seconds() > 1:
mid = lo + (hi - lo) / 2
mid = mid.replace(microsecond=0)
if mid <= lo:
break
a = _unwrap(a0, ascendant_longitude(req, mid))
if a < degree:
lo = mid
else:
hi = mid
return hi
def find_lines(req: ChartRequest, start: datetime, end: datetime) -> list[Line]:
"""All dividing lines in [start, end], to the second."""
if end <= start:
raise ChartChoiceInputError("window end must be after start")
lines: list[Line] = []
t = start
a_prev = ascendant_longitude(req, t)
while t < end:
t_next = min(t + timedelta(minutes=1), end)
a_next = _unwrap(a_prev, ascendant_longitude(req, t_next))
for deg in _crossing_degrees(a_prev, a_next):
at = _bisect_time(req, t, a_prev, t_next, deg)
before = signature((deg - 1e-6) % 360)
after = signature((deg + 1e-6) % 360)
flips = {k: (before[k], after[k]) for k in before if before[k] != after[k]}
if flips:
lines.append(Line(at=at, degree=round(deg % 30, 6), flips=flips))
t, a_prev = t_next, a_next
return lines
def nearest_line(lines: list[Line], reported: datetime) -> Line | None:
if not lines:
return None
return min(lines, key=lambda line: abs((line.at - reported).total_seconds()))
def segment_around(lines: list[Line], line: Line, start: datetime, end: datetime) -> tuple[tuple[datetime, datetime], tuple[datetime, datetime]]:
"""(before_segment, after_segment) bounded by the neighbouring lines and the window."""
idx = lines.index(line)
prev_at = lines[idx - 1].at if idx > 0 else start
next_at = lines[idx + 1].at if idx + 1 < len(lines) else end
return (max(prev_at, start), line.at), (line.at, min(next_at, end))
def representative_minute(segment: tuple[datetime, datetime], reported: datetime) -> datetime:
"""Whole minute to save for a side. Keeps the reported time when it lies on that side."""
lo, hi = segment
if lo <= reported < hi:
return reported
guard = timedelta(seconds=EDGE_GUARD_SECONDS)
inner_lo, inner_hi = lo + guard, hi - guard
if inner_hi < inner_lo: # very short segment: use its middle
inner_lo = inner_hi = lo + (hi - lo) / 2
mid = inner_lo + (inner_hi - inner_lo) / 2
minute = mid.replace(second=0, microsecond=0)
if minute < lo:
minute += timedelta(minutes=1)
if minute >= hi:
minute = (hi - timedelta(seconds=1)).replace(second=0, microsecond=0)
return minute
+57
View File
@@ -0,0 +1,57 @@
"""Fixed rules that turn self-assessment answers into a chart choice.
No scoring. The rules were agreed with the product owner on 2026-11-06:
* When the reported time is within ``NEAR_LINE_MINUTES`` of the line, or is a
rounded time (minute divisible by 15), the reported time carries no real
preference, so the side with more answers wins. A tie keeps the reported side.
* Otherwise the reported side is kept unless at least ``OVERRIDE_VOTES`` answers
point to the other side and at most one answer points to the reported side.
* Domains whose answer points away from the chosen side are read in both versions.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Final, Literal
Side = Literal["before", "after"]
NEAR_LINE_MINUTES: Final = 5
OVERRIDE_VOTES: Final = 4
def reported_is_neutral(reported: datetime, line_at: datetime, *, reported_known: bool) -> bool:
if not reported_known:
return True
if reported.minute % 15 == 0:
return True
return abs((reported - line_at).total_seconds()) < NEAR_LINE_MINUTES * 60
def decide(
answers: dict[str, str],
*,
reported_side: Side,
neutral: bool,
) -> dict[str, Any]:
votes = {"before": 0, "after": 0, "unsure": 0}
for value in answers.values():
votes[value if value in votes else "unsure"] += 1
other: Side = "after" if reported_side == "before" else "before"
if neutral:
if votes[other] > votes[reported_side]:
chosen, rule = other, "majority"
elif votes[reported_side] > votes[other]:
chosen, rule = reported_side, "majority"
else:
chosen, rule = reported_side, "tie_keeps_reported"
elif votes[other] >= OVERRIDE_VOTES and votes[reported_side] <= 1:
chosen, rule = other, "strong_override"
else:
chosen, rule = reported_side, "reported_kept"
both_versions = sorted(domain for domain, value in answers.items() if value in ("before", "after") and value != chosen)
return {
"chosen_side": chosen,
"rule": rule,
"votes": votes,
"both_versions_domains": both_versions,
}
+260
View File
@@ -0,0 +1,260 @@
"""Plain-language differences between the two sides of a dividing line.
Every sentence is derived from chart facts (sign of the divisional ascendant,
occupants of the domain houses, where the domain-house lords sit). The copy
states classical tendencies in modest words; it never claims that a life event
proves either side (BUG-1091).
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Final
_SCRIPTS = Path(__file__).resolve().parents[1]
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
import varga # noqa: E402
SIGN_CN: Final = ("白羊", "金牛", "双子", "巨蟹", "狮子", "处女", "天秤", "天蝎", "射手", "摩羯", "水瓶", "双鱼")
SIGN_LORD: Final = ("Mars", "Venus", "Mercury", "Moon", "Sun", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Saturn", "Jupiter")
PLANETS: Final = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
PLANET_CN: Final = {
"Sun": "太阳", "Moon": "月亮", "Mars": "火星", "Mercury": "水星", "Jupiter": "木星",
"Venus": "金星", "Saturn": "土星", "Rahu": "罗睺", "Ketu": "计都",
}
# Domain per tracked chart, in the order questions are asked when more than five flip.
DOMAIN_ORDER: Final = ("D1", "D10", "D9", "D4", "D2", "D12", "D24")
DOMAIN: Final = {
"D1": {"key": "self", "label": "整体性格", "chart": "主盘 D1"},
"D2": {"key": "money", "label": "钱财", "chart": "钱财盘 D2"},
"D4": {"key": "home", "label": "住处", "chart": "住处盘 D4"},
"D9": {"key": "relationship", "label": "婚恋", "chart": "婚恋盘 D9"},
"D10": {"key": "career", "label": "事业", "chart": "事业盘 D10"},
"D12": {"key": "parents", "label": "父母", "chart": "父母盘 D12"},
"D24": {"key": "study", "label": "学业", "chart": "学业盘 D24"},
}
# What a planet brings to whichever domain house it occupies.
PLANET_TONE: Final = {
"Sun": "权威和管理,想出头、要面子",
"Moon": "和人打交道,变化多、投入感情",
"Mars": "竞争和技术,行动快、敢拼",
"Mercury": "沟通、商业、文字和数据",
"Jupiter": "大平台和贵人,重学识、有分寸",
"Venus": "审美、设计和人缘,讲究舒适",
"Saturn": "慢热、担责任、能坚持,早年辛苦、后劲足",
"Rahu": "野心大、不走寻常路,和外来或新兴的东西有缘",
"Ketu": "专精、抽离,不太在乎外界认可",
}
# Where the key lord of a domain sits, read for that domain. Index = house 1..12.
LORD_IN_HOUSE: Final = {
"career": (
"事业和自我绑在一起,靠个人特色和自己做主",
"事业和钱、家族或口才有关,重视收入稳定",
"路多半是自己闯出来的,靠主动、表达、写说做内容",
"工作和家、住处分不太开,适合和生活方式有关的行业",
"靠专长、兴趣和脑力吃饭,偏技术、研究、创作或教育",
"在竞争和解决问题里成长,适合服务、专业技术或要拼的岗位",
"事业靠合作、客户和对外打交道推动",
"事业有转折和起伏,适合幕后、研究或需要深挖的工作",
"遇到导师、去远方、读更高的学历会推动事业",
"事业心强,容易站到显眼的位置上",
"机会多来自人脉和圈子,收入导向明显",
"事业和外地、外企或幕后有关,常要离开熟悉的环境",
),
"home": (
"对住的地方有主见,住处跟着自己的选择走",
"住处和家里的钱、家族资源有关",
"搬动多,但多在同一城市或熟悉的范围里",
"住处安稳,和老家、家人的牵连深",
"住的地方和兴趣、学习或孩子有关",
"住处容易有琐碎的麻烦,要花心思打理",
"住处的变化常因为伴侣或合作的人",
"住处偶尔会有突然的变化",
"住处和远方、求学有关,可能为了深造搬家",
"人跟着工作走,为了事业定居",
"住处和朋友圈、收益有关,住得热闹",
"长期在出生地以外生活,心里有离乡感",
),
"money": (), # D2 uses the hora rule, not house lords
"relationship": (
"感情里很看重自我,伴侣和自己性格相近",
"伴侣和家庭、钱财观念紧密相关",
"感情从交流开始,伴侣可能是同学、同事或邻近的人",
"感情和家庭绑在一起,重视安稳",
"感情从兴趣和浪漫开始,重视心动的感觉",
"感情里有磨合和要处理的问题",
"重视伴侣本身,关系是生活的重心",
"感情有深度也有起伏,容易有转折",
"伴侣可能来自远方,或带着学识、信念上的影响",
"伴侣和事业、社会地位相关",
"感情从朋友圈里来,重视共同的圈子",
"感情和远方或私密的一面有关,可能异地",
),
"parents": (
"和这位家长性格相近,影响直接",
"这位家长和家里的钱、家族传承关系大",
"这位家长的支持是鼓励你自己去闯",
"这位家长和家庭、住处的牵连深",
"这位家长很看重你的学习和兴趣",
"和这位家长之间有要磨合的地方",
"这位家长的影响通过合作或对外关系体现",
"和这位家长的关系有深度也有变化",
"这位家长像导师,带给你信念和见识",
"这位家长在事业或社会上有分量",
"这位家长对你帮助大,带来实际的支持",
"和这位家长之间有距离,可能分开生活",
),
"study": (
"学习靠自己,主动性强",
"学的东西和挣钱、实用技能有关",
"学习靠多交流、多动手,读写表达好",
"学习需要安稳的环境,和家里的支持有关",
"学习从兴趣出发,专长明显",
"学业靠考试和拼劲,在竞争里出成绩",
"学习和合作、老师或同伴关系大",
"学业有停顿或转向,偏研究和深层的东西",
"深造运好,容易去远方读书",
"学的东西后来直接用在工作上,偏实用",
"学习带来实际收获和人脉",
"深造多半在外地,或者来得晚",
),
"self": (
"性格以自我为中心,有主见",
"看重钱和家里,说话有分量",
"主动、爱表达,靠自己闯",
"恋家,内心需要安全感",
"重兴趣和创造,脑子活",
"能吃苦,在磨炼里成长",
"以人为镜,重视伴侣和合作",
"经历转折多,喜欢探究深层的东西",
"有信念,容易遇到贵人",
"事业心重,看重地位",
"重视圈子和收获",
"向往远方,有离乡或内省的一面",
),
}
# (domain houses whose occupants matter, house whose lord is read, label of that lord)
DOMAIN_FOCUS: Final = {
"career": ((10, 1), 10, "管事业的星"),
"home": ((4, 12), 4, "管住处的星"),
"relationship": ((7, 1), 7, "管伴侣的星"),
"parents": ((4, 9), 4, "管母亲的星"),
"study": ((4, 5, 9), 9, "管深造的星"),
"self": ((1,), 1, "命主星"),
}
HOUSE_NAME: Final = {
1: "命位", 4: "家的位置", 5: "专长的位置", 7: "伴侣的位置", 9: "远方和贵人的位置",
10: "事业位", 12: "离乡的位置",
}
# Second lord read for parents (father) and study (the learner itself).
EXTRA_LORD: Final = {
"parents": (9, "管父亲的星"),
"study": (1, "代表学习者自己的星"),
"career": (1, "代表你自己的星"),
"home": (1, "代表你自己的星"),
"relationship": (1, "代表你自己的星"),
}
# Which person a parents sentence talks about, by the house whose lord is read.
PARENT_BY_HOUSE: Final = {4: "母亲", 9: "父亲"}
HORA_TEXT: Final = {
4: ("太阳这一半", "钱更多来自自己出头、拿职位、担责任,在体系里往上走,来得慢但稳"),
3: ("月亮这一半", "钱更多来自和人打交道、做买卖、做内容或和审美有关的东西,来得灵活、零散"),
}
def varga_chart(planet_longitudes: dict[str, float], ascendant_longitude: float, division: int) -> dict[str, int]:
"""Sign index of the ascendant and each planet in one divisional chart."""
if division == 1:
out = {"Ascendant": int(ascendant_longitude // 30) % 12}
out.update({p: int(planet_longitudes[p] // 30) % 12 for p in PLANETS if p in planet_longitudes})
return out
out = {"Ascendant": int(varga.calc_varga(ascendant_longitude, division)["sign_idx"])}
for p in PLANETS:
if p in planet_longitudes:
out[p] = int(varga.calc_varga(planet_longitudes[p], division)["sign_idx"])
return out
def _house(chart: dict[str, int], sign: int) -> int:
return (sign - chart["Ascendant"]) % 12 + 1
def _occupants(chart: dict[str, int], house: int) -> list[str]:
return [p for p in PLANETS if p in chart and _house(chart, chart[p]) == house]
def _lord_of(chart: dict[str, int], house: int) -> str:
return SIGN_LORD[(chart["Ascendant"] + house - 1) % 12]
def describe(chart_key: str, chart: dict[str, int]) -> dict[str, Any]:
"""Facts plus plain sentences for one side of one divisional chart."""
meta = DOMAIN[chart_key]
domain = meta["key"]
asc = chart["Ascendant"]
points: list[dict[str, str]] = []
if chart_key == "D2":
half, text = HORA_TEXT.get(asc, ("", ""))
own = [PLANET_CN[p] for p in PLANETS if chart.get(p) == asc]
detail = f"{'、'.join(own)}在你这一半。" if own else ""
points.append({"kind": "hora", "fact": f"上升落在{half}。{detail}", "meaning": text})
return {"chart": chart_key, "domain": domain, "label": meta["label"], "chart_label": meta["chart"],
"ascendant": SIGN_CN[asc], "points": points}
houses, lord_house, lord_label = DOMAIN_FOCUS[domain]
for house in houses:
for planet in _occupants(chart, house):
points.append({"kind": "occupant", "fact": f"{PLANET_CN[planet]}在{HOUSE_NAME.get(house, f'第 {house} 宫')}",
"meaning": PLANET_TONE[planet]})
lord_reads = [(lord_house, lord_label)]
if domain in EXTRA_LORD:
lord_reads.append(EXTRA_LORD[domain])
seen: set[str] = set()
for house, label in lord_reads:
lord = _lord_of(chart, house)
if lord in seen or lord not in chart:
continue
seen.add(lord)
where = _house(chart, chart[lord])
table = LORD_IN_HOUSE["self"] if (domain == "career" and house == 1) else LORD_IN_HOUSE[domain]
meaning = table[where - 1]
if domain == "parents":
meaning = meaning.replace("这位家长", PARENT_BY_HOUSE[house])
points.append({"kind": "lord", "fact": f"{label}({PLANET_CN[lord]})落在第 {where} 宫", "meaning": meaning})
return {"chart": chart_key, "domain": domain, "label": meta["label"], "chart_label": meta["chart"],
"ascendant": SIGN_CN[asc], "points": points}
def _option_text(own: dict[str, Any], other: dict[str, Any]) -> str:
"""Up to two lord readings that differ from the other side, else the first differing occupant tone."""
other_meanings = {p["meaning"] for p in other["points"]}
lords = [p["meaning"] for p in own["points"] if p["kind"] == "lord" and p["meaning"] not in other_meanings]
if lords:
return ";".join(lords[:2])
tones = [p for p in own["points"] if p["kind"] in ("occupant", "hora") and p["meaning"] not in other_meanings]
return tones[0]["meaning"] if tones else ""
def build_question(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any] | None:
"""One self-assessment question for a flipped chart, or None when both sides read alike."""
a, b = _option_text(before, after), _option_text(after, before)
if not a or not b or a == b:
return None
return {
"domain": before["domain"],
"chart": before["chart"],
"label": before["label"],
"prompt": f"你的{before['label']},更像哪一种?",
"options": [
{"value": "before", "text": a},
{"value": "after", "text": b},
{"value": "unsure", "text": "说不清"},
],
}
+214
View File
@@ -0,0 +1,214 @@
"""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"]],
},
}
+8
View File
@@ -10722,6 +10722,14 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService):
elif path == '/api/active_rectification_events_v4':
result = self._compute_active_rectification_events_v4(body)
self._json(result)
elif path in ('/api/chart_choice/packet', '/api/chart_choice/decide'):
from scripts.chart_choice.boundaries import ChartChoiceInputError
from scripts.chart_choice.service import build_packet, decide_packet
try:
handler = build_packet if path.endswith('/packet') else decide_packet
self._json(handler(body if isinstance(body, dict) else {}))
except ChartChoiceInputError as exc:
raise BadRequest(str(exc)) from exc
elif path == '/api/rectification/v5/varga-segments':
_load_local_module('rectification_varga_segments_api').handle_varga_segments(self, body)
elif path == '/api/rectification/v5/candidate-features':