Files
Jyotisha/scripts/chart_choice/interview.py
T
jesse-ux 42b9f44963
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m55s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m38s
Independent Staging Quality Gate / publish (pull_request) Skipped
Reject out-of-range event dates and record validation
Refs #241, BUG-1875
2026-11-13 14:44:05 +08:00

229 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Questions and dated evidence for the multi-round chart-choice flow."""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Any
from scripts.chart_choice import astrologer
from scripts.chart_choice.boundaries import ChartChoiceInputError, ChartRequest
from chart_dasha.birth_moment import YEAR_DAYS, split_period
TOPICS = {
"D9": ("感情", "开始恋爱、结婚或分开", ("relationship", "marriage", "divorce")),
"D10": ("工作", "入职、升职、离职、换工作或创业", ("career", "job_change", "business")),
"D2": ("收入和支出", "收入明显增加、意外支出或较大的损失", ("finance_pos", "finance_neg")),
}
CHOICES = ("before", "after", "both", "neither", "unsure")
def _today(req: ChartRequest) -> datetime:
return (datetime.now(timezone.utc) + timedelta(hours=req.tz)).replace(
tzinfo=None, hour=0, minute=0, second=0, microsecond=0,
)
def _boundaries(row: dict, start: datetime, end: datetime) -> set[datetime]:
points = set()
for major in row["timeline"]:
if major["end"] <= start or major["start"] >= end:
continue
for minor in split_period(major):
if minor["end"] > start and minor["start"] < end:
points.update(p["start"] for p in split_period(minor))
majors, minors = row["narayana"]
periods = list(majors or [])
if minors:
for children in minors.values():
periods.extend(children or [])
for period in periods:
points.add(row["at"] + timedelta(days=float(period["start_age"]) * YEAR_DAYS))
points.add(row["at"] + timedelta(days=float(period["end_age"]) * YEAR_DAYS))
return {at for at in points if start < at < end}
def _age(at: datetime, birth: datetime) -> int:
return at.year - birth.year - ((at.month, at.day) < (birth.month, birth.day))
def _age_label(ages: tuple[int, int]) -> str:
return f"{ages[0]} 岁" if ages[0] == ages[1] else f"{ages[0]}–{ages[1]} 岁"
def _date_span(at: datetime, precision: str) -> tuple[datetime, datetime]:
if precision == "year":
return at.replace(month=1, day=1), at.replace(month=12, day=31)
start = at.replace(day=1)
next_month = (start.replace(day=28) + timedelta(days=4)).replace(day=1)
return start, next_month - timedelta(days=1)
def _follow_up_options(chart: str, categories: tuple[str, ...]) -> list[dict]:
if chart == "D2":
return [
{"id": "income_increase", "value": "finance_pos", "text": "收入明显增加"},
{"id": "unexpected_expense", "value": "finance_neg", "text": "一笔较大的意外支出"},
{"id": "financial_loss", "value": "finance_neg", "text": "一笔较大的损失"},
]
return [{"id": key, "value": key, "text": astrologer.EVENT_CATEGORIES[key]["label"]} for key in categories]
def _event_question(chart: str, rows: tuple[dict, dict], points: list[datetime], birth: datetime) -> dict | None:
label, examples, categories = TOPICS[chart]
windows: list[dict[tuple[int, int], tuple[float, float]]] = [{}, {}]
for category in categories:
event = {"category": category}
varga = [astrologer._varga_score(event, row) for row in rows]
for lo, hi in zip(points, points[1:]):
event["at"] = lo + (hi - lo) / 2
(before, left), (after, right) = (astrologer._dasha_score(event, row) for row in rows)
if left.get("unavailable") != right.get("unavailable"):
continue
scores = (before, after)
ages = (_age(lo, birth), _age(hi - timedelta(microseconds=1), birth))
strong = [score >= 4 or (score > 0 and v > 0) for score, v in zip(scores, varga)]
for i, score in enumerate(scores):
difference = score - scores[1 - i] + varga[i] - varga[1 - i]
if strong[i] and scores[1 - i] == 0 and difference > 0:
rank = (difference, (hi - lo).total_seconds())
windows[i][ages] = max(windows[i].get(ages, rank), rank)
pairs = (
(left, right) for left in windows[0] for right in windows[1]
if left[1] < right[0] or right[1] < left[0]
)
pair = max(pairs, key=lambda p: (windows[0][p[0]][0] + windows[1][p[1]][0],
windows[0][p[0]][1] + windows[1][p[1]][1]), default=None)
if pair is None:
return None
before, after = map(_age_label, pair)
return {
"id": chart, "chart": chart, "before_ages": pair[0], "after_ages": pair[1],
"prompt": f"{label}上的变化,线前候选对应 {before},线后候选对应 {after}。你在哪一段有过这些变化?比如{examples}。",
"options": [{"value": key, "text": text} for key, text in zip(
CHOICES, (f"{before}有", f"{after}有", "两段都有", "两段都没有", "记不清"),
)],
"follow_up": {
"prompt": "大概是哪一年?月份记得的话,也可以填。那次发生了什么?",
"categories": _follow_up_options(chart, categories),
},
}
def questions(req: ChartRequest, charts: list[str], before: datetime, after: datetime) -> dict:
eligible = [key for key in TOPICS if key in charts]
if not eligible and "D1" not in charts:
return {"event_questions": []}
rows = (astrologer._minute_row(req, before), astrologer._minute_row(req, after))
birth = min(before, after)
result: dict[str, Any] = {"event_questions": []}
if eligible:
start = birth.replace(year=birth.year + 15, day=28 if (birth.month, birth.day) == (2, 29) else birth.day)
end = min(_today(req) + timedelta(days=1), *(row["timeline"][-1]["end"] for row in rows))
if start < end:
points = sorted({start, end} | _boundaries(rows[0], start, end) | _boundaries(rows[1], start, end))
for chart in eligible:
if question := _event_question(chart, rows, points, birth):
result["event_questions"].append(question)
if len(result["event_questions"]) == 2:
break
if "D1" in charts:
options = astrologer.appearance_options(list(rows))
if len(options) == 2:
result["appearance_question"] = {
"prompt": "哪一组外表描述更接近你?",
"options": [
{"value": side, "text": item["features"]} for side, item in zip(("before", "after"), options)
] + [{"value": "unsure", "text": "说不清"}],
}
result["birthmark_question"] = {
"prompt": "你有胎记吗?有的话,可以选它所在的位置。",
"multiple": True,
"options": [{"value": part, "text": spec["label"]} for part, spec in astrologer.BIRTHMARK_PARTS.items()]
+ [{"value": "none", "text": "没有"}],
}
return result
def _event_answers(packet: dict, raw: Any, birth: datetime, today: datetime) -> tuple[dict, list[dict], list[dict]]:
if not isinstance(raw, dict):
raise ChartChoiceInputError("event_answers must be an object")
normalized, events, sources = {}, [], []
asked = {q["id"]: q for q in packet["event_questions"]}
for question_id, response in raw.items():
if question_id not in asked or not isinstance(response, dict) or response.get("choice") not in CHOICES:
raise ChartChoiceInputError("event answer must match an offered question and option")
choice = response["choice"]
sides = ("before", "after") if choice == "both" else (choice,) if choice in CHOICES[:2] else ()
dates = response.get("dates", {})
if not isinstance(dates, dict) or dates.keys() != set(sides):
raise ChartChoiceInputError("each selected event window needs a date")
categories = TOPICS[question_id][2]
recorded = {}
for side, event in dates.items():
if (not isinstance(event, dict) or event.get("category") not in categories
or not isinstance(event.get("date"), str) or not re.fullmatch(r"\d{4}(?:-\d{2})?", event["date"])):
raise ChartChoiceInputError("event follow-up needs an offered category and a year or month")
item = {key: event[key] for key in ("date", "category")}
parsed = astrologer.parse_events([item], birth)[0]
if parsed["at"].year > today.year:
raise ChartChoiceInputError("event date is outside the selected age window")
lower, upper = _date_span(parsed["at"], parsed["precision"])
upper = min(today, upper)
ages = asked[question_id][f"{side}_ages"]
if lower > upper or _age(upper, birth) < ages[0] or _age(lower, birth) > ages[1]:
raise ChartChoiceInputError("event date is outside the selected age window")
recorded[side] = item
events.append(item)
normalized[question_id] = {"choice": choice, "dates": recorded}
sources.append({"kind": "event", "id": question_id, "choice": choice,
**{side: (1 if choice == "both" else 2) if side in sides else 0 for side in CHOICES[:2]}})
return normalized, events, sources
def evidence(req: ChartRequest, packet: dict, body: dict, birth: datetime) -> tuple[dict, list[dict], list[dict]]:
result: dict[str, Any] = {}
answers, events, sources = _event_answers(packet, body.get("event_answers", {}), birth, _today(req))
if "event_answers" in body:
result["event_answers"] = answers
if "appearance" in body:
choice = body["appearance"]
if "appearance_question" not in packet or choice not in (*CHOICES[:2], "unsure"):
raise ChartChoiceInputError("appearance answer requires a D1 question")
result["appearance"] = choice
sources.append({"kind": "appearance", "choice": choice, "before": int(choice == "before"), "after": int(choice == "after")})
if "birthmarks" in body:
marks = body["birthmarks"]
if ("birthmark_question" not in packet or not isinstance(marks, list)
or any(not isinstance(part, str) or part not in (*astrologer.BIRTHMARK_PARTS, "none") for part in marks)
or ("none" in marks and marks != ["none"])):
raise ChartChoiceInputError("birthmark answers require D1 and valid positions")
result["birthmarks"] = list(dict.fromkeys(marks))
if marks and marks != ["none"]:
points = {}
for side in CHOICES[:2]:
row = astrologer._minute_row(req, datetime.fromisoformat(packet["sides"][side]["saved_at"]))
points[side] = int(any(astrologer._birthmark_supported(part, row) for part in marks))
sources.append({"kind": "birthmark", **points})
elif marks:
sources.append({"kind": "birthmark", "choice": "none", "before": 0, "after": 0})
return result, events, sources
def summarize(req: ChartRequest, segment: tuple[datetime, datetime], reported: datetime, raw: list[dict]) -> dict:
events, _ = astrologer._independent_events(astrologer.parse_events(raw, reported))
cov = astrologer.coverage(events)
base = astrologer._minute_row(req, reported) if events else None
start, end = segment
first = start.replace(second=0, microsecond=0)
if first < start:
first += timedelta(minutes=1)
rows = astrologer.scan(req, first, end) if base and first <= end else []
if base and not rows:
rows = [astrologer._minute_row(req, start + (end - start) // 2)]
ranked = sorted((astrologer.score_minute(events, row, base) for row in rows), key=lambda row: -row["total"])
exact_tie = len(ranked) > 1 and ranked[0]["total"] == ranked[1]["total"]
confidence = ("tendency_only" if len(events) < 3
else astrologer.engine_confidence(ranked, cov["level"] == "thin", exact_tie)["key"])
return {"event_count": len(events), "matched": ranked[0]["matched"] if ranked else 0,
"confidence": confidence, "coverage": cov, "caveat": astrologer.CAVEAT}