Close P16 browser screenshot capture loop

This commit is contained in:
Codex
2026-05-12 07:57:13 +08:00
parent 522c2349c5
commit b436c021b6
14 changed files with 307 additions and 25 deletions
+18 -6
View File
@@ -2,7 +2,7 @@
## 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
@@ -14,8 +14,8 @@ Automation execution-layer P15 is closed locally. P15 expands alert silent-windo
## Automation Status
- state: automation_execution_layer_p15_alert_noise_policy_matrix_closed
- current_phase: P15 Alert Noise Policy Matrix
- state: automation_execution_layer_p16_browser_screenshot_capture_closed
- current_phase: P16 Browser Screenshot Capture Adapter
- latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
- latest_p14_slice: webhook_transport_retry
- 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_route_status: visible_existing_quality_summary
- 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_runtime_visual_watch_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 `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 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
@@ -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 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.
- 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 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`.
@@ -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 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
@@ -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.
- 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.
- 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`.
- Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter.
## 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
+19 -6
View File
@@ -13,7 +13,7 @@
## In Progress
- [x] P15 Alert Noise Policy Matrix local closure complete
- [x] P16 Browser Screenshot Capture Adapter local closure complete
## Next Up
@@ -26,14 +26,15 @@
- [x] P14 local closure complete
- [x] Define finite P15-PXX continuation queue
- [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
- [ ] Treat browser screenshot capture and additional external media adapters as future enhancements
## Automation Status
- [x] state: automation_execution_layer_p15_alert_noise_policy_matrix_closed
- [x] current_phase: P15 Alert Noise Policy Matrix
- [x] state: automation_execution_layer_p16_browser_screenshot_capture_closed
- [x] current_phase: P16 Browser Screenshot Capture Adapter
- [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
@@ -42,7 +43,11 @@
- [x] latest_alert_noise_policy_status: implemented
- [x] latest_alert_noise_policy_route_status: visible_existing_quality_summary
- [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_runtime_visual_watch_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 the workbench `Alert Quality` panel
- [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 focused service, script, API, and runtime tests
- [x] Smoked the visual review script with no pending reviews
@@ -122,6 +132,8 @@
- [x] Smoked the trading runtime with blockers=none
- [x] Smoked P15 dispatcher 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
@@ -170,11 +182,12 @@
- [x] `automation_execution_layer_p14_webhook_transport_retry`
- [x] `automation_execution_layer_p15_pxx_tasklist_ready`
- [x] `automation_execution_layer_p15_alert_noise_policy_matrix`
- [x] `automation_execution_layer_p16_browser_screenshot_capture_adapter`
## P15-PXX Finite Queue
- [x] `P15` Alert Noise Policy Matrix
- [ ] `P16` Browser Screenshot Capture Adapter
- [x] `P16` Browser Screenshot Capture Adapter
- [ ] `P17` Visual Trigger ROI Router
- [ ] `P18` Visual Evidence Media Dispatch
- [ ] `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 已新增 `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。
### 验收
- 非重大错误不打断主循环
@@ -219,10 +219,15 @@
输出建议:
- `image_path`
- `html_path`
- `screenshot_path`
- `captured_ts`
- `source`
- `browser_capture_status`
- `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`
职责:把截图和结构化上下文融合为视觉摘要。
@@ -107,6 +107,12 @@
- 失败时返回 `unavailable` 结构,不中断 runtime。
- 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
+13
View File
@@ -21,6 +21,8 @@ def main(env=None) -> int:
trigger_type=_get_optional_value(env, "VISUAL_CAPTURE_TRIGGER_TYPE"),
source_name=_get_optional_value(env, "VISUAL_CAPTURE_SOURCE_NAME"),
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:
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')}")
if 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"):
print(f"Artifact path: {payload.get('artifact_path')}")
if payload.get("reason"):
@@ -59,5 +65,12 @@ def _get_optional_value(env, key: str):
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__":
raise SystemExit(main())
+13
View File
@@ -431,6 +431,8 @@ def route_request(
trigger_type=_get_body_value(body, "trigger_type", None),
source_name=_get_body_value(body, "source_name", 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":
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"}
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:
value = _get_body_value(body, key, default)
return float(value)
+4
View File
@@ -88,6 +88,8 @@ def capture_visual_watch_snapshot(
trigger_type: str | None = None,
source_name: str | None = None,
output_dir: str | None = None,
capture_url: str | None = None,
browser_capture_enabled: bool | None = None,
) -> dict:
repository = VisualSnapshotRepository()
watch_run = None
@@ -110,6 +112,8 @@ def capture_visual_watch_snapshot(
watch_run=watch_run,
source_name=source_name,
output_dir=output_dir,
capture_url=capture_url,
browser_capture_enabled=browser_capture_enabled,
)
if capture.get("status") != "captured":
return capture
+5
View File
@@ -96,6 +96,11 @@ class Settings(BaseSettings):
visual_capture_cleanup_interval_seconds: int = 3600
visual_capture_retention_hours: int = 168
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_interval_seconds: int = 20
alert_dispatch_max_per_run: int = 25
@@ -6,14 +6,19 @@ from pathlib import Path
from typing import Optional, TYPE_CHECKING
import json
import re
import shutil
import subprocess
if TYPE_CHECKING:
from src.services.operator import ReviewPacketService
from src.config.settings import settings
ROOT = Path(__file__).resolve().parents[3]
DEFAULT_CAPTURE_ROOT = ROOT / ".claude" / "visual_watch" / "captures"
CAPTURE_MODE_REVIEW_PACKET_HTML = "review_packet_html"
CAPTURE_MODE_BROWSER_SCREENSHOT = "browser_screenshot"
class VisualSnapshotCaptureService:
@@ -22,9 +27,15 @@ class VisualSnapshotCaptureService:
*,
review_packet_service: Optional[object] = None,
output_root: str | Path | None = None,
browser_capture_enabled: bool | None = None,
browser_path: str | None = None,
browser_capture_func=None,
) -> None:
self.review_packet_service = review_packet_service
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(
self,
@@ -32,6 +43,9 @@ class VisualSnapshotCaptureService:
watch_run: dict,
source_name: str | None = None,
output_dir: str | Path | None = None,
capture_url: str | None = None,
browser_capture_enabled: bool | None = None,
viewport: dict | None = None,
) -> dict:
watch_payload = dict(watch_run or {})
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)
html_path = output_root / f"{slug}.html"
screenshot_path = output_root / f"{slug}.png"
artifact_path = output_root / f"{slug}.json"
resolved_capture_url = str(capture_url or "").strip() or html_path.as_uri()
payload = _build_capture_payload(
watch_payload=watch_payload,
review_packet=review_packet,
@@ -60,7 +76,10 @@ class VisualSnapshotCaptureService:
captured_ts=captured_ts,
source_name=resolved_source_name,
html_path=html_path,
screenshot_path=None,
artifact_path=artifact_path,
capture_url=resolved_capture_url,
browser_capture=None,
)
html_path.write_text(
_render_capture_html(
@@ -71,16 +90,38 @@ class VisualSnapshotCaptureService:
),
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")
image_path = str(payload.get("image_path") or html_path)
return {
"status": "captured",
"watch_run_id": int(watch_payload["id"]),
"instrument_id": int(watch_payload["instrument_id"]),
"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),
"metadata_path": str(artifact_path),
"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_error": review_packet_error,
"payload": payload,
@@ -105,6 +146,75 @@ class VisualSnapshotCaptureService:
except Exception as 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:
if path is None or str(path).strip() == "":
@@ -134,14 +244,22 @@ def _build_capture_payload(
captured_ts: datetime,
source_name: str,
html_path: Path,
screenshot_path: Path | None,
artifact_path: Path,
capture_url: str,
browser_capture: dict | None,
) -> dict:
return {
"capture_mode": CAPTURE_MODE_REVIEW_PACKET_HTML,
"captured_ts": captured_ts.isoformat(),
"source_name": source_name,
"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),
"metadata_path": str(artifact_path),
"capture_url": capture_url,
"browser_capture": browser_capture or {},
"watch_run": _watch_summary(watch_payload),
"review_packet_status": "captured" if review_packet is not None else "unavailable",
"review_packet_error": review_packet_error,
@@ -242,3 +360,27 @@ def _render_capture_html(
def _json_block(payload: dict) -> 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
+13
View File
@@ -652,6 +652,16 @@ function getVisualSnapshotArtifactPath(review) {
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) {
const summary = review?.feedback_summary || {};
const labels = summary.labels || {};
@@ -689,6 +699,7 @@ function renderVisualReviews(reviewRows, decisionPacketId) {
['reviews', String(items.length)],
['latest state', latest?.visual_state || 'n/a'],
['latest source', latest?.snapshot_source_name || 'n/a'],
['latest browser capture', getVisualSnapshotBrowserStatus(latest)],
['latest artifact', getVisualSnapshotArtifactPath(latest)],
['latest feedback', `${latestFeedback.count} | ${latestFeedback.latestLabel}`],
['feedback labels', latestFeedback.labels],
@@ -708,7 +719,9 @@ function renderVisualReviews(reviewRows, decisionPacketId) {
[
['snapshot', review.snapshot_id || 'n/a'],
['source', review.snapshot_source_name || 'n/a'],
['browser capture', getVisualSnapshotBrowserStatus(review)],
['image path', review.snapshot_image_path || 'n/a'],
['html path', getVisualSnapshotHtmlPath(review)],
['artifact path', getVisualSnapshotArtifactPath(review)],
['reviewed', formatTimestamp(review.reviewed_ts)],
['confidence', formatNumber(review.visual_confidence, 2)],
+18 -9
View File
@@ -130,25 +130,34 @@ class ApiTests(unittest.TestCase):
self.assertEqual(payload["watch_run_id"], 9)
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(
"/trading/visual-watch/capture",
{},
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={
"trading_visual_capture_snapshot": lambda **kwargs: {
"status": "captured",
"watch_run_id": kwargs["watch_run_id"],
"snapshot_id": 21,
"image_path": "captures/watch-9.html",
"artifact_path": "captures/watch-9.json",
}
"trading_visual_capture_snapshot": fake_capture
},
)
self.assertEqual(status, 201)
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:
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",
"watch_run_id": 11,
"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",
"browser_capture_status": "captured",
}
output = io.StringIO()
with contextlib.redirect_stdout(output):
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())
self.assertEqual(exit_code, 0)
self.assertEqual(payload["status"], "captured")
self.assertEqual(payload["snapshot_id"], 55)
capture_mock.assert_called_once()
self.assertTrue(capture_mock.call_args.kwargs["browser_capture_enabled"])
if __name__ == "__main__":
@@ -11,6 +11,7 @@ class VisualSnapshotCaptureServiceTests(unittest.TestCase):
service = VisualSnapshotCaptureService(
review_packet_service=_ReviewPacketService(),
output_root=Path(tmp),
browser_capture_enabled=False,
)
result = service.capture_watch_run_snapshot(
@@ -30,8 +31,10 @@ class VisualSnapshotCaptureServiceTests(unittest.TestCase):
self.assertEqual(result["status"], "captured")
self.assertEqual(result["capture_mode"], "review_packet_html")
self.assertEqual(result["browser_capture_status"], "disabled")
self.assertEqual(result["source_name"], "unit_capture")
self.assertTrue(Path(result["image_path"]).is_file())
self.assertTrue(Path(result["html_path"]).is_file())
self.assertTrue(Path(result["artifact_path"]).is_file())
html = Path(result["image_path"]).read_text(encoding="utf-8")
self.assertIn("Visual Watch Snapshot", html)
@@ -39,6 +42,45 @@ class VisualSnapshotCaptureServiceTests(unittest.TestCase):
self.assertIn("confirm_ticket", html)
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:
def build_packet(self, *, instrument_id: int) -> dict: