feat(rectification): expose candidate result reports
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, 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
|
||||
from scripts.rectification.contracts import (
|
||||
EVENT_CONTRACT_VERSION,
|
||||
RectificationRequest,
|
||||
is_primary_scoreable_event,
|
||||
)
|
||||
from scripts.rectification.decision_policy import (
|
||||
EXECUTION_LEDGER_VERSION,
|
||||
POLICY_VERSION,
|
||||
@@ -23,6 +28,130 @@ from scripts.rectification.scoring_service import (
|
||||
)
|
||||
|
||||
|
||||
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 candidate_features(request: RectificationRequest) -> dict[str, Any]:
|
||||
spec = calculation_spec(request)
|
||||
spec_hash = sha256(spec)
|
||||
@@ -55,6 +184,35 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
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,
|
||||
@@ -78,6 +236,10 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
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),
|
||||
@@ -112,6 +274,10 @@ def diagnostics(request: RectificationRequest) -> dict[str, Any]:
|
||||
"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"],
|
||||
|
||||
@@ -156,8 +156,8 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
if not isinstance(end_time, str) or not _CLOCK.fullmatch(end_time):
|
||||
raise ValueError("end_time must be HH:MM")
|
||||
events = body.get("events")
|
||||
if not isinstance(events, list) or not 1 <= len(events) <= 100:
|
||||
raise ValueError("events must contain between 1 and 100 items")
|
||||
if not isinstance(events, list) or not 0 <= len(events) <= 100:
|
||||
raise ValueError("events must contain between 0 and 100 items")
|
||||
upper_date = today or date.today()
|
||||
cleaned_events: list[LifeEvent] = []
|
||||
for index, raw_event in enumerate(events):
|
||||
|
||||
Reference in New Issue
Block a user