feat: add agent guided birth time rectification
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
# ─── How to run ───
|
||||
# PYTHONPATH=scripts .venv/bin/python scripts/active_rectification_event_engine.py
|
||||
"""Local chart and dual-Dasha computation for birth-time event scoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Final, assert_never
|
||||
|
||||
from scripts.active_rectification_events import (
|
||||
CandidateEvidence,
|
||||
CandidateResult,
|
||||
CandidateScoreRow,
|
||||
EventDomain,
|
||||
LifeEvent,
|
||||
RectificationEventRequest,
|
||||
adjudicate_candidate_rows,
|
||||
precision_weight,
|
||||
)
|
||||
|
||||
SCRIPTS: Final = Path(__file__).resolve().parent
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import dasha_analyzer # noqa: E402
|
||||
import domain_calculation_service # noqa: E402
|
||||
import narayana_dasha # noqa: E402
|
||||
import varga # noqa: E402
|
||||
|
||||
DomainConfig = tuple[str, tuple[int, ...]]
|
||||
DOMAIN_CONFIG: Final[dict[EventDomain, DomainConfig]] = {
|
||||
"education": ("D24", (4, 5, 9)),
|
||||
"relocation": ("D4", (4, 12)),
|
||||
"relationship": ("D9", (7,)),
|
||||
"career": ("D10", (10,)),
|
||||
"health_pressure": ("D30", (6, 8, 12)),
|
||||
}
|
||||
|
||||
|
||||
class RectificationEventCalculationError(RuntimeError):
|
||||
"""Raised when stored rectification evidence cannot be calculated safely."""
|
||||
|
||||
|
||||
def _event_datetime(event: LifeEvent) -> datetime:
|
||||
match event["precision"]:
|
||||
case "day":
|
||||
return datetime.strptime(event["date"], "%Y-%m-%d")
|
||||
case "month":
|
||||
return datetime.strptime(f"{event['date']}-15", "%Y-%m-%d")
|
||||
case "year":
|
||||
return datetime.strptime(f"{event['date']}-07-01", "%Y-%m-%d")
|
||||
case unreachable:
|
||||
assert_never(unreachable)
|
||||
|
||||
|
||||
def _candidate_datetimes(request: RectificationEventRequest) -> list[datetime]:
|
||||
birth_date = date.fromisoformat(request["birth_date"])
|
||||
start = datetime.combine(birth_date, time.fromisoformat(request["start_time"]))
|
||||
end = datetime.combine(birth_date, time.fromisoformat(request["end_time"]))
|
||||
if end < start:
|
||||
end += timedelta(days=1)
|
||||
minute_count = int((end - start).total_seconds() // 60) + 1
|
||||
if minute_count < 1 or minute_count > 1_440:
|
||||
raise RectificationEventCalculationError("candidate_range_out_of_bounds")
|
||||
return [start + timedelta(minutes=offset) for offset in range(minute_count)]
|
||||
|
||||
|
||||
def _active_vimshottari(
|
||||
birth_date: str,
|
||||
moon_longitude: float,
|
||||
event_at: datetime,
|
||||
) -> tuple[str, str]:
|
||||
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
|
||||
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
|
||||
birth_date,
|
||||
nakshatra,
|
||||
progress,
|
||||
)
|
||||
_, major = dasha_analyzer.find_current(timeline, event_at)
|
||||
minor = dasha_analyzer.find_current_sub(
|
||||
dasha_analyzer.build_antardasha(major),
|
||||
event_at,
|
||||
)
|
||||
return str(major["lord"]), str(minor["lord"])
|
||||
|
||||
|
||||
def _active_narayana(
|
||||
ascendant_index: int,
|
||||
planet_longitudes: dict[str, float],
|
||||
birth_at: datetime,
|
||||
event_at: datetime,
|
||||
) -> tuple[int | None, int | None]:
|
||||
periods = narayana_dasha.calc_narayana_mahadasha(
|
||||
ascendant_index,
|
||||
planet_longitudes,
|
||||
)
|
||||
age = max((event_at - birth_at).total_seconds() / (365.2425 * 86_400), 0.0)
|
||||
active = narayana_dasha.get_current_narayana_dasha(periods, age)
|
||||
major = active.get("md") or {}
|
||||
minor = active.get("ad") or {}
|
||||
return major.get("sign_idx"), minor.get("sign_idx")
|
||||
|
||||
|
||||
def _varga_chart(charts: dict, prefix: str) -> dict | None:
|
||||
return next(
|
||||
(chart for name, chart in charts.items() if name.startswith(f"{prefix}_")),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _relative_house(sign_index: int, ascendant_index: int) -> int:
|
||||
return (sign_index - ascendant_index) % 12 + 1
|
||||
|
||||
|
||||
def _house_lords(ascendant_index: int, houses: tuple[int, ...]) -> set[str]:
|
||||
return {
|
||||
narayana_dasha.SIGN_LORDS[
|
||||
narayana_dasha.SIGNS[(ascendant_index + house - 1) % 12]
|
||||
]
|
||||
for house in houses
|
||||
}
|
||||
|
||||
|
||||
def _planet_house(chart: dict, planet: str) -> int | None:
|
||||
raw = (chart.get("planets", {}).get(planet) or {}).get("house")
|
||||
return int(raw) if isinstance(raw, int | float) else None
|
||||
|
||||
|
||||
def _varga_house(chart: dict, planet: str) -> int | None:
|
||||
ascendant = chart.get("Ascendant") or {}
|
||||
placement = chart.get(planet) or {}
|
||||
ascendant_index = ascendant.get("sign_idx")
|
||||
planet_index = placement.get("sign_idx")
|
||||
if not isinstance(ascendant_index, int) or not isinstance(planet_index, int):
|
||||
return None
|
||||
return _relative_house(planet_index, ascendant_index)
|
||||
|
||||
|
||||
def _score_event(
|
||||
*,
|
||||
candidate_time: str,
|
||||
event: LifeEvent,
|
||||
natal_chart: dict,
|
||||
varga_chart: dict,
|
||||
vimshottari: tuple[str, str],
|
||||
narayana: tuple[int | None, int | None],
|
||||
) -> CandidateEvidence:
|
||||
_, target_houses = DOMAIN_CONFIG[event["domain"]]
|
||||
ascendant_index = int(natal_chart["ascendant"]["lon"] // 30)
|
||||
target_lords = _house_lords(ascendant_index, target_houses)
|
||||
major_lord, minor_lord = vimshottari
|
||||
rules: list[str] = []
|
||||
points = 0.0
|
||||
|
||||
for lord, weight, label in (
|
||||
(major_lord, 2.0, "vim_md"),
|
||||
(minor_lord, 1.5, "vim_ad"),
|
||||
):
|
||||
if _planet_house(natal_chart, lord) in target_houses:
|
||||
rules.append(f"{label}_domain_house")
|
||||
points += weight
|
||||
if lord in target_lords:
|
||||
rules.append(f"{label}_domain_lord")
|
||||
points += weight
|
||||
if _varga_house(varga_chart, lord) in target_houses:
|
||||
rules.append(f"{label}_domain_varga")
|
||||
points += weight / 2
|
||||
|
||||
for sign_index, weight, label in (
|
||||
(narayana[0], 2.0, "narayana_md"),
|
||||
(narayana[1], 1.0, "narayana_ad"),
|
||||
):
|
||||
if sign_index is not None and _relative_house(sign_index, ascendant_index) in target_houses:
|
||||
rules.append(f"{label}_domain_house")
|
||||
points += weight
|
||||
|
||||
weighted_points = round(points * precision_weight(event["precision"]), 4)
|
||||
return {
|
||||
"event_id": event["id"],
|
||||
"domain": event["domain"],
|
||||
"candidate_time": candidate_time,
|
||||
"rule_ids": rules or ["no_domain_activation"],
|
||||
"points": weighted_points,
|
||||
}
|
||||
|
||||
|
||||
def _candidate_row(
|
||||
request: RectificationEventRequest,
|
||||
candidate_at: datetime,
|
||||
) -> CandidateScoreRow:
|
||||
chart = domain_calculation_service.compute_chart({
|
||||
"year": candidate_at.year,
|
||||
"month": candidate_at.month,
|
||||
"day": candidate_at.day,
|
||||
"hour": candidate_at.hour,
|
||||
"minute": candidate_at.minute,
|
||||
"lat": request["lat"],
|
||||
"lon": request["lon"],
|
||||
"tz": request["tz"],
|
||||
"ayanamsa": "lahiri",
|
||||
"node_mode": "true",
|
||||
})
|
||||
planet_longitudes = {
|
||||
name: float(data["lon"])
|
||||
for name, data in chart.get("planets", {}).items()
|
||||
if isinstance(data, dict) and isinstance(data.get("lon"), int | float)
|
||||
}
|
||||
ascendant_longitude = float(chart["ascendant"]["lon"])
|
||||
ascendant_index = int(ascendant_longitude // 30)
|
||||
charts = varga.calc_all_vargas(
|
||||
planet_longitudes,
|
||||
ascendant_longitude,
|
||||
divisions=[4, 9, 10, 24, 30],
|
||||
)
|
||||
moon_longitude = planet_longitudes["Moon"]
|
||||
evidence: list[CandidateEvidence] = []
|
||||
missing_layers: list[str] = []
|
||||
|
||||
for event in request["events"]:
|
||||
event_at = _event_datetime(event)
|
||||
prefix, _ = DOMAIN_CONFIG[event["domain"]]
|
||||
domain_varga = _varga_chart(charts, prefix)
|
||||
if domain_varga is None:
|
||||
missing_layers.append(prefix)
|
||||
continue
|
||||
vimshottari = _active_vimshottari(request["birth_date"], moon_longitude, event_at)
|
||||
narayana = _active_narayana(
|
||||
ascendant_index,
|
||||
planet_longitudes,
|
||||
candidate_at,
|
||||
event_at,
|
||||
)
|
||||
evidence.append(_score_event(
|
||||
candidate_time=candidate_at.strftime("%H:%M"),
|
||||
event=event,
|
||||
natal_chart=chart,
|
||||
varga_chart=domain_varga,
|
||||
vimshottari=vimshottari,
|
||||
narayana=narayana,
|
||||
))
|
||||
|
||||
return {
|
||||
"time": candidate_at.strftime("%H:%M"),
|
||||
"score": round(sum(item["points"] for item in evidence), 4),
|
||||
"evidence": evidence,
|
||||
"missing_layers": sorted(set(missing_layers)),
|
||||
}
|
||||
|
||||
|
||||
def compute_event_candidate_result(request: RectificationEventRequest) -> CandidateResult:
|
||||
"""Compute actual minute candidates locally and return a guarded result."""
|
||||
normalized = json.dumps(request, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
rows = [_candidate_row(request, candidate) for candidate in _candidate_datetimes(request)]
|
||||
return adjudicate_candidate_rows(
|
||||
rows,
|
||||
event_count=len(request["events"]),
|
||||
domain_count=len({event["domain"] for event in request["events"]}),
|
||||
request_fingerprint=fingerprint,
|
||||
)
|
||||
@@ -0,0 +1,211 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
# ─── How to run ───
|
||||
# .venv/bin/python -m pytest -q tests/test_active_rectification_events.py
|
||||
"""Deterministically adjudicate dated events against birth-time candidates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Final, Literal, TypedDict, assert_never
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
EventPrecision = Literal["year", "month", "day"]
|
||||
EventDomain = Literal[
|
||||
"education",
|
||||
"relocation",
|
||||
"relationship",
|
||||
"career",
|
||||
"health_pressure",
|
||||
]
|
||||
Confidence = Literal["low", "medium", "high"]
|
||||
|
||||
ALGORITHM_VERSION: Final = "birth-time-event-scoring-v1"
|
||||
PRECISION_WEIGHTS: Final[dict[EventPrecision, float]] = {
|
||||
"day": 1.0,
|
||||
"month": 0.8,
|
||||
"year": 0.5,
|
||||
}
|
||||
|
||||
|
||||
class CandidateEvidence(TypedDict):
|
||||
event_id: str
|
||||
domain: str
|
||||
candidate_time: str
|
||||
rule_ids: list[str]
|
||||
points: float
|
||||
|
||||
|
||||
class LifeEvent(TypedDict):
|
||||
id: str
|
||||
domain: EventDomain
|
||||
date: str
|
||||
precision: EventPrecision
|
||||
|
||||
|
||||
class RectificationEventRequest(TypedDict):
|
||||
birth_date: str
|
||||
start_time: str
|
||||
end_time: str
|
||||
lat: float
|
||||
lon: float
|
||||
tz: float
|
||||
events: list[LifeEvent]
|
||||
|
||||
|
||||
class CandidateScoreRow(TypedDict):
|
||||
time: str
|
||||
score: float
|
||||
evidence: list[CandidateEvidence]
|
||||
missing_layers: list[str]
|
||||
|
||||
|
||||
class WinningSegment(TypedDict):
|
||||
start_time: str
|
||||
end_time: str
|
||||
representative_time: str
|
||||
width_minutes: int
|
||||
|
||||
|
||||
class CandidateResult(TypedDict):
|
||||
result_id: str
|
||||
confidence: Confidence
|
||||
can_apply: bool
|
||||
winning_segment: WinningSegment | None
|
||||
event_count: int
|
||||
domain_count: int
|
||||
top_score: float
|
||||
second_score: float
|
||||
margin_percent: float
|
||||
reasons: list[str]
|
||||
evidence: list[CandidateEvidence]
|
||||
algorithm_version: str
|
||||
|
||||
|
||||
def precision_weight(precision: EventPrecision) -> float:
|
||||
"""Return the fixed evidence weight for a declared date precision."""
|
||||
match precision:
|
||||
case "day" | "month" | "year":
|
||||
return PRECISION_WEIGHTS[precision]
|
||||
case unreachable:
|
||||
assert_never(unreachable)
|
||||
|
||||
|
||||
def _minute_value(value: str) -> int:
|
||||
hour, minute = value.split(":", maxsplit=1)
|
||||
return int(hour) * 60 + int(minute)
|
||||
|
||||
|
||||
def _is_next_minute(previous: str, current: str) -> bool:
|
||||
return (_minute_value(current) - _minute_value(previous)) % (24 * 60) == 1
|
||||
|
||||
|
||||
def _top_segments(rows: Sequence[CandidateScoreRow], top_score: float) -> list[list[CandidateScoreRow]]:
|
||||
segments: list[list[CandidateScoreRow]] = []
|
||||
for row in rows:
|
||||
if row["score"] != top_score:
|
||||
continue
|
||||
if segments and _is_next_minute(segments[-1][-1]["time"], row["time"]):
|
||||
segments[-1].append(row)
|
||||
else:
|
||||
segments.append([row])
|
||||
return segments
|
||||
|
||||
|
||||
def _winning_segment(rows: Sequence[CandidateScoreRow]) -> WinningSegment:
|
||||
width = len(rows)
|
||||
representative = rows[(width - 1) // 2]["time"]
|
||||
return {
|
||||
"start_time": rows[0]["time"],
|
||||
"end_time": rows[-1]["time"],
|
||||
"representative_time": representative,
|
||||
"width_minutes": width,
|
||||
}
|
||||
|
||||
|
||||
def adjudicate_candidate_rows(
|
||||
rows: Sequence[CandidateScoreRow],
|
||||
*,
|
||||
event_count: int,
|
||||
domain_count: int,
|
||||
request_fingerprint: str,
|
||||
) -> CandidateResult:
|
||||
"""Rank precomputed candidate rows and apply conservative confidence gates."""
|
||||
if not rows:
|
||||
return {
|
||||
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{request_fingerprint}")),
|
||||
"confidence": "low",
|
||||
"can_apply": False,
|
||||
"winning_segment": None,
|
||||
"event_count": event_count,
|
||||
"domain_count": domain_count,
|
||||
"top_score": 0.0,
|
||||
"second_score": 0.0,
|
||||
"margin_percent": 0.0,
|
||||
"reasons": ["no_candidate_rows"],
|
||||
"evidence": [],
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
}
|
||||
|
||||
ranked_scores = sorted({row["score"] for row in rows}, reverse=True)
|
||||
top_score = ranked_scores[0]
|
||||
second_score = ranked_scores[1] if len(ranked_scores) > 1 else top_score
|
||||
margin = round((top_score - second_score) / max(abs(top_score), 1.0) * 100, 2)
|
||||
segments = _top_segments(rows, top_score)
|
||||
missing_layers = sorted({layer for row in rows for layer in row["missing_layers"]})
|
||||
reasons: list[str] = []
|
||||
|
||||
if len(segments) != 1:
|
||||
reasons.append("tied_leader")
|
||||
if event_count < 3:
|
||||
reasons.append("insufficient_events")
|
||||
if domain_count < 2:
|
||||
reasons.append("insufficient_domains")
|
||||
if missing_layers:
|
||||
reasons.append("missing_mandatory_layers")
|
||||
|
||||
segment = _winning_segment(segments[0]) if len(segments) == 1 else None
|
||||
if segment and segment["width_minutes"] > 15:
|
||||
reasons.append("winning_interval_too_wide")
|
||||
if margin < 10:
|
||||
reasons.append("lead_margin_below_medium_threshold")
|
||||
|
||||
if reasons:
|
||||
confidence: Confidence = "low"
|
||||
elif (
|
||||
event_count >= 4
|
||||
and domain_count >= 3
|
||||
and segment is not None
|
||||
and segment["width_minutes"] <= 5
|
||||
and margin >= 20
|
||||
):
|
||||
confidence = "high"
|
||||
else:
|
||||
confidence = "medium"
|
||||
|
||||
top_rows = segments[0] if len(segments) == 1 else []
|
||||
representative_row = top_rows[(len(top_rows) - 1) // 2] if top_rows else None
|
||||
evidence = list(representative_row["evidence"]) if representative_row else []
|
||||
return {
|
||||
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{request_fingerprint}")),
|
||||
"confidence": confidence,
|
||||
"can_apply": confidence == "high",
|
||||
"winning_segment": segment,
|
||||
"event_count": event_count,
|
||||
"domain_count": domain_count,
|
||||
"top_score": top_score,
|
||||
"second_score": second_score,
|
||||
"margin_percent": margin,
|
||||
"reasons": reasons,
|
||||
"evidence": evidence,
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def score_life_events(request: RectificationEventRequest) -> CandidateResult:
|
||||
"""Compute actual candidate rows, then apply the versioned confidence gates."""
|
||||
from scripts.active_rectification_event_engine import compute_event_candidate_result
|
||||
|
||||
return compute_event_candidate_result(request)
|
||||
@@ -8,23 +8,7 @@ import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
OPTIONS = [
|
||||
{"key": "A", "label": "明确有,且时间大致吻合", "score": 2},
|
||||
{"key": "B", "label": "有类似,但时间略偏或不够重大", "score": 1},
|
||||
{"key": "C", "label": "没有明显发生", "score": -2},
|
||||
{"key": "D", "label": "不确定 / 不记得", "score": 0},
|
||||
]
|
||||
|
||||
QUESTION_TEMPLATES = [
|
||||
("education_environment_shift", 1, "education", ["D24", "D4", "Dasha"], "age_16_to_18", "16-18岁附近,是否有明显学业、学校、专业方向或学习环境变化?", "middle_candidate_cluster", "against_D24_sensitive_cluster"),
|
||||
("residence_relocation_shift", 1, "residence", ["D4", "12H", "Rahu/Ketu", "Transit"], "age_20_to_24", "20-24岁附近,是否有搬家、离乡、长期异地、住宿或居住结构变化?", "D4_relocation_cluster", "against_D4_relocation_cluster"),
|
||||
("relationship_or_partner_entry", 1, "relationship", ["D9", "UL", "A7", "7H"], "age_21_to_26", "21-26岁附近,是否有关系对象进入、关系断裂、暧昧升级或关系观明显转变?", "D9_UL_A7_cluster", "against_relationship_cluster"),
|
||||
("career_responsibility_pressure", 1, "career", ["D10", "A10", "Saturn", "10H"], "age_26_to_30", "26-30岁附近,是否有责任增加、合作压力、工作结构变化或长期压力阶段?", "D10_A10_saturn_cluster", "against_career_pressure_cluster"),
|
||||
("research_tool_expression_shift", 1, "career_learning", ["D10", "D24", "Mercury", "A10"], "recent_three_years", "近三年是否明显进入写作、技术、系统化学习、工具搭建、内容表达、AI/研究类方向?", "Mercury_D24_A10_cluster", "against_learning_expression_cluster"),
|
||||
("health_crisis_or_low_period", 2, "health_pressure", ["D30", "6H", "8H", "Saturn/Mars"], "largest_pressure_window", "某个压力窗口附近,是否有健康、事故、低谷、睡眠/精神压力或身体负担明显阶段?", "D30_crisis_cluster", "against_D30_crisis_cluster"),
|
||||
("public_role_or_project_visibility", 2, "public_work", ["A10", "D10", "AmK", "Karakamsha"], "career_visibility_window", "某个事业窗口附近,是否有项目公开、作品产出、职位/身份变化或被他人看见的机会?", "A10_public_visibility_cluster", "against_A10_cluster"),
|
||||
("sequence_inner_vs_outer", 3, "fine_timing", ["KP_cusp", "Pratyantar", "Dasha_boundary"], "top_candidate_window", "关键变化更像先有内在转向、后有外部结果,还是几乎同时发生?", "fine_boundary_cluster", "neutral"),
|
||||
]
|
||||
from scripts.active_rectification_scoring import build_questions, score_answers
|
||||
|
||||
|
||||
def _parse_time(value: str) -> datetime:
|
||||
@@ -113,7 +97,6 @@ def _candidate_recast(
|
||||
return None
|
||||
import domain_calculation_service
|
||||
import jaimini
|
||||
import kp_system
|
||||
import varga
|
||||
|
||||
chart = domain_calculation_service.compute_chart({
|
||||
@@ -134,7 +117,7 @@ def _candidate_recast(
|
||||
if name in {"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"}
|
||||
}
|
||||
asc_lon = chart["ascendant"]["lon"]
|
||||
vargas = varga.calc_all_vargas(planet_lons, asc_lon, divisions=[9, 10, 24, 30, 60])
|
||||
vargas = varga.calc_all_vargas(planet_lons, asc_lon, divisions=[4, 9, 10, 24, 30, 60])
|
||||
arudha = jaimini.calc_arudha_padas(int(asc_lon // 30), planet_lons)
|
||||
padas = arudha.get("padas", {})
|
||||
upapada = arudha.get("upapada", {})
|
||||
@@ -145,8 +128,16 @@ def _candidate_recast(
|
||||
"degree_in_sign": chart["ascendant"].get("degree_in_sign"),
|
||||
},
|
||||
"varga_lagna": {
|
||||
key: value.get("Ascendant", {})
|
||||
for key, value in vargas.items()
|
||||
**{
|
||||
key: value.get("Ascendant", {})
|
||||
for key, value in vargas.items()
|
||||
},
|
||||
**{
|
||||
f"D{division}": value.get("Ascendant", {})
|
||||
for division in (4, 9, 10, 24, 30)
|
||||
for key, value in vargas.items()
|
||||
if key.startswith(f"D{division}_")
|
||||
},
|
||||
},
|
||||
"arudha": {
|
||||
"A7": padas.get("A7", {}),
|
||||
@@ -189,23 +180,7 @@ def build_questionnaire(
|
||||
tz: float | None = None,
|
||||
ayanamsa: str = "lahiri",
|
||||
) -> dict[str, Any]:
|
||||
questions = []
|
||||
for qid, round_id, domain, sensitivity, window, prompt, yes_bias, no_bias in QUESTION_TEMPLATES:
|
||||
questions.append({
|
||||
"id": qid,
|
||||
"round": round_id,
|
||||
"domain": domain,
|
||||
"sensitivity": sensitivity,
|
||||
"window": window,
|
||||
"prompt": prompt,
|
||||
"options": OPTIONS,
|
||||
"scoring_map": {
|
||||
"A": {"effect": "support", "cluster": yes_bias, "points": 2},
|
||||
"B": {"effect": "weak_support", "cluster": yes_bias, "points": 1},
|
||||
"C": {"effect": "exclude_or_penalize", "cluster": no_bias, "points": -2},
|
||||
"D": {"effect": "neutral", "cluster": "neutral", "points": 0},
|
||||
},
|
||||
})
|
||||
questions = build_questions()
|
||||
return {
|
||||
"scope": "active_birth_time_rectification_questionnaire",
|
||||
"schema_version": 1,
|
||||
@@ -237,51 +212,6 @@ def build_questionnaire(
|
||||
}
|
||||
|
||||
|
||||
def score_answers(questionnaire: dict[str, Any], answers: dict[str, str]) -> dict[str, Any]:
|
||||
questions = questionnaire.get("questions") if isinstance(questionnaire.get("questions"), list) else []
|
||||
by_id = {question["id"]: question for question in questions if isinstance(question, dict) and question.get("id")}
|
||||
cluster_scores: dict[str, int] = {}
|
||||
applied = []
|
||||
unknown_ids = []
|
||||
invalid_answers = []
|
||||
|
||||
for question_id, raw_choice in (answers or {}).items():
|
||||
question = by_id.get(question_id)
|
||||
if not question:
|
||||
unknown_ids.append(question_id)
|
||||
continue
|
||||
choice = str(raw_choice or "").strip().upper()
|
||||
scoring = (question.get("scoring_map") or {}).get(choice)
|
||||
if not isinstance(scoring, dict):
|
||||
invalid_answers.append({"id": question_id, "answer": raw_choice})
|
||||
continue
|
||||
cluster = str(scoring.get("cluster") or "neutral")
|
||||
points = int(scoring.get("points") or 0)
|
||||
if cluster != "neutral":
|
||||
cluster_scores[cluster] = cluster_scores.get(cluster, 0) + points
|
||||
applied.append({"id": question_id, "answer": choice, "cluster": cluster, "points": points})
|
||||
|
||||
answered_ids = {item["id"] for item in applied}
|
||||
unanswered = [question for question in questions if question.get("id") not in answered_ids]
|
||||
next_round = min((int(question.get("round") or 0) for question in unanswered), default=None)
|
||||
rankings = [
|
||||
{"cluster": cluster, "score": score}
|
||||
for cluster, score in sorted(cluster_scores.items(), key=lambda item: (-item[1], item[0]))
|
||||
]
|
||||
return {
|
||||
"scope": "active_birth_time_rectification_scoring",
|
||||
"schema_version": 1,
|
||||
"answered_count": len(applied),
|
||||
"candidate_cluster_rankings": rankings,
|
||||
"next_round": next_round,
|
||||
"next_round_questions": [question for question in unanswered if question.get("round") == next_round],
|
||||
"applied_scoring": applied,
|
||||
"unknown_question_ids": unknown_ids,
|
||||
"invalid_answers": invalid_answers,
|
||||
"boundary": "This narrows candidate clusters only; final rectification requires scoring answers against actual candidate chart differences.",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--birth-time", required=True, help="Approximate local birth time, YYYY-MM-DD HH:MM")
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Build and score deterministic birth-time rectification questions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, Literal, TypeAlias, TypedDict
|
||||
|
||||
AnswerChoice: TypeAlias = Literal["A", "B", "C", "D"]
|
||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
|
||||
|
||||
class QuestionOption(TypedDict):
|
||||
key: AnswerChoice
|
||||
label: str
|
||||
score: int
|
||||
|
||||
|
||||
class ScoringRule(TypedDict):
|
||||
effect: str
|
||||
cluster: str
|
||||
points: int
|
||||
|
||||
|
||||
class RectificationQuestion(TypedDict):
|
||||
id: str
|
||||
round: int
|
||||
domain: str
|
||||
sensitivity: list[str]
|
||||
window: str
|
||||
prompt: str
|
||||
options: list[QuestionOption]
|
||||
scoring_map: dict[AnswerChoice, ScoringRule]
|
||||
|
||||
|
||||
class AppliedScore(TypedDict):
|
||||
id: str
|
||||
answer: str
|
||||
cluster: str
|
||||
points: int
|
||||
|
||||
|
||||
class ClusterRanking(TypedDict):
|
||||
cluster: str
|
||||
score: int
|
||||
|
||||
|
||||
class InvalidAnswer(TypedDict):
|
||||
id: str
|
||||
answer: str
|
||||
|
||||
|
||||
class ScoringResult(TypedDict):
|
||||
scope: str
|
||||
schema_version: int
|
||||
answered_count: int
|
||||
candidate_cluster_rankings: list[ClusterRanking]
|
||||
next_round: int | None
|
||||
next_round_questions: list[dict[str, JsonValue]]
|
||||
applied_scoring: list[AppliedScore]
|
||||
unknown_question_ids: list[str]
|
||||
invalid_answers: list[InvalidAnswer]
|
||||
boundary: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QuestionTemplate:
|
||||
id: str
|
||||
round: int
|
||||
domain: str
|
||||
sensitivity: tuple[str, ...]
|
||||
window: str
|
||||
prompt: str
|
||||
yes_bias: str
|
||||
no_bias: str
|
||||
|
||||
|
||||
OPTIONS: Final[tuple[QuestionOption, ...]] = (
|
||||
{"key": "A", "label": "明确有,且时间大致吻合", "score": 2},
|
||||
{"key": "B", "label": "有类似,但时间略偏或不够重大", "score": 1},
|
||||
{"key": "C", "label": "没有明显发生", "score": -2},
|
||||
{"key": "D", "label": "不确定 / 不记得", "score": 0},
|
||||
)
|
||||
|
||||
QUESTION_TEMPLATES: Final[tuple[QuestionTemplate, ...]] = (
|
||||
QuestionTemplate("education_environment_shift", 1, "education", ("D24", "D4", "Dasha"), "age_16_to_18", "16-18岁附近,是否有明显学业、学校、专业方向或学习环境变化?", "middle_candidate_cluster", "against_D24_sensitive_cluster"),
|
||||
QuestionTemplate("residence_relocation_shift", 1, "residence", ("D4", "12H", "Rahu/Ketu", "Transit"), "age_20_to_24", "20-24岁附近,是否有搬家、离乡、长期异地、住宿或居住结构变化?", "D4_relocation_cluster", "against_D4_relocation_cluster"),
|
||||
QuestionTemplate("relationship_or_partner_entry", 1, "relationship", ("D9", "UL", "A7", "7H"), "age_21_to_26", "21-26岁附近,是否有关系对象进入、关系断裂、暧昧升级或关系观明显转变?", "D9_UL_A7_cluster", "against_relationship_cluster"),
|
||||
QuestionTemplate("career_responsibility_pressure", 1, "career", ("D10", "A10", "Saturn", "10H"), "age_26_to_30", "26-30岁附近,是否有责任增加、合作压力、工作结构变化或长期压力阶段?", "D10_A10_saturn_cluster", "against_career_pressure_cluster"),
|
||||
QuestionTemplate("research_tool_expression_shift", 1, "career_learning", ("D10", "D24", "Mercury", "A10"), "recent_three_years", "近三年是否明显进入写作、技术、系统化学习、工具搭建、内容表达、AI/研究类方向?", "Mercury_D24_A10_cluster", "against_learning_expression_cluster"),
|
||||
QuestionTemplate("health_crisis_or_low_period", 2, "health_pressure", ("D30", "6H", "8H", "Saturn/Mars"), "largest_pressure_window", "某个压力窗口附近,是否有健康、事故、低谷、睡眠/精神压力或身体负担明显阶段?", "D30_crisis_cluster", "against_D30_crisis_cluster"),
|
||||
QuestionTemplate("public_role_or_project_visibility", 2, "public_work", ("A10", "D10", "AmK", "Karakamsha"), "career_visibility_window", "某个事业窗口附近,是否有项目公开、作品产出、职位/身份变化或被他人看见的机会?", "A10_public_visibility_cluster", "against_A10_cluster"),
|
||||
QuestionTemplate("sequence_inner_vs_outer", 3, "fine_timing", ("KP_cusp", "Pratyantar", "Dasha_boundary"), "top_candidate_window", "关键变化更像先有内在转向、后有外部结果,还是几乎同时发生?", "fine_boundary_cluster", "neutral"),
|
||||
)
|
||||
|
||||
|
||||
def build_questions() -> list[RectificationQuestion]:
|
||||
return [
|
||||
{
|
||||
"id": template.id,
|
||||
"round": template.round,
|
||||
"domain": template.domain,
|
||||
"sensitivity": list(template.sensitivity),
|
||||
"window": template.window,
|
||||
"prompt": template.prompt,
|
||||
"options": list(OPTIONS),
|
||||
"scoring_map": {
|
||||
"A": {"effect": "support", "cluster": template.yes_bias, "points": 2},
|
||||
"B": {"effect": "weak_support", "cluster": template.yes_bias, "points": 1},
|
||||
"C": {"effect": "exclude_or_penalize", "cluster": template.no_bias, "points": -2},
|
||||
"D": {"effect": "neutral", "cluster": "neutral", "points": 0},
|
||||
},
|
||||
}
|
||||
for template in QUESTION_TEMPLATES
|
||||
]
|
||||
|
||||
|
||||
def score_answers(
|
||||
questionnaire: Mapping[str, JsonValue],
|
||||
answers: Mapping[str, str],
|
||||
) -> ScoringResult:
|
||||
questions_value = questionnaire.get("questions")
|
||||
questions = questions_value if isinstance(questions_value, list) else []
|
||||
by_id = {
|
||||
question["id"]: question
|
||||
for question in questions
|
||||
if isinstance(question, dict) and isinstance(question.get("id"), str)
|
||||
}
|
||||
canonical_by_id = {question["id"]: question for question in build_questions()}
|
||||
cluster_scores: dict[str, int] = {}
|
||||
applied: list[AppliedScore] = []
|
||||
unknown_ids: list[str] = []
|
||||
invalid_answers: list[InvalidAnswer] = []
|
||||
|
||||
for question_id, raw_choice in answers.items():
|
||||
question = by_id.get(question_id)
|
||||
if not question:
|
||||
unknown_ids.append(question_id)
|
||||
continue
|
||||
choice = raw_choice.strip().upper()
|
||||
scoring_map = question.get("scoring_map")
|
||||
if not isinstance(scoring_map, dict):
|
||||
canonical = canonical_by_id.get(question_id)
|
||||
scoring_map = canonical["scoring_map"] if canonical else None
|
||||
scoring = scoring_map.get(choice) if isinstance(scoring_map, dict) else None
|
||||
if not isinstance(scoring, dict):
|
||||
invalid_answers.append({"id": question_id, "answer": raw_choice})
|
||||
continue
|
||||
cluster_value = scoring.get("cluster")
|
||||
points_value = scoring.get("points")
|
||||
cluster = cluster_value if isinstance(cluster_value, str) else "neutral"
|
||||
points = int(points_value) if isinstance(points_value, int | float | str) else 0
|
||||
if cluster != "neutral":
|
||||
cluster_scores[cluster] = cluster_scores.get(cluster, 0) + points
|
||||
applied.append({"id": question_id, "answer": choice, "cluster": cluster, "points": points})
|
||||
|
||||
answered_ids = {item["id"] for item in applied}
|
||||
unanswered = [question for question in questions if question.get("id") not in answered_ids]
|
||||
round_values = [question.get("round") for question in unanswered]
|
||||
next_round = min((int(value) for value in round_values if isinstance(value, int | float | str)), default=None)
|
||||
rankings = [
|
||||
{"cluster": cluster, "score": score}
|
||||
for cluster, score in sorted(cluster_scores.items(), key=lambda item: (-item[1], item[0]))
|
||||
]
|
||||
return {
|
||||
"scope": "active_birth_time_rectification_scoring",
|
||||
"schema_version": 1,
|
||||
"answered_count": len(applied),
|
||||
"candidate_cluster_rankings": rankings,
|
||||
"next_round": next_round,
|
||||
"next_round_questions": [question for question in unanswered if question.get("round") == next_round],
|
||||
"applied_scoring": applied,
|
||||
"unknown_question_ids": unknown_ids,
|
||||
"invalid_answers": invalid_answers,
|
||||
"boundary": "This narrows candidate clusters only; final rectification requires scoring answers against actual candidate chart differences.",
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -1258,6 +1259,7 @@ API_COMMAND_MAP = {
|
||||
'rectification': '/api/rectification_gate',
|
||||
'active-rectification-questions': '/api/active_rectification_questions',
|
||||
'active-rectification-score': '/api/active_rectification_score',
|
||||
'active-rectification-events': '/api/active_rectification_events',
|
||||
'case-validation': '/api/case_validation',
|
||||
'divisional-yoga': '/api/divisional_yoga',
|
||||
'deep-varga-avastha': '/api/deep_varga_avastha',
|
||||
@@ -1291,6 +1293,7 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = {
|
||||
'/api/rectification_gate',
|
||||
'/api/active_rectification_questions',
|
||||
'/api/active_rectification_score',
|
||||
'/api/active_rectification_events',
|
||||
'/api/relationship',
|
||||
'/api/remedies',
|
||||
'/api/sade_sati',
|
||||
@@ -1710,6 +1713,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
elif path == '/api/active_rectification_score':
|
||||
result = self._compute_active_rectification_score(body)
|
||||
self._json(result)
|
||||
elif path == '/api/active_rectification_events':
|
||||
result = self._compute_active_rectification_events(body)
|
||||
self._json(result)
|
||||
elif path == '/api/case_validation':
|
||||
result = self._compute_case_validation(body)
|
||||
self._json(result)
|
||||
@@ -6750,6 +6756,79 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
**result,
|
||||
}
|
||||
|
||||
def _compute_active_rectification_events(self, body):
|
||||
allowed_fields = {
|
||||
'birth_date', 'start_time', 'end_time', 'lat', 'lon', 'tz', 'events',
|
||||
}
|
||||
unsupported_fields = sorted(set(body) - allowed_fields)
|
||||
if unsupported_fields:
|
||||
raise BadRequest(
|
||||
f'unsupported active rectification event field: {unsupported_fields[0]}'
|
||||
)
|
||||
birth_date = body.get('birth_date')
|
||||
start_time = body.get('start_time')
|
||||
end_time = body.get('end_time')
|
||||
if not isinstance(birth_date, str):
|
||||
raise BadRequest('birth_date must be YYYY-MM-DD')
|
||||
if not isinstance(start_time, str) or not isinstance(end_time, str):
|
||||
raise BadRequest('candidate times must be HH:MM')
|
||||
try:
|
||||
parsed_birth_date = datetime.strptime(birth_date, '%Y-%m-%d')
|
||||
datetime.strptime(start_time, '%H:%M')
|
||||
datetime.strptime(end_time, '%H:%M')
|
||||
except ValueError as exc:
|
||||
raise BadRequest('birth date or candidate time has invalid format') from exc
|
||||
lat = self._get_float(body, 'lat', 0, -90, 90)
|
||||
lon = self._get_float(body, 'lon', 0, -180, 180)
|
||||
tz = self._get_float(body, 'tz', 0, -14, 14)
|
||||
events = body.get('events')
|
||||
if not isinstance(events, list) or not 3 <= len(events) <= 6:
|
||||
raise BadRequest('events must contain between 3 and 6 items')
|
||||
normalized_events = []
|
||||
allowed_domains = {'education', 'relocation', 'relationship', 'career', 'health_pressure'}
|
||||
formats = {'year': '%Y', 'month': '%Y-%m', 'day': '%Y-%m-%d'}
|
||||
for raw_event in events:
|
||||
if not isinstance(raw_event, dict) or set(raw_event) != {'id', 'domain', 'date', 'precision'}:
|
||||
raise BadRequest('each event must contain only id, domain, date, and precision')
|
||||
try:
|
||||
uuid.UUID(str(raw_event['id']))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise BadRequest('event id must be a UUID') from exc
|
||||
domain = raw_event['domain']
|
||||
precision = raw_event['precision']
|
||||
event_date = raw_event['date']
|
||||
if domain not in allowed_domains:
|
||||
raise BadRequest('event domain is unsupported')
|
||||
if precision not in formats or not isinstance(event_date, str):
|
||||
raise BadRequest('event precision is unsupported')
|
||||
try:
|
||||
parsed_event_date = datetime.strptime(event_date, formats[precision])
|
||||
except ValueError as exc:
|
||||
raise BadRequest('event date does not match its precision') from exc
|
||||
if parsed_event_date.year < parsed_birth_date.year or parsed_event_date > datetime.now():
|
||||
raise BadRequest('event date must be between birth and today')
|
||||
normalized_events.append({
|
||||
'id': str(raw_event['id']),
|
||||
'domain': domain,
|
||||
'date': event_date,
|
||||
'precision': precision,
|
||||
})
|
||||
module = _load_local_module('active_rectification_events')
|
||||
result = module.score_life_events({
|
||||
'birth_date': birth_date,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'tz': tz,
|
||||
'events': normalized_events,
|
||||
})
|
||||
return {
|
||||
'success': True,
|
||||
'endpoint': 'active_rectification_events',
|
||||
**result,
|
||||
}
|
||||
|
||||
def _compute_case_validation(self, body):
|
||||
planets, _, _ = self._normalized_planets_from_body(body)
|
||||
current_md = body.get('current_md', body.get('dasha_lord', ''))
|
||||
@@ -7512,6 +7591,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'/api/rectification_gate': self._compute_rectification_gate,
|
||||
'/api/active_rectification_questions': self._compute_active_rectification_questions,
|
||||
'/api/active_rectification_score': self._compute_active_rectification_score,
|
||||
'/api/active_rectification_events': self._compute_active_rectification_events,
|
||||
'/api/relationship': self._compute_relationship,
|
||||
'/api/remedies': self._compute_remedies,
|
||||
'/api/sade_sati': self._compute_sade_sati,
|
||||
@@ -7636,6 +7716,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'/api/pancha_mahapurusha': 'Assess Pancha Mahapurusha yoga strength',
|
||||
'/api/prashna': 'Compute Prashna chart and answer evidence',
|
||||
'/api/rectification_gate': 'Evaluate birth-time precision gate',
|
||||
'/api/active_rectification_events': 'Score dated life events against actual birth-time candidates',
|
||||
'/api/relationship': 'Compute relationship and spouse-status evidence',
|
||||
'/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha',
|
||||
'/api/sade_sati': 'Compute Sade Sati status and phase',
|
||||
|
||||
Reference in New Issue
Block a user