From d77dcda7538d6dd0f32183282b868292eea2a126 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 13 May 2026 09:17:33 +0800 Subject: [PATCH] Close decision assistant H14 F15 G14 D16 --- SESSION.md | 27 +- TASKS.md | 31 +- .../operator/operator_workspace_service.py | 280 +++++++++++++++++- src/web/app.js | 23 ++ tests/unit/test_operator_workspace_service.py | 24 ++ 5 files changed, 369 insertions(+), 16 deletions(-) diff --git a/SESSION.md b/SESSION.md index 467f255..af5f03b 100644 --- a/SESSION.md +++ b/SESSION.md @@ -2,7 +2,7 @@ ## Current Goal -Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, and concrete OKX announcements/system-status read-only sources. This run closed H13/F14/G13/D15 by adding handoff lane cleanup/snooze ergonomics, digest refresh confirmation trails, template feedback aggregation and tuned recommendation fields, plus setup-family drift watchlist/sample-threshold fields. Local validation passed, the current working tree was deployed, and remote smoke confirmed the latest Decision Assistant fields on the live host. +Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, and concrete OKX announcements/system-status read-only sources. This run closed H14/F15/G14/D16 by adding handoff lane grouping/overdue cleanup visibility, digest proof-aging escalation, template feedback trend history with negative-feedback guardrails, and setup-family promotion-readiness hints. Local validation passed, the current working tree was deployed, and remote smoke confirmed the latest Decision Assistant fields on the live host. ## Current Branch @@ -80,6 +80,10 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_digest_confirmation_trail_slice: refresh_proof_confirmation_trail - latest_decision_assistant_template_feedback_tuning_slice: feedback_aggregation_recommendation_tuning - latest_decision_assistant_replay_family_watchlist_slice: setup_family_drift_watchlist_thresholds +- latest_decision_assistant_handoff_grouping_slice: handoff_lane_grouping_overdue_cleanup +- latest_decision_assistant_digest_proof_escalation_slice: proof_aging_trend_stale_escalation +- latest_decision_assistant_template_guardrail_slice: feedback_trend_negative_guardrails +- latest_decision_assistant_replay_promotion_slice: setup_family_watchlist_promotion_readiness - latest_decision_assistant_summary_status: implemented - latest_decision_assistant_change_summary_status: implemented - latest_decision_assistant_evidence_status: implemented @@ -144,10 +148,14 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_digest_confirmation_trail_status: implemented - latest_decision_assistant_template_feedback_tuning_status: implemented - latest_decision_assistant_replay_family_watchlist_status: implemented +- latest_decision_assistant_handoff_grouping_status: implemented +- latest_decision_assistant_digest_proof_escalation_status: implemented +- latest_decision_assistant_template_guardrail_status: implemented +- latest_decision_assistant_replay_promotion_status: implemented - latest_decision_assistant_workbench_status: visible_local_smoke - latest_remote_decision_assistant_deploy_status: deployed_current_head - latest_remote_decision_assistant_smoke_status: passed -- latest_remote_decision_assistant_review_packet_status: handoff_cleanup_confirmation_trail_feedback_tuning_family_watchlist_visible +- latest_remote_decision_assistant_review_packet_status: handoff_grouping_proof_escalation_feedback_guardrails_promotion_visible - latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup - latest_p14_slice: webhook_transport_retry - latest_p15_pxx_tasklist_status: ready @@ -226,6 +234,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. ## Completed In This Run +- Added handoff lane grouping / overdue cleanup visibility (`handoff_lane_groups`, `overdue_cleanup_count`, `next_cleanup_*`, per-marker `lane_group`, `overdue_cleanup_candidate`, and `cleanup_priority`) plus matching cockpit summaries. +- Added digest proof-aging trend and stale-proof escalation fields (`proof_age_trend`, `proof_escalation_level`, `proof_escalation_prompt`, `proof_aging_trend_counts`, and `stale_proof_escalation_count`). +- Added template feedback trend history and negative-feedback guardrails (`feedback_trend_history`, `negative_feedback_guardrail`, `negative_feedback_guardrail_count`, and guardrail template IDs) with cockpit visibility. +- Added setup-family watchlist prioritization / promotion-readiness hints (`watchlist_priority_score`, `promotion_ready`, `promotion_readiness_hint`, and `promotion_blockers`) on replay family trend deltas. +- Passed focused validation: `unittest tests.unit.test_operator_workspace_service`, `unittest tests.unit.test_operator_workspace_service tests.unit.test_api_static_server tests.unit.test_api_server`, full `unittest discover -s tests/unit` (713 tests), `scripts/run_script_static_checks.py`, and `node --check src/web/app.js`. +- Deployed the current working tree to `http://192.168.3.90:8000`; deploy smoke passed 11 endpoints, `run_remote_ops_smoke.py` passed 9 checks, and remote field smoke confirmed handoff grouping, proof aging/escalation, template guardrails, setup-family promotion readiness, and matching `/static/app.js` UI strings. - Added handoff lane cleanup/snooze metadata (`cleanup_candidate_count`, `handoff_cleanup_recommendation`, per-marker `snooze_seconds`, and `snooze_options`) plus workbench snooze/clear-snooze controls for persisted handoff markers. - Added digest refresh proof history trails (`refresh_confirmation_trail`, `refresh_confirmation_trail_count`, `latest_confirmation_trail_item`) so each recall group carries an auditable freshness confirmation path. - Added template feedback aggregation and recommendation tuning fields (`template_feedback_aggregation`, `feedback_tuned_template_id`, `recommendation_tuning_status`) plus workbench tuned-template actions. @@ -507,11 +521,18 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. ## Exact Next Step -Continue the Decision Assistant dense operator workspace with the next bounded slice: H14 handoff lane grouping / overdue cleanup visibility, F15 digest proof aging trend / stale proof escalation, G14 template feedback trend history / negative-feedback guardrails, and D16 setup-family watchlist prioritization / promotion-readiness hints. PXX remains closed; do not create P25/P26 automatically, and do not enable real trading. +Continue the Decision Assistant dense operator workspace with the next bounded slice: H15 handoff lane lifecycle summary / operator resume queue, F16 digest proof escalation follow-up queue / resolved proof states, G15 template guardrail resolution / preferred-template decay, and D17 setup-family promotion queue / demotion history. PXX remains closed; do not create P25/P26 automatically, and do not enable real trading. ## Validation Commands Used Recently ```bash +D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_operator_workspace_service +D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_operator_workspace_service tests.unit.test_api_static_server tests.unit.test_api_server +D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py +D:\ai-exchange\.venv\Scripts\python.exe -m unittest discover -s tests/unit +node --check src/web/app.js +D:\ai-exchange\.venv\Scripts\python.exe scripts\deploy_with_password.py --host 192.168.3.90 --user root --port 22 --password +D:\ai-exchange\.venv\Scripts\python.exe scripts\run_remote_ops_smoke.py --base-url http://192.168.3.90:8000 --attempts 2 --timeout-seconds 20 D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_visual_review_service tests.unit.test_run_visual_review tests.unit.test_trading_runtime_service tests.unit.test_api_and_main tests.unit.test_api_server D:\ai-exchange\.venv\Scripts\python.exe scripts\run_script_static_checks.py D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_run_until_blocked tests.unit.test_monitoring_alert_rollout tests.unit.test_runtime_script_json diff --git a/TASKS.md b/TASKS.md index 42eed59..e709c75 100644 --- a/TASKS.md +++ b/TASKS.md @@ -80,6 +80,10 @@ - [x] Decision Assistant F14 digest refresh proof history / confirmation trail slice complete - [x] Decision Assistant G13 template feedback aggregation / recommendation tuning slice complete - [x] Decision Assistant D15 setup-family drift watchlist / sample thresholds slice complete +- [x] Decision Assistant H14 handoff lane grouping / overdue cleanup visibility slice complete +- [x] Decision Assistant F15 digest proof aging trend / stale proof escalation slice complete +- [x] Decision Assistant G14 template feedback trend history / negative-feedback guardrails slice complete +- [x] Decision Assistant D16 setup-family watchlist prioritization / promotion-readiness hints slice complete ## Next Up @@ -91,10 +95,14 @@ - [x] F14 deepen digest recall with refresh proof history / confirmation trail - [x] G13 continue review template ergonomics with feedback aggregation / recommendation tuning - [x] D15 extend replay comparison with setup-family drift watchlist / sample thresholds -- [ ] H14 continue compact operator workspace polish with handoff lane grouping / overdue cleanup visibility -- [ ] F15 deepen digest recall with proof aging trend / stale proof escalation -- [ ] G14 continue review template ergonomics with feedback trend history / negative-feedback guardrails -- [ ] D16 extend replay comparison with setup-family watchlist prioritization / promotion-readiness hints +- [x] H14 continue compact operator workspace polish with handoff lane grouping / overdue cleanup visibility +- [x] F15 deepen digest recall with proof aging trend / stale proof escalation +- [x] G14 continue review template ergonomics with feedback trend history / negative-feedback guardrails +- [x] D16 extend replay comparison with setup-family watchlist prioritization / promotion-readiness hints +- [ ] H15 continue compact operator workspace polish with handoff lane lifecycle summary / operator resume queue +- [ ] F16 deepen digest recall with proof escalation follow-up queue / resolved proof states +- [ ] G15 continue review template ergonomics with template guardrail resolution / preferred-template decay +- [ ] D17 extend replay comparison with setup-family promotion queue / demotion history - [x] P8 local closure complete - [x] P9 local closure complete - [x] P10 local closure complete @@ -182,6 +190,10 @@ - [x] latest_decision_assistant_digest_confirmation_trail_slice: refresh_proof_confirmation_trail - [x] latest_decision_assistant_template_feedback_tuning_slice: feedback_aggregation_recommendation_tuning - [x] latest_decision_assistant_replay_family_watchlist_slice: setup_family_drift_watchlist_thresholds +- [x] latest_decision_assistant_handoff_grouping_slice: handoff_lane_grouping_overdue_cleanup +- [x] latest_decision_assistant_digest_proof_escalation_slice: proof_aging_trend_stale_escalation +- [x] latest_decision_assistant_template_guardrail_slice: feedback_trend_negative_guardrails +- [x] latest_decision_assistant_replay_promotion_slice: setup_family_watchlist_promotion_readiness - [x] latest_decision_assistant_summary_status: implemented - [x] latest_decision_assistant_change_summary_status: implemented - [x] latest_decision_assistant_evidence_status: implemented @@ -243,10 +255,14 @@ - [x] latest_decision_assistant_digest_confirmation_trail_status: implemented - [x] latest_decision_assistant_template_feedback_tuning_status: implemented - [x] latest_decision_assistant_replay_family_watchlist_status: implemented +- [x] latest_decision_assistant_handoff_grouping_status: implemented +- [x] latest_decision_assistant_digest_proof_escalation_status: implemented +- [x] latest_decision_assistant_template_guardrail_status: implemented +- [x] latest_decision_assistant_replay_promotion_status: implemented - [x] latest_decision_assistant_workbench_status: visible_local_smoke - [x] latest_remote_decision_assistant_deploy_status: deployed_current_head - [x] latest_remote_decision_assistant_smoke_status: passed -- [x] latest_remote_decision_assistant_review_packet_status: handoff_cleanup_confirmation_trail_feedback_tuning_family_watchlist_visible +- [x] latest_remote_decision_assistant_review_packet_status: handoff_grouping_proof_escalation_feedback_guardrails_promotion_visible - [x] latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup - [x] latest_p14_slice: webhook_transport_retry - [x] latest_p15_pxx_tasklist_status: ready @@ -324,6 +340,11 @@ ## Newly Done +- [x] Added handoff lane grouping / overdue cleanup visibility and matching cockpit summaries +- [x] Added digest proof-aging trend, stale-proof escalation fields, and summary counts +- [x] Added template feedback trend history and negative-feedback guardrail visibility +- [x] Added setup-family watchlist priority score, promotion readiness hints, and blockers +- [x] Passed focused validation, full unit discovery, script static checks, JS syntax check, deploy smoke, remote ops smoke, and remote field smoke for H14/F15/G14/D16 - [x] Added handoff lane cleanup/snooze metadata plus persisted-handoff snooze and clear-snooze controls - [x] Added digest refresh confirmation trail fields and workbench proof-trail visibility - [x] Added template feedback aggregation and feedback-tuned recommendation fields plus tuned-template actions diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index 697579f..a7a3dab 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -1083,6 +1083,9 @@ def _build_decision_assistant_review_entrypoints( item["feedback_loop_status"] = outcome.get("feedback_loop_status") item["feedback_prompt"] = outcome.get("feedback_prompt") item["feedback_actions"] = outcome.get("feedback_actions") or [] + item["feedback_trend_history"] = outcome.get("feedback_trend_history") or [] + item["negative_feedback_guardrail"] = outcome.get("negative_feedback_guardrail") + item["negative_feedback_guardrail_prompt"] = outcome.get("negative_feedback_guardrail_prompt") return { "status": "active", "note_count": len(recent_notes), @@ -1346,18 +1349,39 @@ def _score_template_outcome(*, template_id: str, usage: dict, notes: list[dict]) negative_count = 0 action_count = 0 evidence = [] + feedback_trend_history = [] for note in matched_notes: tags = [str(tag).lower() for tag in note.get("tags") or []] body = str(note.get("body") or "").lower() - if any(tag in {"template-useful", "outcome:useful", "useful", "actionable"} for tag in tags) or "useful" in body: + positive_signal = any(tag in {"template-useful", "outcome:useful", "useful", "actionable"} for tag in tags) or "useful" in body + negative_signal = any(tag in {"template-misleading", "outcome:misleading", "misleading"} for tag in tags) or "misleading" in body + actionable_signal = any(tag in {"actionable", "outcome:actionable"} for tag in tags) or "actionable" in body + if positive_signal: positive_count += 1 evidence.append("positive_feedback") - if any(tag in {"template-misleading", "outcome:misleading", "misleading"} for tag in tags) or "misleading" in body: + if negative_signal: negative_count += 1 evidence.append("negative_feedback") - if any(tag in {"actionable", "outcome:actionable"} for tag in tags) or "actionable" in body: + if actionable_signal: action_count += 1 evidence.append("actionable_feedback") + feedback_signal = "reuse_only" + if negative_signal: + feedback_signal = "negative" + elif positive_signal and actionable_signal: + feedback_signal = "positive_actionable" + elif positive_signal: + feedback_signal = "positive" + elif actionable_signal: + feedback_signal = "actionable" + feedback_trend_history.append( + { + "created_ts": note.get("created_ts"), + "signal": feedback_signal, + "label": _template_feedback_trend_label(feedback_signal), + "body_preview": _truncate_text(str(note.get("body") or ""), limit=90), + } + ) outcome_score = max(0.0, min(1.0, (reuse_count * 0.25) + (positive_count * 0.35) + (action_count * 0.2) - (negative_count * 0.4))) usefulness_score = max(0.0, min(1.0, outcome_score)) if negative_count: @@ -1386,9 +1410,34 @@ def _score_template_outcome(*, template_id: str, usage: dict, notes: list[dict]) "feedback_loop_status": feedback_loop.get("status"), "feedback_prompt": feedback_loop.get("prompt"), "feedback_actions": feedback_loop.get("actions"), + "feedback_trend_history": feedback_trend_history[:5], + "negative_feedback_guardrail": "pause_before_reuse" if negative_count else "clear", + "negative_feedback_guardrail_prompt": _template_negative_guardrail_prompt( + template_id=template_id, + negative_count=negative_count, + ), } +def _template_feedback_trend_label(signal: str) -> str: + if signal == "negative": + return "Negative feedback" + if signal == "positive_actionable": + return "Useful and actionable" + if signal == "positive": + return "Useful" + if signal == "actionable": + return "Actionable" + return "Reuse captured" + + +def _template_negative_guardrail_prompt(*, template_id: str, negative_count: int) -> str: + template_label = template_id.replace("_", " ") or "template" + if negative_count: + return f"Pause {template_label} reuse until a newer note explains whether the misleading feedback is resolved." + return f"{template_label} has no negative-feedback guardrail; continue to capture outcome notes after reuse." + + def _build_template_feedback_loop(*, template_id: str, outcome_status: str, reuse_count: int) -> dict: template_label = template_id.replace("_", " ") or "template" if outcome_status == "review_before_reuse": @@ -1454,6 +1503,11 @@ def _build_template_feedback_aggregation( useful_count = sum(int(item.get("positive_count") or 0) for item in template_outcomes.values()) actionable_count = sum(int(item.get("actionable_count") or 0) for item in template_outcomes.values()) misleading_count = sum(int(item.get("negative_count") or 0) for item in template_outcomes.values()) + negative_feedback_guardrail_ids = [ + str(item.get("template_id") or "") + for item in template_outcomes.values() + if item.get("negative_feedback_guardrail") == "pause_before_reuse" + ] rated_template_count = sum( 1 for item in template_outcomes.values() @@ -1473,6 +1527,14 @@ def _build_template_feedback_aggregation( else: tuned_template_id = best_template_id tuning_status = "feedback_only_recommendation" if best_template_id else "no_feedback_signal" + if negative_feedback_guardrail_ids: + feedback_trend_status = "negative_guardrail" + elif useful_count or actionable_count: + feedback_trend_status = "positive_trend" + elif rated_template_count: + feedback_trend_status = "mixed_feedback" + else: + feedback_trend_status = "needs_more_feedback" return { "status": "active" if template_outcomes else "idle", "useful_feedback_count": useful_count, @@ -1484,6 +1546,10 @@ def _build_template_feedback_aggregation( "feedback_tuned_template_id": tuned_template_id, "recommendation_tuning_status": tuning_status, "recommendation_delta_score": best_score - recommended_score if best_template_id else 0.0, + "feedback_trend_status": feedback_trend_status, + "feedback_trend_history_count": sum(len(item.get("feedback_trend_history") or []) for item in template_outcomes.values()), + "negative_feedback_guardrail_count": len(negative_feedback_guardrail_ids), + "negative_feedback_guardrail_template_ids": _dedupe(negative_feedback_guardrail_ids)[:5], } @@ -1710,6 +1776,15 @@ def _build_decision_assistant_digest_history_summary(history: list[dict]) -> dic for group in groups for item in list(group.get("refresh_confirmation_trail") or []) ] + proof_aging_trend_counts = {} + for group in groups: + trend = str(group.get("proof_age_trend") or "unknown") + proof_aging_trend_counts[trend] = int(proof_aging_trend_counts.get(trend) or 0) + 1 + stale_proof_escalations = [ + group + for group in groups + if group.get("proof_escalation_level") in {"stale_refresh", "missing_proof"} + ] refresh_confirmation_count = sum( 1 for group in groups @@ -1740,6 +1815,10 @@ def _build_decision_assistant_digest_history_summary(history: list[dict]) -> dic "refresh_prompts": refresh_prompts[:3], "refresh_confirmation_trail_count": len(refresh_confirmation_trail), "refresh_confirmation_trail": refresh_confirmation_trail[:5], + "proof_aging_trend_counts": proof_aging_trend_counts, + "stale_proof_escalation_count": len(stale_proof_escalations), + "next_stale_proof_kind": stale_proof_escalations[0].get("kind") if stale_proof_escalations else None, + "next_stale_proof_prompt": stale_proof_escalations[0].get("proof_escalation_prompt") if stale_proof_escalations else None, } @@ -1760,6 +1839,11 @@ def _build_decision_assistant_digest_history_groups(history: list[dict], *, reco refresh_evidence_anchor = _digest_history_refresh_evidence_anchor(kind=kind, items=items, latest_ts=latest_ts) refresh_confirmation_status = _digest_history_refresh_confirmation_status(age=age) freshness_proof_status = _digest_history_freshness_proof_status(age=age) + proof_age_trend = _digest_history_proof_age_trend(age=age) + proof_escalation_level = _digest_history_proof_escalation_level( + age=age, + freshness_proof_status=freshness_proof_status, + ) refresh_confirmation_trail = _digest_history_refresh_confirmation_trail( kind=kind, items=items, @@ -1799,6 +1883,14 @@ def _build_decision_assistant_digest_history_groups(history: list[dict], *, reco age=age, evidence_anchor=str(refresh_evidence_anchor.get("anchor") or ""), ), + "proof_age_trend": proof_age_trend, + "proof_escalation_level": proof_escalation_level, + "proof_escalation_prompt": _digest_history_proof_escalation_prompt( + kind=kind, + proof_age_trend=proof_age_trend, + proof_escalation_level=proof_escalation_level, + evidence_anchor=str(refresh_evidence_anchor.get("anchor") or ""), + ), "refresh_confirmation_trail": refresh_confirmation_trail, "latest_confirmation_trail_item": refresh_confirmation_trail[0] if refresh_confirmation_trail else None, "items": items[:3], @@ -1960,6 +2052,48 @@ def _digest_history_confirmation_proof_label(*, confirmation_status: str, freshn return "confirmation needed" +def _digest_history_proof_age_trend(*, age: dict) -> str: + age_band = str(age.get("age_band") or "unknown_age") + if age_band == "fresh_now": + return "fresh" + if age_band == "monitor_window": + return "monitor" + if age_band == "same_session": + return "aging" + if age_band == "stale": + return "stale" + if age_band in {"untimed", "unknown_age"}: + return "missing" + return "unknown" + + +def _digest_history_proof_escalation_level(*, age: dict, freshness_proof_status: str) -> str: + trend = _digest_history_proof_age_trend(age=age) + if freshness_proof_status == "missing_timestamp_proof" or trend == "missing": + return "missing_proof" + if trend == "stale": + return "stale_refresh" + if trend == "aging": + return "watch" + return "none" + + +def _digest_history_proof_escalation_prompt( + *, + kind: str, + proof_age_trend: str, + proof_escalation_level: str, + evidence_anchor: str, +) -> str: + if proof_escalation_level == "missing_proof": + return f"Add a timestamp proof for {kind} before this digest group is reused." + if proof_escalation_level == "stale_refresh": + return f"Refresh {kind} from {evidence_anchor or 'the digest source'} before treating it as live context." + if proof_escalation_level == "watch": + return f"{kind} proof is aging; recheck it before it drives the next decision note." + return f"{kind} proof trend is {proof_age_trend}; no escalation is needed." + + def _digest_history_refresh_evidence_anchor(*, kind: str, items: list[dict], latest_ts) -> dict: latest = dict(items[0] or {}) if items else {} title = str(latest.get("title") or kind or "history") @@ -2268,11 +2402,20 @@ def _build_decision_assistant_scan_handoff_summary(cards: list[dict]) -> dict: "stale_warning": item.get("handoff_stale_warning"), "snooze_seconds": _scan_card_handoff_snooze_seconds(item), "cleanup_action_label": _scan_card_handoff_cleanup_action_label(item), + "lane_group": _scan_handoff_lane_group(str(item.get("handoff_status") or "")), + "overdue_cleanup_candidate": str(item.get("handoff_status") or "") in {"blocked_handoff", "refresh_handoff"}, + "cleanup_priority": _scan_handoff_cleanup_priority(str(item.get("handoff_status") or "")), } for item in cards if item.get("handoff_marker") ] next_marker = next((item for item in markers if item.get("handoff_status") == "next_handoff"), {}) + cleanup_markers = [ + item + for item in sorted(markers, key=lambda row: int(row.get("cleanup_priority") or 0), reverse=True) + if bool(item.get("overdue_cleanup_candidate")) + ] + next_cleanup_marker = dict(cleanup_markers[0] or {}) if cleanup_markers else {} stale_after_values = [ int(item.get("stale_after_seconds") or 0) for item in markers @@ -2290,11 +2433,12 @@ def _build_decision_assistant_scan_handoff_summary(cards: list[dict]) -> dict: for item in markers if item.get("handoff_status") in {"next_handoff", "blocked_handoff", "refresh_handoff"} ), - "cleanup_candidate_count": sum( - 1 - for item in markers - if item.get("handoff_status") in {"blocked_handoff", "refresh_handoff"} - ), + "cleanup_candidate_count": len(cleanup_markers), + "overdue_cleanup_count": len(cleanup_markers), + "next_cleanup_marker": next_cleanup_marker.get("handoff_marker"), + "next_cleanup_title": next_cleanup_marker.get("title"), + "next_cleanup_prompt": next_cleanup_marker.get("handoff_prompt"), + "handoff_lane_groups": _build_scan_handoff_lane_groups(markers), "handoff_stale_policy": "Persisted handoffs older than their stale window should be reopened and refreshed before reuse.", "handoff_cleanup_recommendation": _scan_handoff_cleanup_recommendation(markers), "snooze_options": [ @@ -2305,6 +2449,73 @@ def _build_decision_assistant_scan_handoff_summary(cards: list[dict]) -> dict: } +def _scan_handoff_lane_group(handoff_status: str) -> str: + if handoff_status == "blocked_handoff": + return "blocked" + if handoff_status == "refresh_handoff": + return "refresh" + if handoff_status == "next_handoff": + return "next" + if handoff_status == "context_ready": + return "carry_forward" + return "ready" + + +def _scan_handoff_cleanup_priority(handoff_status: str) -> int: + if handoff_status == "blocked_handoff": + return 100 + if handoff_status == "refresh_handoff": + return 80 + if handoff_status == "next_handoff": + return 40 + return 10 + + +def _build_scan_handoff_lane_groups(markers: list[dict]) -> list[dict]: + lanes: dict[str, list[dict]] = {} + for marker in markers: + lane = str(marker.get("lane_group") or _scan_handoff_lane_group(str(marker.get("handoff_status") or ""))) + lanes.setdefault(lane, []).append(marker) + ranked_lanes = ["blocked", "refresh", "next", "ready", "carry_forward"] + groups = [] + for lane in ranked_lanes: + items = lanes.get(lane) or [] + if not items: + continue + cleanup_items = [item for item in items if bool(item.get("overdue_cleanup_candidate"))] + next_cleanup = dict( + sorted(cleanup_items, key=lambda row: int(row.get("cleanup_priority") or 0), reverse=True)[0] + if cleanup_items + else {} + ) + groups.append( + { + "lane_group": lane, + "status": lane, + "label": _scan_handoff_lane_label(lane), + "count": len(items), + "overdue_count": len(cleanup_items), + "cleanup_count": len(cleanup_items), + "titles": [str(item.get("title") or "handoff") for item in items[:3]], + "next_cleanup_marker": next_cleanup.get("handoff_marker"), + "next_cleanup_title": next_cleanup.get("title"), + } + ) + return groups + + +def _scan_handoff_lane_label(lane: str) -> str: + if lane == "blocked": + return "Blocked" + if lane == "refresh": + return "Refresh" + if lane == "next": + return "Next" + if lane == "carry_forward": + return "Carry Forward" + return "Ready" + + def _scan_card_handoff_marker(card: dict) -> str: rank = int(card.get("rank") or 0) target = str(card.get("target_section") or "scan") @@ -2573,6 +2784,10 @@ def _build_setup_replay_broader_baseline_summary( "drift_severity": "none", "watchlist_status": "collect_more_samples", "watchlist_priority": "low", + "watchlist_priority_score": 10, + "promotion_ready": False, + "promotion_readiness_hint": f"{setup_family} needs at least 5 samples before promotion readiness can be assessed.", + "promotion_blockers": ["insufficient_samples"], "focus_recommendation": f"Collect samples before changing focus for {setup_family}.", "focus_reasons": ["insufficient_samples"], "drift_watchlist_items": ["collect_more_replay_samples"], @@ -2771,6 +2986,10 @@ def _build_setup_family_trend_delta( "drift_severity": "none", "watchlist_status": "collect_more_samples", "watchlist_priority": "low", + "watchlist_priority_score": 10, + "promotion_ready": False, + "promotion_readiness_hint": f"{setup_family} needs more replay samples before promotion readiness can be assessed.", + "promotion_blockers": ["insufficient_samples"], "focus_recommendation": f"Collect samples before changing focus for {setup_family}.", "focus_reasons": ["insufficient_samples"], "drift_watchlist_items": ["collect_more_replay_samples"], @@ -2830,6 +3049,19 @@ def _build_setup_family_trend_delta( drift_watchlist_items.append("sample_threshold_not_met") if drift_alert: drift_watchlist_items.append("review_before_promotion") + promotion_blockers = [] + if sample_gap > 0: + promotion_blockers.append("sample_threshold_not_met") + if drift_alert: + promotion_blockers.append("drift_alert") + if trend_status == "mixed_vs_family_peers": + promotion_blockers.append("mixed_family_delta") + promotion_ready = watchlist_status == "promote_candidate" and not promotion_blockers + watchlist_priority_score = _setup_family_watchlist_priority_score( + watchlist_priority=watchlist_priority, + drift_alert=drift_alert, + sample_gap=sample_gap, + ) return { "status": "active", "trend_status": trend_status, @@ -2847,6 +3079,15 @@ def _build_setup_family_trend_delta( "drift_severity": drift_severity, "watchlist_status": watchlist_status, "watchlist_priority": watchlist_priority, + "watchlist_priority_score": watchlist_priority_score, + "promotion_ready": promotion_ready, + "promotion_readiness_hint": _setup_family_promotion_readiness_hint( + setup_family=setup_family, + promotion_ready=promotion_ready, + promotion_blockers=promotion_blockers, + watchlist_status=watchlist_status, + ), + "promotion_blockers": promotion_blockers, "focus_recommendation": focus_recommendation, "focus_reasons": focus_reasons[:4], "drift_watchlist_items": _dedupe(drift_watchlist_items)[:6], @@ -2857,6 +3098,29 @@ def _build_setup_family_trend_delta( } +def _setup_family_watchlist_priority_score(*, watchlist_priority: str, drift_alert: bool, sample_gap: int) -> int: + base = {"low": 20, "medium": 50, "high": 80}.get(str(watchlist_priority or "low"), 20) + if drift_alert: + base += 15 + if sample_gap > 0: + base += min(20, sample_gap * 3) + return min(100, base) + + +def _setup_family_promotion_readiness_hint( + *, + setup_family: str, + promotion_ready: bool, + promotion_blockers: list[str], + watchlist_status: str, +) -> str: + if promotion_ready: + return f"{setup_family} is promotion-ready for the assistant review lane; keep it analysis-only until operator confirmation." + if promotion_blockers: + return f"{setup_family} is not promotion-ready because {', '.join(promotion_blockers[:3])}." + return f"{setup_family} remains in {watchlist_status or 'watch'} mode pending more replay evidence." + + def _rank_session_composite(rows: list[dict], *, session_code: str) -> int | None: if not session_code: return None diff --git a/src/web/app.js b/src/web/app.js index b1d20ad..ae23693 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -568,6 +568,11 @@ function renderDecisionAssistant(summary) { ['handoff stale policy', scanHandoffSummary.handoff_stale_policy || 'n/a'], ['handoff stale warnings', String(scanHandoffSummary.stale_warning_count ?? 0)], ['handoff cleanup candidates', String(scanHandoffSummary.cleanup_candidate_count ?? 0)], + ['handoff overdue cleanup', String(scanHandoffSummary.overdue_cleanup_count ?? 0)], + ['handoff next cleanup', scanHandoffSummary.next_cleanup_title || scanHandoffSummary.next_cleanup_marker || 'n/a'], + ['handoff cleanup prompt', scanHandoffSummary.next_cleanup_prompt || 'n/a'], + ['handoff lane groups', formatList((scanHandoffSummary.handoff_lane_groups || []).map((item) => `${item.label || item.status}: ${item.count ?? 0}`))], + ['handoff lane overdue', formatList((scanHandoffSummary.handoff_lane_groups || []).filter((item) => Number(item.overdue_count || 0) > 0).map((item) => `${item.label || item.status}: ${item.overdue_count}`))], ['handoff cleanup recommendation', scanHandoffSummary.handoff_cleanup_recommendation || 'n/a'], ['default stale after', formatDurationSeconds(scanHandoffSummary.default_stale_after_seconds)], ['saved handoff marker', assistantScanHandoffMarker?.marker || 'n/a'], @@ -690,6 +695,10 @@ function renderDecisionAssistant(summary) { ['refresh confirmation needed', String(digestSurface.history_summary?.refresh_confirmation_needed_count ?? 0)], ['freshness proofs', String(digestSurface.history_summary?.freshness_proof_count ?? 0)], ['freshness proof gaps', String(digestSurface.history_summary?.freshness_proof_gap_count ?? 0)], + ['proof aging trend', formatJsonInline(digestSurface.history_summary?.proof_aging_trend_counts || {})], + ['stale proof escalations', String(digestSurface.history_summary?.stale_proof_escalation_count ?? 0)], + ['next stale proof kind', digestSurface.history_summary?.next_stale_proof_kind || 'n/a'], + ['next stale proof prompt', digestSurface.history_summary?.next_stale_proof_prompt || 'n/a'], ['confirmation trail items', String(digestSurface.history_summary?.refresh_confirmation_trail_count ?? 0)], ['confirmation trail', formatList((digestSurface.history_summary?.refresh_confirmation_trail || []).map((item) => `${item.kind}:${item.proof_label}`))], ['refresh evidence anchors', String(digestSurface.history_summary?.refresh_evidence_anchor_count ?? 0)], @@ -774,6 +783,9 @@ function renderDecisionAssistant(summary) { ['confirmation prompt', item.refresh_confirmation_prompt || 'n/a'], ['freshness proof', item.freshness_proof_status || 'n/a'], ['proof detail', item.freshness_proof_detail || 'n/a'], + ['proof age trend', item.proof_age_trend || 'n/a'], + ['proof escalation', item.proof_escalation_level || 'n/a'], + ['proof escalation prompt', item.proof_escalation_prompt || 'n/a'], ['latest proof trail', item.latest_confirmation_trail_item?.proof_label || 'n/a'], ['proof trail items', formatList((item.refresh_confirmation_trail || []).map((row) => `${row.title || row.kind}: ${row.proof_label}`))], ['refresh evidence', item.refresh_evidence_anchor || 'n/a'], @@ -828,6 +840,10 @@ function renderDecisionAssistant(summary) { ['useful feedback', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.useful_feedback_count ?? 0)], ['actionable feedback', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.actionable_feedback_count ?? 0)], ['misleading feedback', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.misleading_feedback_count ?? 0)], + ['feedback trend', reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.feedback_trend_status || 'n/a'], + ['feedback trend samples', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.feedback_trend_history_count ?? 0)], + ['negative guardrails', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.negative_feedback_guardrail_count ?? 0)], + ['guardrail templates', formatList(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.negative_feedback_guardrail_template_ids || [])], ['rated templates', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.rated_template_count ?? 0)], ['template outcome avg', formatPercent(reviewEntrypoints.template_reuse_metrics?.template_outcome_summary?.average_usefulness_score)], ['template feedback gaps', String(reviewEntrypoints.template_reuse_metrics?.template_outcome_summary?.feedback_gap_count ?? 0)], @@ -881,6 +897,9 @@ function renderDecisionAssistant(summary) { ['outcome evidence', formatList(item.outcome_evidence || [])], ['feedback loop', item.feedback_loop_status || 'n/a'], ['feedback prompt', item.feedback_prompt || 'n/a'], + ['negative guardrail', item.negative_feedback_guardrail || 'n/a'], + ['guardrail prompt', item.negative_feedback_guardrail_prompt || 'n/a'], + ['feedback trend history', formatList((item.feedback_trend_history || []).map((row) => `${row.label || row.signal}: ${formatTimestamp(row.created_ts)}`))], ], [ { @@ -1175,6 +1194,10 @@ function renderDecisionAssistant(summary) { ['family trend status', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.trend_status || 'n/a'], ['family watchlist status', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.watchlist_status || 'n/a'], ['family watchlist priority', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.watchlist_priority || 'n/a'], + ['family watchlist score', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.watchlist_priority_score ?? 'n/a')], + ['promotion readiness', String(Boolean(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_ready))], + ['promotion readiness hint', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_readiness_hint || 'n/a'], + ['promotion blockers', formatList(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_blockers || [])], ['family sample threshold', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.sample_threshold ?? 'n/a')], ['family sample gap', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.sample_gap ?? 'n/a')], ['family watchlist items', formatList(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.drift_watchlist_items || [])], diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index d90cdfe..39ce300 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -196,12 +196,18 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("feedback_tuned_template_id", summary["review_entrypoints"]["template_reuse_metrics"]) self.assertIn("recommendation_tuning_status", summary["review_entrypoints"]["template_reuse_metrics"]) self.assertGreaterEqual(summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]["useful_feedback_count"], 1) + self.assertIn("feedback_trend_status", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("negative_feedback_guardrail_count", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("negative_feedback_guardrail_template_ids", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) self.assertEqual(summary["review_entrypoints"]["note_templates"][0]["reuse_count"], 1) self.assertEqual(summary["review_entrypoints"]["note_templates"][0]["reuse_status"], "saved_reuse") self.assertEqual(summary["review_entrypoints"]["note_templates"][0]["outcome_status"], "useful_signal") self.assertTrue(summary["review_entrypoints"]["note_templates"][0]["outcome_evidence"]) self.assertTrue(summary["review_entrypoints"]["note_templates"][0]["feedback_actions"]) self.assertIn("feedback_loop_status", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("feedback_trend_history", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("negative_feedback_guardrail", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("negative_feedback_guardrail_prompt", summary["review_entrypoints"]["note_templates"][0]) self.assertTrue(any(item["template_id"] == "next_review" for item in summary["review_entrypoints"]["note_templates"])) self.assertTrue(any(item["template_id"] == "queue_follow_up" for item in summary["review_entrypoints"]["note_templates"])) self.assertTrue(any(item["reuse_hint"] for item in summary["review_entrypoints"]["note_templates"])) @@ -217,6 +223,10 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("freshness_proof_gap_count", summary["digest_surface"]["history_summary"]) self.assertIn("refresh_confirmation_trail_count", summary["digest_surface"]["history_summary"]) self.assertIn("refresh_confirmation_trail", summary["digest_surface"]["history_summary"]) + self.assertIn("proof_aging_trend_counts", summary["digest_surface"]["history_summary"]) + self.assertIn("stale_proof_escalation_count", summary["digest_surface"]["history_summary"]) + self.assertIn("next_stale_proof_kind", summary["digest_surface"]["history_summary"]) + self.assertIn("next_stale_proof_prompt", summary["digest_surface"]["history_summary"]) self.assertIn("refresh_prompts", summary["digest_surface"]["history_summary"]) self.assertIn("digest_history_groups", summary) self.assertIn("group_status", summary["digest_history_groups"][0]) @@ -232,6 +242,9 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("refresh_confirmation_prompt", summary["digest_history_groups"][0]) self.assertIn("freshness_proof_status", summary["digest_history_groups"][0]) self.assertIn("freshness_proof_detail", summary["digest_history_groups"][0]) + self.assertIn("proof_age_trend", summary["digest_history_groups"][0]) + self.assertIn("proof_escalation_level", summary["digest_history_groups"][0]) + self.assertIn("proof_escalation_prompt", summary["digest_history_groups"][0]) self.assertIn("refresh_confirmation_trail", summary["digest_history_groups"][0]) self.assertIn("latest_confirmation_trail_item", summary["digest_history_groups"][0]) self.assertTrue(summary["digest_history_preview"]) @@ -267,6 +280,10 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertTrue(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]["focus_reasons"]) self.assertIn("watchlist_status", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("watchlist_priority", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("watchlist_priority_score", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("promotion_ready", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("promotion_readiness_hint", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("promotion_blockers", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("sample_threshold", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("sample_gap", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertTrue(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]["drift_watchlist_items"]) @@ -295,9 +312,16 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("stale_warning_count", summary["scan_handoff_summary"]) self.assertIn("handoff_stale_policy", summary["scan_handoff_summary"]) self.assertIn("cleanup_candidate_count", summary["scan_handoff_summary"]) + self.assertIn("overdue_cleanup_count", summary["scan_handoff_summary"]) + self.assertIn("next_cleanup_marker", summary["scan_handoff_summary"]) + self.assertIn("next_cleanup_prompt", summary["scan_handoff_summary"]) + self.assertIn("handoff_lane_groups", summary["scan_handoff_summary"]) self.assertIn("handoff_cleanup_recommendation", summary["scan_handoff_summary"]) self.assertTrue(summary["scan_handoff_summary"]["snooze_options"]) self.assertIn("snooze_seconds", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("lane_group", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("overdue_cleanup_candidate", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("cleanup_priority", summary["scan_handoff_summary"]["handoff_markers"][0]) self.assertTrue(summary["external_context_summary"]["operator_questions"]) self.assertTrue(summary["external_context_summary"]["source_cards"]) self.assertEqual(summary["external_context_summary"]["exchange_status"]["status"], "active")