File size: 6,149 Bytes
bb59211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""数据质检 API —— 检查项目录、报告查询、手动触发。

给前端两件事:
* 设置面板的**数据源** —— ``/checks`` 返回检查项目录 + 按设备卡片的分组,
  面板按它渲染 tab 和每一项的阈值输入框。新增检查项前端零改动。
* 报告的**读取入口** —— 列表页和详情页。

写端点少而克制:只有"重跑某集"。质检本身不做任何数据修改,
所以没有删除/清理类的接口。
"""

from __future__ import annotations

import asyncio
from pathlib import Path

from fastapi import APIRouter, HTTPException, Query

router = APIRouter(prefix="/api/v1/quality", tags=["quality"])


def _check_catalog() -> dict:
    """检查项目录 + 按设备卡片分组 —— 前端设置面板的渲染数据。

    ``checks`` 是平铺清单(每项带 ``device_cards`` / ``default_params``);
    ``by_device`` 是按设备卡片分好组的视图,前端 tab 直接用。
    两者给同一份数据,前端按方便取用。
    """
    from app.processing.cleaning.checks import all_checks, all_check_specs
    from app.processing.cleaning.contract import (
        DEVICE_MODALITIES, MODALITY_CHANNELS,
    )
    from app.processing.cleaning.engine import ruleset_revision

    specs = all_check_specs()
    by_device: dict[str, list[str]] = {}
    for modality in DEVICE_MODALITIES:
        by_device[modality] = [
            spec["slug"] for spec in specs
            if modality in (spec.get("device_cards") or [])
        ]

    return {
        "ruleset_revision": ruleset_revision(),
        "checks": specs,
        "by_device": by_device,
        "device_labels": _device_labels(),
        "device_channels": {
            modality: list(channels)
            for modality, channels in MODALITY_CHANNELS.items()
        },
        "severity_labels": _severity_labels(),
        "total": len(all_checks()),
    }


def _device_labels() -> dict[str, str]:
    """设备卡片的显示名 —— 复用 device_naming,不另起一套。"""
    try:
        from app.device_naming import DEVICE_LABELS
        return dict(DEVICE_LABELS)
    except Exception:
        return {}


def _severity_labels() -> dict[str, str]:
    from app.processing.cleaning.contract import SEVERITY_LABELS
    return dict(SEVERITY_LABELS)


@router.get("/checks")
def list_checks() -> dict:
    """检查项目录(含按设备卡片分组)—— 设置面板用。"""
    return _check_catalog()


@router.get("/summary")
def list_summaries(project: str | None = Query(None),
                   status: str | None = Query(None),
                   limit: int = Query(200, ge=1, le=2000)) -> dict:
    """质检报告列表(摘要),按时间倒序。

    ``status`` 过滤按 episode 结论(pass / warn / fail / pending_review / error)。
    """
    from app.processing.cleaning.store import list_reports

    items = list_reports()
    if project:
        items = [item for item in items if str(item.get("project")) == project]
    if status:
        items = [item for item in items if item.get("status") == status]

    counts: dict[str, int] = {}
    for item in items:
        key = str(item.get("status") or "unknown")
        counts[key] = counts.get(key, 0) + 1

    return {"reports": items[:limit], "total": len(items), "counts": counts}


@router.get("/episodes/{episode_id}")
def get_report(episode_id: str) -> dict:
    """某一集的完整质检报告(四层结果)。"""
    from app.processing.cleaning.store import load_report, load_summary

    report = load_report(episode_id)
    if report is not None:
        return report
    summary = load_summary(episode_id)
    if summary is not None:
        # 完整报告被清掉了但摘要还在(state 里)—— 至少把摘要给出去
        return {"episode_id": episode_id, "summary_only": True, **summary}
    raise HTTPException(status_code=404, detail="No quality report for this episode")


@router.post("/episodes/{episode_id}/run")
async def run_episode_checks(
    episode_id: str,
    probe_video: bool = Query(False, description="是否跑视频检查(慢)"),
    persist: bool = Query(True, description="是否把报告写进 episode state"),
) -> dict:
    """重跑某一集的质检。

    跑在**线程池**里 —— 读 parquet/视频是阻塞 IO,直接放事件循环上会把
    页面请求卡住(仓库里 ``routes/devices.py`` 有同样的处理)。
    """
    episode = await asyncio.to_thread(_find_episode, episode_id)
    if episode is None:
        raise HTTPException(status_code=404, detail="Episode not found")

    report = await asyncio.to_thread(_run_checks_for, episode, probe_video)
    saved = False
    if persist:
        from app.processing.cleaning.store import save_report
        saved = await asyncio.to_thread(save_report, episode_id, report)
    return {"episode_id": episode_id, "saved": saved, "report": report}


def _find_episode(episode_id: str) -> dict | None:
    """在会话索引里找这一集(含 ``path``,质检要用它读数据)。"""
    from app.localstore import scan_sessions

    for episode in scan_sessions():
        if str(episode.get("id")) == episode_id:
            return episode
    return None


def _run_checks_for(episode: dict, probe_video: bool) -> dict:
    from app.processing.cleaning.engine import run_checks

    path = Path(str(episode.get("path") or ""))
    if not path.is_dir():
        raise HTTPException(status_code=409, detail="Episode data directory missing")

    # 设备卡片取自 episode 的设备信息 —— 与工作流派发用的是同一套口径
    modality = None
    try:
        from app.workflow_dispatch import _episode_input_groups
        groups = _episode_input_groups(episode) or []
        if groups:
            modality = str(groups[0].get("input_type") or "") or None
    except Exception:
        modality = None

    return run_checks(
        path,
        episode_id=str(episode.get("id") or ""),
        project=str(episode.get("project") or ""),
        probe_video=probe_video,
        modality_hint=modality,
    )