Files
Jyotisha/scripts/rectification/api_service.py
T
Jesse_Chen 814c924e4a
Independent Staging Quality Gate / validate (push) Successful in 9m20s
Independent Staging Quality Gate / publish (push) Successful in 6m51s
fix(rectification): exhaustion exit, explain layer, range reading, unknown-time scan (BUG-565–568)
Keep askable cards after exhaustion, explain each probe, read the adopted credible range in reports and chat, and compare declared periods before the minute grid when the clock is unknown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 09:10:37 +08:00

485 lines
20 KiB
Python

from __future__ import annotations
from typing import Any, Mapping, Sequence
from uuid import NAMESPACE_URL, uuid5
from scripts.active_rectification_events import build_candidate_result_summary
from scripts.rectification.candidate_feature_service import build_candidate_feature_snapshot
from scripts.rectification.contracts import (
EVENT_CONTRACT_VERSION,
RectificationRequest,
is_primary_scoreable_event,
)
from scripts.rectification.decision_policy import (
EXECUTION_LEDGER_VERSION,
POLICY_VERSION,
build_candidate_decisions,
build_decision_receipt,
build_execution_ledger,
)
from scripts.rectification.diagnostics_service import run_diagnostics
from scripts.rectification.scoring_service import (
ALGORITHM_VERSION,
build_event_contribution_matrix,
calculation_spec,
score_from_matrix,
scoreable_request,
sha256,
)
def _clock_minutes(value: str) -> int:
hour, minute = value[:5].split(":", 1)
return int(hour) * 60 + int(minute)
def _window_width(start_time: str, end_time: str) -> int:
return (_clock_minutes(end_time) - _clock_minutes(start_time)) % 1_440 + 1
def _report_candidate_range(
request: RectificationRequest,
candidate_scores: Sequence[dict[str, Any]],
representative_time: str | None,
) -> dict[str, Any]:
top_score = max((float(row.get("score") or 0) for row in candidate_scores), default=None)
top_times = [
str(row.get("time"))[:5]
for row in candidate_scores
if top_score is not None and float(row.get("score") or 0) == top_score
]
if not top_times:
return {
"start_time": request["start_time"],
"end_time": request["end_time"],
"representative_time": representative_time,
"width_minutes": _window_width(request["start_time"], request["end_time"]),
"representative_is_unique": False,
}
return {
"start_time": top_times[0],
"end_time": top_times[-1],
"representative_time": representative_time or top_times[len(top_times) // 2],
"width_minutes": len(top_times),
"representative_is_unique": False,
}
def _report_evidence(
request: RectificationRequest,
built: dict[str, Any],
representative_time: str | None,
) -> list[dict[str, Any]]:
matrix = built.get("matrix") or {}
rows: list[dict[str, Any]] = []
for event in request.get("events") or []:
if not is_primary_scoreable_event(event):
continue
contribution = (matrix.get(event["id"]) or {}).get(representative_time or "")
contribution = contribution if isinstance(contribution, dict) else {}
points = float(contribution.get("points") or 0)
status = "supporting" if points > 0 else "contradictory" if points < 0 else "unconfirmed"
rows.append({
"event_id": event["id"],
"summary": str(event.get("summary") or "").strip(),
"domain": event["domain"],
"date": {
"start": event["date_start"],
"end": event["date_end"],
"precision": event["precision"],
},
"status": status,
"supports_candidate_time": representative_time if status == "supporting" else None,
"methods": sorted({str(layer) for layer in contribution.get("technique_layers") or []}),
})
return rows
def _report_excluded_candidates(
candidate_decisions: Sequence[dict[str, Any]],
representative_time: str | None,
) -> list[dict[str, Any]]:
return [
{
"time": str(candidate.get("time") or "")[:5],
"reason": "not_the_leading_candidate",
"representative_time": representative_time,
}
for candidate in candidate_decisions
if str(candidate.get("time") or "")[:5] != (representative_time or "")
]
def _confirmation_blockers(receipt: dict[str, Any]) -> list[dict[str, str]]:
allowed = {"VedAstro 分钟级校验", "唯一分钟确认"}
return [
{
"technique": str(row.get("technique")),
"status": str(row.get("status")),
"user_meaning": str(row.get("note") or ""),
}
for row in receipt.get("technique_audit_table") or []
if isinstance(row, dict)
and str(row.get("technique")) in allowed
and str(row.get("status")) != "executed"
]
def _rectification_report(
request: RectificationRequest,
built: dict[str, Any],
candidate_scores: Sequence[dict[str, Any]],
candidate_decisions: Sequence[dict[str, Any]],
receipt: dict[str, Any],
) -> dict[str, Any]:
representative_time = str(receipt.get("representative_time") or "")[:5] or None
blockers = _confirmation_blockers(receipt)
candidate_range = _report_candidate_range(request, candidate_scores, representative_time)
limitations = [item["user_meaning"] for item in blockers if item["user_meaning"]]
if not limitations:
limitations.append("本会话以代表性时间收口,不确认唯一分钟。")
return {
"candidate_range": candidate_range,
"representative_time": representative_time,
"representative_label": "代表性候选,不是唯一解",
"confidence": receipt.get("overall_confidence", "low"),
"evidence": _report_evidence(request, built, representative_time),
"excluded_candidates": _report_excluded_candidates(candidate_decisions, representative_time),
"next_step_codes": [],
"confirmation_gate_blockers": blockers,
"limitations": limitations,
"claim_status": "candidate_range_not_birth_time_truth",
}
def engine_scoring_versions() -> dict[str, str]:
"""Identity fields also returned by `/api/rectification/v5/score`, without scoring."""
return {
"algorithm_version": ALGORITHM_VERSION,
"event_contract_version": EVENT_CONTRACT_VERSION,
"decision_policy_version": POLICY_VERSION,
}
def candidate_features(request: RectificationRequest) -> dict[str, Any]:
spec = calculation_spec(request)
spec_hash = sha256(spec)
scoring_request = scoreable_request(request)
return {
"algorithm_version": ALGORITHM_VERSION,
"event_contract_version": EVENT_CONTRACT_VERSION,
"decision_policy_version": POLICY_VERSION,
"calculation_spec": spec,
"calculation_spec_hash": spec_hash,
"candidate_feature_snapshot": build_candidate_feature_snapshot(scoring_request, spec_hash),
"can_confirm_exact_minute": False,
}
def score_candidates(request: RectificationRequest) -> dict[str, Any]:
scoring_request = scoreable_request(request)
built = build_event_contribution_matrix(scoring_request)
rows = score_from_matrix(scoring_request, built)
spec = calculation_spec(request)
spec_hash = sha256(spec)
diagnostic_values = run_diagnostics(scoring_request, rows, built)
fingerprint = sha256({
key: value
for key, value in request.items()
if key != "asked_probe_keys"
})
result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}"))
candidate_decisions = build_candidate_decisions(
rows,
result_id=result_id,
static_contexts=built.get("static_contexts") if isinstance(built.get("static_contexts"), list) else None,
)
decision_receipt = build_decision_receipt(request, candidate_decisions, built, diagnostic_values)
execution_ledger = build_execution_ledger(request, built, diagnostic_values, candidate_decisions)
representative = candidate_decisions[0] if candidate_decisions else None
representative_time = str(representative.get("time") or "")[:5] if representative else None
report_range = _report_candidate_range(request, rows, representative_time)
report_evidence = _report_evidence(request, built, representative_time)
summary_evidence = [
{
"event_id": item["event_id"],
"domain": item["domain"],
"candidate_time": representative_time or "",
"rule_ids": item["methods"],
"points": 1 if item["status"] == "supporting" else -1 if item["status"] == "contradictory" else 0,
}
for item in report_evidence
]
candidate_summary = build_candidate_result_summary({
"winning_segment": report_range,
"event_count": len(scoring_request.get("events", [])),
"margin_percent": decision_receipt.get("margin_percent", 0),
"reasons": decision_receipt.get("reasons", []),
"evidence": summary_evidence,
})
candidate_summary["stability"] = {"label": decision_receipt.get("overall_confidence", "low")}
rectification_report = _rectification_report(
request, built, [{
"time": row["time"],
"score": row["score"],
} for row in rows], candidate_decisions, decision_receipt,
)
rectification_report["next_step_codes"] = candidate_summary["next_step_codes"]
candidate_summary["report"] = rectification_report
return {
"result_id": result_id,
"algorithm_version": ALGORITHM_VERSION,
"event_contract_version": EVENT_CONTRACT_VERSION,
"decision_policy_version": POLICY_VERSION,
"calculation_spec": spec,
"calculation_spec_hash": spec_hash,
"candidate_scores": [{
"time": row["time"],
"score": row["score"],
"supporting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] > 0],
"conflicting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] < 0],
} for row in rows],
"candidate_decisions": candidate_decisions,
"candidate_decision_receipt": decision_receipt,
"decision_receipt": decision_receipt,
"execution_ledger_version": EXECUTION_LEDGER_VERSION,
"execution_ledger": execution_ledger,
"event_contribution_matrix": built["matrix"],
"candidate_feature_snapshot": build_candidate_feature_snapshot(
scoring_request, spec_hash, built.get("static_contexts")
),
"diagnostics": diagnostic_values,
"candidate_summary": candidate_summary,
"next_step_codes": candidate_summary["next_step_codes"],
"stability": candidate_summary["stability"],
"rectification_report": rectification_report,
"robustness": {
"neighbor_support_minutes": diagnostic_values.get("neighbor_support_minutes", 0),
"leave_one_out_retention_rate": diagnostic_values.get("leave_one_event_out_retention_rate", 0),
"leave_one_domain_out_retention_rate": diagnostic_values.get("leave_one_domain_out_retention_rate", 0),
"date_sensitivity_retention_rate": diagnostic_values.get("date_sensitivity_retention_rate", 0),
},
"missing_layers": built["missing_layers"],
"display_allowed": decision_receipt["display_allowed"],
"selection_allowed": decision_receipt["selection_allowed"],
"acceptance_allowed": decision_receipt["acceptance_allowed"],
"propose_allowed": decision_receipt["propose_allowed"],
"confirmation_allowed": decision_receipt["confirmation_allowed"],
"representative_candidate_id": representative["candidate_id"] if representative else None,
"representative_time": representative["time"] if representative else None,
"overall_confidence": decision_receipt["overall_confidence"],
"margin_percent": decision_receipt["margin_percent"],
"can_confirm_exact_minute": decision_receipt["confirmation_allowed"],
}
def diagnostics(request: RectificationRequest) -> dict[str, Any]:
scored = score_candidates(request)
return {
"result_id": scored["result_id"],
"algorithm_version": scored["algorithm_version"],
"event_contract_version": scored["event_contract_version"],
"decision_policy_version": scored["decision_policy_version"],
"calculation_spec_hash": scored["calculation_spec_hash"],
"candidate_decisions": scored["candidate_decisions"],
"candidate_decision_receipt": scored["candidate_decision_receipt"],
"decision_receipt": scored["decision_receipt"],
"execution_ledger_version": scored["execution_ledger_version"],
"execution_ledger": scored["execution_ledger"],
"diagnostics": scored["diagnostics"],
"candidate_summary": scored.get("candidate_summary", {"next_step_codes": ["do_not_apply_as_birth_time_truth"]}),
"next_step_codes": scored.get("next_step_codes", ["do_not_apply_as_birth_time_truth"]),
"stability": scored.get("stability", {"label": scored.get("overall_confidence", "low")}),
"rectification_report": scored.get("rectification_report", {}),
"missing_layers": scored["missing_layers"],
"display_allowed": scored["display_allowed"],
"selection_allowed": scored["selection_allowed"],
"acceptance_allowed": scored["acceptance_allowed"],
"propose_allowed": scored["propose_allowed"],
"confirmation_allowed": scored["confirmation_allowed"],
"representative_candidate_id": scored["representative_candidate_id"],
"representative_time": scored["representative_time"],
"overall_confidence": scored["overall_confidence"],
"margin_percent": scored["margin_percent"],
"can_confirm_exact_minute": scored["confirmation_allowed"],
}
def range_reading(request: Mapping[str, Any]) -> dict[str, Any]:
"""Stable vs minute-sensitive themes for one unresolved clock window."""
from types import SimpleNamespace
from scripts.jyotish_engine import _build_birth_time_sensitivity
body = dict(request or {})
birth_date = str(body.get("birth_date") or "").strip()
if birth_date:
year_text, month_text, day_text = birth_date.split("-", 2)
year, month, day = int(year_text), int(month_text), int(day_text)
else:
year, month, day = int(body["year"]), int(body["month"]), int(body["day"])
representative = str(body.get("representative_time") or "")[:5]
if len(representative) == 5 and representative[2] == ":":
hour, minute = int(representative[:2]), int(representative[3:])
else:
hour = int(body.get("hour") or 12)
minute = int(body.get("minute") or 0)
representative = f"{hour:02d}:{minute:02d}"
raw_range = body.get("candidate_range")
if isinstance(raw_range, Mapping):
start_time = str(raw_range.get("start_time") or "")[:5]
end_time = str(raw_range.get("end_time") or "")[:5]
representative = str(raw_range.get("representative_time") or representative)[:5]
else:
start_time = str(body.get("start_time") or "")[:5]
end_time = str(body.get("end_time") or "")[:5]
hour, minute = int(representative[:2]), int(representative[3:])
accuracy = str(body.get("birth_time_accuracy") or "provisional")
args = SimpleNamespace(
year=year,
month=month,
day=day,
hour=hour,
minute=minute,
second=0,
lat=float(body["lat"]),
lon=float(body["lon"]),
tz=float(body["tz"]),
ayanamsa=body.get("ayanamsa") or "raman",
node_mode=body.get("node_mode") or "mean",
birth_time_accuracy=accuracy,
candidate_range={
"start_time": start_time,
"end_time": end_time,
"representative_time": representative,
},
representative_time=representative,
declared_window_start=None,
declared_window_end=None,
uncertainty_before_minutes=None,
uncertainty_after_minutes=None,
)
sensitivity = _build_birth_time_sensitivity(args)
themes = sensitivity.get("theme_sensitivity")
themes = themes if isinstance(themes, dict) else {}
stable = [
key for key, row in themes.items()
if isinstance(row, dict) and row.get("status") == "stable"
]
sensitive = [
key for key, row in themes.items()
if isinstance(row, dict) and row.get("status") == "sensitive"
]
return {
"window": sensitivity.get("window"),
"stable_themes": stable,
"sensitive_themes": sensitive,
"claim_boundary": sensitivity.get("claim_boundary"),
"theme_sensitivity": themes,
"accuracy": sensitivity.get("accuracy"),
"status": sensitivity.get("status"),
}
BLOCK_SCAN_PERIODS: tuple[tuple[str, str, str], ...] = (
("early_morning", "04:00", "07:59"),
("morning", "08:00", "11:59"),
("afternoon", "12:00", "17:59"),
("evening", "18:00", "22:59"),
("late_night", "23:00", "03:59"),
)
def _clock_in_declared_period(clock: str, start_time: str, end_time: str) -> bool:
current = _clock_minutes(clock[:5])
start = _clock_minutes(start_time)
end = _clock_minutes(end_time)
if start <= end:
return start <= current <= end
return current >= start or current <= end
def _normalize_relative_support(raw: Sequence[float]) -> list[float]:
floored = [max(0.0, float(value)) for value in raw]
total = sum(floored)
if total <= 0:
return [20.0 for _ in floored]
shares = [round(100.0 * value / total, 1) for value in floored]
delta = round(100.0 - sum(shares), 1)
if shares:
shares[shares.index(max(shares))] = round(shares[shares.index(max(shares))] + delta, 1)
return shares
def block_scan(request: RectificationRequest) -> dict[str, Any]:
"""Aggregate 24h event scores into the five declared birth-time periods."""
step = int(request.get("minute_step") or 10)
if step <= 1:
step = 10
scoring_request = {**request, "minute_step": step}
scored = score_candidates(scoring_request)
events_by_id = {
str(event.get("id") or ""): event
for event in request.get("events") or []
if isinstance(event, dict)
}
rows = [
row for row in scored.get("candidate_scores") or []
if isinstance(row, dict) and str(row.get("time") or "")[:5]
]
raw_support: list[float] = []
blocks: list[dict[str, Any]] = []
for period, start_time, end_time in BLOCK_SCAN_PERIODS:
members = [
row for row in rows
if _clock_in_declared_period(str(row.get("time") or "")[:5], start_time, end_time)
]
counts: dict[str, int] = {}
for row in members:
for event_id in row.get("supporting_event_ids") or []:
key = str(event_id)
if key:
counts[key] = counts.get(key, 0) + 1
top_events = []
for event_id, _count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))[:3]:
event = events_by_id.get(event_id) or {}
top_events.append({
"event_id": event_id,
"domain": event.get("domain"),
"summary": event.get("summary"),
})
raw_support.append(sum(float(row.get("score") or 0) for row in members))
blocks.append({
"period": period,
"start_time": start_time,
"end_time": end_time,
"relative_support": 0,
"top_events": top_events,
"candidate_count": len(members),
})
shares = _normalize_relative_support(raw_support)
for block, share in zip(blocks, shares):
block["relative_support"] = share
receipt = scored.get("decision_receipt") if isinstance(scored.get("decision_receipt"), dict) else {}
return {
"result_id": scored.get("result_id"),
"algorithm_version": scored.get("algorithm_version"),
"calculation_spec": scored.get("calculation_spec"),
"calculation_spec_hash": scored.get("calculation_spec_hash"),
"minute_step": step,
"candidate_count": len(rows),
"precision_stage": {"current": "block_scan"},
"blocks": blocks,
"discriminating_event_probes": [],
"acceptance_allowed": False,
"selection_allowed": False,
"display_allowed": False,
"decision_receipt": {
**receipt,
"precision_stage": {"current": "block_scan"},
"discriminating_event_probes": [],
"acceptance_allowed": False,
"selection_allowed": False,
},
}