File size: 22,389 Bytes
439fd56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
"""Read-only, public-safe progress for candidate aerial batch annotations.

This section is independent of official video statistics and never publishes
frames or writes review decisions. SQL projects an explicit public whitelist;
the returned immutable model validates those fields again before presentation.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from functools import lru_cache
import json
import os
from pathlib import Path
from typing import Callable, Mapping
from uuid import UUID
from zoneinfo import ZoneInfo

import pandas as pd
import psycopg
import streamlit as st


_STATUSES = ("queued", "extracting", "labeling", "completed", "validation_failed", "failed", "skipped")
_STATES = frozenset(("observed", "not_observed", "unknown", "unlabeled", "not_applicable"))
_SCOPES = frozenset(("frame",))
_TIMEZONE = ZoneInfo("Asia/Shanghai")
_DATA = Path(__file__).resolve().parent / "data"
_ERRORS = {
    "source_unavailable": ("源视频暂不可读取", "Source video unavailable"),
    "no_valid_duration": ("缺少有效视频时长", "No valid video duration"),
    "frame_extract_failed": ("代表帧提取失败", "Representative frame extraction failed"),
    "api_auth": ("模型服务认证失败", "Model service authentication failed"),
    "api_quota": ("模型服务额度不足", "Model service quota exhausted"),
    "api_rate_limit": ("模型服务请求受限", "Model service rate limited"),
    "api_unavailable": ("模型服务暂不可用", "Model service unavailable"),
    "api_timeout": ("模型服务响应超时", "Model service timed out"),
    "invalid_json": ("模型返回格式未通过校验", "Model response format failed validation"),
    "invalid_labels": ("候选标签未通过校验", "Candidate labels failed validation"),
    "model_mismatch": ("返回模型与配置不一致", "Returned model differs from configuration"),
    "worker_exception": ("标注任务异常", "Annotation worker error"),
    "interrupted": ("标注任务已中断", "Annotation interrupted"),
    "source_group_changed": ("标注证据已更改,保留人工修改", "Annotation evidence changed; manual edits preserved"),
}

# A batch is eligible only while at least one associated video remains active.
# No source paths, hashes, GPS, model response, reviewer or arbitrary error text
# are selected. JSON label evidence is deliberately excluded at the SQL layer.
_PROGRESS_SQL = """
WITH eligible AS (
    SELECT b.batch_id,
           CASE WHEN b.processing_info->>'status' IS NULL THEN 'queued'
                WHEN b.processing_info->>'status' IN
                    ('queued','extracting','labeling','completed','validation_failed','failed','skipped')
                THEN b.processing_info->>'status' ELSE 'unknown' END AS status,
           b.review_status, b.validation_passed, b.updated_at,
           b.processing_info->>'heartbeat_at' AS heartbeat_at,
           CASE WHEN jsonb_typeof(b.processing_info->'grouping_warning') = 'array'
                THEN jsonb_array_length(b.processing_info->'grouping_warning') > 0
                ELSE (b.processing_info->'grouping_warning') = 'true'::jsonb
                END AS grouping_warning,
           COUNT(*) AS active_videos
    FROM aerial.annotation_batches b
    JOIN aerial.annotation_batch_videos bv ON bv.batch_id = b.batch_id
    JOIN aerial.videos v ON v.sha256 = bv.video_sha256 AND v.deleted_at IS NULL
    GROUP BY b.batch_id
), status_totals AS (
    SELECT status, COUNT(*) AS batches, SUM(active_videos) AS videos
    FROM eligible GROUP BY status
), ranked AS (
    SELECT e.*, ROW_NUMBER() OVER (
        PARTITION BY (status IN ('extracting','labeling'))
        ORDER BY updated_at DESC, batch_id
    ) AS position
    FROM eligible e
    WHERE status IN ('extracting','labeling','completed','validation_failed','failed','skipped')
), details AS (
    SELECT e.status, e.updated_at, jsonb_build_object(
        'batch_id', e.batch_id, 'status', e.status,
        'city', b.processing_info->>'city',
        'attempts', b.processing_info->'attempts',
        'active_videos', e.active_videos,
        'review_status', e.review_status, 'validation_passed', e.validation_passed,
        'started_at', b.processing_info->>'started_at',
        'finished_at', b.processing_info->>'finished_at',
        'heartbeat_at', e.heartbeat_at, 'updated_at', e.updated_at,
        'error_code', b.processing_info->>'error_code',
        'grouping_warning', e.grouping_warning,
        'label_count', jsonb_array_length(b.labels),
        'labels', CASE WHEN e.status = 'completed' AND e.validation_passed
                               AND e.review_status = 'pending'
                      THEN COALESCE((
                          SELECT jsonb_agg(jsonb_build_object(
                              'field', item->'field', 'state', item->'state',
                              'values', item->'values', 'scope', item->'scope'))
                          FROM jsonb_array_elements(b.labels) item
                      ), '[]'::jsonb) ELSE '[]'::jsonb END
    ) AS public_row
    FROM ranked e JOIN aerial.annotation_batches b ON b.batch_id = e.batch_id
    WHERE e.position <= CASE WHEN e.status IN ('extracting','labeling') THEN 10 ELSE 20 END
)
SELECT jsonb_build_object(
    'active_videos', (SELECT COUNT(*) FROM aerial.videos WHERE deleted_at IS NULL),
    'eligible_batches', COUNT(*), 'eligible_videos', COALESCE(SUM(active_videos), 0),
    'completed_batches', COUNT(*) FILTER (WHERE status = 'completed' AND validation_passed),
    'completed_videos', COALESCE(SUM(active_videos) FILTER (WHERE status = 'completed' AND validation_passed), 0),
    'pending_review_batches', COUNT(*) FILTER (
        WHERE status = 'completed' AND validation_passed AND review_status = 'pending'),
    'approved_batches', COUNT(*) FILTER (
        WHERE status = 'completed' AND validation_passed AND review_status = 'approved'),
    'grouping_warning_batches', COUNT(*) FILTER (WHERE grouping_warning),
    'last_heartbeat', MAX(heartbeat_at), 'latest_updated_at', MAX(updated_at),
    'working_heartbeats', COALESCE(jsonb_agg(heartbeat_at) FILTER (
        WHERE status IN ('extracting','labeling')), '[]'::jsonb),
    'status_counts', COALESCE((SELECT jsonb_agg(to_jsonb(s)) FROM status_totals s), '[]'::jsonb),
    'working', COALESCE((SELECT jsonb_agg(public_row ORDER BY updated_at DESC)
        FROM details WHERE status IN ('extracting','labeling')), '[]'::jsonb),
    'recent', COALESCE((SELECT jsonb_agg(public_row ORDER BY updated_at DESC)
        FROM details WHERE status NOT IN ('extracting','labeling')), '[]'::jsonb)
) FROM eligible
"""


@dataclass(frozen=True)
class StatusCount:
    status: str
    batches: int
    videos: int


@dataclass(frozen=True)
class CandidateLabel:
    field: str
    name: str
    state: str
    values: tuple[str, ...]
    scope: str


@dataclass(frozen=True)
class AnnotationBatch:
    batch_id: str
    city: str
    status: str
    attempts: int
    active_videos: int
    label_count: int
    review_status: str
    validation_passed: bool
    started_at: datetime | None
    finished_at: datetime | None
    heartbeat_at: datetime | None
    updated_at: datetime | None
    error_code: str
    grouping_warning: bool
    labels: tuple[CandidateLabel, ...]


@dataclass(frozen=True)
class AnnotationProgress:
    active_videos: int
    eligible_batches: int
    eligible_videos: int
    completed_batches: int
    completed_videos: int
    pending_review_batches: int
    approved_batches: int
    grouping_warning_batches: int
    stale_working_batches: int
    last_heartbeat: datetime | None
    latest_updated_at: datetime | None
    status_counts: tuple[StatusCount, ...]
    working: tuple[AnnotationBatch, ...]
    recent: tuple[AnnotationBatch, ...]

    @property
    def progress(self) -> float:
        return min(self.completed_batches / self.eligible_batches, 1.0) if self.eligible_batches else 0.0

    @property
    def attempted_batches(self) -> int:
        return sum(row.batches for row in self.status_counts
                   if row.status in ("completed", "validation_failed", "failed", "skipped"))

    @property
    def processing_progress(self) -> float:
        return min(self.attempted_batches / self.eligible_batches, 1.0) if self.eligible_batches else 0.0


def _count(value: object) -> int:
    return max(value, 0) if isinstance(value, int) and not isinstance(value, bool) else 0


def _time(value: object) -> datetime | None:
    try:
        result = value if isinstance(value, datetime) else datetime.fromisoformat(value)
        return result if result.tzinfo is not None else None
    except (ValueError, TypeError):
        return None


def _stale_working_count(raw: object) -> int:
    """Check every working heartbeat without casting untrusted text in SQL."""
    if not isinstance(raw, (list, tuple)):
        return 0
    now = datetime.now(timezone.utc)
    return sum((heartbeat := _time(value)) is None or
               (now - heartbeat).total_seconds() > 180 for value in raw)


@lru_cache(maxsize=4)
def _public_cities(path: str, modified_ns: int) -> frozenset[str]:
    collection = json.loads(Path(path).read_text(encoding="utf-8"))
    names = {row["properties"]["地名"] for row in collection["features"]}
    names.difference_update(("福州市", "抚州市"))
    names.update(("Fuzhou (Fujian)", "Fuzhou (Jiangxi)", "缺少坐标", "边界外或坐标异常"))
    return frozenset(names)


@lru_cache(maxsize=1)
def _taxonomy() -> dict[str, dict]:
    # Versioned public taxonomy is shared with the worker's strict validator.
    definitions = json.loads((_DATA / "annotation-fields-v1.json").read_text(encoding="utf-8"))
    return {row["code"]: row for row in definitions}


def _candidate_labels(raw: object) -> tuple[CandidateLabel, ...]:
    if not isinstance(raw, list):
        return ()
    definitions = _taxonomy()
    labels = []
    for item in raw[:200]:
        if not isinstance(item, dict):
            continue
        code, state, scope, values = (item.get(key) for key in ("field", "state", "scope", "values"))
        if (not isinstance(code, str) or code not in definitions
                or not isinstance(state, str) or state not in _STATES
                or not isinstance(scope, str) or scope not in _SCOPES
                or not isinstance(values, list) or len(values) > 30):
            continue
        definition = definitions[code]
        if not all(isinstance(value, str) and value in definition["values"] for value in values):
            continue
        if (len(set(values)) != len(values) or (not definition["multi"] and len(values) > 1)
                or "无" in values or (state == "observed" and not values)
                or (state != "observed" and values)
                or (definition["unobservable"] and state not in ("unknown", "unlabeled"))):
            continue
        path = definition.get("original_path")
        name = " / ".join(path[1:]) if path else definition["name"]
        labels.append(CandidateLabel(code, name, state, tuple(values), scope))
    return tuple(labels)


def _batch(raw: Mapping, cities: frozenset[str]) -> AnnotationBatch | None:
    try:
        identity = UUID(str(raw.get("batch_id"))).hex[-16:]
    except (ValueError, TypeError, AttributeError):
        return None
    status = raw.get("status")
    status = status if isinstance(status, str) and status in _STATUSES else "unknown"
    review = raw.get("review_status")
    review = review if isinstance(review, str) and review in ("pending", "approved", "rejected") else "unknown"
    valid = raw.get("validation_passed") is True
    city = raw.get("city")
    city = city if isinstance(city, str) and city in cities else "未知"
    error = raw.get("error_code")
    error = error if isinstance(error, str) and error in _ERRORS else ""
    labels = _candidate_labels(raw.get("labels")) if status == "completed" and valid and review == "pending" else ()
    return AnnotationBatch(identity, city, status, _count(raw.get("attempts")),
                           _count(raw.get("active_videos")), _count(raw.get("label_count")),
                           review, valid, *(_time(raw.get(key)) for key in
                                           ("started_at", "finished_at", "heartbeat_at", "updated_at")),
                           error, raw.get("grouping_warning") is True, labels)


def progress_snapshot(payload: Mapping) -> AnnotationProgress:
    """Validate the public projection; malformed fields never become UI text."""
    city_path = Path(os.environ.get("NDS_CITY_BOUNDARIES_PATH", str(_DATA / "prefectures-2023.geojson")))
    cities = _public_cities(str(city_path), city_path.stat().st_mtime_ns)
    status_counts = []
    raw_counts = payload.get("status_counts", ())
    for row in raw_counts if isinstance(raw_counts, (list, tuple)) else ():
        if isinstance(row, dict):
            status = row.get("status")
            status = status if isinstance(status, str) and status in _STATUSES else "unknown"
            status_counts.append(StatusCount(status, _count(row.get("batches")), _count(row.get("videos"))))
    rank = {status: index for index, status in enumerate((*_STATUSES, "unknown"))}
    status_counts.sort(key=lambda row: rank[row.status])
    def batches(key: str, limit: int) -> tuple[AnnotationBatch, ...]:
        raw = payload.get(key, ())
        if not isinstance(raw, (list, tuple)):
            return ()
        return tuple(batch for row in raw[:limit] if isinstance(row, dict)
                     if (batch := _batch(row, cities)) is not None)
    return AnnotationProgress(
        *(_count(payload.get(key)) for key in (
            "active_videos", "eligible_batches", "eligible_videos", "completed_batches",
            "completed_videos", "pending_review_batches", "approved_batches", "grouping_warning_batches")),
        _stale_working_count(payload.get("working_heartbeats")),
        _time(payload.get("last_heartbeat")), _time(payload.get("latest_updated_at")),
        tuple(status_counts), batches("working", 10), batches("recent", 20),
    )


def fetch_annotation_progress(dsn: str) -> AnnotationProgress:
    """One bounded read-only query; schema/connection errors remain unavailable."""
    with psycopg.connect(dsn, connect_timeout=5,
                         options="-c default_transaction_read_only=on -c statement_timeout=5000") as connection:
        with connection.cursor() as cursor:
            cursor.execute(_PROGRESS_SQL)
            payload = cursor.fetchone()[0]
    return progress_snapshot(payload)


@st.fragment(run_every="10s")
def render_annotation_progress(dsn: str, translate: Callable[[str], str]) -> None:
    """An independent refresh; candidate labels never enter official statistics."""
    def tr(zh: str, en: str) -> str:
        result = translate(zh)
        return en if st.session_state.get("lang") == "en" and result == zh else result

    def timestamp(value: datetime | None) -> str:
        return value.astimezone(_TIMEZONE).strftime("%Y-%m-%d %H:%M:%S") if value else "—"

    status_names = {
        "queued": tr("排队中", "Queued"), "extracting": tr("提取代表帧", "Extracting frame"),
        "labeling": tr("模型标注中", "Labeling"), "completed": tr("标注完成", "Completed"),
        "validation_failed": tr("校验失败", "Validation failed"), "failed": tr("任务失败", "Failed"),
        "skipped": tr("已跳过", "Skipped"), "unknown": tr("未知", "Unknown"),
    }
    review_names = {"pending": tr("未人工审核", "Not human-reviewed"),
                    "approved": tr("已审核", "Approved"), "rejected": tr("已退回", "Rejected"),
                    "unknown": tr("未知", "Unknown")}
    st.subheader(tr("实时标注进度", "Live annotation progress"), anchor="annotation-progress")
    st.caption(tr("每 10 秒自动刷新 · 时间为北京时间 · 统计所有关联有效视频的标注批次",
                  "Refreshes every 10 seconds · China Standard Time · All batches with active video members"))
    if not dsn:
        st.info(tr("尚未配置 PostgreSQL 数据源。", "PostgreSQL data source is not configured."))
        return
    try:
        snapshot = fetch_annotation_progress(dsn)
    except Exception:
        st.warning(tr("标注进度暂不可用,请稍后刷新。", "Annotation progress is unavailable. Please refresh later."))
        return

    st.info(tr("通过校验的自动标注结果用于标签分布统计,当前无需人工审核。标签描述代表帧观察;校验通过不等同于人工确认标签正确。",
               "Validated automatic annotations feed label distributions without a human review prerequisite. Labels describe representative frames; validation does not mean a human confirmed their accuracy."))
    metrics = (
        (tr("有效标注批次", "Eligible batches"), f"{snapshot.eligible_batches:,}"),
        (tr("校验通过批次", "Validated batches"), f"{snapshot.completed_batches:,} / {snapshot.eligible_batches:,}"),
        (tr("已生成候选批次的关联视频", "Videos linked to validated batches"), f"{snapshot.completed_videos:,} / {snapshot.active_videos:,}"),
        (tr("排队或处理中的批次", "Queued or working batches"),
         f"{sum(row.batches for row in snapshot.status_counts if row.status in ('queued', 'extracting', 'labeling')):,}"),
    )
    for column, (name, value) in zip(st.columns(4), metrics):
        column.metric(name, value)
    st.caption(tr("关联视频尚未逐一观察;动态标签仅描述代表帧中的观察结果。",
                  "Individual linked source videos have not all been observed. Dynamic labels describe only observations in the representative frame."))
    st.progress(snapshot.processing_progress,
                text=tr("本轮处理进度(含失败与跳过)", "Processing progress (includes failures and skips)")
                + f" {snapshot.attempted_batches:,} / {snapshot.eligible_batches:,} ({snapshot.processing_progress:.1%})")
    st.progress(snapshot.progress, text=tr("校验通过进度", "Validation progress") + f" {snapshot.progress:.1%}")
    st.caption(f"{tr('已关联视频', 'Linked active videos')}: {snapshot.eligible_videos:,} / {snapshot.active_videos:,}  ·  "
               f"{tr('已人工审核批次', 'Human-approved batches')}: {snapshot.approved_batches:,}  ·  "
               f"{tr('最近心跳', 'Last heartbeat')}: {timestamp(snapshot.last_heartbeat)}  ·  "
               f"{tr('库内最近更新', 'Latest database update')}: {timestamp(snapshot.latest_updated_at)}")
    if not snapshot.eligible_batches:
        st.info(tr("尚无关联有效视频的标注批次。", "There are no annotation batches with active video members."))
        return
    if snapshot.stale_working_batches:
        st.warning(tr("运行中任务缺少有效心跳或心跳超过 3 分钟未更新,请检查标注服务。",
                      "Working batches have missing or invalid heartbeats, or heartbeats older than 3 minutes. Check the annotation service.")
                   + f" ({snapshot.stale_working_batches:,})")
    if snapshot.grouping_warning_batches:
        st.warning(tr("部分采集分组需人工核查分组信息与代表帧。",
                      "Some collection groups need manual review of grouping information and representative frames.")
                   + f" ({snapshot.grouping_warning_batches:,})")
    st.dataframe(pd.DataFrame([
        {tr("处理状态", "Processing status"): status_names[row.status],
         tr("批次数", "Batches"): row.batches, tr("关联有效视频数", "Active video members"): row.videos}
        for row in snapshot.status_counts
    ]), hide_index=True, width="stretch")

    def table(rows: tuple[AnnotationBatch, ...]) -> None:
        if not rows:
            st.caption(tr("暂无记录", "No records"))
            return
        st.dataframe(pd.DataFrame([
            {tr("批次", "Batch"): row.batch_id, tr("城市", "City"): translate(row.city),
             tr("处理状态", "Processing status"): status_names[row.status],
             tr("关联有效视频数", "Active video members"): row.active_videos,
             tr("尝试次数", "Attempts"): row.attempts, tr("标签数", "Labels"): row.label_count,
             tr("人工审核", "Human review"): review_names[row.review_status],
             tr("开始时间", "Started"): timestamp(row.started_at),
             tr("完成时间", "Finished"): timestamp(row.finished_at),
             tr("心跳时间", "Heartbeat"): timestamp(row.heartbeat_at),
             tr("最近更新", "Updated"): timestamp(row.updated_at),
             tr("原因", "Reason"): tr(*_ERRORS[row.error_code]) if row.error_code else "—"}
            for row in rows
        ]), hide_index=True, width="stretch")

    st.markdown("**" + tr("正在处理的批次", "Working batches") + "**")
    table(snapshot.working)
    st.markdown("**" + tr("最近完成、失败或跳过的批次", "Recent completed, failed or skipped batches") + "**")
    table(snapshot.recent)
    candidates = [row for row in snapshot.recent if row.labels]
    if candidates:
        with st.expander(tr("自动标注结果预览", "Automatic annotation preview")):
            state_names = {"observed": tr("已观察", "Observed"), "not_observed": tr("采样帧未观察到", "Not observed in sampled frame"),
                           "unknown": tr("未知", "Unknown"), "unlabeled": tr("未标注", "Unlabeled"),
                           "not_applicable": tr("不适用", "Not applicable")}
            st.dataframe(pd.DataFrame([
                {tr("批次", "Batch"): row.batch_id, tr("城市", "City"): translate(row.city),
                 tr("标签字段", "Label field"): label.name, tr("观察状态", "Observation state"): state_names[label.state],
                 tr("候选值", "Candidate values"): "、".join(label.values) or "—",
                 tr("证据范围", "Evidence scope"): label.scope,
                 tr("人工审核", "Human review"): review_names["pending"]}
                for row in candidates for label in row.labels
            ]), hide_index=True, width="stretch")