From e8bd3a5208f79eea753bee073cd2b1745114b55b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 31 Aug 2026 05:51:30 +0800 Subject: [PATCH] feat(rectification): expose candidate result reports --- BLOCKED.md | 6 + ...RESS-rectification-convergence-20260830.md | 70 ++++++++ scripts/active_rectification_events.py | 55 +++++- scripts/rectification/api_service.py | 170 +++++++++++++++++- scripts/rectification/contracts.py | 4 +- tests/test_rectification_v5_services.py | 66 +++++++ 6 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 PROGRESS-rectification-convergence-20260830.md diff --git a/BLOCKED.md b/BLOCKED.md index b318b253..69250e6f 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -29,3 +29,9 @@ - **顺手活一律未做,登记在此:** 其一,`frontend/src/lib/skill-package-registry.ts:523` 的 `readFileSync(currentRegistryPath, "utf8")` 触发 Turbopack 构建警告「Dynamic filesystem access causes tracing of the whole project」,会把整个项目(含 `public/`)打进 server 产物,影响部署体积;升级前后都存在,与本轮无关,未改。其二,`npx eslint` 有 4 个既有 `no-unused-vars` warning,分别在 `birth-time-candidate-result.tsx:143`、`birth-time-candidate-completion.ts:10,11`、`tests/identity-auth-factory.test.ts:48`,未改。其三,`eslint-config-next` 仍是 16.2.10、与 next 16.3.1 版本号不同步,但 eslint 实测 0 error,按「不许顺手升别的依赖」未动。 - **测试环境噪声(非阻塞,已自行消化):** `frontend/tests/rectification-v9-database.test.ts` 的「v9 migration applies on a fresh database and re-applies idempotently」在全量并发下偶发失败(`database migration failed`,1 !== 0),单独重跑 7/7 通过、全量重跑 1592/1592 通过。靠 Docker 起临时 Postgres,判定为资源争用型 flake,与 Next 版本无关。未改任何测试文件。 - **文件名偏离:** 任务书要求新建 `PROGRESS.md`,但根目录已有受版本控制的 `progress.md`(1025 行)且本机文件系统大小写不敏感,写 `PROGRESS.md` 等于覆盖清单外的文件,故进度记录落在 `PROGRESS-react-compiler-20260817.md`。 + +## 生时校正收敛重构任务 0(2026-08-31,分支 `codex/rectification-convergence-impl-20260830`) + +- 无法获取任务书要求的上游 `interview_playbook.md`、`evidence_thresholds.md`:任务书所指的 `~/.workbuddy/skills/jyotish-birth-time-rectification/` 在当前执行环境不存在,仓库内只有测试对该外部路径的引用;未伪造文件,也没有可验证的上游来源可供导入。 +- 无法获取一次真实本地校正会话完整记录:当前仓库没有可证明为真实线上会话的完整原始记录,执行环境也没有受控会话/上游维护者提供的记录。因此无法可靠回答轮数、最终区间宽度、`confidence` 与 `can_apply`。 +- 该信息收集缺口不阻塞任务 1–3,按 v2 任务书继续实现并在进度文件中标记为未验证;不得据此声称已验证“固定题数后停止”的上游机制。 diff --git a/PROGRESS-rectification-convergence-20260830.md b/PROGRESS-rectification-convergence-20260830.md new file mode 100644 index 00000000..fd493f6c --- /dev/null +++ b/PROGRESS-rectification-convergence-20260830.md @@ -0,0 +1,70 @@ +# 生时校正收敛重构进度(2026-08-30) + +## 基线与范围 + +- Git 根目录:`/Users/jesse/Downloads/Copse/astrology/yinduzhanxing` +- 隔离 worktree:`/Users/jesse/Downloads/Copse/astrology/.worktrees/rectification-convergence-impl-20260830` +- 分支:`codex/rectification-convergence-impl-20260830` +- 基线:`origin/staging` @ `db6716e76c7a44adb568701dc811de52d8d29a96` +- staging 同步范围:`7db2dd2d..db6716e7 staging -> staging` +- 当前状态:已提交,未 push 或 deploy。 + +## 任务 0:上游资料与真实会话 + +当前无法完成,原因已记录在 `BLOCKED.md`:外部 `~/.workbuddy/skills/jyotish-birth-time-rectification/` 不存在,仓库没有受控的真实本地校正会话完整记录,也没有可验证的上游资料来源。因此以下三项不能被事实化回答:总轮数、最终交付区间宽度、`confidence` / `can_apply`。 + +基于 v2 任务书与当前仓库/上游代码比较,可以确认本轮落点不是更换评分器,而是恢复两个缺失的生产契约: + +1. 服务端持久化预算必须被生产判定消费,超预算仍交付候选区间并结束; +2. 第 0 轮起就要有可读的候选结果/校正报告,停下来时要说明下一步与局限。 + +尚不能确认或声称上游机制等于“问完固定题数后给出候选区间并停止”。 + +## 任务 1:预算与终止机制 + +已实现: + +- 接入服务端持久化的 `inferenceRounds`、`effectiveAnswerCount`、`plateauRounds`。 +- 复用既有预算常量:`DEFAULT_MAX_DISCRIMINATION_ROUNDS = 8`、`RECTIFICATION_POLICY.maxPlateauRounds`、`EFFECTIVE_ANSWER_SAFETY_CAP = 10`。 +- `!separation.sufficient` 分支在继续 `discriminate` 前进行预算检查;超预算统一走既有 `completeWithRange(..., "exhausted")`。 +- 未修改 `convergence-evaluator.ts`、confirmation gate blocker 或 sealed holdout 阈值。 +- 新增 `frontend/tests/rectification-convergence-budget.test.ts`,覆盖可终止、连续拒答终止、超预算交付区间、区间宽度单调性。 + +## 任务 2:提问权与问题槽 + +已完成并审核: + +- live path 不再 import/call `spoken-answer.ts`;该旧解析/拼接模块已删除。 +- 模型只输出确认/承接正文;`current_question` / `choice_card` 由服务端结构化返回。 +- UI 使用统一问题槽展示选择题和自由输入提示;缺失问题时显示可监控状态。 +- 旧历史回放保留原始 assistant 正文,不再重新解析或拼接问题。 + +## 任务 3:候选结果交代契约 + +已实现: + +- 恢复 `build_candidate_result_summary()`。 +- `score_candidates()` / `diagnostics()` 返回 `candidate_summary`、`next_step_codes`、`stability.label` 与 `rectification_report`。 +- 报告包含当前候选区间、代表分钟及“代表性候选,不是唯一解”标记、置信度、逐条事件证据状态、technique layers、被排除候选、confirmation gate blocker 文案与局限声明。 +- `events=[]` 时第 0 轮区间回退到用户声明的出生窗口;跨午夜窗口已验证可用;不生成唯一分钟结论。 +- 新增回归覆盖零证据、跨午夜窗口、事件方法、排除候选、报告一致性与 `stability.label`。 + +## 已通过验证 + +- `python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`:pass。 +- `python3.12 -m pytest -q tests/test_active_rectification_events.py tests/test_rectification_v5_services.py`:通过(当前修改对应测试)。 +- 前端聚焦测试:79/79 通过: + `rectification-decision-authority.test.ts`、`rectification-decide-next-action.test.ts`、`rectification-inference-machine.test.ts`、`rectification-range-offer-deadend.test.ts`、`rectification-collect-stall.test.ts`、`rectification-convergence-budget.test.ts`。 +- `git diff --check`:通过。 +- 任务 2 聚焦测试:98/98 通过;相关补充测试(含问题槽 CSS 合同):38/38 通过。 +- `./node_modules/.bin/tsc --noEmit`:通过。 +- `npm run lint`:0 errors;25 个既有 warnings。 +- `npm run build`:通过;仅有既有 Turbopack filesystem tracing warnings。 +- `npm run test:db`:34/34 通过,fail=0。 +- 前端全量测试:2338/2338 通过,fail=0;使用 `PYTHON=/opt/anaconda3/bin/python3.12`,避免默认解释器缺少 PyYAML 的环境性失败。 + +## 交付状态 + +- 任务 1、任务 2、任务 3 已分别精确提交。 +- 任务 4(标定数据入口)与任务 5(清理/同步机制)按任务书明确留待后续,不在本轮实现。 +- 未 push、未 deploy;等待用户明确要求。 diff --git a/scripts/active_rectification_events.py b/scripts/active_rectification_events.py index 4f2d7e98..e5314278 100644 --- a/scripts/active_rectification_events.py +++ b/scripts/active_rectification_events.py @@ -108,6 +108,57 @@ class CandidateResult(TypedDict): stability_diagnostics: dict[str, Any] missing_layers: list[str] candidate_ranking_summary: NotRequired[list[dict[str, Any]]] + candidate_summary: NotRequired[dict[str, Any]] + + +def build_candidate_result_summary(result: dict[str, Any]) -> dict[str, Any]: + """Project a candidate result into stable, non-confirmatory next steps.""" + supported: dict[str, dict[str, Any]] = {} + unconfirmed: dict[str, dict[str, Any]] = {} + contradictory: dict[str, dict[str, Any]] = {} + + for item in result.get("evidence", []): + if not isinstance(item, dict): + continue + domain = str(item.get("domain") or "unknown") + points = float(item.get("points") or 0) + bucket = supported if points > 0 else contradictory if points < 0 else unconfirmed + row = bucket.setdefault(domain, {"domain": domain, "event_count": 0, "total_points": 0.0, "rule_ids": set()}) + row["event_count"] += 1 + row["total_points"] += points + row["rule_ids"].update(str(rule_id) for rule_id in item.get("rule_ids", [])) + + def rows(bucket: dict[str, dict[str, Any]], *, include_points: bool) -> list[dict[str, Any]]: + output = [] + for row in bucket.values(): + item: dict[str, Any] = { + "domain": row["domain"], + "event_count": row["event_count"], + "rule_ids": sorted(row["rule_ids"]), + } + if include_points: + item["total_points"] = round(row["total_points"], 3) + output.append(item) + return sorted(output, key=lambda item: (-item.get("total_points", 0), item["domain"])) + + segment = result.get("winning_segment") + next_steps: list[str] = [] + if int(result.get("event_count") or 0) < 5: + next_steps.append("collect_at_least_five_events") + if float(result.get("margin_percent") or 0) <= 0 or "tied_leader" in (result.get("reasons") or []): + next_steps.append("resolve_candidate_tie_or_narrow_window") + if isinstance(segment, dict) and int(segment.get("width_minutes") or 0) > 5: + next_steps.append("narrow_window_before_minute_claim") + next_steps.append("do_not_apply_as_birth_time_truth") + return { + "claim_status": "candidate_range_not_birth_time_truth", + "candidate_range": segment, + "supporting_evidence": rows(supported, include_points=True), + "unconfirmed_evidence": rows(unconfirmed, include_points=False), + "contradictory_evidence": rows(contradictory, include_points=True), + "reasons": list(result.get("reasons") or []), + "next_step_codes": next_steps, + } def precision_weight(precision: EventPrecision) -> float: @@ -347,4 +398,6 @@ def score_life_events(request: RectificationEventRequest) -> CandidateResult: """Compute actual candidate rows, then apply the versioned confidence gates.""" from scripts.active_rectification_event_engine import compute_event_candidate_result - return compute_event_candidate_result(request) + result = compute_event_candidate_result(request) + result["candidate_summary"] = build_candidate_result_summary(result) + return result diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py index 527a2ea2..ca1efeff 100644 --- a/scripts/rectification/api_service.py +++ b/scripts/rectification/api_service.py @@ -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"], diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py index 0d0293e6..d046dfa9 100644 --- a/scripts/rectification/contracts.py +++ b/scripts/rectification/contracts.py @@ -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): diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index efa23ba5..d6e4caa4 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -680,6 +680,72 @@ class RectificationV5ServicesTest(unittest.TestCase): self.assertEqual(API_COMMAND_MAP[command], endpoint) self.assertIn(endpoint, TECHNIQUE_EXAMPLE_ENDPOINTS) + def test_zero_evidence_returns_declared_window_report(self): + body = request(start_time="23:58", end_time="00:02") + body["events"] = [] + normalized = normalize_rectification_request(body, today=date(2026, 7, 28)) + + result = score_candidates(normalized) + report = result["rectification_report"] + + self.assertEqual(report["candidate_range"]["start_time"], "23:58") + self.assertEqual(report["candidate_range"]["end_time"], "00:02") + self.assertEqual(report["candidate_range"]["width_minutes"], 5) + self.assertIsNone(report["representative_time"]) + self.assertEqual(report["evidence"], []) + self.assertFalse(report["candidate_range"]["representative_is_unique"]) + self.assertIn("do_not_apply_as_birth_time_truth", result["next_step_codes"]) + + def test_report_exposes_evidence_excluded_candidates_and_diagnostics_consistently(self): + body = request() + body["events"] = [ + event(1, "education", "education_start"), + event(2, "career", "promotion"), + ] + normalized = normalize_rectification_request(body, today=date(2026, 7, 28)) + built = { + "candidate_times": ["05:13", "05:14", "05:15"], + "matrix": { + item["id"]: { + "05:13": {"points": 4, "rule_ids": ["D10:test"], "technique_layers": ["D10"]}, + "05:14": {"points": 4, "rule_ids": ["D9:test"], "technique_layers": ["D9"]}, + "05:15": {"points": -1, "rule_ids": ["D1:test"], "technique_layers": ["D1"]}, + } + for item in normalized["events"] + }, + "date_sensitivity": [], + "missing_layers": [], + "static_contexts": [ + {"feature": {"time": "05:13", "ascendant_sign_index": 1}}, + {"feature": {"time": "05:14", "ascendant_sign_index": 2}}, + {"feature": {"time": "05:15", "ascendant_sign_index": 3}}, + ], + } + diagnostic_values = { + "primary_cluster_retention_rate": 1, + "leave_one_event_out_retention_rate": 1, + "leave_one_domain_out_retention_rate": 1, + "date_sensitivity_retention_rate": 1, + "neighbor_support_minutes": 1, + "primary_secondary_margin_percent": 30, + } + with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch( + "scripts.rectification.api_service.build_candidate_feature_snapshot", return_value={} + ), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values): + scored = score_candidates(normalized) + diagnostic_result = diagnostics(normalized) + + report = scored["rectification_report"] + self.assertEqual(report["candidate_range"]["start_time"], "05:13") + self.assertEqual(report["candidate_range"]["end_time"], "05:14") + self.assertEqual(report["representative_time"], "05:13") + self.assertEqual([row["status"] for row in report["evidence"]], ["supporting", "supporting"]) + self.assertEqual(report["evidence"][0]["methods"], ["D10"]) + self.assertGreaterEqual(len(report["excluded_candidates"]), 1) + self.assertEqual(scored["candidate_summary"]["stability"]["label"], scored["overall_confidence"]) + self.assertEqual(diagnostic_result["rectification_report"], report) + self.assertEqual(diagnostic_result["next_step_codes"], scored["next_step_codes"]) + def test_http_handler_enforces_subject_and_event_kind_boundaries(self): handler = object.__new__(JyotishAPIHandler) retained = handler._rectification_v5_request(request(domain="family", event_kind="family_event"))