Close P17 visual trigger ROI router

This commit is contained in:
Codex
2026-05-12 08:11:16 +08:00
parent b436c021b6
commit bc2bde8410
19 changed files with 407 additions and 58 deletions
+24 -6
View File
@@ -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
```
+22 -7
View File
@@ -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
@@ -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 计数。
### 验收
- 非重大错误不打断主循环
@@ -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/`
@@ -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
+4
View File
@@ -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')} "
@@ -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")),
}
@@ -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,
@@ -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"
+6
View File
@@ -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",
@@ -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
@@ -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]:
+4
View File
@@ -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)],
@@ -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"])
@@ -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:
@@ -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)
@@ -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)
+8 -1
View File
@@ -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:
@@ -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()