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:
@@ -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``).
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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": "说不清"},
|
||||
],
|
||||
}
|
||||
@@ -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"]],
|
||||
},
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user