diff --git a/SESSION.md b/SESSION.md index 4a8aa76..06dc707 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 H16/F17/G16/D18 by adding handoff resume persistence / resolved-lane state, digest proof follow-up resolution history / stale queue aging, template preference recovery / guardrail audit trails, and setup-family promotion decision history / review-lane aging. 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 H17/F18/G17/D19 by adding handoff resume history / stale-persistence cleanup, digest proof-resolution SLA / repeated-stale suppression, template recovery recommendations / audit filtering, and setup-family review-lane SLA / promotion backlog prioritization. Local validation passed, the current working tree was deployed, and remote smoke confirmed the latest Decision Assistant fields on the live host. ## Current Branch @@ -92,6 +92,10 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_digest_resolution_history_slice: proof_followup_resolution_history_queue_aging - latest_decision_assistant_template_recovery_slice: preference_recovery_guardrail_audit - latest_decision_assistant_replay_review_lane_slice: promotion_decision_history_review_lane_aging +- latest_decision_assistant_handoff_resume_history_slice: resume_history_stale_persistence_cleanup +- latest_decision_assistant_digest_proof_sla_slice: proof_resolution_sla_repeated_stale_suppression +- latest_decision_assistant_template_recovery_recommendation_slice: recovery_recommendations_audit_filtering +- latest_decision_assistant_replay_backlog_slice: review_lane_sla_promotion_backlog_priority - latest_decision_assistant_summary_status: implemented - latest_decision_assistant_change_summary_status: implemented - latest_decision_assistant_evidence_status: implemented @@ -168,10 +172,14 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_digest_resolution_history_status: implemented - latest_decision_assistant_template_recovery_status: implemented - latest_decision_assistant_replay_review_lane_status: implemented +- latest_decision_assistant_handoff_resume_history_status: implemented +- latest_decision_assistant_digest_proof_sla_status: implemented +- latest_decision_assistant_template_recovery_recommendation_status: implemented +- latest_decision_assistant_replay_backlog_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: resume_persistence_proof_resolution_template_recovery_review_lane_visible +- latest_remote_decision_assistant_review_packet_status: resume_history_proof_sla_template_filter_review_backlog_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 @@ -250,6 +258,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. ## Completed In This Run +- Added handoff resume-history / stale-persistence cleanup fields (`resume_history`, `resume_history_count`, `stale_persistence_cleanup_queue`, `stale_persistence_cleanup_count`, and `next_stale_persistence_cleanup_prompt`) plus cockpit visibility. +- Added digest proof-resolution SLA / repeated-stale suppression fields (`proof_resolution_sla_status`, `proof_resolution_sla_seconds`, `proof_resolution_sla_queue`, `proof_resolution_sla_breach_count`, and `repeated_stale_proof_suppression_count`). +- Added template recovery recommendation / audit-filter fields (`preference_recovery_recommendation`, `preference_recovery_recommendations`, `guardrail_audit_filter`, `guardrail_audit_filter_counts`, and `guardrail_audit_filtered_trail`). +- Added setup-family review-lane SLA / promotion backlog fields (`review_lane_sla_seconds`, `review_lane_sla_status`, `promotion_backlog_priority`, `promotion_backlog_rank`, and `promotion_backlog_item`). +- Passed focused validation: `unittest tests.unit.test_operator_workspace_service`, `node --check src/web/app.js`, `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), and `scripts/run_script_static_checks.py`. +- 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 resume history, proof SLA, recovery audit filtering, and review-lane backlog fields. - Added handoff resume persistence / resolved-lane fields (`resume_persistence_key`, `resume_persistence_status`, `resolved_lane_state`, `resolved_lane_prompt`, `resume_queue_persistence_status`, and `resolved_lane_state_counts`) plus cockpit visibility. - Added digest proof follow-up resolution history / stale queue aging (`proof_follow_up_age_band`, `proof_follow_up_stale_queue`, `proof_resolution_history`, `stale_proof_follow_up_count`, and `proof_follow_up_age_counts`). - Added template preference recovery / guardrail audit fields (`preference_recovery_status`, `preference_recovery_prompt`, `guardrail_audit_trail`, `preference_recovery_queue`, and `guardrail_audit_trail_count`). @@ -546,11 +560,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - P24 remote ops smoke passed on `http://192.168.3.90:8000` with 9 passed checks, warnings=[], blockers=[]. - PXX final validation passed: 709 unit tests, 73 script static checks, `node --check src/web/app.js`, P24 remote ops smoke passed, and closure gate passed. - Latest H16/F17/G16/D18 validation passed: 713 unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote field smoke returned `status=passed`. +- Latest H17/F18/G17/D19 validation passed: 713 unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote field smoke returned `status=passed`. - Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter. ## Exact Next Step -Continue the Decision Assistant dense operator workspace with the next bounded slice: H17 handoff resume history / stale persistence cleanup, F18 digest proof resolution SLA / repeated-stale proof suppression, G17 template recovery recommendations / audit filtering, and D19 setup-family review-lane SLA / promotion backlog prioritization. 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: H18 handoff cleanup action readiness / resume backlog grouping, F19 digest proof SLA backlog routing / freshness-debt summary, G18 template recovery action shortcuts / audit lane grouping, and D20 setup-family promotion evidence queue / backlog trend handoff. PXX remains closed; do not create P25/P26 automatically, and do not enable real trading. ## Validation Commands Used Recently diff --git a/TASKS.md b/TASKS.md index 11f4fe2..0801da2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -92,6 +92,10 @@ - [x] Decision Assistant F17 digest proof follow-up resolution history / stale queue aging slice complete - [x] Decision Assistant G16 template preference recovery / guardrail audit trail slice complete - [x] Decision Assistant D18 setup-family promotion decision history / review lane aging slice complete +- [x] Decision Assistant H17 handoff resume history / stale persistence cleanup slice complete +- [x] Decision Assistant F18 digest proof resolution SLA / repeated-stale proof suppression slice complete +- [x] Decision Assistant G17 template recovery recommendations / audit filtering slice complete +- [x] Decision Assistant D19 setup-family review-lane SLA / promotion backlog prioritization slice complete ## Next Up @@ -115,10 +119,14 @@ - [x] F17 deepen digest recall with proof follow-up resolution history / stale queue aging - [x] G16 continue review template ergonomics with template preference recovery / guardrail audit trail - [x] D18 extend replay comparison with setup-family promotion decision history / review lane aging -- [ ] H17 continue compact operator workspace polish with handoff resume history / stale persistence cleanup -- [ ] F18 deepen digest recall with proof resolution SLA / repeated-stale proof suppression -- [ ] G17 continue review template ergonomics with template recovery recommendations / audit filtering -- [ ] D19 extend replay comparison with setup-family review-lane SLA / promotion backlog prioritization +- [x] H17 continue compact operator workspace polish with handoff resume history / stale persistence cleanup +- [x] F18 deepen digest recall with proof resolution SLA / repeated-stale proof suppression +- [x] G17 continue review template ergonomics with template recovery recommendations / audit filtering +- [x] D19 extend replay comparison with setup-family review-lane SLA / promotion backlog prioritization +- [ ] H18 continue compact operator workspace polish with handoff cleanup action readiness / resume backlog grouping +- [ ] F19 deepen digest recall with proof SLA backlog routing / freshness-debt summary +- [ ] G18 continue review template ergonomics with template recovery action shortcuts / audit lane grouping +- [ ] D20 extend replay comparison with setup-family promotion evidence queue / backlog trend handoff - [x] P8 local closure complete - [x] P9 local closure complete - [x] P10 local closure complete @@ -218,6 +226,10 @@ - [x] latest_decision_assistant_digest_resolution_history_slice: proof_followup_resolution_history_queue_aging - [x] latest_decision_assistant_template_recovery_slice: preference_recovery_guardrail_audit - [x] latest_decision_assistant_replay_review_lane_slice: promotion_decision_history_review_lane_aging +- [x] latest_decision_assistant_handoff_resume_history_slice: resume_history_stale_persistence_cleanup +- [x] latest_decision_assistant_digest_proof_sla_slice: proof_resolution_sla_repeated_stale_suppression +- [x] latest_decision_assistant_template_recovery_recommendation_slice: recovery_recommendations_audit_filtering +- [x] latest_decision_assistant_replay_backlog_slice: review_lane_sla_promotion_backlog_priority - [x] latest_decision_assistant_summary_status: implemented - [x] latest_decision_assistant_change_summary_status: implemented - [x] latest_decision_assistant_evidence_status: implemented @@ -291,10 +303,14 @@ - [x] latest_decision_assistant_digest_resolution_history_status: implemented - [x] latest_decision_assistant_template_recovery_status: implemented - [x] latest_decision_assistant_replay_review_lane_status: implemented +- [x] latest_decision_assistant_handoff_resume_history_status: implemented +- [x] latest_decision_assistant_digest_proof_sla_status: implemented +- [x] latest_decision_assistant_template_recovery_recommendation_status: implemented +- [x] latest_decision_assistant_replay_backlog_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: resume_persistence_proof_resolution_template_recovery_review_lane_visible +- [x] latest_remote_decision_assistant_review_packet_status: resume_history_proof_sla_template_filter_review_backlog_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 diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index 047edef..71df7ab 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -1094,6 +1094,11 @@ def _build_decision_assistant_review_entrypoints( item["preference_recovery_prompt"] = outcome.get("preference_recovery_prompt") item["guardrail_audit_trail"] = outcome.get("guardrail_audit_trail") or [] item["guardrail_audit_trail_count"] = outcome.get("guardrail_audit_trail_count") or 0 + item["preference_recovery_recommendation"] = outcome.get("preference_recovery_recommendation") + item["guardrail_audit_filter"] = outcome.get("guardrail_audit_filter") + item["guardrail_audit_filter_reason"] = outcome.get("guardrail_audit_filter_reason") + item["guardrail_audit_filtered_trail"] = outcome.get("guardrail_audit_filtered_trail") or [] + item["guardrail_audit_filtered_count"] = outcome.get("guardrail_audit_filtered_count") or 0 return { "status": "active", "note_count": len(recent_notes), @@ -1415,6 +1420,15 @@ def _score_template_outcome(*, template_id: str, usage: dict, notes: list[dict]) feedback_trend_history=feedback_trend_history, negative_count=negative_count, ) + guardrail_audit_filter = _template_guardrail_audit_filter( + negative_count=negative_count, + positive_count=positive_count, + actionable_count=action_count, + ) + guardrail_audit_filtered_trail = _template_guardrail_audit_filtered_trail( + guardrail_audit_trail=guardrail_audit_trail, + guardrail_audit_filter=guardrail_audit_filter, + ) return { "template_id": template_id, "reuse_count": reuse_count, @@ -1456,6 +1470,17 @@ def _score_template_outcome(*, template_id: str, usage: dict, notes: list[dict]) ), "guardrail_audit_trail": guardrail_audit_trail[:5], "guardrail_audit_trail_count": len(guardrail_audit_trail), + "preference_recovery_recommendation": _template_preference_recovery_recommendation( + template_id=template_id, + preference_recovery_status=preference_recovery_status, + negative_count=negative_count, + positive_count=positive_count, + actionable_count=action_count, + ), + "guardrail_audit_filter": guardrail_audit_filter, + "guardrail_audit_filter_reason": _template_guardrail_audit_filter_reason(guardrail_audit_filter), + "guardrail_audit_filtered_trail": guardrail_audit_filtered_trail[:5], + "guardrail_audit_filtered_count": len(guardrail_audit_filtered_trail), } @@ -1557,6 +1582,74 @@ def _template_guardrail_audit_trail(*, template_id: str, feedback_trend_history: return trail +def _template_preference_recovery_recommendation( + *, + template_id: str, + preference_recovery_status: str, + negative_count: int, + positive_count: int, + actionable_count: int, +) -> dict: + template_label = template_id.replace("_", " ") or "template" + if preference_recovery_status == "recovery_required": + action = "collect_recovery_note" + priority = "high" + reason = f"{negative_count} misleading feedback signal(s) still need recovery evidence." + elif preference_recovery_status == "recovery_in_progress": + action = "review_recovery_evidence" + priority = "medium" + reason = f"{positive_count + actionable_count} useful/actionable signal(s) may offset the guardrail." + elif preference_recovery_status == "awaiting_feedback": + action = "capture_first_outcome" + priority = "low" + reason = "No preference evidence has been captured yet." + else: + action = "keep_preferred_candidate" + priority = "low" + reason = "Preference evidence is stable." + return { + "template_id": template_id, + "action": action, + "priority": priority, + "reason": reason, + "prompt": f"{template_label}: {reason}", + } + + +def _template_guardrail_audit_filter(*, negative_count: int, positive_count: int, actionable_count: int) -> str: + if negative_count: + return "negative_guardrail_only" + if positive_count or actionable_count: + return "recovery_evidence" + return "awaiting_feedback" + + +def _template_guardrail_audit_filter_reason(guardrail_audit_filter: str) -> str: + if guardrail_audit_filter == "negative_guardrail_only": + return "show unresolved or misleading feedback before reuse" + if guardrail_audit_filter == "recovery_evidence": + return "show useful/actionable evidence supporting stable preference" + return "show templates that still need first outcome feedback" + + +def _template_guardrail_audit_filtered_trail( + *, + guardrail_audit_trail: list[dict], + guardrail_audit_filter: str, +) -> list[dict]: + if guardrail_audit_filter == "negative_guardrail_only": + filtered = [item for item in guardrail_audit_trail if item.get("event") == "negative_guardrail"] + return filtered or guardrail_audit_trail[:1] + if guardrail_audit_filter == "recovery_evidence": + filtered = [ + item + for item in guardrail_audit_trail + if item.get("signal") in {"positive_actionable", "positive", "actionable"} + ] + return filtered or guardrail_audit_trail[:2] + return guardrail_audit_trail[:2] + + 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": @@ -1654,6 +1747,20 @@ def _build_template_feedback_aggregation( for item in template_outcomes.values() for row in list(item.get("guardrail_audit_trail") or []) ] + guardrail_audit_filtered_trail = [ + row + for item in template_outcomes.values() + for row in list(item.get("guardrail_audit_filtered_trail") or []) + ] + preference_recovery_recommendations = [ + dict(item.get("preference_recovery_recommendation") or {}) + for item in template_outcomes.values() + if dict(item.get("preference_recovery_recommendation") or {}).get("action") != "keep_preferred_candidate" + ] + guardrail_audit_filter_counts = {} + for item in template_outcomes.values(): + audit_filter = str(item.get("guardrail_audit_filter") or "unknown") + guardrail_audit_filter_counts[audit_filter] = int(guardrail_audit_filter_counts.get(audit_filter) or 0) + 1 rated_template_count = sum( 1 for item in template_outcomes.values() @@ -1717,6 +1824,17 @@ def _build_template_feedback_aggregation( ), "guardrail_audit_trail": guardrail_audit_trail[:5], "guardrail_audit_trail_count": len(guardrail_audit_trail), + "preference_recovery_recommendations": preference_recovery_recommendations[:5], + "preference_recovery_recommendation_count": len(preference_recovery_recommendations), + "next_preference_recovery_action": ( + preference_recovery_recommendations[0].get("action") if preference_recovery_recommendations else None + ), + "next_preference_recovery_prompt": ( + preference_recovery_recommendations[0].get("prompt") if preference_recovery_recommendations else None + ), + "guardrail_audit_filter_counts": guardrail_audit_filter_counts, + "guardrail_audit_filtered_trail": guardrail_audit_filtered_trail[:5], + "guardrail_audit_filtered_count": len(guardrail_audit_filtered_trail), } @@ -1963,6 +2081,10 @@ def _build_decision_assistant_digest_history_summary(history: list[dict]) -> dic "age_seconds": group.get("proof_follow_up_age_seconds"), "age_band": group.get("proof_follow_up_age_band"), "stale_queue": group.get("proof_follow_up_stale_queue"), + "sla_status": group.get("proof_resolution_sla_status"), + "sla_seconds": group.get("proof_resolution_sla_seconds"), + "sla_prompt": group.get("proof_resolution_sla_prompt"), + "repeated_stale_suppression": group.get("repeated_stale_proof_suppression"), } for group in groups if group.get("proof_follow_up_state") in {"follow_up_required", "watch"} @@ -1981,6 +2103,27 @@ def _build_decision_assistant_digest_history_summary(history: list[dict]) -> dic for group in groups for row in list(group.get("proof_resolution_history") or []) ] + proof_resolution_sla_status_counts = {} + for group in groups: + sla_status = str(group.get("proof_resolution_sla_status") or "unknown") + proof_resolution_sla_status_counts[sla_status] = int(proof_resolution_sla_status_counts.get(sla_status) or 0) + 1 + proof_resolution_sla_queue = [ + { + "kind": str(group.get("kind") or "history"), + "sla_status": group.get("proof_resolution_sla_status"), + "sla_seconds": group.get("proof_resolution_sla_seconds"), + "prompt": group.get("proof_resolution_sla_prompt"), + "age_seconds": group.get("proof_follow_up_age_seconds"), + "suppression": group.get("repeated_stale_proof_suppression"), + } + for group in groups + if group.get("proof_resolution_sla_status") in {"breached", "due_soon", "missing_timestamp_sla"} + ] + repeated_stale_suppression_count = sum( + 1 + for group in groups + if group.get("repeated_stale_proof_suppression") == "suppress_repeated_stale_prompt" + ) proof_follow_up_with_age = [ group for group in groups @@ -2045,6 +2188,13 @@ def _build_decision_assistant_digest_history_summary(history: list[dict]) -> dic "proof_resolution_history_count": len(proof_resolution_history), "oldest_proof_follow_up_kind": oldest_proof_follow_up.get("kind"), "oldest_proof_follow_up_age_seconds": oldest_proof_follow_up.get("proof_follow_up_age_seconds"), + "proof_resolution_sla_status_counts": proof_resolution_sla_status_counts, + "proof_resolution_sla_breach_count": int(proof_resolution_sla_status_counts.get("breached") or 0), + "proof_resolution_sla_queue": proof_resolution_sla_queue[:5], + "proof_resolution_sla_queue_count": len(proof_resolution_sla_queue), + "repeated_stale_proof_suppression_count": repeated_stale_suppression_count, + "next_proof_resolution_sla_kind": proof_resolution_sla_queue[0].get("kind") if proof_resolution_sla_queue else None, + "next_proof_resolution_sla_prompt": proof_resolution_sla_queue[0].get("prompt") if proof_resolution_sla_queue else None, } @@ -2097,6 +2247,19 @@ def _build_decision_assistant_digest_history_groups(history: list[dict], *, reco proof_resolved_state=proof_resolved_state, refresh_confirmation_trail=refresh_confirmation_trail, ) + proof_resolution_sla_seconds = _digest_history_proof_resolution_sla_seconds( + proof_follow_up_state=proof_follow_up_state, + ) + proof_resolution_sla_status = _digest_history_proof_resolution_sla_status( + age=age, + proof_follow_up_state=proof_follow_up_state, + proof_resolution_sla_seconds=proof_resolution_sla_seconds, + ) + repeated_stale_proof_suppression = _digest_history_repeated_stale_proof_suppression( + item_count=len(items), + proof_follow_up_stale_queue=proof_follow_up_stale_queue, + proof_resolution_sla_status=proof_resolution_sla_status, + ) groups.append( { "kind": kind, @@ -2147,6 +2310,14 @@ def _build_decision_assistant_digest_history_groups(history: list[dict], *, reco "proof_follow_up_age_band": proof_follow_up_age_band, "proof_follow_up_stale_queue": proof_follow_up_stale_queue, "proof_resolution_history": proof_resolution_history, + "proof_resolution_sla_seconds": proof_resolution_sla_seconds, + "proof_resolution_sla_status": proof_resolution_sla_status, + "proof_resolution_sla_prompt": _digest_history_proof_resolution_sla_prompt( + kind=kind, + proof_resolution_sla_status=proof_resolution_sla_status, + proof_resolution_sla_seconds=proof_resolution_sla_seconds, + ), + "repeated_stale_proof_suppression": repeated_stale_proof_suppression, "refresh_confirmation_trail": refresh_confirmation_trail, "latest_confirmation_trail_item": refresh_confirmation_trail[0] if refresh_confirmation_trail else None, "items": items[:3], @@ -2432,6 +2603,64 @@ def _digest_history_proof_resolution_history( return history +def _digest_history_proof_resolution_sla_seconds(*, proof_follow_up_state: str) -> int: + if proof_follow_up_state == "follow_up_required": + return 3600 + if proof_follow_up_state == "watch": + return 7200 + return 0 + + +def _digest_history_proof_resolution_sla_status( + *, + age: dict, + proof_follow_up_state: str, + proof_resolution_sla_seconds: int, +) -> str: + if proof_follow_up_state == "resolved": + return "resolved" + age_seconds = age.get("age_seconds") + if age_seconds is None: + return "missing_timestamp_sla" + if proof_resolution_sla_seconds <= 0: + return "resolved" + if int(age_seconds or 0) >= proof_resolution_sla_seconds: + return "breached" + if int(age_seconds or 0) >= max(1, int(proof_resolution_sla_seconds * 0.75)): + return "due_soon" + return "within_sla" + + +def _digest_history_proof_resolution_sla_prompt( + *, + kind: str, + proof_resolution_sla_status: str, + proof_resolution_sla_seconds: int, +) -> str: + if proof_resolution_sla_status == "breached": + return f"Resolve or refresh {kind} proof now; it has exceeded the {proof_resolution_sla_seconds}s proof SLA." + if proof_resolution_sla_status == "due_soon": + return f"Resolve {kind} proof before the {proof_resolution_sla_seconds}s SLA expires." + if proof_resolution_sla_status == "missing_timestamp_sla": + return f"Add timestamp proof for {kind} so the SLA can be measured." + if proof_resolution_sla_status == "within_sla": + return f"{kind} proof is inside SLA but still needs follow-up." + return f"{kind} proof is resolved; no proof SLA action is pending." + + +def _digest_history_repeated_stale_proof_suppression( + *, + item_count: int, + proof_follow_up_stale_queue: bool, + proof_resolution_sla_status: str, +) -> str: + if proof_follow_up_stale_queue and item_count > 1 and proof_resolution_sla_status in {"breached", "missing_timestamp_sla"}: + return "suppress_repeated_stale_prompt" + if proof_follow_up_stale_queue: + return "allow_single_stale_prompt" + return "not_stale" + + 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") @@ -2756,6 +2985,27 @@ def _build_decision_assistant_scan_handoff_summary(cards: list[dict]) -> dict: title=str(item.get("title") or item.get("target_section") or "handoff"), handoff_status=str(item.get("handoff_status") or ""), ), + "resume_history": _scan_handoff_resume_history( + title=str(item.get("title") or item.get("target_section") or "handoff"), + handoff_status=str(item.get("handoff_status") or ""), + persistence_key=str(item.get("handoff_persistence_key") or item.get("handoff_marker") or ""), + stale_after_seconds=int(item.get("handoff_stale_after_seconds") or 0), + ), + "resume_history_count": len( + _scan_handoff_resume_history( + title=str(item.get("title") or item.get("target_section") or "handoff"), + handoff_status=str(item.get("handoff_status") or ""), + persistence_key=str(item.get("handoff_persistence_key") or item.get("handoff_marker") or ""), + stale_after_seconds=int(item.get("handoff_stale_after_seconds") or 0), + ) + ), + "stale_persistence_cleanup_status": _scan_handoff_stale_persistence_cleanup_status( + str(item.get("handoff_status") or "") + ), + "stale_persistence_cleanup_prompt": _scan_handoff_stale_persistence_cleanup_prompt( + title=str(item.get("title") or item.get("target_section") or "handoff"), + handoff_status=str(item.get("handoff_status") or ""), + ), } for item in cards if item.get("handoff_marker") @@ -2769,6 +3019,12 @@ def _build_decision_assistant_scan_handoff_summary(cards: list[dict]) -> dict: next_cleanup_marker = dict(cleanup_markers[0] or {}) if cleanup_markers else {} operator_resume_queue = _build_scan_handoff_resume_queue(markers) resolved_lane_state_counts = _build_scan_handoff_resolved_lane_state_counts(markers) + resume_history = [ + row + for item in markers + for row in list(item.get("resume_history") or []) + ] + stale_persistence_cleanup_queue = _build_scan_handoff_stale_persistence_cleanup_queue(markers) next_unresolved_marker = next( ( item @@ -2817,6 +3073,17 @@ def _build_decision_assistant_scan_handoff_summary(cards: list[dict]) -> dict: if state not in {"resolved_ready", "resolved_carry_forward"} ), "next_resolved_lane_prompt": next_unresolved_marker.get("resolved_lane_prompt"), + "resume_history": resume_history[:8], + "resume_history_count": len(resume_history), + "resume_history_latest_event": resume_history[0].get("event") if resume_history else None, + "stale_persistence_cleanup_queue": stale_persistence_cleanup_queue[:5], + "stale_persistence_cleanup_count": len(stale_persistence_cleanup_queue), + "next_stale_persistence_cleanup_key": ( + stale_persistence_cleanup_queue[0].get("resume_persistence_key") if stale_persistence_cleanup_queue else None + ), + "next_stale_persistence_cleanup_prompt": ( + stale_persistence_cleanup_queue[0].get("stale_persistence_cleanup_prompt") if stale_persistence_cleanup_queue else None + ), "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": [ @@ -2940,6 +3207,100 @@ def _build_scan_handoff_resolved_lane_state_counts(markers: list[dict]) -> dict: return counts +def _scan_handoff_resume_history( + *, + title: str, + handoff_status: str, + persistence_key: str, + stale_after_seconds: int, +) -> list[dict]: + key = str(persistence_key or title or "handoff").strip() or "handoff" + history = [ + { + "event": "handoff_marker_seen", + "title": title, + "status": handoff_status or "ready_handoff", + "persistence_key": key, + }, + { + "event": "resume_state_persisted", + "title": title, + "status": _scan_handoff_resume_persistence_status(handoff_status), + "persistence_key": _scan_handoff_resume_persistence_key( + persistence_key=key, + handoff_marker=key, + ), + }, + { + "event": "resolved_lane_checked", + "title": title, + "status": _scan_handoff_resolved_lane_state(handoff_status), + "persistence_key": key, + }, + ] + cleanup_status = _scan_handoff_stale_persistence_cleanup_status(handoff_status) + if cleanup_status in {"cleanup_required", "cleanup_after_resume"}: + history.append( + { + "event": "stale_persistence_cleanup_candidate", + "title": title, + "status": cleanup_status, + "persistence_key": key, + "stale_after_seconds": stale_after_seconds or 3600, + } + ) + return history + + +def _scan_handoff_stale_persistence_cleanup_status(handoff_status: str) -> str: + if handoff_status in {"blocked_handoff", "refresh_handoff"}: + return "cleanup_required" + if handoff_status == "ready_handoff": + return "cleanup_after_resume" + if handoff_status == "next_handoff": + return "monitor_stale_window" + return "no_cleanup_needed" + + +def _scan_handoff_stale_persistence_cleanup_prompt(*, title: str, handoff_status: str) -> str: + cleanup_status = _scan_handoff_stale_persistence_cleanup_status(handoff_status) + if cleanup_status == "cleanup_required": + return f"Clean or refresh stale persisted context for {title} before reusing this handoff." + if cleanup_status == "cleanup_after_resume": + return f"Clear the persisted {title} resume state after the lane is reopened." + if cleanup_status == "monitor_stale_window": + return f"Keep {title} persistence active, then clean it if the stale window expires." + return f"{title} does not need stale persistence cleanup." + + +def _build_scan_handoff_stale_persistence_cleanup_queue(markers: list[dict]) -> list[dict]: + cleanup_status_rank = { + "cleanup_required": 1, + "cleanup_after_resume": 2, + "monitor_stale_window": 3, + } + queue = [ + { + "handoff_marker": item.get("handoff_marker"), + "title": item.get("title"), + "resume_persistence_key": item.get("resume_persistence_key"), + "stale_persistence_cleanup_status": item.get("stale_persistence_cleanup_status"), + "stale_persistence_cleanup_prompt": item.get("stale_persistence_cleanup_prompt"), + "resume_queue_rank": item.get("resume_queue_rank"), + } + for item in markers + if item.get("stale_persistence_cleanup_status") in cleanup_status_rank + ] + return sorted( + queue, + key=lambda row: ( + cleanup_status_rank.get(str(row.get("stale_persistence_cleanup_status") or ""), 99), + int(row.get("resume_queue_rank") or 99), + str(row.get("title") or ""), + ), + ) + + def _build_scan_handoff_resume_queue(markers: list[dict]) -> list[dict]: return sorted( [ @@ -2956,6 +3317,10 @@ def _build_scan_handoff_resume_queue(markers: list[dict]) -> list[dict]: "resume_persistence_status": item.get("resume_persistence_status"), "resolved_lane_state": item.get("resolved_lane_state"), "resolved_lane_prompt": item.get("resolved_lane_prompt"), + "resume_history": item.get("resume_history") or [], + "resume_history_count": item.get("resume_history_count") or 0, + "stale_persistence_cleanup_status": item.get("stale_persistence_cleanup_status"), + "stale_persistence_cleanup_prompt": item.get("stale_persistence_cleanup_prompt"), } for item in markers if item.get("lifecycle_state") != "carry_forward" @@ -3314,6 +3679,18 @@ def _build_setup_replay_broader_baseline_summary( "review_lane_age_status": "aging_collect_samples", "review_lane_age_score": 30, "review_lane_aging_hint": f"{setup_family} review lane is aging until enough replay samples exist.", + "review_lane_sla_seconds": 86400, + "review_lane_sla_status": "collecting_samples", + "promotion_backlog_priority": "low", + "promotion_backlog_rank": 5, + "promotion_backlog_reason": "insufficient_samples", + "promotion_backlog_item": { + "setup_family": setup_family, + "priority": "low", + "rank": 5, + "reason": "insufficient_samples", + "action": "Collect replay samples before promotion backlog review.", + }, "demotion_history": [], "demotion_history_count": 0, "focus_recommendation": f"Collect samples before changing focus for {setup_family}.", @@ -3535,6 +3912,18 @@ def _build_setup_family_trend_delta( "review_lane_age_status": "aging_collect_samples", "review_lane_age_score": 30, "review_lane_aging_hint": f"{setup_family} review lane is aging until enough replay samples exist.", + "review_lane_sla_seconds": 86400, + "review_lane_sla_status": "collecting_samples", + "promotion_backlog_priority": "low", + "promotion_backlog_rank": 5, + "promotion_backlog_reason": "insufficient_samples", + "promotion_backlog_item": { + "setup_family": setup_family, + "priority": "low", + "rank": 5, + "reason": "insufficient_samples", + "action": "Collect replay samples before promotion backlog review.", + }, "demotion_history": [], "demotion_history_count": 0, "focus_recommendation": f"Collect samples before changing focus for {setup_family}.", @@ -3650,6 +4039,26 @@ def _build_setup_family_trend_delta( sample_gap=sample_gap, drift_alert=drift_alert, ) + review_lane_sla_seconds = _setup_family_review_lane_sla_seconds( + review_lane_age_status=review_lane_age_status, + ) + review_lane_sla_status = _setup_family_review_lane_sla_status( + review_lane_age_status=review_lane_age_status, + review_lane_age_score=review_lane_age_score, + ) + promotion_backlog_priority = _setup_family_promotion_backlog_priority( + promotion_queue_status=promotion_queue_status, + review_lane_age_score=review_lane_age_score, + promotion_ready=promotion_ready, + ) + promotion_backlog_reason = _setup_family_promotion_backlog_reason( + promotion_queue_status=promotion_queue_status, + promotion_blockers=promotion_blockers, + review_lane_sla_status=review_lane_sla_status, + ) + promotion_backlog_rank = _setup_family_promotion_backlog_rank( + promotion_backlog_priority=promotion_backlog_priority, + ) return { "status": "active", "trend_status": trend_status, @@ -3688,6 +4097,18 @@ def _build_setup_family_trend_delta( setup_family=setup_family, review_lane_age_status=review_lane_age_status, ), + "review_lane_sla_seconds": review_lane_sla_seconds, + "review_lane_sla_status": review_lane_sla_status, + "promotion_backlog_priority": promotion_backlog_priority, + "promotion_backlog_rank": promotion_backlog_rank, + "promotion_backlog_reason": promotion_backlog_reason, + "promotion_backlog_item": _setup_family_promotion_backlog_item( + setup_family=setup_family, + promotion_backlog_priority=promotion_backlog_priority, + promotion_backlog_rank=promotion_backlog_rank, + promotion_backlog_reason=promotion_backlog_reason, + promotion_next_action=promotion_next_action, + ), "demotion_history": demotion_history[:5], "demotion_history_count": len(demotion_history), "focus_recommendation": focus_recommendation, @@ -3903,6 +4324,84 @@ def _setup_family_review_lane_aging_hint(*, setup_family: str, review_lane_age_s return f"{setup_family} remains in watch mode while review-lane evidence accumulates." +def _setup_family_review_lane_sla_seconds(*, review_lane_age_status: str) -> int: + if review_lane_age_status == "ready_now": + return 3600 + if review_lane_age_status == "stale_demotion_review": + return 1800 + if review_lane_age_status == "aging_watch": + return 14400 + if review_lane_age_status == "aging_collect_samples": + return 86400 + return 28800 + + +def _setup_family_review_lane_sla_status(*, review_lane_age_status: str, review_lane_age_score: int) -> str: + if review_lane_age_status == "stale_demotion_review": + return "breached" + if review_lane_age_status == "ready_now": + return "due_now" + if review_lane_age_status == "aging_collect_samples": + return "collecting_samples" + if int(review_lane_age_score or 0) >= 70: + return "due_soon" + return "within_sla" + + +def _setup_family_promotion_backlog_priority( + *, + promotion_queue_status: str, + review_lane_age_score: int, + promotion_ready: bool, +) -> str: + if promotion_queue_status == "demotion_review": + return "urgent" + if promotion_ready or promotion_queue_status == "ready_for_operator_review": + return "high" + if int(review_lane_age_score or 0) >= 60: + return "medium" + return "low" + + +def _setup_family_promotion_backlog_rank(*, promotion_backlog_priority: str) -> int: + return { + "urgent": 1, + "high": 2, + "medium": 3, + "low": 4, + }.get(str(promotion_backlog_priority or "low"), 4) + + +def _setup_family_promotion_backlog_reason( + *, + promotion_queue_status: str, + promotion_blockers: list[str], + review_lane_sla_status: str, +) -> str: + if review_lane_sla_status in {"breached", "due_now", "due_soon"}: + return review_lane_sla_status + if promotion_blockers: + return str(promotion_blockers[0]) + return str(promotion_queue_status or "watch_lane") + + +def _setup_family_promotion_backlog_item( + *, + setup_family: str, + promotion_backlog_priority: str, + promotion_backlog_rank: int, + promotion_backlog_reason: str, + promotion_next_action: str, +) -> dict: + return { + "setup_family": setup_family, + "priority": promotion_backlog_priority, + "rank": promotion_backlog_rank, + "reason": promotion_backlog_reason, + "action": promotion_next_action, + } + + 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 fd81250..40c44a4 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -582,6 +582,11 @@ function renderDecisionAssistant(summary) { ['resolved lanes', String(scanHandoffSummary.resolved_lane_count ?? 0)], ['unresolved lanes', String(scanHandoffSummary.unresolved_lane_count ?? 0)], ['next resolved lane prompt', scanHandoffSummary.next_resolved_lane_prompt || 'n/a'], + ['resume history', String(scanHandoffSummary.resume_history_count ?? 0)], + ['resume latest event', scanHandoffSummary.resume_history_latest_event || 'n/a'], + ['stale persistence cleanup', String(scanHandoffSummary.stale_persistence_cleanup_count ?? 0)], + ['next persistence cleanup', scanHandoffSummary.next_stale_persistence_cleanup_key || 'n/a'], + ['persistence cleanup prompt', scanHandoffSummary.next_stale_persistence_cleanup_prompt || 'n/a'], ['next resume action', scanHandoffSummary.next_resume_action_label || 'n/a'], ['next resume prompt', scanHandoffSummary.next_resume_prompt || 'n/a'], ['handoff cleanup recommendation', scanHandoffSummary.handoff_cleanup_recommendation || 'n/a'], @@ -718,6 +723,12 @@ function renderDecisionAssistant(summary) { ['resolved proofs', String(digestSurface.history_summary?.resolved_proof_count ?? 0)], ['proof resolution states', formatJsonInline(digestSurface.history_summary?.proof_resolution_state_counts || {})], ['proof resolution history', formatList((digestSurface.history_summary?.proof_resolution_history || []).map((item) => `${item.kind}:${item.state}`))], + ['proof SLA states', formatJsonInline(digestSurface.history_summary?.proof_resolution_sla_status_counts || {})], + ['proof SLA breaches', String(digestSurface.history_summary?.proof_resolution_sla_breach_count ?? 0)], + ['proof SLA queue', String(digestSurface.history_summary?.proof_resolution_sla_queue_count ?? 0)], + ['stale proof suppressions', String(digestSurface.history_summary?.repeated_stale_proof_suppression_count ?? 0)], + ['next proof SLA kind', digestSurface.history_summary?.next_proof_resolution_sla_kind || 'n/a'], + ['next proof SLA prompt', digestSurface.history_summary?.next_proof_resolution_sla_prompt || 'n/a'], ['next proof follow-up', digestSurface.history_summary?.next_proof_follow_up_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}`))], @@ -812,6 +823,10 @@ function renderDecisionAssistant(summary) { ['proof follow-up age', item.proof_follow_up_age_band || 'n/a'], ['proof stale queue', String(Boolean(item.proof_follow_up_stale_queue))], ['proof resolution history', formatList((item.proof_resolution_history || []).map((row) => `${row.proof_label || row.state}: ${formatTimestamp(row.ts)}`))], + ['proof SLA status', item.proof_resolution_sla_status || 'n/a'], + ['proof SLA', formatDurationSeconds(item.proof_resolution_sla_seconds)], + ['proof SLA prompt', item.proof_resolution_sla_prompt || 'n/a'], + ['stale suppression', item.repeated_stale_proof_suppression || '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'], @@ -874,6 +889,11 @@ function renderDecisionAssistant(summary) { ['preference recovery queue', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.preference_recovery_count ?? 0)], ['next preference recovery', reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.next_preference_recovery_template_id || 'n/a'], ['guardrail audit trail', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.guardrail_audit_trail_count ?? 0)], + ['recovery recommendations', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.preference_recovery_recommendation_count ?? 0)], + ['next recovery action', reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.next_preference_recovery_action || 'n/a'], + ['next recovery prompt', reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.next_preference_recovery_prompt || 'n/a'], + ['audit filters', formatJsonInline(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.guardrail_audit_filter_counts || {})], + ['filtered audit trail', String(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.guardrail_audit_filtered_count ?? 0)], ['preferred template decay', reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.preferred_template_decay_status || 'n/a'], ['preferred decay templates', formatList(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.preferred_template_decay_template_ids || [])], ['max preference decay', formatPercent(reviewEntrypoints.template_reuse_metrics?.template_feedback_aggregation?.preferred_template_decay_max)], @@ -938,7 +958,11 @@ function renderDecisionAssistant(summary) { ['decay reason', item.preferred_template_decay_reason || 'n/a'], ['preference recovery', item.preference_recovery_status || 'n/a'], ['recovery prompt', item.preference_recovery_prompt || 'n/a'], + ['recovery recommendation', item.preference_recovery_recommendation?.action || 'n/a'], + ['audit filter', item.guardrail_audit_filter || 'n/a'], + ['audit filter reason', item.guardrail_audit_filter_reason || 'n/a'], ['guardrail audit trail', formatList((item.guardrail_audit_trail || []).map((row) => `${row.label || row.signal}: ${row.resolution_status}`))], + ['filtered audit trail', formatList((item.guardrail_audit_filtered_trail || []).map((row) => `${row.label || row.signal}: ${row.resolution_status}`))], ['feedback trend history', formatList((item.feedback_trend_history || []).map((row) => `${row.label || row.signal}: ${formatTimestamp(row.created_ts)}`))], ], [ @@ -1246,6 +1270,11 @@ function renderDecisionAssistant(summary) { ['review lane age', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.review_lane_age_status || 'n/a'], ['review lane age score', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.review_lane_age_score ?? 'n/a')], ['review lane aging hint', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.review_lane_aging_hint || 'n/a'], + ['review lane SLA status', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.review_lane_sla_status || 'n/a'], + ['review lane SLA', formatDurationSeconds(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.review_lane_sla_seconds)], + ['promotion backlog priority', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_backlog_priority || 'n/a'], + ['promotion backlog rank', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_backlog_rank ?? 'n/a')], + ['promotion backlog reason', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.promotion_backlog_reason || 'n/a'], ['demotion history', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.demotion_history_count ?? 0)], ['demotion reasons', formatList((setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.demotion_history || []).map((item) => item.reason || item.event))], ['family sample threshold', String(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.sample_threshold ?? 'n/a')], diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index b9bbb4a..83a4ce1 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -208,6 +208,12 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("next_preference_recovery_template_id", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) self.assertIn("guardrail_audit_trail", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) self.assertIn("guardrail_audit_trail_count", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("preference_recovery_recommendations", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("preference_recovery_recommendation_count", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("next_preference_recovery_action", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("guardrail_audit_filter_counts", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("guardrail_audit_filtered_trail", summary["review_entrypoints"]["template_reuse_metrics"]["template_feedback_aggregation"]) + self.assertIn("guardrail_audit_filtered_count", 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") @@ -225,6 +231,11 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("preference_recovery_prompt", summary["review_entrypoints"]["note_templates"][0]) self.assertIn("guardrail_audit_trail", summary["review_entrypoints"]["note_templates"][0]) self.assertIn("guardrail_audit_trail_count", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("preference_recovery_recommendation", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("guardrail_audit_filter", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("guardrail_audit_filter_reason", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("guardrail_audit_filtered_trail", summary["review_entrypoints"]["note_templates"][0]) + self.assertIn("guardrail_audit_filtered_count", 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"])) @@ -255,6 +266,12 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("proof_resolution_history_count", summary["digest_surface"]["history_summary"]) self.assertIn("oldest_proof_follow_up_kind", summary["digest_surface"]["history_summary"]) self.assertIn("oldest_proof_follow_up_age_seconds", summary["digest_surface"]["history_summary"]) + self.assertIn("proof_resolution_sla_status_counts", summary["digest_surface"]["history_summary"]) + self.assertIn("proof_resolution_sla_breach_count", summary["digest_surface"]["history_summary"]) + self.assertIn("proof_resolution_sla_queue", summary["digest_surface"]["history_summary"]) + self.assertIn("proof_resolution_sla_queue_count", summary["digest_surface"]["history_summary"]) + self.assertIn("repeated_stale_proof_suppression_count", summary["digest_surface"]["history_summary"]) + self.assertIn("next_proof_resolution_sla_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]) @@ -280,6 +297,10 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("proof_follow_up_age_band", summary["digest_history_groups"][0]) self.assertIn("proof_follow_up_stale_queue", summary["digest_history_groups"][0]) self.assertIn("proof_resolution_history", summary["digest_history_groups"][0]) + self.assertIn("proof_resolution_sla_seconds", summary["digest_history_groups"][0]) + self.assertIn("proof_resolution_sla_status", summary["digest_history_groups"][0]) + self.assertIn("proof_resolution_sla_prompt", summary["digest_history_groups"][0]) + self.assertIn("repeated_stale_proof_suppression", 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"]) @@ -328,6 +349,12 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("review_lane_age_status", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("review_lane_age_score", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("review_lane_aging_hint", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("review_lane_sla_seconds", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("review_lane_sla_status", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("promotion_backlog_priority", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("promotion_backlog_rank", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("promotion_backlog_reason", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) + self.assertIn("promotion_backlog_item", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("demotion_history", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("demotion_history_count", 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"]) @@ -373,6 +400,13 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("resolved_lane_count", summary["scan_handoff_summary"]) self.assertIn("unresolved_lane_count", summary["scan_handoff_summary"]) self.assertIn("next_resolved_lane_prompt", summary["scan_handoff_summary"]) + self.assertIn("resume_history", summary["scan_handoff_summary"]) + self.assertIn("resume_history_count", summary["scan_handoff_summary"]) + self.assertIn("resume_history_latest_event", summary["scan_handoff_summary"]) + self.assertIn("stale_persistence_cleanup_queue", summary["scan_handoff_summary"]) + self.assertIn("stale_persistence_cleanup_count", summary["scan_handoff_summary"]) + self.assertIn("next_stale_persistence_cleanup_key", summary["scan_handoff_summary"]) + self.assertIn("next_stale_persistence_cleanup_prompt", 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]) @@ -386,6 +420,10 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("resume_persistence_status", summary["scan_handoff_summary"]["handoff_markers"][0]) self.assertIn("resolved_lane_state", summary["scan_handoff_summary"]["handoff_markers"][0]) self.assertIn("resolved_lane_prompt", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("resume_history", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("resume_history_count", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("stale_persistence_cleanup_status", summary["scan_handoff_summary"]["handoff_markers"][0]) + self.assertIn("stale_persistence_cleanup_prompt", 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")