diff --git a/SESSION.md b/SESSION.md index 4fe49b7..467f255 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 H12/F13/G12/D14 by adding persisted handoff aging/stale warnings, digest refresh confirmations and freshness proof fields, template outcome feedback capture loops, and setup-family drift/focus recommendations. 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 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. ## Current Branch @@ -76,6 +76,10 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_digest_refresh_proof_slice: refresh_confirmation_freshness_proof - latest_decision_assistant_template_feedback_loop_slice: outcome_feedback_capture_loops - latest_decision_assistant_replay_family_drift_slice: setup_family_drift_alerts +- latest_decision_assistant_handoff_cleanup_slice: handoff_lane_cleanup_snooze +- 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_summary_status: implemented - latest_decision_assistant_change_summary_status: implemented - latest_decision_assistant_evidence_status: implemented @@ -136,10 +140,14 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_digest_refresh_proof_status: implemented - latest_decision_assistant_template_feedback_loop_status: implemented - latest_decision_assistant_replay_family_drift_status: implemented +- latest_decision_assistant_handoff_cleanup_status: implemented +- 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_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_aging_refresh_proof_template_feedback_family_drift_visible +- latest_remote_decision_assistant_review_packet_status: handoff_cleanup_confirmation_trail_feedback_tuning_family_watchlist_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 @@ -218,6 +226,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. ## Completed In This Run +- 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. +- Added setup-family drift watchlist/sample-threshold fields (`watchlist_status`, `watchlist_priority`, `sample_threshold`, `sample_gap`, `drift_watchlist_items`) 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`, `node --check src/web/app.js`, and a local field smoke for H13/F14/G13/D15 fields. +- 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 cleanup/snooze, digest confirmation trails, template feedback tuning, setup-family drift watchlist, and matching `/static/app.js` UI strings. - Added persisted handoff aging/stale policy fields (`handoff_stale_after_seconds`, `handoff_stale_warning`, `default_stale_after_seconds`) plus workbench saved-handoff age/status/warning display. - Added digest refresh confirmation and freshness-proof fields (`refresh_confirmation_status`, `freshness_proof_status`, proof counts, and confirmation prompts) so recall groups show whether their evidence is current enough to reuse. - Added review-template feedback capture loops with per-template feedback actions and `feedback_loop_summary`; workbench buttons now save useful/actionable/misleading feedback notes through the existing operator-note route. @@ -493,7 +507,7 @@ 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: H13 handoff lane cleanup/snooze ergonomics, F14 digest refresh proof history trail, G13 template feedback aggregation/recommendation tuning, and D15 setup-family drift watchlist/sample thresholds. 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: 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. ## Validation Commands Used Recently diff --git a/TASKS.md b/TASKS.md index 662c074..42eed59 100644 --- a/TASKS.md +++ b/TASKS.md @@ -76,6 +76,10 @@ - [x] Decision Assistant F13 digest refresh confirmation / freshness proof slice complete - [x] Decision Assistant G12 template outcome feedback capture loop slice complete - [x] Decision Assistant D14 setup-family drift alert / focus recommendation slice complete +- [x] Decision Assistant H13 handoff lane cleanup / snooze ergonomics slice complete +- [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 ## Next Up @@ -83,10 +87,14 @@ - [x] F13 deepen digest recall with refresh confirmation / evidence freshness proof - [x] G12 continue review template ergonomics with outcome feedback capture loops - [x] D14 extend replay comparison with setup-family drift alerts / focus recommendations -- [ ] H13 continue compact operator workspace polish with handoff lane cleanup / snooze ergonomics -- [ ] F14 deepen digest recall with refresh proof history / confirmation trail -- [ ] G13 continue review template ergonomics with feedback aggregation / recommendation tuning -- [ ] D15 extend replay comparison with setup-family drift watchlist / sample thresholds +- [x] H13 continue compact operator workspace polish with handoff lane cleanup / snooze ergonomics +- [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] P8 local closure complete - [x] P9 local closure complete - [x] P10 local closure complete @@ -170,6 +178,10 @@ - [x] latest_decision_assistant_digest_refresh_proof_slice: refresh_confirmation_freshness_proof - [x] latest_decision_assistant_template_feedback_loop_slice: outcome_feedback_capture_loops - [x] latest_decision_assistant_replay_family_drift_slice: setup_family_drift_alerts +- [x] latest_decision_assistant_handoff_cleanup_slice: handoff_lane_cleanup_snooze +- [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_summary_status: implemented - [x] latest_decision_assistant_change_summary_status: implemented - [x] latest_decision_assistant_evidence_status: implemented @@ -227,10 +239,14 @@ - [x] latest_decision_assistant_digest_refresh_proof_status: implemented - [x] latest_decision_assistant_template_feedback_loop_status: implemented - [x] latest_decision_assistant_replay_family_drift_status: implemented +- [x] latest_decision_assistant_handoff_cleanup_status: implemented +- [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_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_aging_refresh_proof_template_feedback_family_drift_visible +- [x] latest_remote_decision_assistant_review_packet_status: handoff_cleanup_confirmation_trail_feedback_tuning_family_watchlist_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 @@ -308,6 +324,12 @@ ## Newly Done +- [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 +- [x] Added setup-family drift watchlist status, priority, sample threshold/gap, and watchlist items +- [x] Passed focused validation, full unit discovery, script static checks, JS syntax check, and local field smoke for H13/F14/G13/D15 +- [x] Deployed current working tree and passed remote smoke for handoff cleanup/snooze, confirmation trails, template feedback tuning, and setup-family watchlist fields - [x] Added persisted handoff aging/stale marker policy fields and saved-handoff age/status/warning display - [x] Added digest refresh confirmation and freshness-proof fields, counts, prompts, and workbench visibility - [x] Added review-template feedback capture loops with useful/actionable/misleading operator-note buttons diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index 5761390..697579f 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -1277,6 +1277,10 @@ def _build_review_template_reuse_metrics( ) for template_id in template_usage } + template_feedback_aggregation = _build_template_feedback_aggregation( + template_outcomes=template_outcomes, + recommended_template_id=recommended_template_id, + ) recommended_outcome = dict(template_outcomes.get(str(recommended_template_id or "")) or {}) scored_values = [ float(item.get("usefulness_score") or 0.0) @@ -1294,7 +1298,10 @@ def _build_review_template_reuse_metrics( "latest_template_note_ts": latest_template_note_ts, "template_usage": template_usage, "template_outcomes": template_outcomes, + "template_feedback_aggregation": template_feedback_aggregation, "recommended_template_id": recommended_template_id, + "feedback_tuned_template_id": template_feedback_aggregation.get("feedback_tuned_template_id"), + "recommendation_tuning_status": template_feedback_aggregation.get("recommendation_tuning_status"), "recommended_reuse_count": recommended_reuse_count, "recommended_last_used_ts": recommended_usage.get("last_used_ts"), "recommended_outcome_status": recommended_outcome.get("outcome_status"), @@ -1310,6 +1317,7 @@ def _build_review_template_reuse_metrics( template_outcomes=template_outcomes, recommended_template_id=recommended_template_id, feedback_capture_needed_count=feedback_capture_needed_count, + feedback_tuned_template_id=str(template_feedback_aggregation.get("feedback_tuned_template_id") or ""), ), "reuse_status": reuse_status, } @@ -1411,6 +1419,7 @@ def _build_template_feedback_loop_summary( template_outcomes: dict[str, dict], recommended_template_id: str | None, feedback_capture_needed_count: int, + feedback_tuned_template_id: str | None = None, ) -> dict: recommended = dict(template_outcomes.get(str(recommended_template_id or "")) or {}) candidate_id = None @@ -1433,6 +1442,48 @@ def _build_template_feedback_loop_summary( "feedback_capture_needed_count": feedback_capture_needed_count, "next_feedback_template_id": candidate_id, "next_feedback_prompt": candidate_prompt, + "feedback_tuned_template_id": feedback_tuned_template_id or recommended_template_id, + } + + +def _build_template_feedback_aggregation( + *, + template_outcomes: dict[str, dict], + recommended_template_id: str | None, +) -> dict: + 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()) + rated_template_count = sum( + 1 + for item in template_outcomes.values() + if int(item.get("positive_count") or 0) + or int(item.get("actionable_count") or 0) + or int(item.get("negative_count") or 0) + ) + best_template_id = _pick_best_template_outcome(template_outcomes) + recommended_score = float((template_outcomes.get(str(recommended_template_id or "")) or {}).get("usefulness_score") or 0.0) + best_score = float((template_outcomes.get(str(best_template_id or "")) or {}).get("usefulness_score") or 0.0) + if best_template_id and best_score > recommended_score: + tuned_template_id = best_template_id + tuning_status = "feedback_tuned_to_best_outcome" + elif recommended_template_id: + tuned_template_id = recommended_template_id + tuning_status = "kept_focus_recommendation" + else: + tuned_template_id = best_template_id + tuning_status = "feedback_only_recommendation" if best_template_id else "no_feedback_signal" + return { + "status": "active" if template_outcomes else "idle", + "useful_feedback_count": useful_count, + "actionable_feedback_count": actionable_count, + "misleading_feedback_count": misleading_count, + "feedback_sample_count": useful_count + actionable_count + misleading_count, + "rated_template_count": rated_template_count, + "best_template_id": best_template_id, + "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, } @@ -1654,6 +1705,11 @@ def _build_decision_assistant_digest_history_summary(history: list[dict]) -> dic for group in groups if bool(group.get("refresh_needed")) ] + refresh_confirmation_trail = [ + item + for group in groups + for item in list(group.get("refresh_confirmation_trail") or []) + ] refresh_confirmation_count = sum( 1 for group in groups @@ -1682,6 +1738,8 @@ def _build_decision_assistant_digest_history_summary(history: list[dict]) -> dic "freshness_proof_gap_count": max(0, len(groups) - freshness_proof_count), "refresh_evidence_anchor_count": sum(1 for item in refresh_prompts if item.get("evidence_anchor")), "refresh_prompts": refresh_prompts[:3], + "refresh_confirmation_trail_count": len(refresh_confirmation_trail), + "refresh_confirmation_trail": refresh_confirmation_trail[:5], } @@ -1702,6 +1760,13 @@ 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) + refresh_confirmation_trail = _digest_history_refresh_confirmation_trail( + kind=kind, + items=items, + refresh_confirmation_status=refresh_confirmation_status, + freshness_proof_status=freshness_proof_status, + refresh_evidence_anchor=refresh_evidence_anchor, + ) groups.append( { "kind": kind, @@ -1734,6 +1799,8 @@ def _build_decision_assistant_digest_history_groups(history: list[dict], *, reco age=age, 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], } ) @@ -1834,6 +1901,65 @@ def _digest_history_freshness_proof_detail(*, kind: str, age: dict, evidence_anc return f"{kind} proof is present but aged ({age_band}); refresh before treating it as live context." +def _digest_history_refresh_confirmation_trail( + *, + kind: str, + items: list[dict], + refresh_confirmation_status: str, + freshness_proof_status: str, + refresh_evidence_anchor: dict, +) -> list[dict]: + anchor = str(refresh_evidence_anchor.get("anchor") or "") + path = str(refresh_evidence_anchor.get("path") or "") + trail = [] + for index, item in enumerate(items[:3], start=1): + ts = item.get("ts") + trail.append( + { + "kind": kind, + "title": item.get("title") or kind, + "ts": ts, + "trail_index": index, + "confirmation_status": refresh_confirmation_status, + "freshness_proof_status": freshness_proof_status, + "evidence_anchor": anchor, + "evidence_path": path, + "proof_label": _digest_history_confirmation_proof_label( + confirmation_status=refresh_confirmation_status, + freshness_proof_status=freshness_proof_status, + ), + } + ) + if not trail: + trail.append( + { + "kind": kind, + "title": f"{kind} refresh proof", + "ts": None, + "trail_index": 1, + "confirmation_status": refresh_confirmation_status, + "freshness_proof_status": freshness_proof_status, + "evidence_anchor": anchor, + "evidence_path": path, + "proof_label": _digest_history_confirmation_proof_label( + confirmation_status=refresh_confirmation_status, + freshness_proof_status=freshness_proof_status, + ), + } + ) + return trail + + +def _digest_history_confirmation_proof_label(*, confirmation_status: str, freshness_proof_status: str) -> str: + if confirmation_status == "confirmed_fresh" and freshness_proof_status == "freshness_proven": + return "confirmed fresh" + if freshness_proof_status == "missing_timestamp_proof": + return "missing timestamp proof" + if confirmation_status == "refresh_required": + return "refresh required" + return "confirmation 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") @@ -2140,6 +2266,8 @@ def _build_decision_assistant_scan_handoff_summary(cards: list[dict]) -> dict: "persistence_key": item.get("handoff_persistence_key"), "stale_after_seconds": item.get("handoff_stale_after_seconds"), "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), } for item in cards if item.get("handoff_marker") @@ -2162,7 +2290,17 @@ 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"} + ), "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": [ + {"label": "Snooze 30m", "seconds": 1800}, + {"label": "Snooze 2h", "seconds": 7200}, + ], "handoff_markers": markers[:5], } @@ -2224,6 +2362,40 @@ def _scan_card_handoff_stale_policy(card: dict) -> dict: } +def _scan_card_handoff_snooze_seconds(card: dict) -> int: + state = str(card.get("completion_state") or "") + if state == "blocked_until_resolved": + return 1800 + if state == "needs_refresh": + return 3600 + return 7200 + + +def _scan_card_handoff_cleanup_action_label(card: dict) -> str: + state = str(card.get("completion_state") or "") + if state == "blocked_until_resolved": + return "Keep As Blocker" + if state == "needs_refresh": + return "Refresh Or Clear" + if state == "fresh_context": + return "Carry Forward" + return "Snooze If Not Acting" + + +def _scan_handoff_cleanup_recommendation(markers: list[dict]) -> str: + if not markers: + return "No handoff lane is active." + cleanup_candidates = [ + item + for item in markers + if item.get("handoff_status") in {"blocked_handoff", "refresh_handoff"} + ] + if cleanup_candidates: + titles = ", ".join(str(item.get("title") or "handoff") for item in cleanup_candidates[:2]) + return f"Review or clear stale-sensitive handoff lanes first: {titles}." + return "Handoff lanes are clean; snooze anything you are intentionally deferring." + + def _build_setup_replay_statistics( *, setup_type: str, @@ -2390,14 +2562,20 @@ def _build_setup_replay_broader_baseline_summary( "setup_family_trend_delta": { "status": "idle", "trend_status": "insufficient_samples", + "sample_count": 0, + "sample_threshold": 5, + "sample_gap": 5, "baseline_count": 0, "framework_delta": 0.0, "avg_r_delta": 0.0, "risk_delta": 0.0, "drift_alert": False, "drift_severity": "none", + "watchlist_status": "collect_more_samples", + "watchlist_priority": "low", "focus_recommendation": f"Collect samples before changing focus for {setup_family}.", "focus_reasons": ["insufficient_samples"], + "drift_watchlist_items": ["collect_more_replay_samples"], "trend_story": f"No setup-family trend delta is available yet for {setup_family}.", }, "replay_verdict": "unranked", @@ -2578,17 +2756,24 @@ def _build_setup_family_trend_delta( sample_count = int(current_session_stats.get("count") or 0) baseline_rows = peer_sessions or fallback_rows if sample_count <= 0 or not baseline_rows: + sample_threshold = 5 return { "status": "idle", "trend_status": "insufficient_samples", + "sample_count": sample_count, + "sample_threshold": sample_threshold, + "sample_gap": max(0, sample_threshold - sample_count), "baseline_count": len(baseline_rows), "framework_delta": 0.0, "avg_r_delta": 0.0, "risk_delta": 0.0, "drift_alert": False, "drift_severity": "none", + "watchlist_status": "collect_more_samples", + "watchlist_priority": "low", "focus_recommendation": f"Collect samples before changing focus for {setup_family}.", "focus_reasons": ["insufficient_samples"], + "drift_watchlist_items": ["collect_more_replay_samples"], "trend_story": f"{setup_family} does not yet have enough current samples for a trend delta.", } baseline_count = len(baseline_rows) @@ -2608,6 +2793,8 @@ def _build_setup_family_trend_delta( else: trend_status = "mixed_vs_family_peers" drift_alert = trend_status == "weaker_vs_family_peers" + sample_threshold = 5 + sample_gap = max(0, sample_threshold - sample_count) if drift_alert: drift_severity = "warning" focus_recommendation = f"Keep {setup_family} in a tighter review lane until framework, avg-R, or risk drift recovers." @@ -2626,9 +2813,29 @@ def _build_setup_family_trend_delta( focus_reasons.append("risk_above_family_baseline") if not focus_reasons: focus_reasons.append("family_delta_constructive" if trend_status == "improving_vs_family_peers" else "mixed_family_delta") + if sample_gap > 0: + watchlist_status = "collect_more_samples" + watchlist_priority = "medium" if drift_alert else "low" + elif drift_alert: + watchlist_status = "drift_alert" + watchlist_priority = "high" + elif trend_status == "mixed_vs_family_peers": + watchlist_status = "watch" + watchlist_priority = "medium" + else: + watchlist_status = "promote_candidate" + watchlist_priority = "low" + drift_watchlist_items = list(focus_reasons) + if sample_gap > 0: + drift_watchlist_items.append("sample_threshold_not_met") + if drift_alert: + drift_watchlist_items.append("review_before_promotion") return { "status": "active", "trend_status": trend_status, + "sample_count": sample_count, + "sample_threshold": sample_threshold, + "sample_gap": sample_gap, "baseline_count": baseline_count, "framework_baseline": framework_baseline, "avg_r_baseline": avg_r_baseline, @@ -2638,8 +2845,11 @@ def _build_setup_family_trend_delta( "risk_delta": risk_delta, "drift_alert": drift_alert, "drift_severity": drift_severity, + "watchlist_status": watchlist_status, + "watchlist_priority": watchlist_priority, "focus_recommendation": focus_recommendation, "focus_reasons": focus_reasons[:4], + "drift_watchlist_items": _dedupe(drift_watchlist_items)[:6], "trend_story": ( f"{setup_family} is {trend_status.replace('_', ' ')}: " f"framework delta {framework_delta:.2f}, avg-R delta {avg_r_delta:.2f}, risk delta {risk_delta:.2f}." diff --git a/src/web/app.js b/src/web/app.js index 96f694a..b1d20ad 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -118,6 +118,16 @@ function persistAssistantScanHandoffMarker(marker) { } } +function snoozeAssistantScanHandoffMarker(seconds, reason = 'operator_snooze') { + if (!assistantScanHandoffMarker) return; + const duration = Math.max(60, Number(seconds || 1800)); + persistAssistantScanHandoffMarker({ + ...assistantScanHandoffMarker, + snoozed_until: new Date(Date.now() + duration * 1000).toISOString(), + snooze_reason: reason, + }); +} + function normalizeAssistantSectionId(title) { return String(title || '') .toLowerCase() @@ -198,8 +208,10 @@ function resolveAssistantHandoffAge(marker, fallbackStaleAfterSeconds = 3600) { return { status: 'none', stale: false, + snoozed: false, age_seconds: null, age_label: 'n/a', + snooze_label: 'n/a', warning: 'No persisted handoff marker.', }; } @@ -208,22 +220,31 @@ function resolveAssistantHandoffAge(marker, fallbackStaleAfterSeconds = 3600) { return { status: 'unknown_age', stale: true, + snoozed: false, age_seconds: null, age_label: 'unknown', + snooze_label: 'n/a', warning: 'Saved handoff timestamp is not readable; reopen before reuse.', }; } + const snoozedUntil = marker.snoozed_until ? new Date(marker.snoozed_until) : null; + const snoozed = Boolean(snoozedUntil && !Number.isNaN(snoozedUntil.getTime()) && snoozedUntil.getTime() > Date.now()); const staleAfterSeconds = Number(marker.stale_after_seconds || marker.handoff_stale_after_seconds || fallbackStaleAfterSeconds || 3600); const ageSeconds = Math.max(0, Math.round((Date.now() - savedAt.getTime()) / 1000)); const stale = ageSeconds > staleAfterSeconds; const aging = ageSeconds > staleAfterSeconds / 2; return { - status: stale ? 'stale_handoff' : (aging ? 'aging_handoff' : 'fresh_handoff'), + status: snoozed ? 'snoozed_handoff' : (stale ? 'stale_handoff' : (aging ? 'aging_handoff' : 'fresh_handoff')), stale, + snoozed, age_seconds: ageSeconds, age_label: formatDurationSeconds(ageSeconds), stale_after_seconds: staleAfterSeconds, - warning: stale + snoozed_until: marker.snoozed_until || null, + snooze_label: snoozed ? formatTimestamp(marker.snoozed_until) : 'n/a', + warning: snoozed + ? `Saved handoff is snoozed until ${formatTimestamp(marker.snoozed_until)}.` + : stale ? (marker.stale_warning || 'Saved handoff is stale; reopen the target section before reuse.') : (marker.stale_warning || 'Saved handoff is still inside its freshness window.'), }; @@ -546,12 +567,16 @@ function renderDecisionAssistant(summary) { ['next handoff prompt', scanHandoffSummary.next_prompt || 'n/a'], ['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 cleanup recommendation', scanHandoffSummary.handoff_cleanup_recommendation || 'n/a'], ['default stale after', formatDurationSeconds(scanHandoffSummary.default_stale_after_seconds)], ['saved handoff marker', assistantScanHandoffMarker?.marker || 'n/a'], ['saved handoff title', assistantScanHandoffMarker?.title || 'n/a'], ['saved handoff time', formatTimestamp(assistantScanHandoffMarker?.saved_ts)], ['saved handoff age', savedHandoffAge.age_label], ['saved handoff status', savedHandoffAge.status], + ['saved handoff snoozed until', savedHandoffAge.snooze_label], + ['saved handoff snooze reason', assistantScanHandoffMarker?.snooze_reason || 'n/a'], ['saved handoff warning', savedHandoffAge.warning], ]) ); @@ -568,6 +593,7 @@ function renderDecisionAssistant(summary) { status: 'next_handoff', stale_after_seconds: nextHandoff.stale_after_seconds || scanHandoffSummary.default_stale_after_seconds || 3600, stale_warning: nextHandoff.stale_warning || '', + snooze_seconds: nextHandoff.snooze_seconds || 1800, saved_ts: new Date().toISOString(), }); renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); @@ -581,6 +607,27 @@ function renderDecisionAssistant(summary) { }); } if (assistantScanHandoffMarker) { + (scanHandoffSummary.snooze_options || []).forEach((option) => { + handoffActions.push({ + label: option.label || 'Snooze', + onClick: () => { + snoozeAssistantScanHandoffMarker(option.seconds || assistantScanHandoffMarker.snooze_seconds || 1800, 'operator_snooze'); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }); + }); + if (assistantScanHandoffMarker.snoozed_until) { + handoffActions.push({ + label: 'Clear Snooze', + onClick: () => { + const nextMarker = { ...assistantScanHandoffMarker }; + delete nextMarker.snoozed_until; + delete nextMarker.snooze_reason; + persistAssistantScanHandoffMarker(nextMarker); + renderDecisionAssistant(window.__latestDecisionAssistantSummary || summary); + }, + }); + } handoffActions.push({ label: 'Clear Handoff', onClick: () => { @@ -643,6 +690,8 @@ 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)], + ['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)], ['refresh prompts', formatList((digestSurface.history_summary?.refresh_prompts || []).map((item) => `${item.kind}: ${item.prompt}`))], ['history filter', assistantHistoryKindFilter || 'all'], @@ -725,6 +774,8 @@ 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'], + ['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'], ['evidence path', item.refresh_evidence_path || 'n/a'], ['count', String(item.count ?? 0)], @@ -772,6 +823,12 @@ function renderDecisionAssistant(summary) { ['latest template note', formatTimestamp(reviewEntrypoints.template_reuse_metrics?.latest_template_note_ts)], ['recommended outcome', reviewEntrypoints.template_reuse_metrics?.recommended_outcome_status || 'n/a'], ['recommended usefulness', formatPercent(reviewEntrypoints.template_reuse_metrics?.recommended_usefulness_score)], + ['feedback tuned template', reviewEntrypoints.template_reuse_metrics?.feedback_tuned_template_id || 'n/a'], + ['recommendation tuning', reviewEntrypoints.template_reuse_metrics?.recommendation_tuning_status || 'n/a'], + ['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)], + ['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)], ['template feedback needed', String(reviewEntrypoints.template_reuse_metrics?.template_outcome_summary?.feedback_capture_needed_count ?? 0)], @@ -801,6 +858,8 @@ function renderDecisionAssistant(summary) { const noteTemplates = reviewEntrypoints.note_templates || []; if (noteTemplates.length) { const recommendedTemplate = noteTemplates.find((item) => item.template_id === reviewEntrypoints.recommended_template_id) || noteTemplates[0]; + const tunedTemplateId = reviewEntrypoints.template_reuse_metrics?.feedback_tuned_template_id || reviewEntrypoints.recommended_template_id; + const tunedTemplate = noteTemplates.find((item) => item.template_id === tunedTemplateId) || recommendedTemplate; const templateGrid = createElement('div', 'cards assistant-tight-grid'); noteTemplates.forEach((item) => { const isRecommended = item.template_id === reviewEntrypoints.recommended_template_id; @@ -852,6 +911,14 @@ function renderDecisionAssistant(summary) { label: 'Save Recommended', onClick: () => saveOperatorTemplateNote(recommendedTemplate), }, + { + label: 'Use Tuned', + onClick: () => prefillOperatorNote(tunedTemplate?.body || ''), + }, + { + label: 'Save Tuned', + onClick: () => saveOperatorTemplateNote(tunedTemplate), + }, ]) ); const templateBar = buildActionBar( @@ -1106,6 +1173,11 @@ function renderDecisionAssistant(summary) { ['setup family peers', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_peer_summary || 'n/a'], ['setup family focus', setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_focus || 'n/a'], ['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 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 || [])], ['family framework delta', formatPercent(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.framework_delta)], ['family avg R delta', formatNumber(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.avg_r_delta, 2)], ['family risk delta', formatNumber(setupLearningRollup.replay_statistics?.broader_baseline_summary?.setup_family_trend_delta?.risk_delta, 2)], diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index aa28e5f..d90cdfe 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -192,6 +192,10 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("feedback_capture_needed_count", summary["review_entrypoints"]["template_reuse_metrics"]["template_outcome_summary"]) self.assertIn("feedback_loop_summary", summary["review_entrypoints"]["template_reuse_metrics"]) self.assertIn("next_feedback_template_id", summary["review_entrypoints"]["template_reuse_metrics"]["feedback_loop_summary"]) + self.assertIn("template_feedback_aggregation", summary["review_entrypoints"]["template_reuse_metrics"]) + 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.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") @@ -211,6 +215,8 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("refresh_confirmation_needed_count", summary["digest_surface"]["history_summary"]) self.assertIn("freshness_proof_count", summary["digest_surface"]["history_summary"]) 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("refresh_prompts", summary["digest_surface"]["history_summary"]) self.assertIn("digest_history_groups", summary) self.assertIn("group_status", summary["digest_history_groups"][0]) @@ -226,6 +232,8 @@ 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("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"]) self.assertEqual(summary["digest_history_preview"][0]["kind"], "reminder") self.assertEqual(summary["personalized_reminders"][0]["severity"], "high") @@ -257,6 +265,11 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("drift_severity", summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["setup_family_trend_delta"]) self.assertIn("focus_recommendation", 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"]["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("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"]) self.assertTrue(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["top_sessions"]) self.assertTrue(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["top_safe_sessions"]) self.assertTrue(summary["setup_learning_rollup"]["replay_statistics"]["broader_baseline_summary"]["top_composite_sessions"]) @@ -281,6 +294,10 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("default_stale_after_seconds", summary["scan_handoff_summary"]) 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("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.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")