Files
Jyotisha/scripts/active_rectification_scoring.py
T
2026-07-18 18:40:23 +08:00

178 lines
7.9 KiB
Python

"""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.",
}