Spaces:
Running
Running
stouchinglhh
Claude
sync processor from GitLab c697b6e: 版本 1.19.4 / 前端 1.12.4 —— 服务端 i18n(首屏不再闪英文)、推理模块并入模型页、前端抽出 shared/、缓存键改用前端号
bb59211 Download processor/app/processing/cleaning/store.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 8.04 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/processing/cleaning/store.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/app/processing/cleaning/store.py
-
curl -L -o store.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/processing/cleaning/store.py
8.04 kB
| """质检报告落盘 —— 写进 episode state 的 ``cleaning_report`` 字段。 | |
| **报告与状态流转是同一次写入**:判 FAIL/ERROR 的批次会被拦回 | |
| ``to_review``(人工审核),并留下 ``cleaning_summary.blocking`` 这个粘性标记。 | |
| 只写报告、不碰状态的方案看着更"干净",但挡住不坏数据 —— 自动批准散在 | |
| ``ai_annotation`` / ``video_quality`` 两处门禁里,它们各看各的报告, | |
| 谁都不会读质检结论。判 FAIL 的集只要视频干净,照样被自动批准放行。 | |
| 另外**不动** ``deleted`` / ``rejected`` 这类状态:质检不能把用户删掉或否决过的 | |
| 批次复活。 | |
| 放在 ``state/episode_states/<episode_id>.json`` 而不是项目数据集里: | |
| 后者是给 LeRobot 消费的干净树(``project_dataset`` 明确不建 ``processed/``、 | |
| ``meta/processing/``),而质检报告是 episode 级的可变状态 —— ``state/`` 正是 | |
| 它的既有归属,``ai_quality_report`` / ``video_quality_report`` 也都在这。 | |
| 读走 ``localstore.read_episode_state``(带内存缓存),写走 | |
| ``write_episode_state``(原子替换 + 同步更新缓存)—— 两条路径都自带锁, | |
| 不要自己读写那个文件。 | |
| """ | |
| from __future__ import annotations | |
| from typing import Any | |
| from app.processing.cleaning.contract import is_blocking | |
| # episode state 里放质检报告的字段名。 | |
| REPORT_FIELD = "cleaning_report" | |
| # 只放摘要,完整报告另存 —— 报告里可能有几十条区间,全塞进 state 会让 | |
| # 每次 ``read_episode_state`` 都要解析几百 KB。完整报告见 ``reports_dir()``。 | |
| SUMMARY_FIELD = "cleaning_summary" | |
| # 完整报告单独存一个目录,避免污染 episode state | |
| _REPORTS_SUBDIR = "quality_reports" | |
| # 允许被质检拦回人工审核的状态。 | |
| # | |
| # **不含 ``to_review``**(本来就在那儿,再写一次是空操作)、也不含 ``deleted`` | |
| # 与 ``rejected`` —— 质检不能把用户删掉/否决过的批次复活。 | |
| _BLOCKABLE_STATUSES = frozenset({ | |
| "processing", "completed", "reviewed", "approved", "failed", | |
| }) | |
| def _reports_dir(): | |
| from app.localstore import STATE_ROOT | |
| return STATE_ROOT / _REPORTS_SUBDIR | |
| def summary_of(report: dict) -> dict[str, Any]: | |
| """从完整报告里抽一个轻量摘要 —— 列表页/徽标只读这个。 | |
| ``blocking`` 是**给自动批准路径看的**闸门(见 ``is_approval_blocked``), | |
| 不是展示字段:判 FAIL/ERROR 才为真,WARN 只是警告、不拦。 | |
| """ | |
| episode = report.get("episode") or {} | |
| return { | |
| "schema_version": report.get("schema_version"), | |
| "passed": bool(report.get("passed")), | |
| "blocking": is_blocking(str(episode.get("status") or "")), | |
| "status": episode.get("status"), | |
| "status_label": episode.get("status_label"), | |
| "summary": episode.get("summary"), | |
| "counts": { | |
| "fail": len(episode.get("failed_streams") or []), | |
| "warn": len(episode.get("warn_streams") or []), | |
| "pending": len(episode.get("pending_streams") or []), | |
| }, | |
| "ranges": len(report.get("ranges") or []), | |
| "ruleset_revision": report.get("ruleset_revision"), | |
| "computed_at": report.get("computed_at"), | |
| "reason": report.get("reason"), | |
| } | |
| def is_approval_blocked(state: dict) -> bool: | |
| """该集的质检结论是否禁止**自动批准**。 | |
| 自动批准(``to_review`` → ``reviewed``)散在好几处:AI 标注的质量门禁 | |
| (``ai_annotation._set_ai_quality_state``)、视频门禁 | |
| (``video_quality._apply_video_quality_result``)。它们只看**自己的** | |
| 报告,不看数据质检的结论 —— 于是质检判 FAIL、视频门禁判 PASS 时,坏数据 | |
| 会被自动放行。 | |
| ★ 为什么不能只靠"把 status 推回 to_review": | |
| 几条门禁是**并发**跑的(都在 run 完成回调里 create_task),写的先后没有 | |
| 保证。若质检先把状态推回 to_review、视频门禁随后又置成 reviewed, | |
| 拦截就白做了。所以质检除了推状态,还留下这个**粘性标记**,让所有自动 | |
| 批准路径都先看一眼。 | |
| 标记不需要手工清除:下一次质检会整份覆盖 ``cleaning_summary``, | |
| 数据修好后 ``blocking`` 自然变回 False。 | |
| """ | |
| summary = (state or {}).get(SUMMARY_FIELD) or {} | |
| return bool(summary.get("blocking")) | |
| def save_report(episode_id: str, report: dict) -> bool: | |
| """把报告写进 episode state。返回是否写入成功。 | |
| **不改 ``status``** —— 只设 ``cleaning_report`` / ``cleaning_summary`` / | |
| ``cleaning_at`` 三个字段。 | |
| episode 不存在时静默跳过(外键不存在就别凭空造一条 state)。 | |
| """ | |
| from app.localstore import mutate_episode_state, _write_json | |
| key = str(episode_id) | |
| if not key: | |
| return False | |
| # 完整报告先单独落盘 —— 它可能有几十条区间,塞进 episode state 会让每次 | |
| # read_episode_state 都要解析几百 KB。放在下面临界区**外面**:这是个大文件 | |
| # 写入(在 NAS 上可能是几十毫秒),不该占着状态锁。 | |
| try: | |
| _write_json(_reports_dir() / f"{key}.json", report) | |
| except Exception: | |
| pass # 摘要照样进 state,完整报告失败不影响 | |
| summary = summary_of(report) | |
| def _apply(state: dict) -> bool: | |
| state[REPORT_FIELD] = report | |
| state[SUMMARY_FIELD] = summary | |
| state["cleaning_at"] = report.get("computed_at") | |
| # 数据不对 → 拦回人工审核。与写报告**同一次锁内 RMW**:分成两次写的话 | |
| # 中间那个窗口里,另一条并发的门禁可能把状态改成 reviewed。 | |
| if summary.get("blocking"): | |
| current = str(state.get("status") or "") | |
| if current in _BLOCKABLE_STATUSES: | |
| state["status"] = "to_review" | |
| state["approved_at"] = None | |
| return True | |
| # 走锁内 RMW:跑完工作流后 video_quality 的门禁任务与本任务是并发写的, | |
| # 手写 read→write 会让其中一方的字段被另一方的旧快照覆盖(详见 | |
| # localstore.mutate_episode_state 的注释)。episode 不存在时返回 None, | |
| # 保持"不凭空造 state"的既有行为。 | |
| return bool(mutate_episode_state(key, _apply)) | |
| def load_report(episode_id: str) -> dict | None: | |
| """读完整报告(不存在返回 None)。""" | |
| from app.localstore import _read_json | |
| return _read_json(_reports_dir() / f"{episode_id}.json") | |
| def load_summary(episode_id: str) -> dict | None: | |
| """读摘要(从 episode state 取,快)。""" | |
| from app.localstore import read_episode_state | |
| return (read_episode_state(str(episode_id)) or {}).get(SUMMARY_FIELD) | |
| def list_reports(limit: int = 0) -> list[dict]: | |
| """列出全部摘要,按时间倒序 —— 给质量列表页用。 | |
| 只扫 ``quality_reports/`` 目录,不 join ``scan_sessions()`` —— 后者在 | |
| SSHFS 上冷读极慢,而报告是自包含的(项目名就在里面)。 | |
| """ | |
| from app.localstore import _read_json | |
| directory = _reports_dir() | |
| if not directory.is_dir(): | |
| return [] | |
| items: list[dict] = [] | |
| for path in directory.glob("*.json"): | |
| report = _read_json(path) | |
| if not isinstance(report, dict): | |
| continue | |
| item = summary_of(report) | |
| item["episode_id"] = report.get("episode_id") or path.stem | |
| item["project"] = report.get("project") | |
| items.append(item) | |
| items.sort(key=lambda item: str(item.get("computed_at") or ""), reverse=True) | |
| return items[:limit] if limit else items | |
| def forget_report(episode_id: str) -> None: | |
| """删除报告(episode 被删除时调用)。""" | |
| for path in (_reports_dir() / f"{episode_id}.json",): | |
| try: | |
| path.unlink(missing_ok=True) | |
| except OSError: | |
| pass | |