feat(fish_api): SQLite 快照投递、日志与 watch 空闲告警

- 新增 SQLite:measure/health 快照、delivery_cursor 单消费者 pop;clear/start_fresh 可清空库
- biomass GET 仅返回约定 data 字段,X-Fish-Biomass-New 表示是否有新快照;poller 读响应头
- loguru 桥接 uvicorn,子进程 stdout 流式输出;format_json_pretty 与算法摘要日志
- measure/action watch 无新任务时限流 WARNING;watch_idle 共用逻辑
- 依赖 loguru;新增 db、logging_config、subprocess_run、watch_idle、启动脚本

FishMeasure: 更新 fish_video_weight_evaluation 与 predict_weigth_from_svo2;移除未用 refbox/segmentation 脚本
Made-with: Cursor
This commit is contained in:
zaiun xu
2026-04-09 11:54:30 +08:00
parent db181d4f84
commit 5e1b2117c1
29 changed files with 1464 additions and 1714 deletions

View File

@@ -1,14 +1,20 @@
from __future__ import annotations
import asyncio
import json
import traceback
from pathlib import Path
from typing import Any, Dict, Set
from typing import Dict, Set
from loguru import logger
from app.db import add_watch_processed, load_watch_processed, save_health_snapshot
from app.services import action as action_svc
from app.settings import Settings
from app.state import HealthSnapshot, app_state
from app.watch_idle import IdleWatchWarnState, idle_warn_interval_sec, maybe_warn_idle_watch
_ACTION_IDLE_WARN_INTERVAL_SEC = idle_warn_interval_sec(
"FISH_ACTION_WATCH_IDLE_WARN_INTERVAL_SEC"
)
def _state_path(settings: Settings) -> Path:
@@ -18,29 +24,6 @@ def _state_path(settings: Settings) -> Path:
return settings.action_watch_dir / ".fishaction_watch_processed.json"
def load_processed(path: Path) -> Set[str]:
if not path.is_file():
return set()
try:
with open(path, encoding="utf-8") as f:
data: Any = json.load(f)
if isinstance(data, list):
return set(str(x) for x in data)
if isinstance(data, dict) and "processed" in data:
return set(str(x) for x in data["processed"])
except (json.JSONDecodeError, OSError):
pass
return set()
def save_processed(path: Path, processed: Set[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(sorted(processed), f, indent=0, ensure_ascii=False)
tmp.replace(path)
def iter_mp4(watch_dir: Path, recursive: bool) -> list[Path]:
if recursive:
return sorted(
@@ -64,27 +47,30 @@ async def _run_inference_and_state(
key = str(mp4.resolve())
if key in processed:
return
print(f"[action-watch] inference: {mp4}", flush=True)
logger.info("[action-watch] inference: {}", mp4)
async with app_state.action_lock:
app_state.action_status = "running"
try:
snap = await asyncio.to_thread(action_svc.run_full_action, mp4, settings)
app_state.last_health = snap
save_health_snapshot(settings, snap, source_path=key)
app_state.action_status = "idle"
processed.add(key)
if settings.action_watch_use_state_file:
save_processed(state_file, processed)
add_watch_processed(settings, key, "action")
pred = (snap.raw_class_en or "").strip()
print(f"[action-watch] done: {mp4.name} -> {pred}", flush=True)
logger.info("[action-watch] done: {} -> {}", mp4.name, pred)
except Exception as e:
app_state.last_health = HealthSnapshot(
behavior_result="",
health_result="",
error=str(e),
save_health_snapshot(
settings,
HealthSnapshot(
behavior_result="",
health_result="",
error=str(e),
),
source_path=key,
)
app_state.action_status = "error"
print(f"[action-watch] error on {mp4}: {e}", flush=True)
traceback.print_exc()
logger.exception("[action-watch] error on {}: {}", mp4, e)
raise
@@ -136,24 +122,36 @@ async def run_action_watch_loop(settings: Settings) -> None:
assert settings.action_watch_dir is not None
wd = settings.action_watch_dir
if not wd.is_dir():
print(f"[action-watch] skip: not a directory: {wd}", flush=True)
logger.warning("[action-watch] skip: not a directory: {}", wd)
return
state_file = _state_path(settings)
processed: Set[str] = (
load_processed(state_file) if settings.action_watch_use_state_file else set()
load_watch_processed(settings, state_file, "action")
if settings.action_watch_use_state_file
else set()
)
stability: Dict[str, tuple[int, int]] = {}
print(
f"[action-watch] watching {wd} "
f"(poll={settings.action_watch_poll_interval}s, "
f"stable_polls={settings.action_watch_stable_polls}, "
f"state={'on' if settings.action_watch_use_state_file else 'off'} "
f"{state_file if settings.action_watch_use_state_file else ''})",
flush=True,
logger.info(
"[action-watch] watching {} (poll={}s, stable_polls={}, state={} {})",
wd,
settings.action_watch_poll_interval,
settings.action_watch_stable_polls,
"on" if settings.action_watch_use_state_file else "off",
state_file if settings.action_watch_use_state_file else "",
)
idle_warn_state = IdleWatchWarnState()
while True:
await watch_tick(settings, processed, stability, state_file)
did = await watch_tick(settings, processed, stability, state_file)
maybe_warn_idle_watch(
did_work=did,
log_tag="action-watch",
algo_name="FishAction",
idle_hint="目录内无 .mp4、已全部处理完毕或文件尚未达到稳定轮询次数",
watch_dir=wd,
state=idle_warn_state,
interval_sec=_ACTION_IDLE_WARN_INTERVAL_SEC,
)
await asyncio.sleep(max(settings.action_watch_poll_interval, 0.1))