- tcaptcha/web/:FastAPI + SSE + 零构建单页。把同步阻塞的 solve() 包成可观测 后台任务(工作线程 + 全局串行,因 onnxruntime session 非线程安全),用 logging.Handler 零侵入把 client 日志变成事件流;按靶子实测 min_interval 强制节流。 - tcaptcha/render.py:标注图(点击顺序编号 + 质心偏差)与点位量尺,CLI 与 web 共用。 - tcaptcha/targets.py:靶子表提为单一来源(含实测频控间隔 1ms=60s / mliev=3s)。 - fastapi/uvicorn 走 optional extra `web`,核心包零 web 依赖(带回归断言)。 - 修两个真 bug:hook 求解器时 args[0] 是实例自身(真入参从 args[1] 起); client 默认每次新建 solver,预热实例未被复用(每发重载 56MB ONNX)。 - 复验:icon/text/slider 三题型各拿到 errorCode=0;selftest 全绿;依赖隔离通过。 用法:uv run --extra web python -m tcaptcha.web.app
78 lines
3.0 KiB
Python
78 lines
3.0 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""事件模型 —— 把一次求解的中间态结构化,经 SSE 推给前端。
|
||
|
||
设计要点:
|
||
* 前端只认**一种信封**:`{"seq", "ts", "type", "data"}`,分支全在 `type` 上。
|
||
* `type` 的取值集中在下面的 `EVT_*` 常量里,`runner.py` 与前端 `app.js` 共用同一套名字;
|
||
新增事件类型时**只改这里 + 前端一处**。
|
||
* 事件是**追加式**的:每个 job 维护一个列表,客户端可带 `since=<seq>` 续传 ——
|
||
断线重连、或任务跑到一半才打开页面,都能补齐历史(SSE 的 `Last-Event-ID` 走同一套)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Dict
|
||
|
||
# ---- 事件类型 ------------------------------------------------------------- #
|
||
EVT_STARTED = "job.started" # data: {target, kind, mode, params}
|
||
EVT_CHALLENGE = "challenge" # data: {kind, instruction, bg_url, prompt_url, subcapclass}
|
||
EVT_SOLVE = "solve" # data: {points, off_px, annotated_url, note}
|
||
EVT_TELEMETRY = "telemetry" # data: {source, collect_len, segments, traj_points}
|
||
EVT_VERIFY = "verify" # data: {errorCode, ticket, randstr, elapsed_ms, success}
|
||
EVT_LOG = "log" # data: {level, logger, message}
|
||
EVT_FINISHED = "job.finished" # data: {success, errorCode, ticket, elapsed_ms, error, stats}
|
||
EVT_ERROR = "job.error" # data: {message}
|
||
|
||
#: 一次任务最终会落到哪些终态(前端据此停止重连)
|
||
TERMINAL_TYPES = frozenset({EVT_FINISHED, EVT_ERROR})
|
||
|
||
|
||
@dataclass
|
||
class Event:
|
||
"""一条事件。SSE 的 `data:` 就是 `to_dict()` 的 JSON。"""
|
||
|
||
seq: int
|
||
type: str
|
||
data: Dict[str, Any] = field(default_factory=dict)
|
||
ts: float = field(default_factory=time.time)
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {"seq": self.seq, "ts": round(self.ts, 3), "type": self.type, "data": self.data}
|
||
|
||
|
||
class EventLog:
|
||
"""一个 job 的事件缓冲区:追加 + 按 seq 续传。
|
||
|
||
注意它是**只追加**的:`since` 语义是"给我 seq > since 的全部",
|
||
这样 SSE 断开重连不会丢事件,也不会重复推送。
|
||
|
||
带锁:写在工作线程(求解),读在 HTTP 线程(SSE),
|
||
且 `seq = len + 1` 与 `append` 必须是原子的,否则会撞出重复 seq。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._items: list[Event] = []
|
||
self._lock = threading.Lock()
|
||
|
||
def append(self, type_: str, **data: Any) -> Event:
|
||
with self._lock:
|
||
evt = Event(seq=len(self._items) + 1, type=type_, data=dict(data))
|
||
self._items.append(evt)
|
||
return evt
|
||
|
||
def since(self, seq: int) -> list[Event]:
|
||
with self._lock:
|
||
return [e for e in self._items if e.seq > seq]
|
||
|
||
def __len__(self) -> int: # pragma: no cover - 方便调试
|
||
with self._lock:
|
||
return len(self._items)
|
||
|
||
@property
|
||
def last_seq(self) -> int:
|
||
with self._lock:
|
||
return len(self._items)
|