Product 2026-11-07: stay in sync with the astrologer's latest (5fb8379c..ea2a9d65). - Method layer: mirror SKILL.md re-imported with import_yinduzhanxing.py from a clean ea2a9d65 clone (import record, manifest, import plan); root SKILL.md body equals the mirror again (nine-lord editorial reference line). Router and sub-skills unchanged upstream except her rectification skill, which is the protected commercial skill here. - 7b43eaf2: rectification question templates say they are templates (basis, timing status, answer impact), no regression_hint +5 bias, low confidence cap, blocked/external execution status in the audit table, invalid answers ignored by the selector, calculation_profile echoed; API rejects a partial lat/lon/tz and event rectification without a place. - a4b4b267: BPHS 27.19 Drik weighting readings as diagnostic variants in scripts/shadbala_reference/drik_profiles.py (shadbala.py is frozen, ERR-110; default quarter and BPHS piecewise geometry unchanged, BUG-1586 / BUG-1623); oracle audit compares rupa with rupa; VP Jain benchmark prints by default. - bb26dc2d: /api/jaimini special mode carries the Goel fields, no invented Aries ascendant. - d66d13ce: annual report exposes tajika_named_yoga_structures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
760 lines
37 KiB
Python
760 lines
37 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate active-choice birth-time rectification questions.
|
|
|
|
Synced from the astrologer's skill repository (yinduzhanxing
|
|
``scripts/active_rectification_questions.py`` at 5fb8379c, 2026-11-07, product
|
|
order: birth-time rectification follows her skill in full). Local adaptations,
|
|
all marked ``LOCAL``: no ``_script_root`` shim (this repository has none), the
|
|
Raman default and node mode come from ``ayanamsa_utils``, every candidate minute
|
|
is recast (not three samples), the minute scan takes the calculation settings
|
|
inside its payload, and ``build_questionnaire`` accepts an already computed
|
|
in-process ``minute_scan`` so the product page does not spawn one engine
|
|
subprocess per minute.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
if str(SCRIPTS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
|
|
from active_rectification_selector import select_next_questions
|
|
from ayanamsa_utils import DEFAULT_AYANAMSA_NAME # LOCAL
|
|
|
|
DEFAULT_NODE_MODE = "mean" # LOCAL
|
|
|
|
OPTIONS = [
|
|
{"key": "A", "label": "明确有,且时间大致吻合", "score": 2},
|
|
{"key": "B", "label": "有类似,但时间略偏或不够重大", "score": 1},
|
|
{"key": "C", "label": "没有明显发生", "score": -2},
|
|
{"key": "D", "label": "不确定 / 不记得", "score": 0},
|
|
]
|
|
|
|
QUESTION_BINDINGS = {
|
|
"education_environment_shift": {
|
|
"primary_varga": "D24",
|
|
"event_categories": ["education"],
|
|
"dasha_tracks": ["Vimshottari", "Narayana"],
|
|
"reason": "先看学习轨迹是否在候选年龄窗内起伏,可快速排除不合的 D24 候选簇。",
|
|
"why_this_question": [
|
|
"为什么问:学业与学习环境变化通常比情绪感受更容易回忆时间点,适合先缩小候选时间窗。",
|
|
"这题先看 D24,再参考 D4 与大运边界,判断候选簇是否把学习轨迹放在正确年龄段。",
|
|
],
|
|
"answer_impact": {
|
|
"A": "如果明确发生且时间吻合,系统会提高 D24 敏感候选簇的优先级。",
|
|
"B": "如果有类似但不够精确,只做弱支持,不会单独定死出生分钟。",
|
|
"C": "如果没有明显发生,会削弱把学业变化放在该窗口的候选簇。",
|
|
"D": "如果不确定,系统会保留候选簇,转去问更容易确认日期的问题。",
|
|
},
|
|
},
|
|
"residence_relocation_shift": {
|
|
"primary_varga": "D4",
|
|
"event_categories": ["travel", "property"],
|
|
"dasha_tracks": ["Vimshottari", "Narayana", "Transit"],
|
|
"reason": "迁移、搬家、住宿结构变化能快速区分 D4 与 12 宫相关候选簇。",
|
|
"why_this_question": [
|
|
"为什么问:搬家、异地、长期住宿变化通常日期感更强,适合拿来筛掉错误候选簇。",
|
|
"这题主要绑定 D4、12 宫与 Rahu/Ketu 迁移动因,再交叉看行运是否配合。",
|
|
],
|
|
"answer_impact": {
|
|
"A": "若时间吻合,系统会优先保留迁移信号更强的候选簇。",
|
|
"B": "若只有模糊相似,系统只给轻微加分,等待其他主题复核。",
|
|
"C": "若没有该类事件,会压低 D4 迁移敏感候选簇。",
|
|
"D": "若不记得,系统会转去追问事业、关系等更易核验的主题。",
|
|
},
|
|
},
|
|
"relationship_or_partner_entry": {
|
|
"primary_varga": "D9",
|
|
"event_categories": ["marriage", "divorce", "relationship"],
|
|
"dasha_tracks": ["Vimshottari", "Narayana", "UL"],
|
|
"reason": "关系进入或断裂能直接区分 D9 / UL / A7 候选结构。",
|
|
"why_this_question": [
|
|
"为什么问:关系开始、承诺、分离常会触发 D9、UL、A7 的明显差异,是校时高价值题。",
|
|
"这题不是为了立刻下婚恋结论,而是先判断候选时间把关系主题落在了什么阶段。",
|
|
],
|
|
"answer_impact": {
|
|
"A": "如果明确发生且时间吻合,系统会提高 D9 / UL / A7 一致的候选簇。",
|
|
"B": "如果只有模糊相似,系统会记为弱支持,需要其他主题一起确认。",
|
|
"C": "如果完全没有,会削弱把关系变化押在该窗口的候选簇。",
|
|
"D": "如果不确定,系统会降低婚恋题权重,先借事业或迁移题继续收窄。",
|
|
},
|
|
},
|
|
"career_responsibility_pressure": {
|
|
"primary_varga": "D10",
|
|
"event_categories": ["career", "job_change", "business", "fame"],
|
|
"dasha_tracks": ["Vimshottari", "Narayana", "A10"],
|
|
"reason": "职责增加、结构变化、长期压力是区分 D10 / A10 候选簇的主锚点。",
|
|
"why_this_question": [
|
|
"为什么问:事业责任的变化通常会在 D10、A10 和大运阶段里留下清晰锚点。",
|
|
"这题能帮助系统判断候选时间是否把职业兑现、压力上升放在了正确窗口。",
|
|
],
|
|
"answer_impact": {
|
|
"A": "如果时间吻合,系统会明显提高 D10 / A10 倾向候选簇。",
|
|
"B": "如果有类似但不够重大,会保留事业倾向,但不会单独拍板。",
|
|
"C": "如果没有该类压力或责任变化,会削弱对应事业候选簇。",
|
|
"D": "如果不确定,系统会先去找更容易确认日期的职业事件或别的主题。",
|
|
},
|
|
},
|
|
"research_tool_expression_shift": {
|
|
"primary_varga": "D24",
|
|
"event_categories": ["career", "education", "fame"],
|
|
"dasha_tracks": ["Vimshottari", "Narayana", "A10"],
|
|
"reason": "写作、研究、工具搭建、表达方向切换能帮助判断 D24 与 D10 的联动时段。",
|
|
"why_this_question": [
|
|
"为什么问:近年的表达、研究、技术或写作转向,常能把学习轴和事业轴同时拉出来。",
|
|
"这题用来观察 D24 与 D10 是否在近年同步被点亮,而不是单看兴趣描述。",
|
|
],
|
|
"answer_impact": {
|
|
"A": "如果近年明显发生,系统会提高 Mercury / D24 / A10 更一致的候选簇。",
|
|
"B": "如果有但不稳定,只会保留为轻度倾向。",
|
|
"C": "如果没有,会削弱近年表达学习轴过强的候选簇。",
|
|
"D": "如果不确定,系统会回到更容易 dated 的事业或迁移题。",
|
|
},
|
|
},
|
|
"health_crisis_or_low_period": {
|
|
"primary_varga": "D30",
|
|
"event_categories": ["health", "legal", "loss", "finance_neg"],
|
|
"dasha_tracks": ["Vimshottari", "Narayana", "Transit"],
|
|
"reason": "D30 高敏但易过拟合,只在有明确压力/健康/事故窗口时才提高权重。",
|
|
"why_this_question": [
|
|
"为什么问:压力、低谷、事故、健康负担能区分 D30 候选簇,但这类信息必须谨慎使用。",
|
|
"这题是高敏补刀题,不会在证据薄的时候单独决定出生时间。",
|
|
],
|
|
"answer_impact": {
|
|
"A": "如果有明确窗口,系统会把 D30 作为补充验证层加入当前候选比较。",
|
|
"B": "如果只有弱相似,系统只做轻微参考,避免 D30 过拟合。",
|
|
"C": "如果没有,会压低高压窗口候选簇。",
|
|
"D": "如果不确定,系统会把 D30 重新降回后置层。",
|
|
},
|
|
},
|
|
"public_role_or_project_visibility": {
|
|
"primary_varga": "D10",
|
|
"event_categories": ["career", "fame", "business"],
|
|
"dasha_tracks": ["Vimshottari", "Narayana", "A10", "Chara"],
|
|
"reason": "公开身份变化、作品露出、职位显化能分辨 D10 / A10 / Karakamsha 的候选差异。",
|
|
"why_this_question": [
|
|
"为什么问:被看见、公开输出、身份变化往往比日常工作内容更容易对齐到候选时间窗。",
|
|
"这题帮助系统区分事业只是内部酝酿,还是已经外显到身份与名声层。",
|
|
],
|
|
"answer_impact": {
|
|
"A": "如果窗口吻合,系统会加强事业外显更明确的候选簇。",
|
|
"B": "如果只有弱相似,只记为轻度支持。",
|
|
"C": "如果没有,会削弱把公开显化押在该窗口的候选簇。",
|
|
"D": "如果不确定,系统会继续依赖 dated 的关系、迁移或教育证据。",
|
|
},
|
|
},
|
|
"sequence_inner_vs_outer": {
|
|
"primary_varga": "D1",
|
|
"event_categories": ["career", "relationship", "travel", "education"],
|
|
"dasha_tracks": ["Vimshottari", "Narayana", "Pratyantar", "KP cusp"],
|
|
"reason": "最后阶段用先内后外还是同时发生,帮助收紧分钟级边界,不提前当主证据。",
|
|
"why_this_question": [
|
|
"为什么问:当候选时间已接近时,事件是先心理转向还是先外部落地,能帮助比较分钟级边界。",
|
|
"这题属于细分钟收口题,只在前面已有候选窗后才有意义。",
|
|
],
|
|
"answer_impact": {
|
|
"A": "若更像先内在转向,会偏向先动心理或宫头的候选边界。",
|
|
"B": "若大致类似,只会温和调整候选排序。",
|
|
"C": "若完全不符合,会排除部分分钟边界。",
|
|
"D": "若不确定,系统会保留当前候选窗,不强行细分分钟。",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _question_contract(question_id: str, sensitivity: list[str]) -> dict[str, Any]:
|
|
base = QUESTION_BINDINGS.get(question_id, {})
|
|
primary_varga = str(base.get("primary_varga") or next((layer for layer in sensitivity if str(layer).startswith("D")), "D1"))
|
|
event_categories = list(base.get("event_categories") or [])
|
|
dasha_tracks = list(base.get("dasha_tracks") or ["Vimshottari", "Narayana"])
|
|
return {
|
|
"why_this_question": list(base.get("why_this_question") or []),
|
|
"answer_impact": dict(base.get("answer_impact") or {}),
|
|
"audit_binding": {
|
|
"primary_varga": primary_varga,
|
|
"event_categories": event_categories,
|
|
"dasha_tracks": dasha_tracks,
|
|
"reason": "主题与待核验技法的关联,不代表技法已执行或该回答能排除出生时间。",
|
|
"execution_status": "not_executed_by_template",
|
|
},
|
|
}
|
|
|
|
|
|
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"),
|
|
]
|
|
|
|
|
|
def _parse_time(value: str) -> datetime:
|
|
return datetime.strptime(value, "%Y-%m-%d %H:%M")
|
|
|
|
|
|
def _candidate_scan(
|
|
center: datetime,
|
|
uncertainty_minutes: int,
|
|
step_minutes: int,
|
|
*,
|
|
lat: float | None = None,
|
|
lon: float | None = None,
|
|
tz: float | None = None,
|
|
ayanamsa: str = DEFAULT_AYANAMSA_NAME,
|
|
node_mode: str = DEFAULT_NODE_MODE,
|
|
minute_scan: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
start = center - timedelta(minutes=uncertainty_minutes)
|
|
end = center + timedelta(minutes=uncertainty_minutes)
|
|
total_minutes = int((end - start).total_seconds() // 60)
|
|
candidate_count = total_minutes // step_minutes + 1
|
|
# LOCAL: every candidate minute is recast. Sparse start/mid/end samples
|
|
# cannot show where an adjacent-minute Varga or Arudha boundary sits.
|
|
sample_offsets = list(range(-uncertainty_minutes, uncertainty_minutes + 1, step_minutes))
|
|
samples = []
|
|
for offset in sample_offsets:
|
|
candidate = center + timedelta(minutes=offset)
|
|
if offset < 0:
|
|
cluster = "early_candidate_cluster"
|
|
elif offset > 0:
|
|
cluster = "late_candidate_cluster"
|
|
else:
|
|
cluster = "middle_candidate_cluster"
|
|
sample = {
|
|
"time": candidate.strftime("%Y-%m-%d %H:%M"),
|
|
"offset_minutes": offset,
|
|
"cluster": cluster,
|
|
"sensitivity_flags": _sensitivity_flags(abs(offset)),
|
|
}
|
|
recast = _candidate_recast(candidate, lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa, node_mode=node_mode)
|
|
if recast:
|
|
sample.update(recast)
|
|
samples.append(sample)
|
|
has_true_recast = all("varga_lagna" in sample for sample in samples)
|
|
has_kp_recast = all("kp_cusps" in sample for sample in samples)
|
|
if minute_scan is None and lat is not None and lon is not None and tz is not None:
|
|
try:
|
|
from candidate_time_sensitivity_scan import scan_candidate_times
|
|
except ModuleNotFoundError: # pragma: no cover - package import
|
|
from scripts.candidate_time_sensitivity_scan import scan_candidate_times
|
|
# LOCAL: the scan reads ayanamsa / node mode from its payload.
|
|
minute_scan = scan_candidate_times(
|
|
{
|
|
"year": center.year,
|
|
"month": center.month,
|
|
"day": center.day,
|
|
"hour": center.hour,
|
|
"minute": center.minute,
|
|
"lat": lat,
|
|
"lon": lon,
|
|
"tz": tz,
|
|
"ayanamsa": ayanamsa,
|
|
"node_mode": node_mode,
|
|
},
|
|
uncertainty_minutes=uncertainty_minutes,
|
|
step_minutes=step_minutes,
|
|
)
|
|
computed_layers = ["time_range", "candidate_cluster", "question_sensitivity_map"]
|
|
blocked_layers = ["true_varga_recast", "true_kp_cusp_recast", "true_arudha_recast"]
|
|
if has_true_recast:
|
|
computed_layers.extend(["true_varga_recast", "true_arudha_recast"])
|
|
blocked_layers = ["true_kp_cusp_recast"]
|
|
if has_kp_recast:
|
|
computed_layers.append("true_kp_cusp_recast")
|
|
blocked_layers = [layer for layer in blocked_layers if layer != "true_kp_cusp_recast"]
|
|
return {
|
|
"start": start.strftime("%Y-%m-%d %H:%M"),
|
|
"end": end.strftime("%Y-%m-%d %H:%M"),
|
|
"step_minutes": step_minutes,
|
|
"candidate_count": candidate_count,
|
|
"cluster_labels": ["early_candidate_cluster", "middle_candidate_cluster", "late_candidate_cluster"],
|
|
"samples": samples,
|
|
"minute_scan": minute_scan,
|
|
"calculation": {"ayanamsa": ayanamsa, "node_mode": node_mode}, # LOCAL
|
|
"sensitivity_summary": {
|
|
"method": "minute_feature_scan_v2", # LOCAL: every minute, see above
|
|
"high_value_layers": ["D2", "D4", "D9", "D10", "D24", "D30", "D60", "UL", "A7", "A10", "KP_cusp"],
|
|
"computed_layers": computed_layers,
|
|
"blocked_layers": blocked_layers,
|
|
"boundary": "Candidate Varga, Arudha and KP cusp recasts are computed from the local domain chart; external oracle parity remains a separate gate.",
|
|
},
|
|
}
|
|
|
|
|
|
def _sensitivity_flags(abs_offset_minutes: int) -> list[str]:
|
|
flags = ["D9", "D10", "D24", "A10"]
|
|
if abs_offset_minutes >= 10:
|
|
flags.extend(["D30", "UL", "A7"])
|
|
if abs_offset_minutes >= 20:
|
|
flags.extend(["D60", "KP_cusp"])
|
|
return flags
|
|
|
|
|
|
def _candidate_recast(
|
|
candidate: datetime,
|
|
*,
|
|
lat: float | None,
|
|
lon: float | None,
|
|
tz: float | None,
|
|
ayanamsa: str,
|
|
node_mode: str = "mean",
|
|
divisions: list[int] | None = None,
|
|
) -> dict[str, Any] | None:
|
|
if lat is None or lon is None or tz is None:
|
|
return None
|
|
import domain_calculation_service
|
|
import jaimini
|
|
import kp_system
|
|
import varga
|
|
|
|
chart = domain_calculation_service.compute_chart({
|
|
"year": candidate.year,
|
|
"month": candidate.month,
|
|
"day": candidate.day,
|
|
"hour": candidate.hour,
|
|
"minute": candidate.minute,
|
|
"second": candidate.second,
|
|
"lat": lat,
|
|
"lon": lon,
|
|
"tz": tz,
|
|
"ayanamsa": ayanamsa,
|
|
"node_mode": node_mode,
|
|
})
|
|
planet_lons = {
|
|
name: data["lon"]
|
|
for name, data in chart.get("planets", {}).items()
|
|
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=divisions or [2, 4, 9, 10, 11, 24, 30, 60] # LOCAL: D2/D11 kept
|
|
)
|
|
arudha = jaimini.calc_arudha_padas(int(asc_lon // 30), planet_lons)
|
|
padas = arudha.get("padas", {})
|
|
upapada = arudha.get("upapada", {})
|
|
return {
|
|
"ascendant": {
|
|
"lon": round(asc_lon, 6),
|
|
"sign": chart["ascendant"].get("sign"),
|
|
"degree_in_sign": chart["ascendant"].get("degree_in_sign"),
|
|
},
|
|
"varga_lagna": {
|
|
key: value.get("Ascendant", {})
|
|
for key, value in vargas.items()
|
|
},
|
|
"arudha": {
|
|
"A7": padas.get("A7", {}),
|
|
"A10": padas.get("A10", {}),
|
|
"UL": upapada,
|
|
},
|
|
"kp_cusps": _kp_cusp_snapshot(chart),
|
|
}
|
|
|
|
|
|
def recast_candidate_layers(
|
|
candidate: datetime,
|
|
*,
|
|
lat: float | None,
|
|
lon: float | None,
|
|
tz: float | None,
|
|
ayanamsa: str = DEFAULT_AYANAMSA_NAME,
|
|
node_mode: str = DEFAULT_NODE_MODE,
|
|
divisions: list[int] | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Public local-chart recast shared by rectification candidate scanners."""
|
|
return _candidate_recast(
|
|
candidate, lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa, node_mode=node_mode, divisions=divisions
|
|
)
|
|
|
|
|
|
def _kp_cusp_snapshot(chart: dict[str, Any]) -> dict[str, Any]:
|
|
import kp_system
|
|
|
|
snapshot = {}
|
|
for house_key in ("house_1", "house_4", "house_7", "house_10"):
|
|
house = chart.get("houses", {}).get(house_key, {})
|
|
degree = house.get("cusp_degree")
|
|
if degree is None:
|
|
continue
|
|
lords = kp_system.get_kp_lords(float(degree))
|
|
snapshot[house_key] = {
|
|
"cusp_degree": round(float(degree) % 360, 6),
|
|
"sign": lords.get("sign"),
|
|
"rasi_lord": lords.get("rasi_lord"),
|
|
"nakshatra": lords.get("nakshatra"),
|
|
"nakshatra_lord": lords.get("nakshatra_lord"),
|
|
"sub_lord": lords.get("sub_lord"),
|
|
"sub_sub_lord": lords.get("sub_sub_lord"),
|
|
}
|
|
return snapshot
|
|
|
|
|
|
def build_questionnaire(
|
|
birth_time: str,
|
|
uncertainty_minutes: int = 30,
|
|
step_minutes: int = 1,
|
|
*,
|
|
lat: float | None = None,
|
|
lon: float | None = None,
|
|
tz: float | None = None,
|
|
ayanamsa: str = DEFAULT_AYANAMSA_NAME,
|
|
node_mode: str = DEFAULT_NODE_MODE,
|
|
candidate_difference_contract: dict[str, Any] | None = None,
|
|
minute_scan: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
question_bank = []
|
|
for qid, round_id, domain, sensitivity, window, prompt, yes_bias, no_bias in QUESTION_TEMPLATES:
|
|
contract = _question_contract(qid, sensitivity)
|
|
question_bank.append({
|
|
"id": qid,
|
|
"round": round_id,
|
|
"domain": domain,
|
|
"sensitivity": sensitivity,
|
|
"window": window,
|
|
"prompt": prompt,
|
|
"question_basis": "generic_interview_template",
|
|
"timing_claim_status": "not_individually_calculated",
|
|
"options": OPTIONS,
|
|
"positive_cluster": yes_bias,
|
|
"negative_cluster": no_bias,
|
|
"factual_reliability": {
|
|
"education": 1.0,
|
|
"residence": 0.95,
|
|
"relationship": 0.95,
|
|
"career": 0.9,
|
|
"career_learning": 0.88,
|
|
"health_pressure": 0.72,
|
|
"public_work": 0.82,
|
|
"fine_timing": 0.45,
|
|
}.get(domain, 0.75),
|
|
**contract,
|
|
"why_this_question": [
|
|
"为什么问:先确认这类经历是否存在、是否容易回忆,供后续实际候选盘与事件核验使用。",
|
|
"模板年龄段不是个人应期;回答本身不提高、排除或确认任何出生时间。",
|
|
],
|
|
"answer_impact": {
|
|
"A": "记录经历并进一步核实日期,暂不缩小出生时间范围。",
|
|
"B": "记录实际时间与描述,不把时间不吻合算作命中。",
|
|
"C": "记录未发生,不凭模板排除候选。",
|
|
"D": "保留未知状态,换一个更容易回答的问题。",
|
|
},
|
|
"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},
|
|
},
|
|
})
|
|
candidate_scan = _candidate_scan(
|
|
_parse_time(birth_time),
|
|
uncertainty_minutes,
|
|
step_minutes,
|
|
lat=lat,
|
|
lon=lon,
|
|
tz=tz,
|
|
ayanamsa=ayanamsa,
|
|
node_mode=node_mode,
|
|
minute_scan=minute_scan,
|
|
)
|
|
selector_payload = {"question_bank": question_bank, "candidate_scan": candidate_scan}
|
|
if isinstance(candidate_difference_contract, dict):
|
|
selector_payload["candidate_difference_contract"] = candidate_difference_contract
|
|
selection = select_next_questions(selector_payload, {}, limit=1)
|
|
return {
|
|
"scope": "active_birth_time_rectification_questionnaire",
|
|
"calculation_profile": {"ayanamsa": ayanamsa, "node_mode": node_mode},
|
|
"schema_version": 1,
|
|
"candidate_scan": candidate_scan,
|
|
"calculation": {"ayanamsa": ayanamsa, "node_mode": node_mode}, # LOCAL
|
|
"candidate_difference_contract": candidate_difference_contract if isinstance(candidate_difference_contract, dict) else None,
|
|
"workflow": [
|
|
"candidate_time_scan",
|
|
"varga_arudha_kp_sensitivity_diff",
|
|
"high_information_question_generation",
|
|
"multiple_choice_user_answers",
|
|
"dynamic_candidate_cluster_scoring",
|
|
"next_round_question_selection",
|
|
],
|
|
"selection": selection,
|
|
"sensitivity_layers": ["D9", "D10", "D24", "D30", "D60", "D4", "UL", "A7", "A10", "KP_cusp", "Vimshottari", "Narayana", "Chara"],
|
|
"questions": selection["selected_questions"],
|
|
"question_bank": question_bank,
|
|
"rounds": {
|
|
"1": "coarse screen",
|
|
"2": "domain follow-up",
|
|
"3": "fine confirmation",
|
|
},
|
|
"boundary": "Question generation only; final rectification requires scoring answers against actual candidate chart differences.",
|
|
}
|
|
|
|
|
|
def score_answers(
|
|
questionnaire: dict[str, Any],
|
|
answers: dict[str, str],
|
|
narayana_cross_scores: dict[str, int | float] | None = None,
|
|
jaimini_karaka_cross_scores: dict[str, int | float] | None = None,
|
|
vimsopaka_avastha_cross_scores: dict[str, int | float] | None = None,
|
|
shadbala_av_observation_scores: dict[str, int | float] | None = None,
|
|
gochara_transit_observation_scores: dict[str, int | float] | None = None,
|
|
) -> dict[str, Any]:
|
|
questions = questionnaire.get("question_bank") if isinstance(questionnaire.get("question_bank"), list) else 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 = []
|
|
for cluster, score in sorted(cluster_scores.items(), key=lambda item: (-item[1], item[0])):
|
|
narayana_score = 0
|
|
if isinstance(narayana_cross_scores, dict):
|
|
raw_narayana_score = narayana_cross_scores.get(cluster, 0)
|
|
if isinstance(raw_narayana_score, (int, float)):
|
|
narayana_score = raw_narayana_score
|
|
jaimini_score = 0
|
|
if isinstance(jaimini_karaka_cross_scores, dict):
|
|
raw_jaimini_score = jaimini_karaka_cross_scores.get(cluster, 0)
|
|
if isinstance(raw_jaimini_score, (int, float)):
|
|
jaimini_score = raw_jaimini_score
|
|
vimsopaka_score = 0
|
|
if isinstance(vimsopaka_avastha_cross_scores, dict):
|
|
raw_vimsopaka_score = vimsopaka_avastha_cross_scores.get(cluster, 0)
|
|
if isinstance(raw_vimsopaka_score, (int, float)):
|
|
vimsopaka_score = raw_vimsopaka_score
|
|
shadbala_av_score = 0
|
|
if isinstance(shadbala_av_observation_scores, dict):
|
|
raw_shadbala_av_score = shadbala_av_observation_scores.get(cluster, 0)
|
|
if isinstance(raw_shadbala_av_score, (int, float)):
|
|
shadbala_av_score = raw_shadbala_av_score
|
|
gochara_score = 0
|
|
if isinstance(gochara_transit_observation_scores, dict):
|
|
raw_gochara_score = gochara_transit_observation_scores.get(cluster, 0)
|
|
if isinstance(raw_gochara_score, (int, float)):
|
|
gochara_score = raw_gochara_score
|
|
downgrade_reasons = []
|
|
if score > 0 and narayana_score < 0:
|
|
downgrade_reasons.append("narayana")
|
|
if score > 0 and jaimini_score < 0:
|
|
downgrade_reasons.append("jaimini_karaka")
|
|
if score > 0 and vimsopaka_score < 0:
|
|
downgrade_reasons.append("vimsopaka_avastha")
|
|
if score > 0 and shadbala_av_score < 0:
|
|
downgrade_reasons.append("shadbala_av")
|
|
if score > 0 and gochara_score < 0:
|
|
downgrade_reasons.append("gochara_transit")
|
|
rankings.append(
|
|
{
|
|
"cluster": cluster,
|
|
"score": score,
|
|
"narayana_cross_score": narayana_score,
|
|
"narayana_cross_score_source": (
|
|
"provided_cross_score"
|
|
if isinstance(narayana_cross_scores, dict)
|
|
else "not_computed_yet"
|
|
),
|
|
"jaimini_karaka_cross_score": jaimini_score,
|
|
"jaimini_karaka_cross_score_source": (
|
|
"provided_cross_score"
|
|
if isinstance(jaimini_karaka_cross_scores, dict)
|
|
else "not_computed_yet"
|
|
),
|
|
"vimsopaka_avastha_cross_score": vimsopaka_score,
|
|
"vimsopaka_avastha_cross_score_source": (
|
|
"provided_cross_score"
|
|
if isinstance(vimsopaka_avastha_cross_scores, dict)
|
|
else "not_computed_yet"
|
|
),
|
|
"shadbala_av_observation_score": shadbala_av_score,
|
|
"shadbala_av_observation_score_source": (
|
|
"provided_observation_score"
|
|
if isinstance(shadbala_av_observation_scores, dict)
|
|
else "not_computed_yet"
|
|
),
|
|
"gochara_transit_observation_score": gochara_score,
|
|
"gochara_transit_observation_score_source": (
|
|
"provided_observation_score"
|
|
if isinstance(gochara_transit_observation_scores, dict)
|
|
else "not_computed_yet"
|
|
),
|
|
"claim_status": "candidate",
|
|
"truth_status": "not_birth_time_truth",
|
|
"confidence_cap": "low",
|
|
"score_purpose": "interview_topic_priority_not_birth_time_likelihood",
|
|
"conflict_policy": (
|
|
"downgrade_without_replacement"
|
|
if downgrade_reasons
|
|
else "cross_check_only_no_replacement"
|
|
),
|
|
"downgrade_reasons": downgrade_reasons,
|
|
}
|
|
)
|
|
next_selection = select_next_questions(
|
|
{
|
|
"question_bank": questions,
|
|
"candidate_scan": questionnaire.get("candidate_scan") if isinstance(questionnaire.get("candidate_scan"), dict) else None,
|
|
"candidate_difference_contract": questionnaire.get("candidate_difference_contract") if isinstance(questionnaire.get("candidate_difference_contract"), dict) else None,
|
|
},
|
|
{item["id"]: item["answer"] for item in applied},
|
|
limit=1,
|
|
)
|
|
next_questions = next_selection["selected_questions"]
|
|
minute_rankings = []
|
|
candidate_scan = questionnaire.get("candidate_scan") if isinstance(questionnaire.get("candidate_scan"), dict) else {}
|
|
minute_scan = candidate_scan.get("minute_scan") if isinstance(candidate_scan, dict) and isinstance(candidate_scan.get("minute_scan"), dict) else None
|
|
if isinstance(minute_scan, dict) and isinstance(minute_scan.get("rows"), list):
|
|
for row in minute_scan["rows"]:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
score = float(row.get("sensitivity_count") or 0)
|
|
minute_rankings.append(
|
|
{
|
|
"time": row.get("time"),
|
|
"score": round(score, 3),
|
|
"sensitivity_count": row.get("sensitivity_count"),
|
|
"sensitive_layers": row.get("sensitive_layers"),
|
|
"purpose": "question_selection_only_not_birth_time_likelihood",
|
|
}
|
|
)
|
|
minute_rankings.sort(key=lambda item: (-float(item["score"]), str(item["time"])))
|
|
minute_focus = minute_rankings[0] if minute_rankings else None
|
|
workflow_status = "question_rounds_active" if next_questions else "dated_event_review_required"
|
|
return {
|
|
"scope": "active_birth_time_rectification_scoring",
|
|
"schema_version": 1,
|
|
"claim_status": "candidate",
|
|
"truth_status": "not_birth_time_truth",
|
|
"formula_unit_parity_status": "partial",
|
|
"timing_claim_status": "exploratory_unvalidated",
|
|
"calculation": questionnaire.get("calculation", { # LOCAL
|
|
"ayanamsa": DEFAULT_AYANAMSA_NAME,
|
|
"node_mode": DEFAULT_NODE_MODE,
|
|
}),
|
|
"answered_count": len(applied),
|
|
"workflow_status": workflow_status,
|
|
"can_apply": False,
|
|
"can_narrow_from_template_scores": False,
|
|
"required_next_evidence": (
|
|
None
|
|
if next_questions
|
|
else {
|
|
"kind": "dated_life_events",
|
|
"minimum_events": 4,
|
|
"minimum_domains": 3,
|
|
}
|
|
),
|
|
"candidate_cluster_rankings": rankings,
|
|
"technique_audit_table": [
|
|
{
|
|
"technique": "Vimshottari Rectification",
|
|
"status": "blocked",
|
|
"role": "requires_executed_candidate_event_timing",
|
|
"boundary": "Template answer points do not execute Vimshottari or establish candidate birth-time likelihood.",
|
|
},
|
|
{
|
|
"technique": "Narayana Dasha Rectification",
|
|
"execution_status": "external_scores_supplied_unverified" if narayana_cross_scores else "blocked",
|
|
"status": "partial",
|
|
"role": "cross_check_downgrade_only",
|
|
"conflict_policy": "downgrade_without_replacement",
|
|
"boundary": "Narayana cross score may be supplied by a separate calculator; absent values default to neutral and never upgrade birth-time truth.",
|
|
},
|
|
{
|
|
"technique": "Jaimini Karaka Rectification",
|
|
"execution_status": "external_scores_supplied_unverified" if jaimini_karaka_cross_scores else "blocked",
|
|
"status": "partial",
|
|
"role": "cross_check_downgrade_only",
|
|
"conflict_policy": "downgrade_without_replacement",
|
|
"boundary": "Jaimini Karaka cross score may be supplied by a separate calculator; absent values default to neutral and never upgrade birth-time truth.",
|
|
},
|
|
{
|
|
"technique": "Vimsopaka Avastha Rectification",
|
|
"execution_status": "external_scores_supplied_unverified" if vimsopaka_avastha_cross_scores else "blocked",
|
|
"status": "partial",
|
|
"role": "cross_check_downgrade_only",
|
|
"conflict_policy": "downgrade_without_replacement",
|
|
"boundary": "Vimsopaka/Avastha cross score may be supplied by a separate calculator; absent values default to neutral and never upgrade birth-time truth.",
|
|
},
|
|
{
|
|
"technique": "Shadbala Ashtakavarga Rectification",
|
|
"execution_status": "external_scores_supplied_unverified" if shadbala_av_observation_scores else "blocked",
|
|
"status": "partial_observation",
|
|
"role": "observation_downgrade_only",
|
|
"weight_policy": "low_weight_only",
|
|
"conflict_policy": "downgrade_without_replacement",
|
|
"boundary": "Shadbala/Ashtakavarga formula and unit parity remain partial; observations cannot drive final rectification or truth claims.",
|
|
},
|
|
{
|
|
"technique": "Gochara Transit Rectification",
|
|
"execution_status": "external_scores_supplied_unverified" if gochara_transit_observation_scores else "blocked",
|
|
"status": "blocked_from_verified_timing",
|
|
"role": "observation_downgrade_only",
|
|
"holdout_gate": "negative_holdout_required",
|
|
"conflict_policy": "downgrade_without_replacement",
|
|
"boundary": "Gochara timing lacks independent negative-holdout validation; it can list exploratory triggers but cannot verify date/month timing claims.",
|
|
},
|
|
],
|
|
"next_round": next_round,
|
|
"next_round_questions": next_questions,
|
|
"next_round_selection": next_selection,
|
|
"minute_rankings": minute_rankings,
|
|
"minute_focus": minute_focus,
|
|
"applied_scoring": applied,
|
|
"unknown_question_ids": unknown_ids,
|
|
"invalid_answers": invalid_answers,
|
|
"boundary": "Template scoring prioritizes interview topics only, does not narrow the birth-time interval, and does not convert candidates into birth-time truth.",
|
|
}
|
|
|
|
|
|
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")
|
|
parser.add_argument("--uncertainty-minutes", type=int, default=30)
|
|
parser.add_argument("--step-minutes", type=int, default=1)
|
|
parser.add_argument("--ayanamsa", default=DEFAULT_AYANAMSA_NAME) # LOCAL
|
|
parser.add_argument("--node-mode", choices=("mean", "true"), default=DEFAULT_NODE_MODE) # LOCAL
|
|
parser.add_argument("--answers-json", default="", help="Optional JSON object mapping question id to A/B/C/D")
|
|
parser.add_argument("--pretty", action="store_true")
|
|
args = parser.parse_args()
|
|
questionnaire = build_questionnaire(
|
|
args.birth_time, args.uncertainty_minutes, args.step_minutes,
|
|
ayanamsa=args.ayanamsa, node_mode=args.node_mode,
|
|
)
|
|
report = score_answers(questionnaire, json.loads(args.answers_json)) if args.answers_json else questionnaire
|
|
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|