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,16 +1,22 @@
"""后台轮询目录中的 .svo2跑 FishMeasure写入 app_state.last_measure与 ingest 共用状态)。"""
"""后台轮询目录中的 .svo2跑 FishMeasure写入 SQLite与 ingest 共用)。"""
from __future__ import annotations
import asyncio
import traceback
from pathlib import Path
from typing import Dict, Set
from loguru import logger
from app.db import add_watch_processed, load_watch_processed, save_measure_snapshot
from app.services import measure as measure_svc
from app.services.action_watch import load_processed, save_processed
from app.settings import Settings
from app.state import MeasureSnapshot, app_state
from app.watch_idle import IdleWatchWarnState, idle_warn_interval_sec, maybe_warn_idle_watch
_MEASURE_IDLE_WARN_INTERVAL_SEC = idle_warn_interval_sec(
"FISH_MEASURE_WATCH_IDLE_WARN_INTERVAL_SEC"
)
def _state_path(settings: Settings) -> Path:
@@ -43,29 +49,32 @@ async def _run_measure_and_state(
key = str(svo.resolve())
if key in processed:
return
print(f"[measure-watch] inference: {svo}", flush=True)
logger.info("[measure-watch] inference: {}", svo)
async with app_state.measure_lock:
app_state.measure_status = "running"
try:
snap = await asyncio.to_thread(measure_svc.run_full_measure, svo, settings)
app_state.last_measure = snap
save_measure_snapshot(settings, snap, source_path=key)
app_state.measure_status = "idle"
processed.add(key)
if settings.measure_watch_use_state_file:
save_processed(state_file, processed)
add_watch_processed(settings, key, "measure")
r0 = snap.result[0] if snap.result else {}
w = r0.get("weight", "")
print(f"[measure-watch] done: {svo.name} weight={w!r}", flush=True)
logger.info("[measure-watch] done: {} weight={!r}", svo.name, w)
except Exception as e:
app_state.last_measure = MeasureSnapshot(
result=[],
video_left="",
video_right="",
error=str(e),
save_measure_snapshot(
settings,
MeasureSnapshot(
result=[],
video_left="",
video_right="",
error=str(e),
),
source_path=key,
)
app_state.measure_status = "error"
print(f"[measure-watch] error on {svo}: {e}", flush=True)
traceback.print_exc()
logger.exception("[measure-watch] error on {}: {}", svo, e)
raise
@@ -116,24 +125,36 @@ async def run_measure_watch_loop(settings: Settings) -> None:
assert settings.measure_watch_dir is not None
wd = settings.measure_watch_dir
if not wd.is_dir():
print(f"[measure-watch] skip: not a directory: {wd}", flush=True)
logger.warning("[measure-watch] skip: not a directory: {}", wd)
return
state_file = _state_path(settings)
processed: Set[str] = (
load_processed(state_file) if settings.measure_watch_use_state_file else set()
load_watch_processed(settings, state_file, "measure")
if settings.measure_watch_use_state_file
else set()
)
stability: Dict[str, tuple[int, int]] = {}
print(
f"[measure-watch] watching {wd} "
f"(poll={settings.measure_watch_poll_interval}s, "
f"stable_polls={settings.measure_watch_stable_polls}, "
f"state={'on' if settings.measure_watch_use_state_file else 'off'} "
f"{state_file if settings.measure_watch_use_state_file else ''})",
flush=True,
logger.info(
"[measure-watch] watching {} (poll={}s, stable_polls={}, state={} {})",
wd,
settings.measure_watch_poll_interval,
settings.measure_watch_stable_polls,
"on" if settings.measure_watch_use_state_file else "off",
state_file if settings.measure_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="measure-watch",
algo_name="FishMeasure",
idle_hint="目录内无 .svo2、已全部处理完毕或文件尚未达到稳定轮询次数",
watch_dir=wd,
state=idle_warn_state,
interval_sec=_MEASURE_IDLE_WARN_INTERVAL_SEC,
)
await asyncio.sleep(max(settings.measure_watch_poll_interval, 0.1))