feat(rectification): add refinement packet without unique-minute claims

Surface D9/D10 change minutes, event-dasha match copy, dual-dasha conflict, and a post-adopt consult handoff so users can keep narrowing or start a reading from a representative time.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-20 07:47:10 +08:00
parent 464bc33202
commit a3196584d2
19 changed files with 1384 additions and 40 deletions
+412
View File
@@ -0,0 +1,412 @@
"""Server-owned P0/P1 refinement packet for birth-time rectification.
Produces candidate-narrowing structure only. Never grants a unique minute,
never emits D9/D10 type labels, and never copies raw scores into public copy.
"""
from __future__ import annotations
from typing import Any, Sequence
from scripts.rectification.house_table import PLANET_ZH, SIGN_LORDS, SIGNS, SIGNS_CN
NAKSHATRA_SPAN = 40.0 / 3.0
NAKSHATRA_BOUNDARY_DEGREES = 2.0
MATCH_LABELS = {
"strong": "强相关",
"medium": "有关联",
"weak": "弱关联",
"none": "未见对应",
}
# Everyday A/B traits only. No Sanskrit names and no D9/D10 personality tables.
NAKSHATRA_TRAITS: tuple[tuple[str, str], ...] = (
("起步快、敢先动手", "更愿意把第一步走完再看"),
("事情来了会立刻表态", "先把感受压一压再开口"),
("喜欢把节奏拉开、自己掌握步调", "更在意别人是否跟得上"),
("照顾身边的人会放在前面", "需要先把自己安顿好"),
("愿意站到台前把话说清楚", "更习惯在旁边把事情理顺"),
("对细节和次序很敏感", "更看重大方向有没有走偏"),
("希望两边都能说得过去", "必要时会直接选边"),
("碰到转折会往深处想", "更想尽快回到能做事的状态"),
("愿意把视野拉远一点再决定", "更盯着眼前能落地的一步"),
("愿意为长期结果多熬一阵", "更怕把时间耗在看不见的地方"),
("想法一多就想换条路试试", "更想把一条路走稳"),
("情绪来了会先自己消化", "更需要说出来才过得去"),
("新开始会让人兴奋", "新开始会让人先观察一阵"),
("承诺一旦出口就很难收回", "承诺前会反复确认自己是不是真想"),
("变化来时先问值不值得", "变化来时先问自己扛不扛得住"),
("家里的事会牵动判断", "更想把家里的事和工作分开"),
("被看见会更有劲", "被看见反而会先退半步"),
("计划乱了会先整理清单", "计划乱了会先找一个人商量"),
("两边关系都想维持住", "维持不住时会干脆拉开距离"),
("压力大时会往内部找原因", "压力大时会先改外部条件"),
("愿意把决定放到更大的时间尺度", "更相信眼前这一段就够判断"),
("愿意为结构稳定让步", "稳定如果太闷就会想拆掉重来"),
("对规则和例外都很敏感", "更想先有一个能用的规则"),
("说不清的感受会先放着", "说不清就会反复确认"),
("一有机会就想动手试", "会先把退路看清楚再动"),
("对人的反应比对事情本身更敏感", "对事情进度比对气氛更敏感"),
("收尾时会想把未完成的交代清", "收尾时会想尽快开始下一件"),
)
def _clock(value: str) -> int:
return int(value[:2]) * 60 + int(value[3:5])
def _feature_time(feature: dict[str, Any]) -> str | None:
raw = feature.get("time")
if isinstance(raw, str) and len(raw) >= 5:
return raw[:5]
return None
def _features(built: dict[str, Any]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for context in built.get("static_contexts") or []:
if not isinstance(context, dict):
continue
feature = context.get("feature")
if not isinstance(feature, dict):
continue
time = _feature_time(feature)
if time:
rows.append(feature)
rows.sort(key=lambda item: _clock(str(_feature_time(item))))
return rows
def match_level(rule_ids: Sequence[str]) -> str:
ids = [str(item) for item in rule_ids]
if not ids or ids == ["no_domain_activation"]:
return "none"
if any(
item.startswith("vim_md_domain") or item.startswith("narayana_md_domain")
for item in ids
):
return "strong"
if any(
item.startswith("vim_ad_") or item.startswith("narayana_ad_")
for item in ids
):
return "medium"
if any(item.startswith("vim_") or item.startswith("narayana_") for item in ids):
return "weak"
return "none"
def _tracks(rule_ids: Sequence[str]) -> list[str]:
tracks: list[str] = []
if any(str(item).startswith("vim_") for item in rule_ids):
tracks.append("vimshottari")
if any(str(item).startswith("narayana_") for item in rule_ids):
tracks.append("narayana")
return tracks
def _split_track_points(rule_ids: Sequence[str], points: float) -> tuple[float, float]:
vim = sum(str(item).startswith("vim_") for item in rule_ids)
narayana = sum(str(item).startswith("narayana_") for item in rule_ids)
total = vim + narayana
if total == 0:
return 0.0, 0.0
return points * vim / total, points * narayana / total
_LAYER_LABEL = {"d1": "本命上升", "d9": "D9", "d10": "D10", "d4": "D4"}
def window_scan(built: dict[str, Any]) -> dict[str, Any]:
"""D1/D9/D10/D4 diversity plus change minutes. Indices only; never sign names."""
counts: dict[str, set[int]] = {layer: set() for layer in _LAYER_LABEL}
transitions: list[dict[str, Any]] = []
previous: dict[str, int | None] | None = None
for feature in _features(built):
vargas = feature.get("varga_ascendants") if isinstance(feature.get("varga_ascendants"), dict) else {}
current = {
"d1": feature.get("ascendant_sign_index") if isinstance(feature.get("ascendant_sign_index"), int) else None,
"d9": vargas.get("D9") if isinstance(vargas.get("D9"), int) else None,
"d10": vargas.get("D10") if isinstance(vargas.get("D10"), int) else None,
"d4": vargas.get("D4") if isinstance(vargas.get("D4"), int) else None,
}
for layer, bucket in counts.items():
value = current[layer]
if isinstance(value, int):
bucket.add(value)
time = _feature_time(feature)
if previous and time:
for layer, label in _LAYER_LABEL.items():
before = previous[layer]
after = current[layer]
if isinstance(before, int) and isinstance(after, int) and before != after:
transitions.append({
"layer": layer,
"at": time,
"user_meaning": f"{label}{time} 发生变化",
})
previous = current
return {
"scanned": True,
"confirmation_allowed": False,
"unique_minute_claim": False,
"d1_lagna_count": len(counts["d1"]),
"d9_lagna_count": len(counts["d9"]),
"d10_lagna_count": len(counts["d10"]),
"d4_lagna_count": len(counts["d4"]),
"d1_candidates_differ": len(counts["d1"]) > 1,
"d9_candidates_differ": len(counts["d9"]) > 1,
"d10_candidates_differ": len(counts["d10"]) > 1,
"d4_candidates_differ": len(counts["d4"]) > 1,
"transitions": transitions,
}
def event_dasha_ledger(
request: dict[str, Any],
built: dict[str, Any],
representative_time: str | None,
) -> list[dict[str, Any]]:
if not representative_time:
return []
matrix = built.get("matrix") or {}
rows: list[dict[str, Any]] = []
for event in request.get("events") or []:
if not isinstance(event, dict):
continue
contribution = (matrix.get(event.get("id")) or {}).get(representative_time)
if not isinstance(contribution, dict):
continue
rule_ids = contribution.get("rule_ids") or []
level = match_level(rule_ids)
summary = str(event.get("summary") or "").strip() or "这条经历"
tracks = _tracks(rule_ids)
track_text = "".join(
"主限" if track == "vimshottari" else "分盘大运" for track in tracks
) or "现有大运层"
rows.append({
"summary": summary[:80],
"match": level,
"match_label": MATCH_LABELS[level],
"tracks": tracks,
"user_meaning": f"{summary[:40]}{MATCH_LABELS[level]}{track_text}",
})
return rows
def dasha_agreement(built: dict[str, Any], candidate_times: Sequence[str]) -> dict[str, Any]:
times = [str(item)[:5] for item in candidate_times if isinstance(item, str) and len(str(item)) >= 5]
if not times:
return {
"status": "unavailable",
"vimshottari_top": None,
"narayana_top": None,
"user_meaning": "还没有足够的大运对照。",
}
vim_scores = {time: 0.0 for time in times}
narayana_scores = {time: 0.0 for time in times}
for contributions in (built.get("matrix") or {}).values():
if not isinstance(contributions, dict):
continue
for time in times:
cell = contributions.get(time)
if not isinstance(cell, dict):
continue
vim_points, narayana_points = _split_track_points(
cell.get("rule_ids") or [],
float(cell.get("points") or 0),
)
vim_scores[time] += vim_points
narayana_scores[time] += narayana_points
if all(value == 0 for value in vim_scores.values()) or all(value == 0 for value in narayana_scores.values()):
return {
"status": "partial",
"vimshottari_top": max(times, key=lambda time: vim_scores[time]) if any(vim_scores.values()) else None,
"narayana_top": max(times, key=lambda time: narayana_scores[time]) if any(narayana_scores.values()) else None,
"user_meaning": "主限和分盘大运还不能做成完整对照,只作观察。",
}
vim_top = max(times, key=lambda time: (vim_scores[time], -_clock(time)))
narayana_top = max(times, key=lambda time: (narayana_scores[time], -_clock(time)))
if vim_top == narayana_top:
return {
"status": "agree",
"vimshottari_top": vim_top,
"narayana_top": narayana_top,
"user_meaning": "主限和分盘大运都更支持同一段代表性时间。这仍不是唯一分钟确认。",
}
return {
"status": "conflict",
"vimshottari_top": vim_top,
"narayana_top": narayana_top,
"user_meaning": f"主限更偏向 {vim_top},分盘大运更偏向 {narayana_top}。冲突时不能按更高把握收口。",
}
def _house_lord_zh(asc_idx: int, house: int) -> str | None:
sign = SIGNS[(asc_idx + house - 1) % 12]
lord = SIGN_LORDS.get(sign)
return PLANET_ZH.get(lord) if lord else None
def lagna_contrast(built: dict[str, Any]) -> dict[str, Any] | None:
features = _features(built)
if not features:
return None
intervals: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
for feature in features:
time = _feature_time(feature)
index = feature.get("ascendant_sign_index")
if not time or not isinstance(index, int) or index < 0 or index > 11:
continue
if current and current["d1_lagna_index"] == index:
current["end"] = time
continue
if current:
intervals.append(current)
sign = SIGNS_CN.get(SIGNS[index])
current = {
"start": time,
"end": time,
"d1_lagna_index": index,
"lagna": sign,
"lords": {
"l1": _house_lord_zh(index, 1),
"l4": _house_lord_zh(index, 4),
"l7": _house_lord_zh(index, 7),
"l10": _house_lord_zh(index, 10),
},
}
if current:
intervals.append(current)
if len(intervals) < 2:
return None
left, right = intervals[0], intervals[1]
return {
"intervals": intervals[:3],
"user_meaning": (
f"窗口里出现两段本命上升:{left['start']}-{left['end']}{left['lagna']}"
f"{right['start']}-{right['end']}{right['lagna']}"
"只比较宫主结构,不给性格或类型标签。"
),
"unique_minute_claim": False,
}
def nakshatra_boundary(built: dict[str, Any], representative_time: str | None) -> dict[str, Any] | None:
features = {
_feature_time(feature): feature
for feature in _features(built)
if _feature_time(feature)
}
feature = features.get(representative_time or "") or (list(features.values())[0] if features else None)
if not isinstance(feature, dict):
return None
longitude = feature.get("ascendant_degree")
if not isinstance(longitude, (int, float)):
return None
wrapped = float(longitude) % 360.0
index = int(wrapped / NAKSHATRA_SPAN) % 27
position = wrapped % NAKSHATRA_SPAN
distance = min(position, NAKSHATRA_SPAN - position)
if distance > NAKSHATRA_BOUNDARY_DEGREES:
return {
"near_boundary": False,
"distance_degrees": round(distance, 4),
"user_meaning": None,
"options": [],
}
earlier_index = index if position <= NAKSHATRA_SPAN / 2 else (index - 1) % 27
later_index = (earlier_index + 1) % 27
earlier = NAKSHATRA_TRAITS[earlier_index]
later = NAKSHATRA_TRAITS[later_index]
return {
"near_boundary": True,
"distance_degrees": round(distance, 4),
"options": [
{
"key": "A",
"time_bias": "earlier",
"traits": list(earlier),
},
{
"key": "B",
"time_bias": "later",
"traits": list(later),
},
],
"user_meaning": (
"升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?"
f"A{earlier[0]}{earlier[1]}"
f"B{later[0]}{later[1]}"
"这只用来偏置时间窗,不能确认唯一分钟。"
),
}
def precision_stage(scan: dict[str, Any], event_count: int) -> dict[str, Any]:
if event_count <= 0:
current = "collect_events"
meaning = "还需要带大概时间的经历,才能开始缩小窗口。"
elif scan.get("d1_candidates_differ"):
current = "lagna_frame"
meaning = "本命上升还可能落在两段里。先补能分开这两段的带日期经历。"
elif scan.get("d9_candidates_differ"):
current = "d9_refine"
meaning = "本命上升已较稳,关系盘仍会换升。可再补一件记得时间的感情或关系变化。"
elif scan.get("d10_candidates_differ"):
current = "d10_refine"
meaning = "关系盘已较稳,事业盘仍会换升。可再补一件记得时间的工作变化。"
elif scan.get("d4_candidates_differ"):
current = "theme_refine"
meaning = "核心分盘已较稳。若还想收窄,可再补一件记得时间的家人或住处变化;也可以先采用代表性时间。"
else:
current = "ready_to_adopt"
meaning = "核心分盘已不再换升。可以采用代表性时间看盘,也可以再补主题经历。"
return {
"current": current,
"can_stop": current in {"d9_refine", "d10_refine", "theme_refine", "ready_to_adopt"},
"user_meaning": meaning,
"unique_minute_claim": False,
}
def oos_blind_prompts(request: dict[str, Any]) -> list[dict[str, Any]]:
covered = {
str(event.get("domain"))
for event in request.get("events") or []
if isinstance(event, dict) and event.get("domain")
}
catalog = (
("relationship", "校时还没用过感情这条线。有没有一件没提过、但记得大概时间的关系变化?"),
("career", "校时还没用过事业这条线。有没有一件没提过、但记得大概时间的工作变化?"),
("family", "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?"),
("education", "校时还没用过学习这条线。有没有一件没提过、但记得大概时间的学业变化?"),
)
prompts = [
{"domain": domain, "user_meaning": meaning, "used_for_scoring": False}
for domain, meaning in catalog
if domain not in covered
]
return prompts[:3]
def build_refinement_packet(
request: dict[str, Any],
built: dict[str, Any],
*,
representative_time: str | None,
candidate_times: Sequence[str],
) -> dict[str, Any]:
scan = window_scan(built)
agreement = dasha_agreement(built, candidate_times)
return {
"window_scan": scan,
"event_dasha_ledger": event_dasha_ledger(request, built, representative_time),
"dasha_agreement": agreement,
"lagna_contrast": lagna_contrast(built),
"nakshatra_boundary": nakshatra_boundary(built, representative_time),
"precision_stage": precision_stage(scan, len(request.get("events") or [])),
"oos_blind_prompts": oos_blind_prompts(request),
"unique_minute_claim": False,
"confirmation_allowed": False,
}