Close P16 browser screenshot capture loop
This commit is contained in:
+18
-6
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Current Goal
|
## Current Goal
|
||||||
|
|
||||||
Automation execution-layer P15 is closed locally. P15 expands alert silent-window noise control into an auditable alert noise policy matrix across alert type, severity, channel, duty mode, and operator mode, with dispatcher, delivery summary, runtime, and workbench visibility. The default next action is P16 `Browser Screenshot Capture Adapter`.
|
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`.
|
||||||
|
|
||||||
## Current Branch
|
## Current Branch
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ Automation execution-layer P15 is closed locally. P15 expands alert silent-windo
|
|||||||
|
|
||||||
## Automation Status
|
## Automation Status
|
||||||
|
|
||||||
- state: automation_execution_layer_p15_alert_noise_policy_matrix_closed
|
- state: automation_execution_layer_p16_browser_screenshot_capture_closed
|
||||||
- current_phase: P15 Alert Noise Policy Matrix
|
- current_phase: P16 Browser Screenshot Capture Adapter
|
||||||
- latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
|
- latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
|
||||||
- latest_p14_slice: webhook_transport_retry
|
- latest_p14_slice: webhook_transport_retry
|
||||||
- latest_p15_pxx_tasklist_status: ready
|
- latest_p15_pxx_tasklist_status: ready
|
||||||
@@ -24,7 +24,11 @@ Automation execution-layer P15 is closed locally. P15 expands alert silent-windo
|
|||||||
- latest_alert_noise_policy_status: implemented
|
- latest_alert_noise_policy_status: implemented
|
||||||
- latest_alert_noise_policy_route_status: visible_existing_quality_summary
|
- latest_alert_noise_policy_route_status: visible_existing_quality_summary
|
||||||
- latest_alert_noise_policy_runtime_status: visible
|
- latest_alert_noise_policy_runtime_status: visible
|
||||||
- next_execution_layer_phase: P16 Browser Screenshot Capture Adapter
|
- latest_p16_slice: browser_screenshot_capture_adapter
|
||||||
|
- 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_visual_review_script_status: idle
|
- latest_visual_review_script_status: idle
|
||||||
- latest_runtime_visual_watch_task_status: ok
|
- latest_runtime_visual_watch_task_status: ok
|
||||||
- latest_visual_capture_cleanup_task_status: ok
|
- latest_visual_capture_cleanup_task_status: ok
|
||||||
@@ -86,6 +90,10 @@ Automation execution-layer P15 is closed locally. P15 expands alert silent-windo
|
|||||||
- Extended `AlertDispatcherService` to persist `delivery_policy` metadata and expose `policy_external_allowed`, `workbench_only`, and `suppressed_noise_policy` counters.
|
- Extended `AlertDispatcherService` to persist `delivery_policy` metadata and expose `policy_external_allowed`, `workbench_only`, and `suppressed_noise_policy` counters.
|
||||||
- Extended `AlertQualitySummaryService`, runtime `alert_dispatch` task details, `run_alert_dispatcher.py`, and the workbench `Alert Quality` panel with noise-policy visibility.
|
- Extended `AlertQualitySummaryService`, runtime `alert_dispatch` task details, `run_alert_dispatcher.py`, and the workbench `Alert Quality` panel with noise-policy visibility.
|
||||||
- Added focused unit tests for policy matrix, dispatcher suppression, summary rollup, runtime details, and docs inventory alignment.
|
- Added focused unit tests for policy matrix, dispatcher suppression, summary rollup, runtime details, and docs inventory alignment.
|
||||||
|
- Added browser-backed screenshot capture support to `VisualSnapshotCaptureService` using local Edge/Chrome headless when available.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Current Working State
|
## Current Working State
|
||||||
|
|
||||||
@@ -101,6 +109,8 @@ Automation execution-layer P15 is closed locally. P15 expands alert silent-windo
|
|||||||
- P15-PXX is now a finite known-scope queue: P15 noise policy matrix, P16 browser screenshot capture adapter, P17 visual trigger ROI router, P18 visual evidence media dispatch, P19 multi-channel routing, P20 operator digest/review queue, P21 runtime incident journal, P22 maintenance rollup, P23 unattended harness, P24 remote smoke, and PXX closure gate.
|
- P15-PXX is now a finite known-scope queue: P15 noise policy matrix, P16 browser screenshot capture adapter, P17 visual trigger ROI router, P18 visual evidence media dispatch, P19 multi-channel routing, P20 operator digest/review queue, P21 runtime incident journal, P22 maintenance rollup, P23 unattended harness, P24 remote smoke, and PXX closure gate.
|
||||||
- P15 noise policy matrix is implemented: external alert sends can be suppressed by severity, alert type, duty mode, operator mode, silent window, or cooldown while workbench visibility remains intact.
|
- P15 noise policy matrix is implemented: external alert sends can be suppressed by severity, alert type, duty mode, operator mode, silent window, or cooldown while workbench visibility remains intact.
|
||||||
- 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.
|
- 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.
|
||||||
- Latest `scripts/run_visual_review.py` smoke found no pending reviews: attempted=0, reviewed=0, status=idle.
|
- 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 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`.
|
- Latest cleanup smoke deleted one expired temporary artifact through `scripts/run_visual_capture_cleanup.py`.
|
||||||
@@ -110,7 +120,7 @@ Automation execution-layer P15 is closed locally. P15 expands alert silent-windo
|
|||||||
- Latest `scripts/run_trading_runtime.py` smoke returned blockers=none and includes a healthy `visual_watch` runtime task.
|
- 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.
|
- Latest local runtime smoke confirmed `runtime_status.tasks.alert_dispatch.details` now exposes retry and silent-window counters with blockers=none.
|
||||||
|
|
||||||
## P15 Blockers
|
## P16 Blockers
|
||||||
|
|
||||||
- none
|
- none
|
||||||
|
|
||||||
@@ -126,13 +136,15 @@ Automation execution-layer P15 is closed locally. P15 expands alert silent-windo
|
|||||||
- Transport retry remains operational only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation.
|
- Transport retry remains operational only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation.
|
||||||
- P15-PXX must remain automation execution-layer work and must not reopen Research Council phases or create new trading permissions.
|
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
- 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`.
|
||||||
- Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter.
|
- Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter.
|
||||||
|
|
||||||
## Exact Next Step
|
## Exact Next Step
|
||||||
|
|
||||||
P15 is locally closed. Default next action is P16 `Browser Screenshot Capture Adapter`: add a browser-backed screenshot capture adapter for local workbench/chart URLs, expose structured unavailable fallback on failure, connect script/API/workbench visibility, validate with focused tests and smoke, then continue to P17 unless a real blocker appears.
|
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.
|
||||||
|
|
||||||
## Validation Commands Used Recently
|
## Validation Commands Used Recently
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
- [x] P15 Alert Noise Policy Matrix local closure complete
|
- [x] P16 Browser Screenshot Capture Adapter local closure complete
|
||||||
|
|
||||||
## Next Up
|
## Next Up
|
||||||
|
|
||||||
@@ -26,14 +26,15 @@
|
|||||||
- [x] P14 local closure complete
|
- [x] P14 local closure complete
|
||||||
- [x] Define finite P15-PXX continuation queue
|
- [x] Define finite P15-PXX continuation queue
|
||||||
- [x] P15 local closure complete
|
- [x] P15 local closure complete
|
||||||
- [ ] Start P16 Browser Screenshot Capture Adapter
|
- [x] P16 local closure complete
|
||||||
|
- [ ] Start P17 Visual Trigger ROI Router
|
||||||
- [ ] Continue periodic analysis-decision monitoring with maturity rollup in parallel
|
- [ ] Continue periodic analysis-decision monitoring with maturity rollup in parallel
|
||||||
- [ ] Treat browser screenshot capture and additional external media adapters as future enhancements
|
- [ ] Treat browser screenshot capture and additional external media adapters as future enhancements
|
||||||
|
|
||||||
## Automation Status
|
## Automation Status
|
||||||
|
|
||||||
- [x] state: automation_execution_layer_p15_alert_noise_policy_matrix_closed
|
- [x] state: automation_execution_layer_p16_browser_screenshot_capture_closed
|
||||||
- [x] current_phase: P15 Alert Noise Policy Matrix
|
- [x] current_phase: P16 Browser Screenshot Capture Adapter
|
||||||
- [x] latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
|
- [x] latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
|
||||||
- [x] latest_p14_slice: webhook_transport_retry
|
- [x] latest_p14_slice: webhook_transport_retry
|
||||||
- [x] latest_p15_pxx_tasklist_status: ready
|
- [x] latest_p15_pxx_tasklist_status: ready
|
||||||
@@ -42,7 +43,11 @@
|
|||||||
- [x] latest_alert_noise_policy_status: implemented
|
- [x] latest_alert_noise_policy_status: implemented
|
||||||
- [x] latest_alert_noise_policy_route_status: visible_existing_quality_summary
|
- [x] latest_alert_noise_policy_route_status: visible_existing_quality_summary
|
||||||
- [x] latest_alert_noise_policy_runtime_status: visible
|
- [x] latest_alert_noise_policy_runtime_status: visible
|
||||||
- [x] next_execution_layer_phase: P16 Browser Screenshot Capture Adapter
|
- [x] latest_p16_slice: browser_screenshot_capture_adapter
|
||||||
|
- [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_visual_review_script_status: idle
|
- [x] latest_visual_review_script_status: idle
|
||||||
- [x] latest_runtime_visual_watch_task_status: ok
|
- [x] latest_runtime_visual_watch_task_status: ok
|
||||||
- [x] latest_visual_capture_cleanup_task_status: ok
|
- [x] latest_visual_capture_cleanup_task_status: ok
|
||||||
@@ -111,6 +116,11 @@
|
|||||||
- [x] Added policy counters to runtime `alert_dispatch` task details
|
- [x] Added policy counters to runtime `alert_dispatch` task details
|
||||||
- [x] Added policy counters to the workbench `Alert Quality` panel
|
- [x] Added policy counters to the workbench `Alert Quality` panel
|
||||||
- [x] Added focused P15 policy, dispatcher, summary, runtime, and docs inventory tests
|
- [x] Added focused P15 policy, dispatcher, summary, runtime, and docs inventory tests
|
||||||
|
- [x] Added browser-backed screenshot capture to `VisualSnapshotCaptureService`
|
||||||
|
- [x] Added visual capture payload fields for `html_path`, `screenshot_path`, `metadata_path`, `capture_url`, and `browser_capture_status`
|
||||||
|
- [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 runtime `visual_watch` task
|
- [x] Added runtime `visual_watch` task
|
||||||
- [x] Added focused service, script, API, and runtime tests
|
- [x] Added focused service, script, API, and runtime tests
|
||||||
- [x] Smoked the visual review script with no pending reviews
|
- [x] Smoked the visual review script with no pending reviews
|
||||||
@@ -122,6 +132,8 @@
|
|||||||
- [x] Smoked the trading runtime with blockers=none
|
- [x] Smoked the trading runtime with blockers=none
|
||||||
- [x] Smoked P15 dispatcher with policy counters visible
|
- [x] Smoked P15 dispatcher with policy counters visible
|
||||||
- [x] Smoked P15 quality-summary API with policy counters visible
|
- [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
|
||||||
|
|
||||||
## Blocked
|
## Blocked
|
||||||
|
|
||||||
@@ -170,11 +182,12 @@
|
|||||||
- [x] `automation_execution_layer_p14_webhook_transport_retry`
|
- [x] `automation_execution_layer_p14_webhook_transport_retry`
|
||||||
- [x] `automation_execution_layer_p15_pxx_tasklist_ready`
|
- [x] `automation_execution_layer_p15_pxx_tasklist_ready`
|
||||||
- [x] `automation_execution_layer_p15_alert_noise_policy_matrix`
|
- [x] `automation_execution_layer_p15_alert_noise_policy_matrix`
|
||||||
|
- [x] `automation_execution_layer_p16_browser_screenshot_capture_adapter`
|
||||||
|
|
||||||
## P15-PXX Finite Queue
|
## P15-PXX Finite Queue
|
||||||
|
|
||||||
- [x] `P15` Alert Noise Policy Matrix
|
- [x] `P15` Alert Noise Policy Matrix
|
||||||
- [ ] `P16` Browser Screenshot Capture Adapter
|
- [x] `P16` Browser Screenshot Capture Adapter
|
||||||
- [ ] `P17` Visual Trigger ROI Router
|
- [ ] `P17` Visual Trigger ROI Router
|
||||||
- [ ] `P18` Visual Evidence Media Dispatch
|
- [ ] `P18` Visual Evidence Media Dispatch
|
||||||
- [ ] `P19` Multi-Channel Alert Routing
|
- [ ] `P19` Multi-Channel Alert Routing
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ candidate -> decision alert -> dispatcher -> 外部消息 -> delivery record
|
|||||||
- 2026-05-12 P14 本地闭环已验证:focused transport retry 单测通过,`tests/unit` 全量通过;脚本级 smoke 因本地 PostgreSQL 未监听 `127.0.0.1:5432` 而未能在最后重跑,但代码路径已被全量单测覆盖。
|
- 2026-05-12 P14 本地闭环已验证:focused transport retry 单测通过,`tests/unit` 全量通过;脚本级 smoke 因本地 PostgreSQL 未监听 `127.0.0.1:5432` 而未能在最后重跑,但代码路径已被全量单测覆盖。
|
||||||
- 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 已新增 `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 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。
|
||||||
|
|
||||||
### 验收
|
### 验收
|
||||||
- 非重大错误不打断主循环
|
- 非重大错误不打断主循环
|
||||||
|
|||||||
@@ -219,10 +219,15 @@
|
|||||||
输出建议:
|
输出建议:
|
||||||
|
|
||||||
- `image_path`
|
- `image_path`
|
||||||
|
- `html_path`
|
||||||
|
- `screenshot_path`
|
||||||
- `captured_ts`
|
- `captured_ts`
|
||||||
- `source`
|
- `source`
|
||||||
|
- `browser_capture_status`
|
||||||
- `meta`
|
- `meta`
|
||||||
|
|
||||||
|
2026-05-12 P16 已落地:当前实现优先使用本机 Edge/Chrome headless 截取 review-packet HTML 或指定 capture URL,成功时输出 PNG;浏览器不可用或截图失败时返回结构化 `browser_capture_status=unavailable`,并保留 HTML/JSON artifact 作为 advisory fallback。
|
||||||
|
|
||||||
#### `src/services/visual_watch/vision_review_service.py`
|
#### `src/services/visual_watch/vision_review_service.py`
|
||||||
职责:把截图和结构化上下文融合为视觉摘要。
|
职责:把截图和结构化上下文融合为视觉摘要。
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,12 @@
|
|||||||
- 失败时返回 `unavailable` 结构,不中断 runtime。
|
- 失败时返回 `unavailable` 结构,不中断 runtime。
|
||||||
- focused tests、脚本 smoke、API smoke 通过。
|
- focused tests、脚本 smoke、API smoke 通过。
|
||||||
|
|
||||||
|
### 当前进展
|
||||||
|
|
||||||
|
- 2026-05-12 P16 已完成 `Browser Screenshot Capture Adapter` 本地闭环:`VisualSnapshotCaptureService` 会继续生成 review-packet HTML/JSON artifact,并在本机 Edge/Chrome headless 可用时额外生成 PNG screenshot。
|
||||||
|
- 2026-05-12 P16 已将 capture payload 扩展为 `html_path`、`screenshot_path`、`metadata_path`、`capture_url`、`browser_capture_status` 与结构化 `browser_capture`,并把这些字段接入 `scripts/run_visual_snapshot_capture.py`、`POST /trading/visual-watch/capture` 和 workbench Visual Reviews 面板。
|
||||||
|
- 2026-05-12 P16 本地闭环已验证:focused browser capture/script/API tests 通过,`tests/unit` 全量 668 个测试通过,script static checks 与 `node --check src/web/app.js` 通过;真实本机 browser screenshot smoke 已生成 PNG,脚本在无 watch run 时仍安全 no-op。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. P17 Visual Trigger ROI Router
|
## 7. P17 Visual Trigger ROI Router
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ def main(env=None) -> int:
|
|||||||
trigger_type=_get_optional_value(env, "VISUAL_CAPTURE_TRIGGER_TYPE"),
|
trigger_type=_get_optional_value(env, "VISUAL_CAPTURE_TRIGGER_TYPE"),
|
||||||
source_name=_get_optional_value(env, "VISUAL_CAPTURE_SOURCE_NAME"),
|
source_name=_get_optional_value(env, "VISUAL_CAPTURE_SOURCE_NAME"),
|
||||||
output_dir=_get_optional_value(env, "VISUAL_CAPTURE_OUTPUT_DIR"),
|
output_dir=_get_optional_value(env, "VISUAL_CAPTURE_OUTPUT_DIR"),
|
||||||
|
capture_url=_get_optional_value(env, "VISUAL_CAPTURE_URL"),
|
||||||
|
browser_capture_enabled=_get_optional_bool(env, "VISUAL_CAPTURE_BROWSER_ENABLED"),
|
||||||
)
|
)
|
||||||
if output_json:
|
if output_json:
|
||||||
print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str))
|
print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str))
|
||||||
@@ -31,6 +33,10 @@ def main(env=None) -> int:
|
|||||||
print(f"Snapshot id: {payload.get('snapshot_id')}")
|
print(f"Snapshot id: {payload.get('snapshot_id')}")
|
||||||
if payload.get("image_path"):
|
if payload.get("image_path"):
|
||||||
print(f"Image path: {payload.get('image_path')}")
|
print(f"Image path: {payload.get('image_path')}")
|
||||||
|
if payload.get("html_path"):
|
||||||
|
print(f"HTML path: {payload.get('html_path')}")
|
||||||
|
if payload.get("browser_capture_status"):
|
||||||
|
print(f"Browser capture: {payload.get('browser_capture_status')}")
|
||||||
if payload.get("artifact_path"):
|
if payload.get("artifact_path"):
|
||||||
print(f"Artifact path: {payload.get('artifact_path')}")
|
print(f"Artifact path: {payload.get('artifact_path')}")
|
||||||
if payload.get("reason"):
|
if payload.get("reason"):
|
||||||
@@ -59,5 +65,12 @@ def _get_optional_value(env, key: str):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _get_optional_bool(env, key: str):
|
||||||
|
value = env.get(key)
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
@@ -431,6 +431,8 @@ def route_request(
|
|||||||
trigger_type=_get_body_value(body, "trigger_type", None),
|
trigger_type=_get_body_value(body, "trigger_type", None),
|
||||||
source_name=_get_body_value(body, "source_name", None),
|
source_name=_get_body_value(body, "source_name", None),
|
||||||
output_dir=_get_body_value(body, "output_dir", None),
|
output_dir=_get_body_value(body, "output_dir", None),
|
||||||
|
capture_url=_get_body_value(body, "capture_url", None),
|
||||||
|
browser_capture_enabled=_get_body_optional_bool(body, "browser_capture_enabled"),
|
||||||
)
|
)
|
||||||
if payload.get("status") == "ignored":
|
if payload.get("status") == "ignored":
|
||||||
return 404, {"error": payload.get("reason") or "not_found"}
|
return 404, {"error": payload.get("reason") or "not_found"}
|
||||||
@@ -810,6 +812,17 @@ def _get_body_bool(body: Optional[dict], key: str, default: bool) -> bool:
|
|||||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_body_optional_bool(body: Optional[dict], key: str):
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return None
|
||||||
|
value = body.get(key)
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
def _get_body_float(body: Optional[dict], key: str, default: float) -> float:
|
def _get_body_float(body: Optional[dict], key: str, default: float) -> float:
|
||||||
value = _get_body_value(body, key, default)
|
value = _get_body_value(body, key, default)
|
||||||
return float(value)
|
return float(value)
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ def capture_visual_watch_snapshot(
|
|||||||
trigger_type: str | None = None,
|
trigger_type: str | None = None,
|
||||||
source_name: str | None = None,
|
source_name: str | None = None,
|
||||||
output_dir: str | None = None,
|
output_dir: str | None = None,
|
||||||
|
capture_url: str | None = None,
|
||||||
|
browser_capture_enabled: bool | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
repository = VisualSnapshotRepository()
|
repository = VisualSnapshotRepository()
|
||||||
watch_run = None
|
watch_run = None
|
||||||
@@ -110,6 +112,8 @@ def capture_visual_watch_snapshot(
|
|||||||
watch_run=watch_run,
|
watch_run=watch_run,
|
||||||
source_name=source_name,
|
source_name=source_name,
|
||||||
output_dir=output_dir,
|
output_dir=output_dir,
|
||||||
|
capture_url=capture_url,
|
||||||
|
browser_capture_enabled=browser_capture_enabled,
|
||||||
)
|
)
|
||||||
if capture.get("status") != "captured":
|
if capture.get("status") != "captured":
|
||||||
return capture
|
return capture
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ class Settings(BaseSettings):
|
|||||||
visual_capture_cleanup_interval_seconds: int = 3600
|
visual_capture_cleanup_interval_seconds: int = 3600
|
||||||
visual_capture_retention_hours: int = 168
|
visual_capture_retention_hours: int = 168
|
||||||
visual_capture_cleanup_max_delete_files: int = 500
|
visual_capture_cleanup_max_delete_files: int = 500
|
||||||
|
visual_browser_capture_enabled: bool = True
|
||||||
|
visual_browser_capture_browser_path: str = ""
|
||||||
|
visual_browser_capture_timeout_seconds: int = 20
|
||||||
|
visual_browser_capture_viewport_width: int = 1440
|
||||||
|
visual_browser_capture_viewport_height: int = 1200
|
||||||
alert_dispatch_enabled: bool = True
|
alert_dispatch_enabled: bool = True
|
||||||
alert_dispatch_interval_seconds: int = 20
|
alert_dispatch_interval_seconds: int = 20
|
||||||
alert_dispatch_max_per_run: int = 25
|
alert_dispatch_max_per_run: int = 25
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ from pathlib import Path
|
|||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.services.operator import ReviewPacketService
|
from src.services.operator import ReviewPacketService
|
||||||
|
|
||||||
|
from src.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
DEFAULT_CAPTURE_ROOT = ROOT / ".claude" / "visual_watch" / "captures"
|
DEFAULT_CAPTURE_ROOT = ROOT / ".claude" / "visual_watch" / "captures"
|
||||||
CAPTURE_MODE_REVIEW_PACKET_HTML = "review_packet_html"
|
CAPTURE_MODE_REVIEW_PACKET_HTML = "review_packet_html"
|
||||||
|
CAPTURE_MODE_BROWSER_SCREENSHOT = "browser_screenshot"
|
||||||
|
|
||||||
|
|
||||||
class VisualSnapshotCaptureService:
|
class VisualSnapshotCaptureService:
|
||||||
@@ -22,9 +27,15 @@ class VisualSnapshotCaptureService:
|
|||||||
*,
|
*,
|
||||||
review_packet_service: Optional[object] = None,
|
review_packet_service: Optional[object] = None,
|
||||||
output_root: str | Path | None = None,
|
output_root: str | Path | None = None,
|
||||||
|
browser_capture_enabled: bool | None = None,
|
||||||
|
browser_path: str | None = None,
|
||||||
|
browser_capture_func=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.review_packet_service = review_packet_service
|
self.review_packet_service = review_packet_service
|
||||||
self.output_root = _resolve_output_dir(output_root)
|
self.output_root = _resolve_output_dir(output_root)
|
||||||
|
self.browser_capture_enabled = settings.visual_browser_capture_enabled if browser_capture_enabled is None else bool(browser_capture_enabled)
|
||||||
|
self.browser_path = str(browser_path or settings.visual_browser_capture_browser_path or "")
|
||||||
|
self.browser_capture_func = browser_capture_func
|
||||||
|
|
||||||
def capture_watch_run_snapshot(
|
def capture_watch_run_snapshot(
|
||||||
self,
|
self,
|
||||||
@@ -32,6 +43,9 @@ class VisualSnapshotCaptureService:
|
|||||||
watch_run: dict,
|
watch_run: dict,
|
||||||
source_name: str | None = None,
|
source_name: str | None = None,
|
||||||
output_dir: str | Path | None = None,
|
output_dir: str | Path | None = None,
|
||||||
|
capture_url: str | None = None,
|
||||||
|
browser_capture_enabled: bool | None = None,
|
||||||
|
viewport: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
watch_payload = dict(watch_run or {})
|
watch_payload = dict(watch_run or {})
|
||||||
if watch_payload.get("id") is None:
|
if watch_payload.get("id") is None:
|
||||||
@@ -52,7 +66,9 @@ class VisualSnapshotCaptureService:
|
|||||||
|
|
||||||
slug = _build_capture_slug(watch_payload=watch_payload, captured_ts=captured_ts)
|
slug = _build_capture_slug(watch_payload=watch_payload, captured_ts=captured_ts)
|
||||||
html_path = output_root / f"{slug}.html"
|
html_path = output_root / f"{slug}.html"
|
||||||
|
screenshot_path = output_root / f"{slug}.png"
|
||||||
artifact_path = output_root / f"{slug}.json"
|
artifact_path = output_root / f"{slug}.json"
|
||||||
|
resolved_capture_url = str(capture_url or "").strip() or html_path.as_uri()
|
||||||
payload = _build_capture_payload(
|
payload = _build_capture_payload(
|
||||||
watch_payload=watch_payload,
|
watch_payload=watch_payload,
|
||||||
review_packet=review_packet,
|
review_packet=review_packet,
|
||||||
@@ -60,7 +76,10 @@ class VisualSnapshotCaptureService:
|
|||||||
captured_ts=captured_ts,
|
captured_ts=captured_ts,
|
||||||
source_name=resolved_source_name,
|
source_name=resolved_source_name,
|
||||||
html_path=html_path,
|
html_path=html_path,
|
||||||
|
screenshot_path=None,
|
||||||
artifact_path=artifact_path,
|
artifact_path=artifact_path,
|
||||||
|
capture_url=resolved_capture_url,
|
||||||
|
browser_capture=None,
|
||||||
)
|
)
|
||||||
html_path.write_text(
|
html_path.write_text(
|
||||||
_render_capture_html(
|
_render_capture_html(
|
||||||
@@ -71,16 +90,38 @@ class VisualSnapshotCaptureService:
|
|||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
browser_enabled = self.browser_capture_enabled if browser_capture_enabled is None else bool(browser_capture_enabled)
|
||||||
|
browser_capture = (
|
||||||
|
self._capture_browser_screenshot(
|
||||||
|
url=resolved_capture_url,
|
||||||
|
screenshot_path=screenshot_path,
|
||||||
|
viewport=viewport,
|
||||||
|
)
|
||||||
|
if browser_enabled
|
||||||
|
else {"status": "disabled", "reason": "browser_capture_disabled", "image_path": None}
|
||||||
|
)
|
||||||
|
if browser_capture.get("status") == "captured":
|
||||||
|
payload["capture_mode"] = CAPTURE_MODE_BROWSER_SCREENSHOT
|
||||||
|
payload["image_path"] = browser_capture.get("image_path")
|
||||||
|
payload["screenshot_path"] = browser_capture.get("image_path")
|
||||||
|
payload["browser_capture"] = browser_capture
|
||||||
artifact_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True, default=str), encoding="utf-8")
|
artifact_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True, default=str), encoding="utf-8")
|
||||||
|
image_path = str(payload.get("image_path") or html_path)
|
||||||
return {
|
return {
|
||||||
"status": "captured",
|
"status": "captured",
|
||||||
"watch_run_id": int(watch_payload["id"]),
|
"watch_run_id": int(watch_payload["id"]),
|
||||||
"instrument_id": int(watch_payload["instrument_id"]),
|
"instrument_id": int(watch_payload["instrument_id"]),
|
||||||
"source_name": resolved_source_name,
|
"source_name": resolved_source_name,
|
||||||
"image_path": str(html_path),
|
"image_path": image_path,
|
||||||
|
"html_path": str(html_path),
|
||||||
|
"screenshot_path": browser_capture.get("image_path"),
|
||||||
"artifact_path": str(artifact_path),
|
"artifact_path": str(artifact_path),
|
||||||
|
"metadata_path": str(artifact_path),
|
||||||
"captured_ts": captured_ts.isoformat(),
|
"captured_ts": captured_ts.isoformat(),
|
||||||
"capture_mode": CAPTURE_MODE_REVIEW_PACKET_HTML,
|
"capture_mode": str(payload.get("capture_mode") or CAPTURE_MODE_REVIEW_PACKET_HTML),
|
||||||
|
"capture_url": resolved_capture_url,
|
||||||
|
"browser_capture_status": browser_capture.get("status"),
|
||||||
|
"browser_capture": browser_capture,
|
||||||
"review_packet_status": payload["review_packet_status"],
|
"review_packet_status": payload["review_packet_status"],
|
||||||
"review_packet_error": review_packet_error,
|
"review_packet_error": review_packet_error,
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
@@ -105,6 +146,75 @@ class VisualSnapshotCaptureService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return None, f"{exc.__class__.__name__}: {exc}"
|
return None, f"{exc.__class__.__name__}: {exc}"
|
||||||
|
|
||||||
|
def _capture_browser_screenshot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
url: str,
|
||||||
|
screenshot_path: Path,
|
||||||
|
viewport: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
if self.browser_capture_func is not None:
|
||||||
|
return dict(self.browser_capture_func(url=url, screenshot_path=screenshot_path, viewport=viewport) or {})
|
||||||
|
browser_path = _resolve_browser_path(self.browser_path)
|
||||||
|
if not browser_path:
|
||||||
|
return {
|
||||||
|
"status": "unavailable",
|
||||||
|
"reason": "browser_not_found",
|
||||||
|
"url": url,
|
||||||
|
"image_path": None,
|
||||||
|
}
|
||||||
|
width = int((viewport or {}).get("width") or settings.visual_browser_capture_viewport_width)
|
||||||
|
height = int((viewport or {}).get("height") or settings.visual_browser_capture_viewport_height)
|
||||||
|
timeout_seconds = max(int(settings.visual_browser_capture_timeout_seconds or 1), 1)
|
||||||
|
cmd = [
|
||||||
|
str(browser_path),
|
||||||
|
"--headless=new",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--hide-scrollbars",
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
f"--window-size={width},{height}",
|
||||||
|
f"--screenshot={screenshot_path}",
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(ROOT),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"status": "unavailable",
|
||||||
|
"reason": "browser_capture_failed",
|
||||||
|
"url": url,
|
||||||
|
"browser_path": str(browser_path),
|
||||||
|
"error_type": exc.__class__.__name__,
|
||||||
|
"error": str(exc),
|
||||||
|
"image_path": None,
|
||||||
|
}
|
||||||
|
if completed.returncode == 0 and screenshot_path.is_file() and screenshot_path.stat().st_size > 0:
|
||||||
|
return {
|
||||||
|
"status": "captured",
|
||||||
|
"url": url,
|
||||||
|
"browser_path": str(browser_path),
|
||||||
|
"image_path": str(screenshot_path),
|
||||||
|
"viewport": {"width": width, "height": height},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "unavailable",
|
||||||
|
"reason": "browser_capture_failed",
|
||||||
|
"url": url,
|
||||||
|
"browser_path": str(browser_path),
|
||||||
|
"returncode": completed.returncode,
|
||||||
|
"stdout": completed.stdout[-1000:],
|
||||||
|
"stderr": completed.stderr[-1000:],
|
||||||
|
"image_path": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_output_dir(path: str | Path | None) -> Path:
|
def _resolve_output_dir(path: str | Path | None) -> Path:
|
||||||
if path is None or str(path).strip() == "":
|
if path is None or str(path).strip() == "":
|
||||||
@@ -134,14 +244,22 @@ def _build_capture_payload(
|
|||||||
captured_ts: datetime,
|
captured_ts: datetime,
|
||||||
source_name: str,
|
source_name: str,
|
||||||
html_path: Path,
|
html_path: Path,
|
||||||
|
screenshot_path: Path | None,
|
||||||
artifact_path: Path,
|
artifact_path: Path,
|
||||||
|
capture_url: str,
|
||||||
|
browser_capture: dict | None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"capture_mode": CAPTURE_MODE_REVIEW_PACKET_HTML,
|
"capture_mode": CAPTURE_MODE_REVIEW_PACKET_HTML,
|
||||||
"captured_ts": captured_ts.isoformat(),
|
"captured_ts": captured_ts.isoformat(),
|
||||||
"source_name": source_name,
|
"source_name": source_name,
|
||||||
"image_path": str(html_path),
|
"image_path": str(html_path),
|
||||||
|
"html_path": str(html_path),
|
||||||
|
"screenshot_path": str(screenshot_path) if screenshot_path else None,
|
||||||
"artifact_path": str(artifact_path),
|
"artifact_path": str(artifact_path),
|
||||||
|
"metadata_path": str(artifact_path),
|
||||||
|
"capture_url": capture_url,
|
||||||
|
"browser_capture": browser_capture or {},
|
||||||
"watch_run": _watch_summary(watch_payload),
|
"watch_run": _watch_summary(watch_payload),
|
||||||
"review_packet_status": "captured" if review_packet is not None else "unavailable",
|
"review_packet_status": "captured" if review_packet is not None else "unavailable",
|
||||||
"review_packet_error": review_packet_error,
|
"review_packet_error": review_packet_error,
|
||||||
@@ -242,3 +360,27 @@ def _render_capture_html(
|
|||||||
|
|
||||||
def _json_block(payload: dict) -> str:
|
def _json_block(payload: dict) -> str:
|
||||||
return escape(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True, default=str))
|
return escape(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_browser_path(configured_path: str | None = None) -> Path | None:
|
||||||
|
configured = str(configured_path or "").strip()
|
||||||
|
candidates: list[str] = []
|
||||||
|
if configured:
|
||||||
|
candidates.append(configured)
|
||||||
|
for name in ("msedge", "chrome", "chromium", "google-chrome", "chromium-browser"):
|
||||||
|
found = shutil.which(name)
|
||||||
|
if found:
|
||||||
|
candidates.append(found)
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||||
|
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
||||||
|
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
|
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
path = Path(candidate)
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|||||||
@@ -652,6 +652,16 @@ function getVisualSnapshotArtifactPath(review) {
|
|||||||
return snapshotPayload.artifact_path || snapshotPayload?.payload?.artifact_path || review?.snapshot_artifact_path || 'n/a';
|
return snapshotPayload.artifact_path || snapshotPayload?.payload?.artifact_path || review?.snapshot_artifact_path || 'n/a';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisualSnapshotBrowserStatus(review) {
|
||||||
|
const snapshotPayload = review?.snapshot_payload || {};
|
||||||
|
return snapshotPayload.browser_capture?.status || snapshotPayload?.payload?.browser_capture?.status || 'n/a';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisualSnapshotHtmlPath(review) {
|
||||||
|
const snapshotPayload = review?.snapshot_payload || {};
|
||||||
|
return snapshotPayload.html_path || snapshotPayload?.payload?.html_path || 'n/a';
|
||||||
|
}
|
||||||
|
|
||||||
function getVisualFeedbackSummary(review) {
|
function getVisualFeedbackSummary(review) {
|
||||||
const summary = review?.feedback_summary || {};
|
const summary = review?.feedback_summary || {};
|
||||||
const labels = summary.labels || {};
|
const labels = summary.labels || {};
|
||||||
@@ -689,6 +699,7 @@ function renderVisualReviews(reviewRows, decisionPacketId) {
|
|||||||
['reviews', String(items.length)],
|
['reviews', String(items.length)],
|
||||||
['latest state', latest?.visual_state || 'n/a'],
|
['latest state', latest?.visual_state || 'n/a'],
|
||||||
['latest source', latest?.snapshot_source_name || 'n/a'],
|
['latest source', latest?.snapshot_source_name || 'n/a'],
|
||||||
|
['latest browser capture', getVisualSnapshotBrowserStatus(latest)],
|
||||||
['latest artifact', getVisualSnapshotArtifactPath(latest)],
|
['latest artifact', getVisualSnapshotArtifactPath(latest)],
|
||||||
['latest feedback', `${latestFeedback.count} | ${latestFeedback.latestLabel}`],
|
['latest feedback', `${latestFeedback.count} | ${latestFeedback.latestLabel}`],
|
||||||
['feedback labels', latestFeedback.labels],
|
['feedback labels', latestFeedback.labels],
|
||||||
@@ -708,7 +719,9 @@ function renderVisualReviews(reviewRows, decisionPacketId) {
|
|||||||
[
|
[
|
||||||
['snapshot', review.snapshot_id || 'n/a'],
|
['snapshot', review.snapshot_id || 'n/a'],
|
||||||
['source', review.snapshot_source_name || 'n/a'],
|
['source', review.snapshot_source_name || 'n/a'],
|
||||||
|
['browser capture', getVisualSnapshotBrowserStatus(review)],
|
||||||
['image path', review.snapshot_image_path || 'n/a'],
|
['image path', review.snapshot_image_path || 'n/a'],
|
||||||
|
['html path', getVisualSnapshotHtmlPath(review)],
|
||||||
['artifact path', getVisualSnapshotArtifactPath(review)],
|
['artifact path', getVisualSnapshotArtifactPath(review)],
|
||||||
['reviewed', formatTimestamp(review.reviewed_ts)],
|
['reviewed', formatTimestamp(review.reviewed_ts)],
|
||||||
['confidence', formatNumber(review.visual_confidence, 2)],
|
['confidence', formatNumber(review.visual_confidence, 2)],
|
||||||
|
|||||||
@@ -130,25 +130,34 @@ class ApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(payload["watch_run_id"], 9)
|
self.assertEqual(payload["watch_run_id"], 9)
|
||||||
|
|
||||||
def test_route_request_visual_snapshot_capture(self) -> None:
|
def test_route_request_visual_snapshot_capture(self) -> None:
|
||||||
|
captured_kwargs = {}
|
||||||
|
|
||||||
|
def fake_capture(**kwargs):
|
||||||
|
captured_kwargs.update(kwargs)
|
||||||
|
return {
|
||||||
|
"status": "captured",
|
||||||
|
"watch_run_id": kwargs["watch_run_id"],
|
||||||
|
"snapshot_id": 21,
|
||||||
|
"image_path": "captures/watch-9.png",
|
||||||
|
"html_path": "captures/watch-9.html",
|
||||||
|
"artifact_path": "captures/watch-9.json",
|
||||||
|
}
|
||||||
|
|
||||||
status, payload = route_request(
|
status, payload = route_request(
|
||||||
"/trading/visual-watch/capture",
|
"/trading/visual-watch/capture",
|
||||||
{},
|
{},
|
||||||
method="POST",
|
method="POST",
|
||||||
body={"watch_run_id": 9, "source_name": "capture_bot"},
|
body={"watch_run_id": 9, "source_name": "capture_bot", "capture_url": "file:///tmp/chart.html", "browser_capture_enabled": True},
|
||||||
dependencies={
|
dependencies={
|
||||||
"trading_visual_capture_snapshot": lambda **kwargs: {
|
"trading_visual_capture_snapshot": fake_capture
|
||||||
"status": "captured",
|
|
||||||
"watch_run_id": kwargs["watch_run_id"],
|
|
||||||
"snapshot_id": 21,
|
|
||||||
"image_path": "captures/watch-9.html",
|
|
||||||
"artifact_path": "captures/watch-9.json",
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(status, 201)
|
self.assertEqual(status, 201)
|
||||||
self.assertEqual(payload["snapshot_id"], 21)
|
self.assertEqual(payload["snapshot_id"], 21)
|
||||||
self.assertEqual(payload["image_path"], "captures/watch-9.html")
|
self.assertEqual(payload["image_path"], "captures/watch-9.png")
|
||||||
|
self.assertEqual(captured_kwargs["capture_url"], "file:///tmp/chart.html")
|
||||||
|
self.assertTrue(captured_kwargs["browser_capture_enabled"])
|
||||||
|
|
||||||
def test_route_request_visual_review_create(self) -> None:
|
def test_route_request_visual_review_create(self) -> None:
|
||||||
with patch("src.api.server.create_visual_review", return_value={"review_id": 14, "status": "reviewed", "watch_run_id": 9, "visual_state": "aligned"}):
|
with patch("src.api.server.create_visual_review", return_value={"review_id": 14, "status": "reviewed", "watch_run_id": 9, "visual_state": "aligned"}):
|
||||||
|
|||||||
@@ -14,19 +14,23 @@ class RunVisualSnapshotCaptureScriptTests(unittest.TestCase):
|
|||||||
"status": "captured",
|
"status": "captured",
|
||||||
"watch_run_id": 11,
|
"watch_run_id": 11,
|
||||||
"snapshot_id": 55,
|
"snapshot_id": 55,
|
||||||
"image_path": "captures/watch-11.html",
|
"image_path": "captures/watch-11.png",
|
||||||
|
"html_path": "captures/watch-11.html",
|
||||||
"artifact_path": "captures/watch-11.json",
|
"artifact_path": "captures/watch-11.json",
|
||||||
|
"browser_capture_status": "captured",
|
||||||
}
|
}
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
with contextlib.redirect_stdout(output):
|
with contextlib.redirect_stdout(output):
|
||||||
exit_code = run_visual_snapshot_capture.main(
|
exit_code = run_visual_snapshot_capture.main(
|
||||||
env={"VISUAL_CAPTURE_WATCH_RUN_ID": "11", "VISUAL_CAPTURE_OUTPUT_JSON": "1"}
|
env={"VISUAL_CAPTURE_WATCH_RUN_ID": "11", "VISUAL_CAPTURE_OUTPUT_JSON": "1", "VISUAL_CAPTURE_BROWSER_ENABLED": "1"}
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = json.loads(output.getvalue())
|
payload = json.loads(output.getvalue())
|
||||||
self.assertEqual(exit_code, 0)
|
self.assertEqual(exit_code, 0)
|
||||||
self.assertEqual(payload["status"], "captured")
|
self.assertEqual(payload["status"], "captured")
|
||||||
self.assertEqual(payload["snapshot_id"], 55)
|
self.assertEqual(payload["snapshot_id"], 55)
|
||||||
|
capture_mock.assert_called_once()
|
||||||
|
self.assertTrue(capture_mock.call_args.kwargs["browser_capture_enabled"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class VisualSnapshotCaptureServiceTests(unittest.TestCase):
|
|||||||
service = VisualSnapshotCaptureService(
|
service = VisualSnapshotCaptureService(
|
||||||
review_packet_service=_ReviewPacketService(),
|
review_packet_service=_ReviewPacketService(),
|
||||||
output_root=Path(tmp),
|
output_root=Path(tmp),
|
||||||
|
browser_capture_enabled=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = service.capture_watch_run_snapshot(
|
result = service.capture_watch_run_snapshot(
|
||||||
@@ -30,8 +31,10 @@ class VisualSnapshotCaptureServiceTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(result["status"], "captured")
|
self.assertEqual(result["status"], "captured")
|
||||||
self.assertEqual(result["capture_mode"], "review_packet_html")
|
self.assertEqual(result["capture_mode"], "review_packet_html")
|
||||||
|
self.assertEqual(result["browser_capture_status"], "disabled")
|
||||||
self.assertEqual(result["source_name"], "unit_capture")
|
self.assertEqual(result["source_name"], "unit_capture")
|
||||||
self.assertTrue(Path(result["image_path"]).is_file())
|
self.assertTrue(Path(result["image_path"]).is_file())
|
||||||
|
self.assertTrue(Path(result["html_path"]).is_file())
|
||||||
self.assertTrue(Path(result["artifact_path"]).is_file())
|
self.assertTrue(Path(result["artifact_path"]).is_file())
|
||||||
html = Path(result["image_path"]).read_text(encoding="utf-8")
|
html = Path(result["image_path"]).read_text(encoding="utf-8")
|
||||||
self.assertIn("Visual Watch Snapshot", html)
|
self.assertIn("Visual Watch Snapshot", html)
|
||||||
@@ -39,6 +42,45 @@ class VisualSnapshotCaptureServiceTests(unittest.TestCase):
|
|||||||
self.assertIn("confirm_ticket", html)
|
self.assertIn("confirm_ticket", html)
|
||||||
self.assertEqual(result["payload"]["review_packet_summary"]["recent_alert_count"], 1)
|
self.assertEqual(result["payload"]["review_packet_summary"]["recent_alert_count"], 1)
|
||||||
|
|
||||||
|
def test_capture_watch_run_snapshot_can_write_browser_screenshot(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
def fake_browser_capture(*, url, screenshot_path, viewport):
|
||||||
|
Path(screenshot_path).write_bytes(b"\x89PNG\r\n\x1a\nunit")
|
||||||
|
return {
|
||||||
|
"status": "captured",
|
||||||
|
"url": url,
|
||||||
|
"image_path": str(screenshot_path),
|
||||||
|
"viewport": viewport or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
service = VisualSnapshotCaptureService(
|
||||||
|
review_packet_service=_ReviewPacketService(),
|
||||||
|
output_root=Path(tmp),
|
||||||
|
browser_capture_func=fake_browser_capture,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.capture_watch_run_snapshot(
|
||||||
|
watch_run={
|
||||||
|
"id": 12,
|
||||||
|
"trigger_type": "candidate_actionable_watch",
|
||||||
|
"instrument_id": 1,
|
||||||
|
"decision_packet_id": 44,
|
||||||
|
"symbol": "BTC-USDT",
|
||||||
|
"timeframe": "1m",
|
||||||
|
"status": "reserved",
|
||||||
|
},
|
||||||
|
source_name="unit_browser_capture",
|
||||||
|
viewport={"width": 800, "height": 600},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "captured")
|
||||||
|
self.assertEqual(result["capture_mode"], "browser_screenshot")
|
||||||
|
self.assertEqual(result["browser_capture_status"], "captured")
|
||||||
|
self.assertTrue(str(result["image_path"]).endswith(".png"))
|
||||||
|
self.assertTrue(Path(result["screenshot_path"]).is_file())
|
||||||
|
self.assertTrue(Path(result["html_path"]).is_file())
|
||||||
|
self.assertEqual(result["payload"]["browser_capture"]["status"], "captured")
|
||||||
|
|
||||||
|
|
||||||
class _ReviewPacketService:
|
class _ReviewPacketService:
|
||||||
def build_packet(self, *, instrument_id: int) -> dict:
|
def build_packet(self, *, instrument_id: int) -> dict:
|
||||||
|
|||||||
Reference in New Issue
Block a user