Files
dl-organizer/src/vlm/review_tui.py
T
79797644e1 chore: trim dead code, modularize CLI, and archive stale docs
Extract review-plan, report, quarantine, state, and config handlers into
commands/ with shared cli_helpers; remove unused exceptions and duplicate
plan summary wrappers. Archive superseded review markdown, sync docs to
517-test baseline, and fix empty series titles when only a quality tag remains.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:36:03 +08:00

539 lines
19 KiB
Python

"""Textual TUI for reviewing high-risk plan operations."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from vlm.plan_review import save_review_csv
from vlm.review_display import (
build_csv_rows,
format_paths_for_detail,
review_row_status_symbol,
risk_flags_to_labels,
)
from vlm.utils import format_size
FILTER_CHOICES = ("all", "manual_review", "sample_source", "spot_check", "conflict", "duplicate")
def _row_matches_filter(row: dict[str, str], filter_key: str) -> bool:
if filter_key == "all":
return True
flags = row.get("risk_flags", "")
if filter_key in flags.split("|"):
return True
if filter_key == "duplicate" and row.get("duplicate_group_id"):
return True
return False
def _change_label(row: dict[str, str]) -> str:
src = row.get("source_name") or Path(row.get("source_path", "")).name
dst = row.get("dest_name") or ""
if dst:
return f"{src}{dst}"
return src
@dataclass(frozen=True)
class ReviewTUIContext:
"""Inputs for the plan review TUI."""
rows: list[dict[str, str]]
counters: dict[str, int]
library_root: Path
output_csv: Path
plan_input: Path
summary_text: str
path_to_quality: dict[str, dict] = field(default_factory=dict)
try:
from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, ScrollableContainer
from textual.geometry import Size
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable, Footer, Static
except ImportError as exc: # pragma: no cover
TEXTUAL_IMPORT_ERROR: ImportError | None = exc
else:
TEXTUAL_IMPORT_ERROR = None
MISSING_TEXTUAL_MESSAGE = 'Textual is not installed. Install with: uv pip install -e ".[tui]"'
if TEXTUAL_IMPORT_ERROR is None:
def _index_from_row_key(row_key) -> int: # noqa: ANN001 - RowKey | str
if isinstance(row_key, str):
return int(row_key)
val = getattr(row_key, "value", None)
if val is None:
return int(row_key)
return int(val)
class SummaryScreen(Screen):
"""Migration summary; Enter continues, q aborts."""
BINDINGS = [
Binding("enter", "continue_", "继续", show=True),
Binding("q", "quit", "退出", show=True),
]
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self._ctx = ctx
def compose(self) -> ComposeResult:
stats_lines = [
"---",
"计划统计",
f" 总操作: {self._ctx.counters['total_operations']}",
f" 高危: {self._ctx.counters['high_risk_operations']}",
f" manual_review: {self._ctx.counters['manual_review']}",
f" sample_source: {self._ctx.counters['sample_source']}",
f" high_season: {self._ctx.counters['high_season']}",
f" high_episode: {self._ctx.counters['high_episode']}",
f" conflicts: {self._ctx.counters['conflicts']}",
"",
f"将要写入: {self._ctx.output_csv}",
"",
"高危操作写入 CSV;可选 --sample-safe 追加安全抽检行。",
"筛选: 1全部 2需人工 3样片路径 4安全抽检 5冲突 6重复 · g 驳回整组重复",
"",
"Enter 进入审核 · q 退出",
]
body = self._ctx.summary_text + "\n\n" + "\n".join(stats_lines)
yield ScrollableContainer(Static(body, id="summary_body"))
yield Footer()
def action_continue_(self) -> None:
self.dismiss(True)
def action_quit(self) -> None:
self.dismiss(False)
class ConfirmDiscardScreen(ModalScreen[bool]):
"""Confirm discarding unsaved edits."""
BINDINGS = [
Binding("y", "yes", show=False),
Binding("n", "no", show=False),
]
def compose(self) -> ComposeResult:
yield Container(
Static("未保存的修改将丢失。放弃? (y / n)", id="confirm_text"),
id="confirm_box",
)
def action_yes(self) -> None:
self.dismiss(True)
def action_no(self) -> None:
self.dismiss(False)
DEFAULT_CSS = """
ConfirmDiscardScreen {
align: center middle;
}
#confirm_box {
width: auto;
height: auto;
padding: 1 2;
border: thick $primary;
background: $surface;
}
"""
class ReviewMainScreen(Screen):
"""High-risk table + detail pane."""
BINDINGS = [
Binding("up", "cursor_up", show=False),
Binding("down", "cursor_down", show=False),
Binding("k", "cursor_up", show=False),
Binding("j", "cursor_down", show=False),
Binding("a", "keep_row", "保留", show=True),
Binding("r", "reject_row", "驳回", show=True),
Binding("u", "undo_row", "撤销", show=True),
Binding("g", "reject_group", "驳回组", show=True),
Binding("s", "save", "保存", show=True),
Binding("q", "request_quit", "退出", show=True),
Binding("1", "filter_all", show=False),
Binding("2", "filter_manual", show=False),
Binding("3", "filter_sample", show=False),
Binding("4", "filter_spot_check", show=False),
Binding("5", "filter_conflict", show=False),
Binding("6", "filter_duplicate", show=False),
]
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self.ctx = ctx
self._by_index: dict[int, dict[str, str]] = {
int(r["index"]): r for r in ctx.rows
}
self.initial_op_by_index: dict[int, str] = {
int(r["index"]): r["operation_type"] for r in ctx.rows
}
self.op_by_index: dict[int, str] = dict(self.initial_op_by_index)
self._filter = "all"
def compose(self) -> ComposeResult:
plan_s = str(self.ctx.plan_input)
if len(plan_s) > 72:
plan_s = plan_s[:35] + "…" + plan_s[-34:]
hdr = (
f"{plan_s} · 高危 {self.ctx.counters['high_risk_operations']}"
f" / {self.ctx.counters['total_operations']}"
)
yield Static(hdr, id="header_line")
yield Static("", id="filter_line")
with Horizontal(id="body"):
yield DataTable(id="review_table", cursor_type="row", zebra_stripes=True)
with ScrollableContainer(id="detail_scroll"):
yield Static("", id="detail_text")
yield Static(
"1-6 筛选 · ↑↓ j/k · a 保留 · r 驳回 · g 驳回重复组 · u 撤销 · s 保存 · q 退出",
id="footer_line",
)
yield Footer()
DEFAULT_CSS = """
#header_line {
dock: top;
padding: 0 1;
background: $primary-darken-2;
color: $text;
}
#filter_line {
dock: top;
padding: 0 1;
background: $panel;
color: $text-muted;
}
#footer_line {
dock: bottom;
padding: 0 1;
background: $panel;
color: $text-muted;
}
#body {
layout: horizontal;
height: 1fr;
}
#body.vertical-split {
layout: vertical;
height: 1fr;
}
#review_table {
width: 1fr;
min-height: 5;
}
#body.vertical-split #review_table {
height: 40%;
}
#detail_scroll {
width: 1fr;
min-height: 5;
border-left: solid $primary-darken-3;
padding: 0 1;
}
#body.vertical-split #detail_scroll {
border-left: none;
border-top: solid $primary-darken-3;
height: 1fr;
}
"""
def _filtered_rows(self) -> list[dict[str, str]]:
return [
r
for r in self.ctx.rows
if _row_matches_filter(r, self._filter)
]
def _update_filter_line(self) -> None:
labels = {
"all": "全部",
"manual_review": "需人工",
"sample_source": "样片路径",
"spot_check": "安全抽检",
"conflict": "冲突",
"duplicate": "重复",
}
visible = len(self._filtered_rows())
self.query_one("#filter_line", Static).update(
f"筛选: {labels.get(self._filter, self._filter)} · 显示 {visible}/{len(self.ctx.rows)} 条"
)
def _rebuild_table(self) -> None:
table = self._table()
table.clear(columns=True)
table.add_column(" ", key="sym", width=3)
table.add_column("#", key="idx", width=4)
table.add_column("类型", key="op", width=10)
table.add_column("风险", key="risk", width=14)
table.add_column("变更", key="change")
for r in self._filtered_rows():
idx = int(r["index"])
table.add_row(
self._symbol_for(idx),
str(idx),
self.op_by_index[idx],
risk_flags_to_labels(r["risk_flags"]),
_change_label(r),
key=str(idx),
)
self._update_filter_line()
if table.row_count > 0:
table.focus()
self._refresh_detail(_index_from_row_key(table.ordered_rows[0].key))
else:
self.query_one("#detail_text", Static).update(
"当前筛选无条目。按 1 显示全部,或 s 保存 CSV。"
)
def on_mount(self) -> None:
self._rebuild_table()
self._apply_body_layout(self.app.size)
def _table(self) -> DataTable:
return self.query_one("#review_table", DataTable)
def _symbol_for(self, index: int) -> str:
return review_row_status_symbol(
self.op_by_index,
self.initial_op_by_index,
index,
)
def _apply_body_layout(self, app_size: Size) -> None:
body = self.query_one("#body", Horizontal)
if app_size.width < 100:
body.add_class("vertical-split")
else:
body.remove_class("vertical-split")
def on_resize(self, event) -> None: # noqa: ANN001 - textual Resize
self._apply_body_layout(self.app.size)
def _current_index(self) -> int | None:
table = self._table()
if table.row_count == 0:
return None
row_index = table.cursor_coordinate.row
row = table.ordered_rows[row_index]
return _index_from_row_key(row.key)
@on(DataTable.RowHighlighted) # type: ignore[misc]
def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if event.data_table.id != "review_table":
return
idx = _index_from_row_key(event.row_key)
self._refresh_detail(idx)
def _format_quality_block(self, index: int) -> str:
r = self._by_index[index]
gid = r.get("duplicate_group_id", "")
if not gid:
return ""
lines = [f"重复组: {gid}"]
group_paths = [
other
for other in self.ctx.rows
if other.get("duplicate_group_id") == gid
]
for other in group_paths:
oidx = int(other["index"])
path = other.get("source_path", "")
qc = self.ctx.path_to_quality.get(path, {})
if qc:
res = qc.get("resolution", "?")
size = format_size(int(qc.get("size_bytes", 0) or 0))
mark = "★" if self.op_by_index.get(oidx) != "no-op" else " "
lines.append(f" {mark} [{oidx}] {Path(path).name}: {res} {size}")
else:
hint = other.get("quality_hint", "")
mark = "★" if self.op_by_index.get(oidx) != "no-op" else " "
lines.append(f" {mark} [{oidx}] {Path(path).name}: {hint or '(no metadata)'}")
return "\n".join(lines)
def _refresh_detail(self, index: int) -> None:
r = self._by_index[index]
op = self.op_by_index[index]
paths = format_paths_for_detail(
r["source_path"],
r.get("destination_path", ""),
self.ctx.library_root,
)
risk_cn = risk_flags_to_labels(r["risk_flags"], max_len=120)
title = r.get("title", "")
summary = (
f"#{index} · {op} · {_change_label(r)}"
+ (f" · {title}" if title else "")
+ (f" · {risk_cn}" if risk_cn else "")
)
quality_block = self._format_quality_block(index)
text = (
f"{summary}\n\n"
f"变更\n{paths}\n\n"
f"依据\n{r['reason']}\n\n"
f"标记\n{r['risk_flags']}"
)
if quality_block:
text += f"\n\n画质对比\n{quality_block}"
self.query_one("#detail_text", Static).update(text)
def _refresh_row_cells(self, index: int) -> None:
table = self._table()
key = str(index)
if key not in [str(_index_from_row_key(r.key)) for r in table.ordered_rows]:
return
table.update_cell(key, "sym", self._symbol_for(index))
table.update_cell(key, "op", self.op_by_index[index])
def _update_dirty_header(self) -> None:
dirty = self._is_dirty()
hdr = self.query_one("#header_line", Static)
plan_s = str(self.ctx.plan_input)
if len(plan_s) > 72:
plan_s = plan_s[:35] + "…" + plan_s[-34:]
star = " *" if dirty else ""
hdr.update(
f"{plan_s}{star} · 高危 {self.ctx.counters['high_risk_operations']}"
f" / {self.ctx.counters['total_operations']}"
)
def _is_dirty(self) -> bool:
return self.op_by_index != self.initial_op_by_index
def action_cursor_up(self) -> None:
if self._table().row_count:
self._table().action_cursor_up()
def action_cursor_down(self) -> None:
if self._table().row_count:
self._table().action_cursor_down()
def action_keep_row(self) -> None:
idx = self._current_index()
if idx is None:
return
self.op_by_index[idx] = self.initial_op_by_index[idx]
self._refresh_row_cells(idx)
self._refresh_detail(idx)
self._update_dirty_header()
def action_reject_row(self) -> None:
idx = self._current_index()
if idx is None:
return
self.op_by_index[idx] = "no-op"
self._refresh_row_cells(idx)
self._refresh_detail(idx)
self._update_dirty_header()
def action_reject_group(self) -> None:
idx = self._current_index()
if idx is None:
return
gid = self._by_index[idx].get("duplicate_group_id", "")
if not gid:
self.action_reject_row()
return
for r in self.ctx.rows:
if r.get("duplicate_group_id") == gid:
self.op_by_index[int(r["index"])] = "no-op"
self._rebuild_table()
self._update_dirty_header()
def action_undo_row(self) -> None:
self.action_keep_row()
def action_filter_all(self) -> None:
self._filter = "all"
self._rebuild_table()
def action_filter_manual(self) -> None:
self._filter = "manual_review"
self._rebuild_table()
def action_filter_sample(self) -> None:
self._filter = "sample_source"
self._rebuild_table()
def action_filter_spot_check(self) -> None:
self._filter = "spot_check"
self._rebuild_table()
def action_filter_conflict(self) -> None:
self._filter = "conflict"
self._rebuild_table()
def action_filter_duplicate(self) -> None:
self._filter = "duplicate"
self._rebuild_table()
def action_save(self) -> None:
out_rows = build_csv_rows(self.ctx.rows, self.op_by_index)
save_review_csv(out_rows, self.ctx.output_csv)
self.dismiss("saved")
def action_request_quit(self) -> None:
if not self._is_dirty():
self.dismiss("aborted")
return
def after_confirm(confirmed: bool | None) -> None:
if confirmed:
self.dismiss("aborted")
self.app.push_screen(ConfirmDiscardScreen(), callback=after_confirm)
class PlanReviewApp(App):
"""Application shell: summary screen then review screen."""
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self.ctx = ctx
def on_mount(self) -> None:
self.push_screen(SummaryScreen(self.ctx), self._after_summary)
def _after_summary(self, result: bool | None) -> None:
if not result:
self.exit(return_code=1)
return
self.push_screen(ReviewMainScreen(self.ctx), self._after_main)
def _after_main(self, result: str | None) -> None:
if result == "saved":
self.exit(return_code=0)
else:
self.exit(return_code=1)
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
"""Block until the user finishes the TUI. Returns process exit code."""
app = PlanReviewApp(ctx)
app.run()
code = app.return_code
return 0 if code is None else code
else:
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
"""Raise a friendly error when the optional Textual dependency is missing."""
raise RuntimeError(MISSING_TEXTUAL_MESSAGE) from TEXTUAL_IMPORT_ERROR