"""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", "d5": "D5", "d7": "D7", "d12": "D12", "d24": "D24", "d2": "D2", "d11": "D11", "d30": "D30", "pada": "Nakshatra pada", "hora": "Hora Lagna", "ghati": "Ghati Lagna", "bhava": "Bhava Lagna", "pranapada": "Pranapada Lagna", } def _scan_layer_value(feature: dict[str, Any], layer: str) -> int | None: vargas = feature.get("varga_ascendants") if isinstance(feature.get("varga_ascendants"), dict) else {} raw = { "d1": feature.get("ascendant_sign_index"), "d9": vargas.get("D9"), "d10": vargas.get("D10"), "d4": vargas.get("D4"), "d5": vargas.get("D5"), "d7": vargas.get("D7"), "d12": vargas.get("D12"), "d24": vargas.get("D24"), "d2": vargas.get("D2"), "d11": vargas.get("D11"), "d30": vargas.get("D30"), "pada": feature.get("pada_index"), "hora": feature.get("hora_sign_index"), "ghati": feature.get("ghati_sign_index"), "bhava": feature.get("bhava_sign_index"), "pranapada": feature.get("pranapada_sign_index"), }.get(layer) return raw if isinstance(raw, int) else None def window_scan(built: dict[str, Any]) -> dict[str, Any]: """D1/D9/D10/D4/D5/D7/D12/D24/D2/D11/D30 plus display-only pada/Hora/Ghati/Bhava/Pranapada.""" 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): current = {layer: _scan_layer_value(feature, layer) for layer in _LAYER_LABEL} 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 payload: dict[str, Any] = { "scanned": True, "confirmation_allowed": False, "unique_minute_claim": False, "transitions": transitions, } for layer in _LAYER_LABEL: payload[f"{layer}_lagna_count" if layer.startswith("d") else f"{layer}_count"] = len(counts[layer]) payload[f"{layer}_candidates_differ"] = len(counts[layer]) > 1 return payload 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 = "d4_refine" meaning = "事业盘已较稳,居所盘仍会换升。可再补一件记得时间的搬家或住处变化。" elif scan.get("d5_candidates_differ") or scan.get("d24_candidates_differ"): current = "d5_refine" meaning = "居所盘已较稳,成就盘或学业盘仍会换升。可再补一件记得时间的学业、考试或被委以责任的变化;不要贴类型标签。" else: current = "ready_to_adopt" meaning = "核心分盘已不再换升。可以采用代表性时间看盘,也可以再补主题经历。" return { "current": current, "can_stop": current in {"d9_refine", "d10_refine", "d4_refine", "d5_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", "校时还没用过学习这条线。有没有一件没提过、但记得大概时间的学业变化?"), ("finance", "校时还没用过财务这条线。有没有一件没提过、但记得大概时间的收入或资产变化?"), ("health_pressure", "校时还没用过健康压力这条线。有没有一件没提过、但记得大概时间的身体或压力变化?"), ) 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, }