diff --git a/SESSION.md b/SESSION.md index 1062492..a7023de 100644 --- a/SESSION.md +++ b/SESSION.md @@ -2,7 +2,7 @@ ## Current Goal -Automation execution-layer P16 is closed locally. P16 adds a browser-backed screenshot capture adapter to the visual snapshot flow so review-packet HTML artifacts can be rendered through local Edge/Chrome headless into PNG screenshots with structured fallback when browser capture is unavailable. The default next action is P17 `Visual Trigger ROI Router`. +Automation execution-layer P17 is closed locally. P17 adds a structured visual trigger ROI router so ordinary observe/monitoring states skip visual review with an auditable reason, while actionable or degraded events create policy-backed visual watch requests and expose trigger/skip counters in runtime and quality summaries. The default next action is P18 `Visual Evidence Media Dispatch`. ## Current Branch @@ -14,8 +14,8 @@ Automation execution-layer P16 is closed locally. P16 adds a browser-backed scre ## Automation Status -- state: automation_execution_layer_p16_browser_screenshot_capture_closed -- current_phase: P16 Browser Screenshot Capture Adapter +- state: automation_execution_layer_p17_visual_trigger_roi_router_closed +- current_phase: P17 Visual Trigger ROI Router - latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup - latest_p14_slice: webhook_transport_retry - latest_p15_pxx_tasklist_status: ready @@ -28,7 +28,12 @@ Automation execution-layer P16 is closed locally. P16 adds a browser-backed scre - latest_visual_browser_capture_status: implemented - latest_visual_browser_capture_smoke_status: captured - latest_visual_capture_script_noop_status: ignored_watch_run_missing -- next_execution_layer_phase: P17 Visual Trigger ROI Router +- latest_p17_slice: visual_trigger_roi_router +- latest_visual_trigger_policy_status: implemented +- latest_visual_trigger_policy_smoke_status: idle_no_pending_watch_runs +- latest_visual_trigger_runtime_status: visible +- latest_visual_trigger_quality_summary_status: visible +- next_execution_layer_phase: P18 Visual Evidence Media Dispatch - latest_visual_review_script_status: idle - latest_runtime_visual_watch_task_status: ok - latest_visual_capture_cleanup_task_status: ok @@ -94,6 +99,10 @@ Automation execution-layer P16 is closed locally. P16 adds a browser-backed scre - Extended visual capture payloads with `html_path`, `screenshot_path`, `metadata_path`, `capture_url`, `browser_capture_status`, and structured `browser_capture` details. - Extended `scripts/run_visual_snapshot_capture.py`, `POST /trading/visual-watch/capture`, and the workbench Visual Reviews panel with browser capture visibility. - Verified a real local browser screenshot smoke produced a PNG from a generated review-packet HTML artifact. +- Added structured `evaluate_visual_trigger_policy()` for visual trigger/skip routing with auditable reason codes. +- Wired decision alert payloads and reserved visual watch requests with `visual_trigger_policy`, `visual_trigger_skip_reason`, and `visual_trigger_roi`. +- Extended runtime `decision_packet` and `visual_watch` task details with visual trigger policy statuses, skip reasons, trigger type counts, and review state counts. +- Extended visual review payloads, alert quality summary, `scripts/run_visual_review.py`, and the workbench panels with trigger type and ROI visibility. ## Current Working State @@ -111,6 +120,8 @@ Automation execution-layer P16 is closed locally. P16 adds a browser-backed scre - P15 visibility is implemented: dispatcher payloads include `delivery_policy`, delivery summary includes noise-policy/workbench-only/external-allowed counters, runtime `alert_dispatch` details expose policy counters, and the workbench `Alert Quality` panel displays them. - P16 browser screenshot capture is implemented: visual snapshot capture now writes HTML/JSON artifacts and, when browser capture is available, a PNG screenshot; when unavailable, it returns structured `browser_capture_status=unavailable` without interrupting the visual-watch flow. - P16 workbench visibility is implemented: Visual Reviews now expose browser capture status, image path, HTML path, and artifact path. +- P17 visual trigger ROI routing is implemented: ordinary observe/monitoring states now produce `observe_monitoring_not_high_value` instead of reserving visual review, while actionable, blocked, market degraded, broker/reconciliation degraded, and council-recommended watch items produce explicit trigger types. +- P17 visibility is implemented: decision alert payloads carry `visual_trigger_policy`/`visual_trigger_roi`, runtime exposes trigger/skip counts, visual review summaries count trigger types and review results, and the workbench displays trigger counters. - Latest `scripts/run_visual_review.py` smoke found no pending reviews: attempted=0, reviewed=0, status=idle. - Latest capture smoke returned `status=ignored` when no watch run was supplied, which is the expected safe no-op. - Latest cleanup smoke deleted one expired temporary artifact through `scripts/run_visual_capture_cleanup.py`. @@ -120,7 +131,7 @@ Automation execution-layer P16 is closed locally. P16 adds a browser-backed scre - Latest `scripts/run_trading_runtime.py` smoke returned blockers=none and includes a healthy `visual_watch` runtime task. - Latest local runtime smoke confirmed `runtime_status.tasks.alert_dispatch.details` now exposes retry and silent-window counters with blockers=none. -## P16 Blockers +## P17 Blockers - none @@ -137,14 +148,18 @@ Automation execution-layer P16 is closed locally. P16 adds a browser-backed scre - P15-PXX must remain automation execution-layer work and must not reopen Research Council phases or create new trading permissions. - P15 noise policy remains operational only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation. - P16 browser screenshot capture remains advisory evidence only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation. +- P17 visual trigger routing remains advisory evidence routing only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation. - A final rerun of `scripts/run_alert_dispatcher.py` and `scripts/run_trading_runtime.py` hit local PostgreSQL connection timeout because nothing was listening on `127.0.0.1:5432`; the P14 code path is still covered by full unit tests and earlier successful smoke during implementation. - P15 dispatcher smoke succeeded with `suppressed_noise_policy`, `workbench_only`, and `policy_external_allowed` counters present. P15 runtime smoke reached `alert_dispatch` successfully, but the overall runtime returned external environment blockers for market stream disconnected and OKX read transport connection refused. - P16 direct browser smoke succeeded locally with `browser_capture_status=captured`. Script-level capture without a watch run still returns safe no-op `watch_run_missing`. +- P17 visual review smoke returned `status=idle`, `skip_reason=no_pending_visual_watch_runs`, and empty trigger/review counters, which is the expected safe no-op when no policy-backed visual watch is pending. +- P17 quality-summary API smoke returned 200 and exposed `visual_review_summary.trigger_type_counts` / `review_result_counts`. +- Latest P17 runtime smoke returned blockers=none and exposes `decision_packet.details.visual_trigger_*` plus `visual_watch.details.skip_reason` / trigger counts. - Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter. ## Exact Next Step -P16 is locally closed. Default next action is P17 `Visual Trigger ROI Router`: add or complete policy-level visual trigger routing so high-value visual reviews are triggered with auditable trigger/skip reasons and ROI-friendly summary counters, then continue to P18 unless a real blocker appears. +P17 is locally closed. Default next action is P18 `Visual Evidence Media Dispatch`: promote visual evidence from paths/summaries into stable media evidence blocks for alert payloads and workbench consumption, with channel-safe fallback when external media attachment is unsupported. ## Validation Commands Used Recently @@ -159,4 +174,7 @@ D:\ai-exchange\.venv\Scripts\python.exe scripts\run_runtime_task_watchdog.py D:\ai-exchange\.venv\Scripts\python.exe scripts\run_alert_silent_window_status.py D:\ai-exchange\.venv\Scripts\python.exe scripts\run_trading_runtime.py D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_visual_snapshot_capture_service tests.unit.test_run_visual_snapshot_capture tests.unit.test_api_static_server +D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_visual_trigger_policy_service tests.unit.test_decision_packet_service tests.unit.test_research_council_decision_packet_fields tests.unit.test_visual_review_service tests.unit.test_alert_quality_summary_service tests.unit.test_trading_runtime_service tests.unit.test_run_visual_review +D:\ai-exchange\.venv\Scripts\python.exe -m unittest discover -s tests/unit +D:\ai-exchange\.venv\Scripts\python.exe scripts\run_visual_review.py ``` diff --git a/TASKS.md b/TASKS.md index d464d06..e728189 100644 --- a/TASKS.md +++ b/TASKS.md @@ -13,7 +13,7 @@ ## In Progress -- [x] P16 Browser Screenshot Capture Adapter local closure complete +- [x] P17 Visual Trigger ROI Router local closure complete ## Next Up @@ -27,14 +27,15 @@ - [x] Define finite P15-PXX continuation queue - [x] P15 local closure complete - [x] P16 local closure complete -- [ ] Start P17 Visual Trigger ROI Router +- [x] P17 local closure complete +- [ ] Start P18 Visual Evidence Media Dispatch - [ ] Continue periodic analysis-decision monitoring with maturity rollup in parallel - [ ] Treat browser screenshot capture and additional external media adapters as future enhancements ## Automation Status -- [x] state: automation_execution_layer_p16_browser_screenshot_capture_closed -- [x] current_phase: P16 Browser Screenshot Capture Adapter +- [x] state: automation_execution_layer_p17_visual_trigger_roi_router_closed +- [x] current_phase: P17 Visual Trigger ROI Router - [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 @@ -47,7 +48,12 @@ - [x] latest_visual_browser_capture_status: implemented - [x] latest_visual_browser_capture_smoke_status: captured - [x] latest_visual_capture_script_noop_status: ignored_watch_run_missing -- [x] next_execution_layer_phase: P17 Visual Trigger ROI Router +- [x] latest_p17_slice: visual_trigger_roi_router +- [x] latest_visual_trigger_policy_status: implemented +- [x] latest_visual_trigger_policy_smoke_status: idle_no_pending_watch_runs +- [x] latest_visual_trigger_runtime_status: visible +- [x] latest_visual_trigger_quality_summary_status: visible +- [x] next_execution_layer_phase: P18 Visual Evidence Media Dispatch - [x] latest_visual_review_script_status: idle - [x] latest_runtime_visual_watch_task_status: ok - [x] latest_visual_capture_cleanup_task_status: ok @@ -121,6 +127,13 @@ - [x] Extended visual snapshot capture script and API route with browser capture controls - [x] Added browser capture status and HTML path visibility to workbench Visual Reviews - [x] Added focused P16 browser capture, script, and API tests +- [x] Added structured `evaluate_visual_trigger_policy()` for visual trigger/skip routing +- [x] Added `visual_trigger_policy`, `visual_trigger_skip_reason`, and `visual_trigger_roi` to decision alert payloads +- [x] Added policy metadata to reserved visual watch requests and visual review payloads +- [x] Added trigger/skip counters to runtime `decision_packet` and `visual_watch` task details +- [x] Added trigger type counts and review result counts to alert quality visual review summary +- [x] Added trigger visibility to workbench Alert Quality and Visual Reviews panels +- [x] Added focused P17 visual trigger, decision packet, visual review, quality summary, runtime, and script tests - [x] Added runtime `visual_watch` task - [x] Added focused service, script, API, and runtime tests - [x] Smoked the visual review script with no pending reviews @@ -134,6 +147,9 @@ - [x] Smoked P15 quality-summary API with policy counters visible - [x] Smoked P16 direct browser screenshot capture with a real PNG output - [x] Smoked P16 capture script safe no-op without watch run +- [x] Smoked P17 visual review safe no-op with `skip_reason=no_pending_visual_watch_runs` +- [x] Smoked P17 quality-summary API with trigger counters visible +- [x] Smoked P17 trading runtime with blockers=none and visual trigger details visible ## Blocked @@ -148,7 +164,6 @@ ## Future Enhancements Not Blocking P8 -- [ ] P16 Browser Screenshot Capture Adapter - [ ] P18 Visual Evidence Media Dispatch - [ ] P19 Multi-Channel Alert Routing @@ -188,7 +203,7 @@ - [x] `P15` Alert Noise Policy Matrix - [x] `P16` Browser Screenshot Capture Adapter -- [ ] `P17` Visual Trigger ROI Router +- [x] `P17` Visual Trigger ROI Router - [ ] `P18` Visual Evidence Media Dispatch - [ ] `P19` Multi-Channel Alert Routing - [ ] `P20` Operator Digest And Review Queue diff --git a/docs/AI_ICT_盯盘告警开发任务清单_v1.md b/docs/AI_ICT_盯盘告警开发任务清单_v1.md index 967835a..571de05 100644 --- a/docs/AI_ICT_盯盘告警开发任务清单_v1.md +++ b/docs/AI_ICT_盯盘告警开发任务清单_v1.md @@ -406,6 +406,7 @@ candidate -> decision alert -> dispatcher -> 外部消息 -> delivery record - 2026-05-12 已新增 `docs/AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md`:P15-PXX 被收敛为有限队列,从 alert noise policy matrix 继续推进到 browser screenshot capture、visual ROI、media dispatch、multi-channel routing、operator digest、runtime incident journal、maintenance rollup、unattended harness、remote smoke 与 PXX closure gate。 - 2026-05-12 P15 已完成 `alert noise policy matrix` 本地闭环:外部发送现在可按 alert type / severity / channel / duty mode / operator mode / silent window / cooldown 形成结构化策略结果,并在 dispatcher、delivery summary、runtime `alert_dispatch` 与 workbench `Alert Quality` 中暴露降噪计数。 - 2026-05-12 P16 已完成 `browser screenshot capture adapter` 本地闭环:visual snapshot capture 现在在保留 HTML/JSON artifact 的同时,可通过本机 Edge/Chrome headless 生成 PNG screenshot,并把 browser capture status、HTML path、screenshot path 接入脚本、API 与 workbench Visual Reviews。 +- 2026-05-12 P17 已完成 `visual trigger ROI router` 本地闭环:视觉触发策略现在区分 `triggered` / `skipped`,普通 observe/monitoring 状态不触发视觉复核,actionable、blocked、market degraded、broker/reconciliation degraded 与 council watch item 会输出可审计 trigger type / reason codes,并在 runtime、alert quality summary、脚本和 workbench 中暴露 trigger/skip/ROI 计数。 ### 验收 - 非重大错误不打断主循环 diff --git a/docs/AI_ICT_盯盘告警技术设计稿_v1.md b/docs/AI_ICT_盯盘告警技术设计稿_v1.md index d1b477e..bbc661c 100644 --- a/docs/AI_ICT_盯盘告警技术设计稿_v1.md +++ b/docs/AI_ICT_盯盘告警技术设计稿_v1.md @@ -268,6 +268,8 @@ 这些触发类型建议贯穿 visual watch run、snapshot、review、alert payload,便于后续统计 ROI。 +2026-05-12 P17 已落地:`evaluate_visual_trigger_policy()` 现在输出 `triggered` / `skipped`、`trigger_types`、`primary_trigger_type`、`reason_codes`、`skip_reason` 与 ROI 友好的基础字段。普通 observe/monitoring 不再触发视觉复核,actionable、blocked、market degraded、broker/reconciliation degraded 与 council watch item 会创建 policy-backed visual watch request;runtime、alert quality summary、脚本与 workbench 均可查看 trigger/skip/ROI 统计。 + --- ### D. 新增 `src/services/feedback/` diff --git a/docs/AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md b/docs/AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md index 89b116c..46ffcd8 100644 --- a/docs/AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md +++ b/docs/AI_ICT_自动化执行层_P15-PXX闭环任务清单_v1.md @@ -34,7 +34,7 @@ - 降噪策略从静默窗口扩展到更细粒度的 channel/type/severity policy。 - 浏览器级真实截图采集仍未落地,当前主要是 advisory HTML capture。 -- 视觉触发策略和视觉证据 ROI 还未形成可统计闭环。 +- 视觉证据已具备策略触发/跳过与 ROI 统计基础;后续缺口集中在媒体证据分发和多通道路由。 - 外部媒体和多通道分发仍未完全编排。 - unattended runtime 的事件日志、恢复建议、长期复盘和端到端演练仍可加强。 - 远端 deploy/smoke 需要在环境可用时完成最终闭环。 @@ -134,6 +134,13 @@ - actionable 或 degraded 状态能触发视觉复核。 - 触发与跳过都有可审计 reason。 +### 当前进展 + +- 2026-05-12 P17 已完成 `Visual Trigger ROI Router` 本地闭环:新增结构化 `evaluate_visual_trigger_policy()`,普通 observe/monitoring 状态返回 `observe_monitoring_not_high_value`,actionable、blocked、market degraded、broker/reconciliation degraded、council watch item 会输出明确 trigger type 与 reason codes。 +- 2026-05-12 P17 已将 `visual_trigger_policy`、`visual_trigger_skip_reason`、`visual_trigger_roi` 写入 decision alert payload,并把同一 policy metadata 传入 reserved visual watch request 与 visual review payload。 +- 2026-05-12 P17 已在 runtime `decision_packet` / `visual_watch` task details、alert quality visual review summary、`scripts/run_visual_review.py`、workbench `Alert Quality` 与 `Visual Reviews` 面板中暴露 trigger/skip/ROI 计数。 +- 2026-05-12 P17 本地闭环已验证:focused P17 tests 通过,`tests/unit` 全量 672 个测试通过,script static checks 与 `node --check src/web/app.js` 通过;visual review smoke 在无 pending watch run 时返回 `skip_reason=no_pending_visual_watch_runs`,quality-summary API 可见 trigger counters,runtime smoke blockers=none。 + --- ## 8. P18 Visual Evidence Media Dispatch diff --git a/scripts/run_visual_review.py b/scripts/run_visual_review.py index ff0979c..ca52694 100644 --- a/scripts/run_visual_review.py +++ b/scripts/run_visual_review.py @@ -39,7 +39,11 @@ def main(env=None) -> int: print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)) else: print(f"Visual review status: {payload.get('status')}") + if payload.get("skip_reason"): + print(f"Skip reason: {payload.get('skip_reason')}") print(f"Attempted: {payload.get('attempted', 1)} / Reviewed: {payload.get('reviewed', 1)}") + if payload.get("trigger_type_counts"): + print(f"Trigger types: {json.dumps(payload.get('trigger_type_counts'), ensure_ascii=False, sort_keys=True)}") for item in payload.get("items") or []: print( f"- watch_run_id={item.get('watch_run_id')} " diff --git a/src/services/alerts/alert_quality_summary_service.py b/src/services/alerts/alert_quality_summary_service.py index a6f58a6..34d8c21 100644 --- a/src/services/alerts/alert_quality_summary_service.py +++ b/src/services/alerts/alert_quality_summary_service.py @@ -171,14 +171,20 @@ def _build_feedback_summary( def _build_visual_review_summary(rows: list[dict]) -> dict: items = list(rows or []) state_counts: dict[str, int] = {} + trigger_type_counts: dict[str, int] = {} latest = items[0] if items else {} for row in items: state = str(row.get("visual_state") or "").strip() or "unknown" state_counts[state] = state_counts.get(state, 0) + 1 + trigger_type = str(row.get("trigger_type") or "").strip() or "unknown" + trigger_type_counts[trigger_type] = trigger_type_counts.get(trigger_type, 0) + 1 return { "count": len(items), "state_counts": state_counts, + "review_result_counts": state_counts, + "trigger_type_counts": trigger_type_counts, "latest_visual_state": latest.get("visual_state"), + "latest_trigger_type": latest.get("trigger_type"), "latest_reviewed_ts": _iso(latest.get("reviewed_ts")), } diff --git a/src/services/decision/decision_packet_service.py b/src/services/decision/decision_packet_service.py index 5a98924..70d65fd 100644 --- a/src/services/decision/decision_packet_service.py +++ b/src/services/decision/decision_packet_service.py @@ -15,8 +15,7 @@ from src.services.trust import ModelTrustService from src.services.visual_watch import ( VisualWatchService, build_visual_watch_request_stub, - suggest_visual_trigger, - suggest_visual_triggers_from_council, + evaluate_visual_trigger_policy, ) @@ -444,20 +443,16 @@ def _alert(key: str, alert_type: str, message: str, packet: DecisionPacket) -> d "signal_id": packet.signal_id, "reason_codes": reason_codes, } - visual_trigger = suggest_visual_trigger( - action_recommendation=packet.action_recommendation, - status=packet.status, - why_blocked=list(packet.why_blocked), - ) council = dict(packet.research_council or {}) - visual_triggers = suggest_visual_triggers_from_council( - recommended_watch_items=list(council.get("recommended_watch_items") or []), + visual_trigger_policy = evaluate_visual_trigger_policy( action_recommendation=packet.action_recommendation, status=packet.status, why_blocked=list(packet.why_blocked), + recommended_watch_items=list(council.get("recommended_watch_items") or []), + operator_mode=packet.operator_mode, ) - if visual_trigger: - visual_triggers = _dedupe([visual_trigger, *visual_triggers]) + visual_trigger = visual_trigger_policy.get("primary_trigger_type") + visual_triggers = list(visual_trigger_policy.get("trigger_types") or []) visual_watch_request = build_visual_watch_request_stub( trigger_type=visual_trigger, instrument_id=packet.instrument_id, @@ -466,6 +461,7 @@ def _alert(key: str, alert_type: str, message: str, packet: DecisionPacket) -> d decision_packet_id=None, symbol=str((candidate.get("evidence") or {}).get("symbol") or ""), timeframe=str((candidate.get("trigger_timeframe") or signal.get("trigger_timeframe") or "1m")), + trigger_policy=visual_trigger_policy, ) visual_watch_requests = [ build_visual_watch_request_stub( @@ -476,10 +472,20 @@ def _alert(key: str, alert_type: str, message: str, packet: DecisionPacket) -> d decision_packet_id=None, symbol=str((candidate.get("evidence") or {}).get("symbol") or ""), timeframe=str((candidate.get("trigger_timeframe") or signal.get("trigger_timeframe") or "1m")), + trigger_policy=visual_trigger_policy, ) for trigger_type in visual_triggers ] visual_watch_requests = [item for item in visual_watch_requests if item] + visual_trigger_roi = { + "status": visual_trigger_policy.get("status"), + "triggered": bool(visual_trigger_policy.get("triggered")), + "trigger_type_count": int(visual_trigger_policy.get("trigger_type_count") or 0), + "primary_trigger_type": visual_trigger, + "skip_reason": visual_trigger_policy.get("skip_reason"), + "reason": visual_trigger_policy.get("reason"), + "expected_review_value": "high" if visual_trigger_policy.get("is_high_value") else "advisory", + } return { "alert_key": f"{key}:{alert_type}", "alert_type": alert_type, @@ -494,6 +500,10 @@ def _alert(key: str, alert_type: str, message: str, packet: DecisionPacket) -> d "why_allowed": list(packet.why_allowed), "dedupe_scope": dedupe_scope, "research_council": council, + "visual_trigger_policy": visual_trigger_policy, + "visual_trigger_skip_reason": visual_trigger_policy.get("skip_reason"), + "visual_trigger_reason_codes": list(visual_trigger_policy.get("reason_codes") or []), + "visual_trigger_roi": visual_trigger_roi, "visual_trigger_type": visual_trigger, "visual_trigger_types": visual_triggers, "visual_watch_request": visual_watch_request, diff --git a/src/services/runtime/trading_runtime_service.py b/src/services/runtime/trading_runtime_service.py index 301bbfd..68000e7 100644 --- a/src/services/runtime/trading_runtime_service.py +++ b/src/services/runtime/trading_runtime_service.py @@ -401,12 +401,32 @@ class TradingRuntimeScheduler: try: packet = self.decision_packet_service.build_persist_and_alert(instrument_id=instrument_id) visual_trigger_types: list[str] = [] + visual_trigger_policy_statuses: list[str] = [] + visual_trigger_skip_reasons: list[str] = [] + visual_trigger_policy_reasons: list[str] = [] + visual_triggered_alert_count = 0 + visual_trigger_skipped_alert_count = 0 for item in list(packet.get("alerts") or []): payload = dict(item.get("payload") or {}) visual_trigger_types.extend(str(value) for value in list(payload.get("visual_trigger_types") or []) if value) if payload.get("visual_trigger_type"): visual_trigger_types.append(str(payload.get("visual_trigger_type"))) + policy = dict(payload.get("visual_trigger_policy") or {}) + if policy.get("status"): + policy_status = str(policy.get("status")) + visual_trigger_policy_statuses.append(policy_status) + if policy_status == "triggered": + visual_triggered_alert_count += 1 + if policy_status == "skipped": + visual_trigger_skipped_alert_count += 1 + if policy.get("skip_reason"): + visual_trigger_skip_reasons.append(str(policy.get("skip_reason"))) + if policy.get("reason"): + visual_trigger_policy_reasons.append(str(policy.get("reason"))) visual_trigger_types = _dedupe(visual_trigger_types) + visual_trigger_policy_statuses = _dedupe(visual_trigger_policy_statuses) + visual_trigger_skip_reasons = _dedupe(visual_trigger_skip_reasons) + visual_trigger_policy_reasons = _dedupe(visual_trigger_policy_reasons) task.details = { "symbol": symbol, "instrument_id": instrument_id, @@ -416,6 +436,11 @@ class TradingRuntimeScheduler: "alerts": len(packet.get("alerts") or []), "visual_trigger_types": visual_trigger_types, "visual_watch_reserved": bool(visual_trigger_types), + "visual_trigger_policy_statuses": visual_trigger_policy_statuses, + "visual_trigger_skip_reasons": visual_trigger_skip_reasons, + "visual_trigger_policy_reasons": visual_trigger_policy_reasons, + "visual_triggered_alert_count": visual_triggered_alert_count, + "visual_trigger_skipped_alert_count": visual_trigger_skipped_alert_count, "research_council_present": bool((packet.get("research_council") or {}).get("present")), } _task_success(task, now) @@ -473,8 +498,11 @@ class TradingRuntimeScheduler: "symbol": symbol, "instrument_id": instrument_id, "status": payload.get("status"), + "skip_reason": payload.get("skip_reason"), "attempted": int(payload.get("attempted") or 0), "visual_reviews_created": int(payload.get("reviewed") or 0), + "trigger_type_counts": dict(payload.get("trigger_type_counts") or {}), + "review_state_counts": dict(payload.get("review_state_counts") or {}), "last_visual_state": latest.get("visual_state"), "last_snapshot_source": ( latest.get("snapshot_source_name") or "snapshot_repository" diff --git a/src/services/visual_watch/__init__.py b/src/services/visual_watch/__init__.py index 0d016d5..6f2d3ac 100644 --- a/src/services/visual_watch/__init__.py +++ b/src/services/visual_watch/__init__.py @@ -3,9 +3,12 @@ from src.services.visual_watch.visual_trigger_policy_service import ( VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW, VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW, VISUAL_TRIGGER_NEWS_WINDOW_REVIEW, + VISUAL_TRIGGER_POLICY_SKIPPED, + VISUAL_TRIGGER_POLICY_TRIGGERED, VISUAL_TRIGGER_RECONCILIATION_REVIEW, VISUAL_TRIGGER_SESSION_FOCUS_WATCH, build_visual_watch_request_stub, + evaluate_visual_trigger_policy, suggest_visual_trigger, suggest_visual_triggers_from_council, ) @@ -19,9 +22,12 @@ __all__ = [ "VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW", "VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW", "VISUAL_TRIGGER_NEWS_WINDOW_REVIEW", + "VISUAL_TRIGGER_POLICY_SKIPPED", + "VISUAL_TRIGGER_POLICY_TRIGGERED", "VISUAL_TRIGGER_RECONCILIATION_REVIEW", "VISUAL_TRIGGER_SESSION_FOCUS_WATCH", "build_visual_watch_request_stub", + "evaluate_visual_trigger_policy", "suggest_visual_trigger", "suggest_visual_triggers_from_council", "VisualSnapshotCaptureService", diff --git a/src/services/visual_watch/visual_review_service.py b/src/services/visual_watch/visual_review_service.py index f9e480b..e74d0fb 100644 --- a/src/services/visual_watch/visual_review_service.py +++ b/src/services/visual_watch/visual_review_service.py @@ -83,6 +83,8 @@ class VisualReviewService: "status": "reviewed", "watch_run_id": watch_run_id, "trigger_type": watch_run.get("trigger_type"), + "trigger_policy": dict((watch_run.get("payload") or {}).get("visual_trigger_policy") or {}), + "trigger_policy_reason": (watch_run.get("payload") or {}).get("visual_trigger_reason"), "candidate_id": watch_run.get("candidate_id"), "decision_packet_id": watch_run.get("decision_packet_id"), "snapshot_id": review_result.get("snapshot_id"), @@ -120,12 +122,17 @@ class VisualReviewService: ) ) status = "completed" if items else "idle" + trigger_type_counts = _count_values([item.get("trigger_type") for item in pending]) + review_state_counts = _count_values([item.get("visual_state") for item in items]) return { "status": status, + "skip_reason": None if items else "no_pending_visual_watch_runs", "attempted": len(pending), "reviewed": len(items), "items": items, "pending_watch_run_ids": [int(item["id"]) for item in pending], + "trigger_type_counts": trigger_type_counts, + "review_state_counts": review_state_counts, } def _resolve_snapshot( @@ -200,10 +207,14 @@ class VisualReviewService: if snapshot is not None and not visual_notes: resolved_notes = f"{resolved_notes} snapshot={snapshot.get('id') or snapshot.get('watch_run_id')}" payload = dict(payload or {}) + watch_payload = dict(watch_run.get("payload") or {}) payload.update( { "watch_run_id": watch_run.get("id"), "trigger_type": watch_run.get("trigger_type"), + "trigger_policy": dict(watch_payload.get("visual_trigger_policy") or {}), + "trigger_policy_reason": watch_payload.get("visual_trigger_reason"), + "trigger_policy_status": watch_payload.get("visual_trigger_policy_status"), "candidate_id": watch_run.get("candidate_id"), "decision_packet_id": watch_run.get("decision_packet_id"), "snapshot_id": snapshot.get("id") if snapshot else None, @@ -263,3 +274,11 @@ def _snapshot_artifact_path(snapshot: dict | None): payload = dict(snapshot.get("payload") or {}) nested = payload.get("payload") return payload.get("artifact_path") or (dict(nested or {}).get("artifact_path") if isinstance(nested, dict) else None) + + +def _count_values(values) -> dict[str, int]: + counts: dict[str, int] = {} + for value in values: + text = str(value or "").strip() or "unknown" + counts[text] = counts.get(text, 0) + 1 + return counts diff --git a/src/services/visual_watch/visual_trigger_policy_service.py b/src/services/visual_watch/visual_trigger_policy_service.py index 8a81931..a8fc5ba 100644 --- a/src/services/visual_watch/visual_trigger_policy_service.py +++ b/src/services/visual_watch/visual_trigger_policy_service.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH = "candidate_actionable_watch" VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW = "candidate_block_reason_review" VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW = "market_anomaly_review" @@ -7,18 +9,108 @@ VISUAL_TRIGGER_SESSION_FOCUS_WATCH = "session_focus_watch" VISUAL_TRIGGER_NEWS_WINDOW_REVIEW = "news_window_review" VISUAL_TRIGGER_RECONCILIATION_REVIEW = "reconciliation_review" +VISUAL_TRIGGER_POLICY_TRIGGERED = "triggered" +VISUAL_TRIGGER_POLICY_SKIPPED = "skipped" + +_ACTIONABLE_ACTIONS = {"build_execution_ticket", "confirm_ticket", "manage_ticket"} +_BLOCK_REVIEW_ACTIONS = {"restricted_decision_support", "wait_for_confirmations", "do_not_execute"} +_OBSERVE_ACTIONS = {"observe_only", "decision_support_only", "wait_for_candidate", "wait_for_active_plan"} + + +def evaluate_visual_trigger_policy( + *, + action_recommendation: str, + status: str, + why_blocked: list[str] | None = None, + recommended_watch_items: list[str] | None = None, + runtime_state: dict | None = None, + operator_mode: str | None = None, + force: bool = False, +) -> dict[str, Any]: + action = _normalize(action_recommendation) + packet_status = _normalize(status) + blocked = _normalize_list(why_blocked) + watch_items = _normalize_list(recommended_watch_items) + runtime = dict(runtime_state or {}) + runtime_codes = _normalize_list([*list(runtime.get("blockers") or []), *list(runtime.get("warnings") or [])]) + + triggers: list[str] = [] + reason_codes: list[str] = [] + if force: + triggers.append(VISUAL_TRIGGER_SESSION_FOCUS_WATCH) + reason_codes.append("force_visual_review") + if action in _ACTIONABLE_ACTIONS: + triggers.append(VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH) + reason_codes.append("high_value_actionable_candidate") + if action in _BLOCK_REVIEW_ACTIONS or (packet_status == "blocked" and blocked): + triggers.append(VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW) + reason_codes.append("candidate_block_reason_review") + if _has_market_anomaly(blocked) or _has_market_anomaly(runtime_codes): + triggers.append(VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW) + reason_codes.append("market_anomaly_or_data_degraded") + if _has_reconciliation_anomaly(blocked) or _has_reconciliation_anomaly(runtime_codes): + triggers.append(VISUAL_TRIGGER_RECONCILIATION_REVIEW) + reason_codes.append("reconciliation_or_broker_degraded") + for item in watch_items: + trigger = _trigger_from_watch_item(item) + if trigger: + triggers.append(trigger) + reason_codes.append(f"council_watch_item:{item}") + + trigger_types = _dedupe(triggers) + if trigger_types: + primary_trigger = trigger_types[0] + high_value = bool( + set(trigger_types) + & { + VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH, + VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW, + VISUAL_TRIGGER_RECONCILIATION_REVIEW, + } + ) + return { + "status": VISUAL_TRIGGER_POLICY_TRIGGERED, + "triggered": True, + "trigger_types": trigger_types, + "primary_trigger_type": primary_trigger, + "reason": reason_codes[0] if reason_codes else "visual_trigger_policy_matched", + "reason_codes": _dedupe(reason_codes), + "skip_reason": None, + "is_high_value": high_value, + "action_recommendation": action, + "packet_status": packet_status, + "operator_mode": _normalize(operator_mode), + "recommended_watch_items": watch_items, + "why_blocked": blocked, + "trigger_type_count": len(trigger_types), + } + + skip_reason = _resolve_skip_reason(action=action, status=packet_status, blocked=blocked, watch_items=watch_items) + return { + "status": VISUAL_TRIGGER_POLICY_SKIPPED, + "triggered": False, + "trigger_types": [], + "primary_trigger_type": None, + "reason": skip_reason, + "reason_codes": [skip_reason], + "skip_reason": skip_reason, + "is_high_value": False, + "action_recommendation": action, + "packet_status": packet_status, + "operator_mode": _normalize(operator_mode), + "recommended_watch_items": watch_items, + "why_blocked": blocked, + "trigger_type_count": 0, + } + def suggest_visual_trigger(*, action_recommendation: str, status: str, why_blocked: list[str] | None = None) -> str | None: - blocked = list(why_blocked or []) - if action_recommendation in {"build_execution_ticket", "confirm_ticket", "manage_ticket"}: - return VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH - if action_recommendation in {"restricted_decision_support", "wait_for_confirmations", "do_not_execute"}: - return VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW - if status == "monitoring": - return VISUAL_TRIGGER_SESSION_FOCUS_WATCH - if blocked: - return VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW - return None + policy = evaluate_visual_trigger_policy( + action_recommendation=action_recommendation, + status=status, + why_blocked=why_blocked, + ) + return policy.get("primary_trigger_type") def suggest_visual_triggers_from_council( @@ -28,29 +120,13 @@ def suggest_visual_triggers_from_council( status: str = "", why_blocked: list[str] | None = None, ) -> list[str]: - triggers: list[str] = [] - base_trigger = suggest_visual_trigger( + policy = evaluate_visual_trigger_policy( action_recommendation=action_recommendation, status=status, why_blocked=why_blocked, + recommended_watch_items=recommended_watch_items, ) - if base_trigger: - triggers.append(base_trigger) - for item in list(recommended_watch_items or []): - normalized = str(item or "").strip().lower() - if not normalized: - continue - if "news" in normalized: - triggers.append(VISUAL_TRIGGER_NEWS_WINDOW_REVIEW) - elif "reconciliation" in normalized or "broker" in normalized or "fill" in normalized: - triggers.append(VISUAL_TRIGGER_RECONCILIATION_REVIEW) - elif "market" in normalized or "stream" in normalized or "gap" in normalized: - triggers.append(VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW) - elif "candidate" in normalized or "confirmation" in normalized or "visual" in normalized: - triggers.append(VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW) - else: - triggers.append(VISUAL_TRIGGER_SESSION_FOCUS_WATCH) - return _dedupe(triggers) + return list(policy.get("trigger_types") or []) def build_visual_watch_request_stub( @@ -62,10 +138,11 @@ def build_visual_watch_request_stub( decision_packet_id: int | None, symbol: str, timeframe: str, + trigger_policy: dict | None = None, ) -> dict | None: if not trigger_type: return None - return { + payload = { "trigger_type": trigger_type, "instrument_id": instrument_id, "candidate_id": candidate_id, @@ -76,6 +153,64 @@ def build_visual_watch_request_stub( "source_name": "reserved_for_visual_watch", "status": "not_implemented", } + if trigger_policy: + payload["visual_trigger_policy"] = dict(trigger_policy) + payload["visual_trigger_reason"] = trigger_policy.get("reason") + payload["visual_trigger_policy_status"] = trigger_policy.get("status") + return payload + + +def _trigger_from_watch_item(item: str) -> str | None: + normalized = _normalize(item) + if not normalized: + return None + if "news" in normalized: + return VISUAL_TRIGGER_NEWS_WINDOW_REVIEW + if "reconciliation" in normalized or "broker" in normalized or "fill" in normalized: + return VISUAL_TRIGGER_RECONCILIATION_REVIEW + if "market" in normalized or "stream" in normalized or "gap" in normalized: + return VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW + if "candidate" in normalized or "confirmation" in normalized or "visual" in normalized: + return VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW + if "session" in normalized or "focus" in normalized: + return VISUAL_TRIGGER_SESSION_FOCUS_WATCH + return VISUAL_TRIGGER_SESSION_FOCUS_WATCH + + +def _resolve_skip_reason(*, action: str, status: str, blocked: list[str], watch_items: list[str]) -> str: + if action in _OBSERVE_ACTIONS and status in {"monitoring", "blocked", ""} and not blocked and not watch_items: + return "observe_monitoring_not_high_value" + if action == "wait_for_active_plan": + return "active_plan_missing_visual_review_not_useful" + if action == "wait_for_candidate": + return "candidate_missing_visual_review_not_useful" + if status == "monitoring": + return "monitoring_without_high_value_visual_trigger" + return "visual_trigger_policy_no_high_value_event" + + +def _has_market_anomaly(values: list[str]) -> bool: + return any( + token in value + for value in values + for token in ("market", "stream", "gap", "freshness", "candle", "price", "data") + ) + + +def _has_reconciliation_anomaly(values: list[str]) -> bool: + return any( + token in value + for value in values + for token in ("broker", "fill", "reconciliation", "execution_sync", "order") + ) + + +def _normalize_list(values) -> list[str]: + return _dedupe([_normalize(value) for value in list(values or [])]) + + +def _normalize(value) -> str: + return str(value or "").strip().lower() def _dedupe(values: list[str]) -> list[str]: diff --git a/src/web/app.js b/src/web/app.js index 45df1af..119035f 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -639,7 +639,9 @@ function renderAlertQuality(summary) { [ ['review rows', String(reviewSummary.count ?? 0)], ['state counts', formatJsonInline(reviewSummary.state_counts || {})], + ['trigger counts', formatJsonInline(reviewSummary.trigger_type_counts || {})], ['latest state', reviewSummary.latest_visual_state || 'n/a'], + ['latest trigger', reviewSummary.latest_trigger_type || 'n/a'], ['latest reviewed', formatTimestamp(reviewSummary.latest_reviewed_ts)], ] ) @@ -698,6 +700,7 @@ function renderVisualReviews(reviewRows, decisionPacketId) { ['decision packet', decisionPacketId || 'n/a'], ['reviews', String(items.length)], ['latest state', latest?.visual_state || 'n/a'], + ['latest trigger', latest?.trigger_type || latest?.payload?.trigger_type || 'n/a'], ['latest source', latest?.snapshot_source_name || 'n/a'], ['latest browser capture', getVisualSnapshotBrowserStatus(latest)], ['latest artifact', getVisualSnapshotArtifactPath(latest)], @@ -717,6 +720,7 @@ function renderVisualReviews(reviewRows, decisionPacketId) { `Watch ${review.watch_run_id || 'n/a'}`, review.visual_state || 'unknown', [ + ['trigger', review.trigger_type || review?.payload?.trigger_type || 'n/a'], ['snapshot', review.snapshot_id || 'n/a'], ['source', review.snapshot_source_name || 'n/a'], ['browser capture', getVisualSnapshotBrowserStatus(review)], diff --git a/tests/unit/test_alert_quality_summary_service.py b/tests/unit/test_alert_quality_summary_service.py index 3d45d3b..4bad469 100644 --- a/tests/unit/test_alert_quality_summary_service.py +++ b/tests/unit/test_alert_quality_summary_service.py @@ -40,8 +40,8 @@ class _FeedbackRepository: class _VisualRepository: def fetch_recent_reviews_for_instrument(self, instrument_id: int, limit: int = 50): return [ - {"id": 31, "visual_state": "confirm", "reviewed_ts": "2026-05-11T10:00:00+00:00"}, - {"id": 32, "visual_state": "mismatch", "reviewed_ts": "2026-05-11T09:59:00+00:00"}, + {"id": 31, "visual_state": "confirm", "trigger_type": "candidate_actionable_watch", "reviewed_ts": "2026-05-11T10:00:00+00:00"}, + {"id": 32, "visual_state": "mismatch", "trigger_type": "market_anomaly_review", "reviewed_ts": "2026-05-11T09:59:00+00:00"}, ][:limit] @@ -90,6 +90,8 @@ class AlertQualitySummaryServiceTests(unittest.TestCase): self.assertAlmostEqual(summary["visual_feedback_summary"]["positive_rate"], 2 / 3) self.assertEqual(summary["visual_review_summary"]["state_counts"]["confirm"], 1) self.assertEqual(summary["visual_review_summary"]["state_counts"]["mismatch"], 1) + self.assertEqual(summary["visual_review_summary"]["trigger_type_counts"]["candidate_actionable_watch"], 1) + self.assertEqual(summary["visual_review_summary"]["trigger_type_counts"]["market_anomaly_review"], 1) self.assertIn("delivery_failures_present", summary["quality_flags"]) self.assertIn("alert_silent_window_active", summary["quality_flags"]) self.assertIn("delivery_retry_exhausted_present", summary["quality_flags"]) diff --git a/tests/unit/test_decision_packet_service.py b/tests/unit/test_decision_packet_service.py index 48b2a56..b0748b1 100644 --- a/tests/unit/test_decision_packet_service.py +++ b/tests/unit/test_decision_packet_service.py @@ -207,8 +207,11 @@ class DecisionPacketServiceTests(unittest.TestCase): self.assertEqual(len(result["alerts"]), 1) alert_payload = result["alerts"][0]["payload"] self.assertEqual(alert_payload["visual_watch_reservation"]["watch_run_id"], 777) + self.assertEqual(alert_payload["visual_trigger_policy"]["status"], "triggered") + self.assertEqual(alert_payload["visual_trigger_roi"]["expected_review_value"], "high") self.assertEqual(visual_watch_service.requests[0]["decision_packet_id"], 901) self.assertEqual(visual_watch_service.requests[0]["signal_id"], 30) + self.assertEqual(visual_watch_service.requests[0]["visual_trigger_policy"]["primary_trigger_type"], "candidate_actionable_watch") self.assertEqual(repository.alert_payloads[0]["payload"]["visual_watch_reservation"]["status"], "reserved") def test_record_snapshot_returns_snapshot_payload(self) -> None: diff --git a/tests/unit/test_research_council_decision_packet_fields.py b/tests/unit/test_research_council_decision_packet_fields.py index dab4b1b..aecb233 100644 --- a/tests/unit/test_research_council_decision_packet_fields.py +++ b/tests/unit/test_research_council_decision_packet_fields.py @@ -61,6 +61,8 @@ class ResearchCouncilDecisionPacketFieldTests(unittest.TestCase): self.assertIn("news_window_review", payload["visual_trigger_types"]) self.assertIn("candidate_block_reason_review", payload["visual_trigger_types"]) + self.assertEqual(payload["visual_trigger_policy"]["status"], "triggered") + self.assertIn("council_watch_item:news_window_review", payload["visual_trigger_policy"]["reason_codes"]) self.assertGreaterEqual(len(payload["visual_watch_requests"]), 2) self.assertEqual(payload["research_council"]["run_id"], 77) diff --git a/tests/unit/test_trading_runtime_service.py b/tests/unit/test_trading_runtime_service.py index 2767220..7efa696 100644 --- a/tests/unit/test_trading_runtime_service.py +++ b/tests/unit/test_trading_runtime_service.py @@ -97,6 +97,11 @@ class FakeDecisionPacketService: "alert_type": "candidate_actionable", "payload": { "visual_trigger_type": "candidate_actionable_watch", + "visual_trigger_policy": { + "status": "triggered", + "reason": "high_value_actionable_candidate", + "trigger_types": ["candidate_actionable_watch"], + }, "visual_watch_reservation": {"status": "reserved", "watch_run_id": 801}, }, } @@ -206,6 +211,8 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): } ], "pending_watch_run_ids": [801], + "trigger_type_counts": {"candidate_actionable_watch": 1}, + "review_state_counts": {"confirm": 1}, } ), visual_capture_retention_service=FakeVisualCaptureRetentionService(), @@ -224,8 +231,11 @@ class TradingRuntimeSchedulerTests(unittest.TestCase): self.assertEqual(payload["tasks"]["research_council"]["last_status"], "ok") self.assertEqual(payload["tasks"]["decision_packet"]["details"]["visual_trigger_types"], ["candidate_actionable_watch"]) self.assertTrue(payload["tasks"]["decision_packet"]["details"]["visual_watch_reserved"]) + self.assertEqual(payload["tasks"]["decision_packet"]["details"]["visual_trigger_policy_statuses"], ["triggered"]) + self.assertEqual(payload["tasks"]["decision_packet"]["details"]["visual_triggered_alert_count"], 1) self.assertEqual(payload["tasks"]["visual_watch"]["details"]["visual_reviews_created"], 1) self.assertEqual(payload["tasks"]["visual_watch"]["details"]["last_visual_state"], "confirm") + self.assertEqual(payload["tasks"]["visual_watch"]["details"]["trigger_type_counts"]["candidate_actionable_watch"], 1) self.assertEqual(payload["tasks"]["visual_capture_cleanup"]["details"]["deleted_file_count"], 2) self.assertFalse(payload["tasks"]["alert_dispatch"]["details"]["silent_window_active"]) self.assertEqual(payload["tasks"]["alert_dispatch"]["details"]["suppressed_noise_policy"], 0) diff --git a/tests/unit/test_visual_review_service.py b/tests/unit/test_visual_review_service.py index 0a19aff..6ba6732 100644 --- a/tests/unit/test_visual_review_service.py +++ b/tests/unit/test_visual_review_service.py @@ -94,7 +94,11 @@ class VisualReviewServiceTests(unittest.TestCase): "symbol": "BTC-USDT", "timeframe": "1m", "status": "reserved", - "payload": {"trigger_type": VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH}, + "payload": { + "trigger_type": VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH, + "visual_trigger_policy": {"status": "triggered", "reason": "high_value_actionable_candidate"}, + "visual_trigger_reason": "high_value_actionable_candidate", + }, } service = VisualReviewService(repository=repository) @@ -110,6 +114,7 @@ class VisualReviewServiceTests(unittest.TestCase): self.assertEqual(result["snapshot_id"], 333) self.assertEqual(repository.snapshot_calls[0]["image_path"], "captures/btc.png") self.assertEqual(repository.review_calls[0]["visual_state"], "confirm") + self.assertEqual(repository.review_calls[0]["payload"]["payload"]["trigger_policy"]["status"], "triggered") self.assertEqual(repository.completed_calls[0]["status"], "reviewed") def test_review_pending_watch_runs_uses_existing_snapshot(self) -> None: @@ -146,6 +151,8 @@ class VisualReviewServiceTests(unittest.TestCase): self.assertEqual(result["reviewed"], 1) self.assertEqual(result["items"][0]["visual_state"], "mismatch") self.assertEqual(result["items"][0]["snapshot_id"], 444) + self.assertEqual(result["trigger_type_counts"][VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW], 1) + self.assertEqual(result["review_state_counts"]["mismatch"], 1) self.assertEqual(repository.completed_calls[0]["payload"]["review_status"], "reviewed") def test_review_watch_run_auto_captures_when_no_snapshot_exists(self) -> None: diff --git a/tests/unit/test_visual_trigger_policy_service.py b/tests/unit/test_visual_trigger_policy_service.py new file mode 100644 index 0000000..f5597fe --- /dev/null +++ b/tests/unit/test_visual_trigger_policy_service.py @@ -0,0 +1,70 @@ +import unittest + +from src.services.visual_watch import ( + VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH, + VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW, + VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW, + VISUAL_TRIGGER_NEWS_WINDOW_REVIEW, + VISUAL_TRIGGER_POLICY_SKIPPED, + VISUAL_TRIGGER_POLICY_TRIGGERED, + VISUAL_TRIGGER_RECONCILIATION_REVIEW, + evaluate_visual_trigger_policy, + suggest_visual_trigger, + suggest_visual_triggers_from_council, +) + + +class VisualTriggerPolicyServiceTests(unittest.TestCase): + def test_observe_monitoring_skips_visual_review(self) -> None: + policy = evaluate_visual_trigger_policy( + action_recommendation="observe_only", + status="monitoring", + why_blocked=[], + ) + + self.assertEqual(policy["status"], VISUAL_TRIGGER_POLICY_SKIPPED) + self.assertFalse(policy["triggered"]) + self.assertEqual(policy["trigger_types"], []) + self.assertEqual(policy["skip_reason"], "observe_monitoring_not_high_value") + self.assertIsNone(suggest_visual_trigger(action_recommendation="observe_only", status="monitoring")) + + def test_actionable_candidate_triggers_high_value_watch(self) -> None: + policy = evaluate_visual_trigger_policy( + action_recommendation="confirm_ticket", + status="actionable", + why_blocked=[], + ) + + self.assertEqual(policy["status"], VISUAL_TRIGGER_POLICY_TRIGGERED) + self.assertTrue(policy["triggered"]) + self.assertEqual(policy["primary_trigger_type"], VISUAL_TRIGGER_CANDIDATE_ACTIONABLE_WATCH) + self.assertTrue(policy["is_high_value"]) + + def test_degraded_market_and_broker_reasons_trigger_auditable_reviews(self) -> None: + policy = evaluate_visual_trigger_policy( + action_recommendation="do_not_execute", + status="blocked", + why_blocked=["market_stream_disconnected", "broker_read_transport_error"], + ) + + self.assertIn(VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW, policy["trigger_types"]) + self.assertIn(VISUAL_TRIGGER_MARKET_ANOMALY_REVIEW, policy["trigger_types"]) + self.assertIn(VISUAL_TRIGGER_RECONCILIATION_REVIEW, policy["trigger_types"]) + self.assertIn("market_anomaly_or_data_degraded", policy["reason_codes"]) + self.assertIn("reconciliation_or_broker_degraded", policy["reason_codes"]) + self.assertTrue(policy["is_high_value"]) + + def test_council_watch_items_map_to_visual_trigger_types(self) -> None: + triggers = suggest_visual_triggers_from_council( + action_recommendation="wait_for_confirmations", + status="blocked", + why_blocked=["mss_missing"], + recommended_watch_items=["news_window_review", "visual_review_missing"], + ) + + self.assertIn(VISUAL_TRIGGER_NEWS_WINDOW_REVIEW, triggers) + self.assertIn(VISUAL_TRIGGER_CANDIDATE_BLOCK_REASON_REVIEW, triggers) + + +if __name__ == "__main__": + unittest.main()