Improve plan review UX with enriched rows, TUI filters, and execute gate.

Make review-plan easier to act on: Chinese risk labels, relative paths,
verdict/next-step footer, optional identity/analysis enrichment, grouping,
spot-check sampling, and structure preview. Extend the TUI with filters,
duplicate-group reject, and quality context. Persist review_context on
plan operations and add --require-review for confirmed execute.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-05-21 09:27:12 +08:00
co-authored by Cursor
parent c9d22d5136
commit 5f0b531269
13 changed files with 1050 additions and 61 deletions
+7 -7
View File
@@ -240,14 +240,14 @@ vlm analyze
vlm plan --analysis artifacts/analysis.json
# Output: artifacts/plan.json with operations, human summary, and duplicate quarantine decisions
# 7. Review the plan in terminal (summary + high-risk preview)
# 7. Review the plan in terminal (summary + high-risk preview + verdict)
vlm review-plan
# Optional: interactive full-screen review (install `.[tui]` first)
vlm review-plan --tui
# Optional: control preview size
vlm review-plan --preview-limit 20
# Optional: show every high-risk operation in terminal
vlm review-plan --show-all
# Enriched CSV (title, relative paths, quality) + duplicate grouping
vlm review-plan --identities artifacts/identities.json --analysis artifacts/analysis.json --group-by duplicate
# Spot-check random safe moves; write target tree preview
vlm review-plan --sample-safe 5 --structure-preview artifacts/plan_structure.txt
# Edit artifacts/plan_manual_review.csv in Excel/Numbers
# Sync your manual decisions back to artifacts/plan.json
vlm apply-review
@@ -257,8 +257,8 @@ vlm apply-review
vlm execute
# Shows what would happen without making changes (respecting your manual edits)
# 9. Execute with confirmation
vlm execute --confirm
# 9. Execute with confirmation (optional review gate)
vlm execute --confirm --require-review
# Actually performs the file operations
# 10. If needed, rollback
+136 -5
View File
@@ -17,7 +17,14 @@ from click.core import ParameterSource
from vlm.config import Config, load_config, create_default_config, validate_config
from vlm.context import CLIContext, pass_context
from vlm.logging_config import setup_logging, get_logger
from vlm.plan_render import fallback_plan_summary, preferred_plan_summary, render_review_preview
from vlm.plan_render import (
fallback_plan_summary,
preferred_plan_summary,
render_review_footer,
render_review_preview,
render_review_verdict,
duplicate_groups_from_plan,
)
from vlm.utils import format_size
@@ -538,6 +545,38 @@ def plan(ctx: CLIContext, input: Path, output: Path, analysis: Path | None):
default=False,
help='Interactive Textual UI (requires: uv pip install -e ".[tui]")'
)
@click.option(
'--identities',
type=click.Path(path_type=Path),
default=None,
help='Identities JSON for enriched review rows (default: artifacts/identities.json if present)'
)
@click.option(
'--analysis',
type=click.Path(path_type=Path),
default=None,
help='Analysis JSON for duplicate grouping and TUI quality pane'
)
@click.option(
'--group-by',
type=click.Choice(['none', 'reason', 'title', 'duplicate'], case_sensitive=False),
default='none',
show_default=True,
help='Reorder review rows for display/export'
)
@click.option(
'--sample-safe',
type=int,
default=0,
show_default=True,
help='Include N random non-high-risk move operations for spot-checking'
)
@click.option(
'--structure-preview',
type=click.Path(path_type=Path),
default=None,
help='Write target library tree preview to this file (e.g. artifacts/plan_structure.txt)'
)
@pass_context
def review_plan_cmd(
ctx: CLIContext,
@@ -548,10 +587,17 @@ def review_plan_cmd(
preview_limit: int,
show_all: bool,
tui: bool,
identities: Optional[Path],
analysis: Optional[Path],
group_by: str,
sample_safe: int,
structure_preview: Optional[Path],
):
"""Review a plan and export high-risk operations for manual confirmation."""
from vlm.io import load_analysis_json, load_identities_json
from vlm.planner import load_plan
from vlm.plan_review import review_plan, save_review_csv
from vlm.plan_review import GROUP_BY_CHOICES, prepare_review_rows, save_review_csv
from vlm.plan_structure_preview import write_structure_preview
logger = ctx.logger
@@ -563,6 +609,13 @@ def review_plan_cmd(
if preview_limit < 1:
click.echo("Error: --preview-limit must be >= 1", err=True)
sys.exit(1)
if sample_safe < 0:
click.echo("Error: --sample-safe must be >= 0", err=True)
sys.exit(1)
group_by = group_by.lower()
if group_by not in GROUP_BY_CHOICES:
click.echo(f"Error: invalid --group-by {group_by}", err=True)
sys.exit(1)
if tui:
if not _review_plan_tui_streams_ok():
@@ -571,15 +624,46 @@ def review_plan_cmd(
click.echo(f"Loading plan: {input}")
execution_plan = load_plan(input)
rows, counters = review_plan(
identities_data = None
identities_path = identities
if identities_path is None:
default_id = default_artifact_path("identities.json")
if default_id.is_file():
identities_path = default_id
if identities_path is not None and identities_path.is_file():
identities_data = load_identities_json(identities_path)
click.echo(f"Loaded identities: {identities_path}")
analysis_data = None
if analysis is not None and analysis.is_file():
analysis_data = load_analysis_json(analysis)
click.echo(f"Loaded analysis: {analysis}")
rows, counters = prepare_review_rows(
execution_plan,
season_threshold=season_threshold,
episode_threshold=episode_threshold,
library_root=ctx.config.library_root,
identities_data=identities_data,
analysis_data=analysis_data,
sample_safe=sample_safe,
group_by=group_by,
)
if structure_preview is not None:
write_structure_preview(
execution_plan,
ctx.config.library_root,
structure_preview,
)
click.echo(f"Wrote structure preview: {structure_preview}")
if tui:
from vlm.plan_review import build_duplicate_path_maps
from vlm.review_tui import ReviewTUIContext, run_plan_review_tui
_, path_to_quality = build_duplicate_path_maps(analysis_data)
tui_ctx = ReviewTUIContext(
rows=rows,
counters=counters,
@@ -587,6 +671,7 @@ def review_plan_cmd(
output_csv=output,
plan_input=input,
summary_text=preferred_plan_summary(execution_plan),
path_to_quality=path_to_quality,
)
rc = run_plan_review_tui(tui_ctx)
if rc != 0:
@@ -617,6 +702,9 @@ def review_plan_cmd(
click.echo(f" high_episode: {counters['high_episode']}")
click.echo(f" conflicts: {counters['conflicts']}")
click.echo()
click.echo(render_review_verdict(counters))
click.echo()
click.echo("High-risk operations preview:")
if rows:
@@ -624,6 +712,7 @@ def review_plan_cmd(
rows,
preview_limit=preview_limit,
show_all=show_all,
library_root=ctx.config.library_root,
)
for line in preview_lines:
click.echo(line)
@@ -638,6 +727,16 @@ def review_plan_cmd(
click.echo()
click.echo(f"Saved manual review CSV to: {output}")
dup_groups = duplicate_groups_from_plan(execution_plan)
click.echo()
for line in render_review_footer(
counters=counters,
output_csv=output,
plan_input=input,
duplicate_groups=dup_groups,
):
click.echo(line)
logger.info(
"Plan review completed: total=%s high_risk=%s output=%s",
counters["total_operations"],
@@ -764,8 +863,30 @@ def apply_review_cmd(
default=False,
help='Enable safe mode: prevent any operations that would destroy directories'
)
@click.option(
'--require-review',
is_flag=True,
default=False,
help='With --confirm, require a current plan_manual_review.csv when high-risk ops exist'
)
@click.option(
'--review-csv',
type=click.Path(path_type=Path),
default=None,
help='Review CSV path for --require-review (default: beside plan file)'
)
@pass_context
def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops: bool, preserve_directories: bool, safe_mode: bool):
def execute(
ctx: CLIContext,
plan: Path,
confirm: bool,
yes: bool,
verbose_ops: bool,
preserve_directories: bool,
safe_mode: bool,
require_review: bool,
review_csv: Optional[Path],
):
"""Execute plan (defaults to dry-run, requires --confirm).
Executes file operations from a plan. Defaults to dry-run mode which
@@ -782,7 +903,17 @@ def execute(ctx: CLIContext, plan: Path, confirm: bool, yes: bool, verbose_ops:
try:
plan = resolve_legacy_default_input_path(plan, "plan", "plan.json", "--plan")
from vlm.commands.execute import execute_cmd
execute_cmd(ctx, plan, confirm, yes, verbose_ops, preserve_directories, safe_mode)
execute_cmd(
ctx,
plan,
confirm,
yes,
verbose_ops,
preserve_directories,
safe_mode,
require_review=require_review,
review_csv=review_csv,
)
except FileNotFoundError:
_command_error(
ctx,
+11
View File
@@ -42,6 +42,8 @@ def execute_cmd(
verbose_ops: bool,
preserve_directories: bool,
safe_mode: bool,
require_review: bool = False,
review_csv: Path | None = None,
) -> None:
"""Execute an execution plan in dry-run or execute mode."""
config = ctx.config
@@ -53,6 +55,15 @@ def execute_cmd(
execution_plan = load_plan(plan)
if require_review and confirm:
from vlm.plan_review import check_review_requirements
csv_path = review_csv or plan.parent / "plan_manual_review.csv"
review_errors = check_review_requirements(execution_plan, plan, csv_path)
if review_errors:
formatted = "\n".join(f" - {e}" for e in review_errors)
raise ValueError(f"REVIEW REQUIRED BEFORE EXECUTE:\n{formatted}")
if preserve_directories:
click.echo("Directory preservation is enabled (plan metadata/operations will be honored).")
click.echo()
+6
View File
@@ -282,6 +282,11 @@ def execution_plan_to_record(plan: ExecutionPlan) -> PlanJSON:
"reason": op.reason,
"has_conflict": op.has_conflict,
"conflict_reason": op.conflict_reason,
**(
{"review_context": op.review_context}
if op.review_context
else {}
),
}
for op in plan.operations
],
@@ -305,6 +310,7 @@ def execution_plan_from_record(data: object) -> ExecutionPlan:
reason=op["reason"],
has_conflict=op["has_conflict"],
conflict_reason=op.get("conflict_reason"),
review_context=op.get("review_context") or {},
)
for op in plan_dict["operations"]
]
+1
View File
@@ -128,6 +128,7 @@ class FileOperation:
reason: str
has_conflict: bool
conflict_reason: Optional[str] = None
review_context: dict = field(default_factory=dict)
@dataclass
+90 -4
View File
@@ -2,8 +2,11 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from vlm.review_display import display_path, risk_flags_to_labels
def fallback_plan_summary(execution_plan: Any) -> str:
"""Build a short plan summary from summary and summary_by_reason.
@@ -34,10 +37,68 @@ def preferred_plan_summary(execution_plan: Any) -> str:
return fallback_plan_summary(execution_plan)
def duplicate_groups_from_plan(execution_plan: Any) -> int:
metadata = getattr(execution_plan, "metadata", {}) or {}
if not isinstance(metadata, dict):
return 0
val = metadata.get("duplicate_groups_considered", 0)
snapshot = metadata.get("validation_snapshot", {})
if isinstance(snapshot, dict) and not val:
val = snapshot.get("duplicate_groups_considered", 0)
try:
return int(val)
except (TypeError, ValueError):
return 0
def render_review_verdict(counters: dict[str, int]) -> str:
"""Return a one-line review verdict for execute readiness."""
high_risk = counters.get("high_risk_operations", 0)
if high_risk > 0:
return f"Verdict: BLOCKED ({high_risk} high-risk operation(s) need review)"
return "Verdict: OK TO DRY-RUN (no high-risk operations)"
def render_review_footer(
*,
counters: dict[str, int],
output_csv: Path,
plan_input: Path,
duplicate_groups: int = 0,
) -> list[str]:
"""Build actionable next-step lines after review-plan output."""
lines: list[str] = []
high_risk = counters.get("high_risk_operations", 0)
if high_risk > 0:
lines.append("Next steps:")
lines.append(
f" 1. Review high-risk items: vlm review-plan --tui --input {plan_input} "
f"--output {output_csv}"
)
lines.append(
f" Or edit {output_csv} (set operation_type to no-op to reject), then:"
)
lines.append(f" vlm apply-review --plan {plan_input} --csv {output_csv}")
lines.append(f" 2. Dry-run after apply-review: vlm execute --plan {plan_input}")
else:
lines.append("Next steps:")
lines.append(f" 1. Dry-run: vlm execute --plan {plan_input}")
lines.append(f" 2. Execute with confirmation: vlm execute --plan {plan_input} --confirm")
if duplicate_groups > 0:
lines.append(
f" Duplicate quality comparison: vlm report duplicates --plan {plan_input}"
)
return lines
def render_review_preview(
rows: list[dict[str, str]],
preview_limit: int = 10,
show_all: bool = False,
library_root: Path | None = None,
) -> tuple[list[str], int]:
"""Render high-risk review rows for console preview.
@@ -53,14 +114,39 @@ def render_review_preview(
for row in selected:
idx = row.get("index", "?")
operation_type = row.get("operation_type", "")
flags = row.get("risk_flags", "") or "none"
flags_raw = row.get("risk_flags", "")
flags_label = risk_flags_to_labels(flags_raw, max_len=80) if flags_raw else "(none)"
source_path = row.get("source_path", "")
destination_path = row.get("destination_path", "")
reason = row.get("reason", "")
lines.append(f" - [{idx}] {operation_type} | flags={flags}")
lines.append(f" source: {source_path}")
lines.append(f" destination: {destination_path or '(none)'}")
source_name = row.get("source_name") or (
Path(source_path).name if source_path else ""
)
dest_name = row.get("dest_name") or (
Path(destination_path).name if destination_path else ""
)
rel_source = row.get("rel_source", "")
rel_dest = row.get("rel_dest", "")
if library_root is not None and source_path and not rel_source:
rel_source = display_path(Path(source_path), library_root)
if library_root is not None and destination_path and not rel_dest:
rel_dest = display_path(Path(destination_path), library_root)
change = f"{source_name} -> {dest_name}" if dest_name else source_name
lines.append(f" - [{idx}] {operation_type} | {flags_label}")
if change:
lines.append(f" change: {change}")
if rel_source:
lines.append(f" source: {rel_source}")
elif source_path:
lines.append(f" source: {source_path}")
if rel_dest:
lines.append(f" destination: {rel_dest}")
elif destination_path:
lines.append(f" destination: {destination_path}")
elif destination_path == "":
lines.append(" destination: (none)")
lines.append(f" reason: {reason}")
return lines, remaining
+401 -6
View File
@@ -3,11 +3,40 @@
from __future__ import annotations
import csv
import random
import re
from pathlib import Path
from typing import Any
from vlm.models import ExecutionPlan, FileOperation
from vlm.utils import is_sample_path
from vlm.models import ExecutionPlan, FileOperation, MovieIdentity, SeriesIdentity
from vlm.review_display import display_path
from vlm.utils import format_size, is_sample_path
REVIEW_CSV_BASE_FIELDS = [
"index",
"operation_type",
"risk_flags",
"source_path",
"destination_path",
"reason",
]
REVIEW_CSV_ENRICHED_FIELDS = [
"title",
"category",
"season",
"episode",
"source_name",
"dest_name",
"rel_source",
"rel_dest",
"quality_hint",
"duplicate_group_id",
]
REVIEW_CSV_ALL_FIELDS = REVIEW_CSV_BASE_FIELDS + REVIEW_CSV_ENRICHED_FIELDS
GROUP_BY_CHOICES = ("none", "reason", "title", "duplicate")
def _extract_season_episode(operation: FileOperation) -> tuple[int | None, int | None]:
@@ -34,6 +63,220 @@ def _extract_season_episode(operation: FileOperation) -> tuple[int | None, int |
return season, episode
def _quality_hint_from_record(record: dict[str, Any]) -> str:
vm = record.get("video_metadata") or {}
if not isinstance(vm, dict):
vm = {}
parts: list[str] = []
if vm.get("resolution"):
parts.append(str(vm["resolution"]))
if vm.get("codec"):
parts.append(str(vm["codec"]))
size = vm.get("size_bytes")
if size:
parts.append(format_size(int(size)))
return " / ".join(parts)
def build_identity_lookup(identities_data: dict[str, Any]) -> dict[str, dict[str, str]]:
"""Map source file path strings to review enrichment fields."""
lookup: dict[str, dict[str, str]] = {}
def _add_record(record: dict[str, Any], category: str) -> None:
path = record.get("path")
if not path:
return
path_key = str(path)
title = str(record.get("display_title") or record.get("title") or "")
ctx: dict[str, str] = {
"title": title,
"category": category,
"quality_hint": _quality_hint_from_record(record),
}
if category == "movie":
year = record.get("year")
if year is not None:
ctx["episode"] = ""
ctx["season"] = ""
elif category == "series":
season = record.get("season")
episodes = record.get("episodes") or []
if season is not None:
ctx["season"] = str(season)
if episodes:
ctx["episode"] = ",".join(str(e) for e in episodes)
lookup[path_key] = ctx
try:
lookup[str(Path(path_key).resolve())] = ctx
except OSError:
pass
for rec in identities_data.get("movies", []) or []:
if isinstance(rec, dict):
_add_record(rec, "movie")
for rec in identities_data.get("series", []) or []:
if isinstance(rec, dict):
_add_record(rec, "series")
for section in ("anime", "other"):
for rec in identities_data.get(section, []) or []:
if isinstance(rec, dict):
_add_record(rec, section)
return lookup
def build_duplicate_path_maps(
analysis_data: dict[str, Any] | None,
) -> tuple[dict[str, str], dict[str, dict[str, Any]]]:
"""Return path -> duplicate_group_id and path -> quality_comparison entry."""
path_to_group: dict[str, str] = {}
path_to_quality: dict[str, dict[str, Any]] = {}
if not analysis_data:
return path_to_group, path_to_quality
for group_idx, dup in enumerate(analysis_data.get("duplicates", []) or []):
if not isinstance(dup, dict):
continue
identity = dup.get("identity") or {}
if not isinstance(identity, dict):
identity = {}
title = identity.get("title", "")
itype = identity.get("type", "")
year = identity.get("year")
season = identity.get("season")
episodes = identity.get("episodes") or []
if itype == "movie" and year is not None:
group_id = f"movie:{title}:{year}"
elif season is not None and episodes:
group_id = f"series:{title}:{season}:{episodes[0]}"
else:
group_id = f"dup:{group_idx}"
quality_list = dup.get("quality_comparison") or []
if not isinstance(quality_list, list):
quality_list = []
for file_path in dup.get("files", []) or []:
path_key = str(file_path)
path_to_group[path_key] = group_id
for qc in quality_list:
if isinstance(qc, dict) and str(qc.get("path")) == path_key:
path_to_quality[path_key] = qc
break
return path_to_group, path_to_quality
def build_review_context(
video_file_path: Path,
identity: MovieIdentity | SeriesIdentity | None,
*,
category: str | None = None,
duplicate_group_id: str = "",
keep_candidate: bool | None = None,
) -> dict[str, Any]:
"""Build plan-time review_context for a single operation."""
ctx: dict[str, Any] = {}
if category:
ctx["category"] = category
elif identity is not None:
ctx["category"] = "movie" if isinstance(identity, MovieIdentity) else "series"
if identity is not None:
ctx["title"] = identity.title
if isinstance(identity, MovieIdentity):
if identity.year is not None:
ctx["year"] = identity.year
else:
if identity.season is not None:
ctx["season"] = identity.season
if identity.episodes:
ctx["episode"] = identity.episodes[0]
if duplicate_group_id:
ctx["duplicate_group_id"] = duplicate_group_id
if keep_candidate is not None:
ctx["keep_candidate"] = keep_candidate
return ctx
def enrich_review_row(
row: dict[str, str],
*,
library_root: Path | None = None,
identity_lookup: dict[str, dict[str, str]] | None = None,
path_to_duplicate_group: dict[str, str] | None = None,
operation: FileOperation | None = None,
) -> dict[str, str]:
"""Add optional enrichment columns to a review row dict."""
enriched = dict(row)
source_path = row.get("source_path", "")
dest_path = row.get("destination_path", "")
enriched["source_name"] = Path(source_path).name if source_path else ""
enriched["dest_name"] = Path(dest_path).name if dest_path else ""
if library_root is not None and source_path:
enriched["rel_source"] = display_path(Path(source_path), library_root)
if library_root is not None and dest_path:
enriched["rel_dest"] = display_path(Path(dest_path), library_root)
ctx = (operation.review_context if operation else None) or {}
if isinstance(ctx, dict) and ctx:
if ctx.get("title"):
enriched["title"] = str(ctx["title"])
if ctx.get("category"):
enriched["category"] = str(ctx["category"])
if ctx.get("season") is not None:
enriched["season"] = str(ctx["season"])
if ctx.get("episode") is not None:
enriched["episode"] = str(ctx["episode"])
if ctx.get("duplicate_group_id"):
enriched["duplicate_group_id"] = str(ctx["duplicate_group_id"])
if identity_lookup and source_path:
id_ctx = identity_lookup.get(source_path) or identity_lookup.get(
str(Path(source_path).resolve())
)
if id_ctx:
for key in ("title", "category", "season", "episode", "quality_hint"):
if id_ctx.get(key) and not enriched.get(key):
enriched[key] = id_ctx[key]
if path_to_duplicate_group and source_path:
gid = path_to_duplicate_group.get(source_path)
if gid and not enriched.get("duplicate_group_id"):
enriched["duplicate_group_id"] = gid
return enriched
def enrich_review_rows(
rows: list[dict[str, str]],
plan: ExecutionPlan,
*,
library_root: Path | None = None,
identity_lookup: dict[str, dict[str, str]] | None = None,
path_to_duplicate_group: dict[str, str] | None = None,
) -> list[dict[str, str]]:
"""Enrich all review rows using plan operations and optional lookups."""
op_by_index = {i + 1: op for i, op in enumerate(plan.operations)}
result: list[dict[str, str]] = []
for row in rows:
idx = int(row["index"])
op = op_by_index.get(idx)
result.append(
enrich_review_row(
row,
library_root=library_root,
identity_lookup=identity_lookup,
path_to_duplicate_group=path_to_duplicate_group,
operation=op,
)
)
return result
def review_plan(
plan: ExecutionPlan,
season_threshold: int = 20,
@@ -70,6 +313,12 @@ def review_plan(
if op.has_conflict:
flags.append("conflict")
counters["conflicts"] += 1
rc = op.review_context if hasattr(op, "review_context") else {}
if isinstance(rc, dict) and rc.get("duplicate_group_id") and op.operation_type == "quarantine":
if "duplicate" not in flags:
flags.append("duplicate")
elif "duplicate" in reason_l and "duplicate" not in flags:
flags.append("duplicate")
if flags:
counters["high_risk_operations"] += 1
rows.append(
@@ -86,12 +335,158 @@ def review_plan(
return rows, counters
def append_sample_safe_rows(
plan: ExecutionPlan,
rows: list[dict[str, str]],
sample_count: int,
*,
rng: random.Random | None = None,
) -> list[dict[str, str]]:
"""Append random non-high-risk move operations for spot-checking."""
if sample_count < 1:
return rows
high_risk_indices = {int(r["index"]) for r in rows}
candidates: list[tuple[int, FileOperation]] = []
for idx, op in enumerate(plan.operations, start=1):
if idx in high_risk_indices:
continue
if op.operation_type != "move":
continue
candidates.append((idx, op))
if not candidates:
return rows
picker = rng or random.Random()
picked = picker.sample(candidates, min(sample_count, len(candidates)))
extra: list[dict[str, str]] = []
for idx, op in sorted(picked, key=lambda x: x[0]):
extra.append(
{
"index": str(idx),
"operation_type": op.operation_type,
"risk_flags": "spot_check",
"source_path": str(op.source_path),
"destination_path": str(op.destination_path) if op.destination_path else "",
"reason": op.reason,
}
)
return rows + extra
def group_review_rows(
rows: list[dict[str, str]],
group_by: str,
) -> list[dict[str, str]]:
"""Reorder review rows for display/export grouping (does not change plan)."""
if group_by == "none" or not rows:
return rows
def sort_key(row: dict[str, str]) -> tuple:
if group_by == "reason":
return (row.get("reason", ""), int(row.get("index", 0)))
if group_by == "title":
return (row.get("title", ""), row.get("duplicate_group_id", ""), int(row.get("index", 0)))
if group_by == "duplicate":
gid = row.get("duplicate_group_id", "")
if not gid:
gid = f"_ungrouped_{row.get('index', '')}"
return (gid, int(row.get("index", 0)))
return (int(row.get("index", 0)),)
return sorted(rows, key=sort_key)
def check_review_requirements(
plan: ExecutionPlan,
plan_path: Path,
review_csv: Path,
*,
season_threshold: int = 20,
episode_threshold: int = 40,
) -> list[str]:
"""Return human-readable errors when review is required but missing or stale."""
_, counters = review_plan(
plan,
season_threshold=season_threshold,
episode_threshold=episode_threshold,
)
high_risk = counters.get("high_risk_operations", 0)
if high_risk == 0:
return []
errors: list[str] = []
if not review_csv.is_file():
errors.append(
f"Review required: {high_risk} high-risk operation(s) but review CSV not found: {review_csv}. "
f"Run: vlm review-plan --input {plan_path}"
)
return errors
try:
if review_csv.stat().st_mtime < plan_path.stat().st_mtime:
errors.append(
f"Review CSV is older than plan ({review_csv}). "
f"Re-run review-plan and apply-review before execute --confirm."
)
except OSError as exc:
errors.append(f"Could not compare review CSV timestamps: {exc}")
return errors
def prepare_review_rows(
plan: ExecutionPlan,
*,
season_threshold: int = 20,
episode_threshold: int = 40,
library_root: Path | None = None,
identities_data: dict[str, Any] | None = None,
analysis_data: dict[str, Any] | None = None,
sample_safe: int = 0,
group_by: str = "none",
) -> tuple[list[dict[str, str]], dict[str, int]]:
"""Build, enrich, optionally sample and group review rows."""
rows, counters = review_plan(
plan,
season_threshold=season_threshold,
episode_threshold=episode_threshold,
)
if sample_safe > 0:
rows = append_sample_safe_rows(plan, rows, sample_safe)
identity_lookup = (
build_identity_lookup(identities_data) if identities_data else None
)
path_to_dup_group, _ = build_duplicate_path_maps(analysis_data)
rows = enrich_review_rows(
rows,
plan,
library_root=library_root,
identity_lookup=identity_lookup,
path_to_duplicate_group=path_to_dup_group,
)
if group_by and group_by != "none":
rows = group_review_rows(rows, group_by)
return rows, counters
def save_review_csv(rows: list[dict[str, str]], output: Path) -> None:
"""Write review rows to CSV."""
output.parent.mkdir(parents=True, exist_ok=True)
fields = ["index", "operation_type", "risk_flags", "source_path", "destination_path", "reason"]
with open(output, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields)
fieldnames = list(REVIEW_CSV_BASE_FIELDS)
for row in rows:
for key in row:
if key in REVIEW_CSV_ENRICHED_FIELDS and key not in fieldnames:
fieldnames.append(key)
for f in REVIEW_CSV_ENRICHED_FIELDS:
if f in fieldnames:
continue
if any(f in row for row in rows):
fieldnames.append(f)
with open(output, "w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
+64
View File
@@ -0,0 +1,64 @@
"""Build a target library tree preview from an execution plan."""
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
from vlm.models import ExecutionPlan
from vlm.review_display import display_path
def build_structure_preview_lines(
plan: ExecutionPlan,
library_root: Path,
*,
max_titles: int = 40,
max_paths_per_title: int = 8,
) -> list[str]:
"""Return ASCII lines showing where move/rename operations will place files."""
by_title: dict[str, list[str]] = defaultdict(list)
for op in plan.operations:
if op.operation_type not in ("move", "rename") or not op.destination_path:
continue
ctx = op.review_context if isinstance(op.review_context, dict) else {}
title = str(ctx.get("title") or op.destination_path.parent.parent.name)
rel = display_path(op.destination_path, library_root)
by_title[title].append(rel)
lines = [
"Target library structure preview (move/rename destinations)",
f"Library root: {library_root}",
"",
]
if not by_title:
lines.append("(no move/rename destinations)")
return lines
sorted_titles = sorted(by_title.keys())[:max_titles]
for title in sorted_titles:
paths = sorted(set(by_title[title]))[:max_paths_per_title]
lines.append(f"{title}/")
for p in paths:
lines.append(f" {p}")
remaining = len(by_title[title]) - len(paths)
if remaining > 0:
lines.append(f" ... +{remaining} more path(s)")
lines.append("")
if len(by_title) > max_titles:
lines.append(f"... +{len(by_title) - max_titles} more title(s)")
return lines
def write_structure_preview(
plan: ExecutionPlan,
library_root: Path,
output_path: Path,
) -> None:
"""Write structure preview text to a file."""
lines = build_structure_preview_lines(plan, library_root)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+44 -5
View File
@@ -9,12 +9,12 @@ import uuid
from pathlib import Path
from typing import Optional, Union
from dataclasses import replace
from vlm.config import Config
from vlm.duplicate_resolve import DuplicateResolutionError, choose_keep_index
from vlm.io import (
load_execution_plan,
save_execution_plan,
)
from vlm.io import load_execution_plan, save_execution_plan
from vlm.plan_review import build_duplicate_path_maps, build_review_context
from vlm.utils import (
canonical_path_str,
is_sample_path,
@@ -143,6 +143,7 @@ def generate_plan(
metadata: dict = {}
validation_snapshot: dict[str, object] = {}
duplicate_resolution_issues: list[dict[str, object]] = []
duplicate_keep_paths: set[str] = set()
if analysis_data is not None:
validation_snapshot["analysis_source"] = analysis_data.get("metadata", {}).get("source_identities", "")
validation_snapshot["duplicate_groups_considered"] = len(analysis_data.get("duplicates", []))
@@ -204,6 +205,7 @@ def generate_plan(
if keep_idx is None:
continue
keep_identity_index = valid_indices[keep_idx]
duplicate_keep_paths.add(str(identities[keep_identity_index][0].path))
quarantine_indices = set(valid_indices) - {keep_identity_index}
reason = _select_duplicate_quarantine_reason(
config.duplicate_keep,
@@ -240,6 +242,14 @@ def generate_plan(
validation_snapshot["duplicate_resolution_issues"] = duplicate_resolution_issues
metadata["duplicate_resolution_issues"] = duplicate_resolution_issues
path_to_dup_group, _ = build_duplicate_path_maps(analysis_data)
operations = _stamp_review_context_on_operations(
operations,
identities,
path_to_dup_group=path_to_dup_group,
duplicate_keep_paths=duplicate_keep_paths,
)
if validation_snapshot:
validation_snapshot["captured_at"] = utc_now().isoformat()
metadata["validation_snapshot"] = validation_snapshot
@@ -555,6 +565,36 @@ def _create_series_operation(
)
def _stamp_review_context_on_operations(
operations: list[FileOperation],
identities: list[tuple],
*,
path_to_dup_group: dict[str, str],
duplicate_keep_paths: set[str],
) -> list[FileOperation]:
"""Attach review_context to file operations aligned with identity indices."""
stamped: list[FileOperation] = []
for i, op in enumerate(operations):
if i < len(identities):
vf, identity = identities[i]
if isinstance(identity, (MovieIdentity, SeriesIdentity)):
gid = path_to_dup_group.get(str(vf.path), "")
keep_candidate = None
if gid:
keep_candidate = str(vf.path) in duplicate_keep_paths
ctx = build_review_context(
vf.path,
identity,
category=vf.category,
duplicate_group_id=gid,
keep_candidate=keep_candidate,
)
stamped.append(replace(op, review_context=ctx))
continue
stamped.append(op)
return stamped
def _generate_summary(operations: list[FileOperation]) -> dict:
"""Generate summary statistics for operations.
@@ -691,7 +731,6 @@ def save_plan(plan: ExecutionPlan, output_path: Path) -> None:
save_execution_plan(plan, output_path)
def load_plan(input_path: Path) -> ExecutionPlan:
"""Load execution plan from JSON file.
+3 -3
View File
@@ -26,7 +26,7 @@ def risk_flags_to_labels(flags: str, *, max_len: int = 24) -> str:
return text[: max_len - 1] + ""
def _display_path(p: Path, library_root: Path) -> str:
def display_path(p: Path, library_root: Path) -> str:
try:
resolved = p.resolve()
root = library_root.resolve()
@@ -44,10 +44,10 @@ def format_paths_for_detail(
"""Build multi-line before/after path text for review detail panes."""
source = Path(source_s)
dest = Path(dest_s) if dest_s.strip() else None
src_line = _display_path(source, library_root)
src_line = display_path(source, library_root)
lines = [f"来源: {src_line}", f"完整: {source}"]
if dest is not None:
dst_line = _display_path(dest, library_root)
dst_line = display_path(dest, library_root)
lines.append(f"目标: {dst_line}")
lines.append(f"完整: {dest}")
else:
+159 -31
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from dataclasses import dataclass
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,
@@ -12,22 +13,28 @@ from vlm.review_display import (
review_row_status_symbol,
risk_flags_to_labels,
)
from vlm.utils import format_size
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 - exercised via runtime fallback
TEXTUAL_IMPORT_ERROR: ImportError | None = exc
else:
TEXTUAL_IMPORT_ERROR = None
FILTER_CHOICES = ("all", "manual_review", "sample_source", "conflict", "duplicate")
MISSING_TEXTUAL_MESSAGE = 'Textual is not installed. Install with: uv pip install -e ".[tui]"'
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)
@@ -40,8 +47,24 @@ class ReviewTUIContext:
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
@@ -52,7 +75,6 @@ if TEXTUAL_IMPORT_ERROR is None:
return int(row_key)
return int(val)
class SummaryScreen(Screen):
"""Migration summary; Enter continues, q aborts."""
@@ -80,6 +102,7 @@ if TEXTUAL_IMPORT_ERROR is None:
f"将要写入: {self._ctx.output_csv}",
"",
"默认仅审核标记为高危的操作(与 CSV 行一致)。",
"筛选: 1全部 2需人工 3样片 4冲突 5重复 · g 驳回整组重复",
"",
"Enter 进入审核 · q 退出",
]
@@ -139,8 +162,14 @@ if TEXTUAL_IMPORT_ERROR is None:
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_conflict", show=False),
Binding("5", "filter_duplicate", show=False),
]
def __init__(self, ctx: ReviewTUIContext) -> None:
@@ -153,6 +182,7 @@ if TEXTUAL_IMPORT_ERROR is None:
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)
@@ -163,12 +193,13 @@ if TEXTUAL_IMPORT_ERROR is None:
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(
"↑↓ j/k 移动 · a 保留 · r 驳回(no-op) · u 撤销本条 · s 保存退出 · q 退出",
"1-5 筛选 · ↑↓ j/k · a 保留 · r 驳回 · g 驳回重复组 · u 撤销 · s 保存 · q 退出",
id="footer_line",
)
yield Footer()
@@ -180,6 +211,12 @@ if TEXTUAL_IMPORT_ERROR is None:
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;
@@ -214,35 +251,58 @@ if TEXTUAL_IMPORT_ERROR is None:
}
"""
def on_mount(self) -> None:
table = self.query_one("#review_table", DataTable)
table.cursor_type = "row"
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": "样片",
"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=11)
table.add_column("风险", key="risk", width=18)
table.add_column("文件", key="file")
table.add_column("类型", key="op", width=10)
table.add_column("风险", key="risk", width=14)
table.add_column("变更", key="change")
for r in self.ctx.rows:
for r in self._filtered_rows():
idx = int(r["index"])
sym = self._symbol_for(idx)
table.add_row(
sym,
self._symbol_for(idx),
str(idx),
self.op_by_index[idx],
risk_flags_to_labels(r["risk_flags"]),
Path(r["source_path"]).name,
_change_label(r),
key=str(idx),
)
self._apply_body_layout(self.app.size)
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(
"无高危项。按 s 保存仅含表头的 CSV(与无 --tui 行为一致)"
"当前筛选无条目。按 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)
@@ -278,6 +338,33 @@ if TEXTUAL_IMPORT_ERROR is None:
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]
@@ -287,23 +374,29 @@ if TEXTUAL_IMPORT_ERROR is None:
self.ctx.library_root,
)
risk_cn = risk_flags_to_labels(r["risk_flags"], max_len=120)
title = r.get("title", "")
summary = (
f"#{index} · {op} · {Path(r['source_path']).name}"
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)
sym = self._symbol_for(index)
table.update_cell(key, "sym", sym)
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:
@@ -347,9 +440,43 @@ if TEXTUAL_IMPORT_ERROR is None:
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_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)
@@ -402,3 +529,4 @@ 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
+4
View File
@@ -104,6 +104,10 @@ def test_review_plan_generates_csv_summary_and_preview(tmp_path):
assert "source:" in result.output
assert "destination:" in result.output
assert "reason:" in result.output
assert "Verdict: BLOCKED" in result.output
assert "需人工判断" in result.output or "样片路径" in result.output
assert "Next steps:" in result.output
assert "apply-review" in result.output
assert output_csv.exists()
with open(output_csv, "r", encoding="utf-8", newline="") as f:
+124
View File
@@ -0,0 +1,124 @@
"""Tests for plan review helpers."""
import json
from pathlib import Path
from vlm.models import ExecutionPlan, FileOperation
from vlm.plan_review import (
build_identity_lookup,
check_review_requirements,
enrich_review_rows,
prepare_review_rows,
review_plan,
)
from vlm.plan_render import render_review_verdict
from vlm.plan_structure_preview import build_structure_preview_lines
from vlm.planner import load_plan, save_plan
from vlm.utils import utc_now
def _minimal_plan(operations: list[FileOperation]) -> ExecutionPlan:
return ExecutionPlan(
plan_id="t",
created_at=utc_now(),
operations=operations,
summary={"total": len(operations), "move": 1, "rename": 0, "quarantine": 0, "no-op": 0},
)
def test_render_review_verdict_blocked_and_ok():
assert "BLOCKED" in render_review_verdict({"high_risk_operations": 2})
assert "OK TO DRY-RUN" in render_review_verdict({"high_risk_operations": 0})
def test_build_identity_lookup_and_enrich(tmp_path):
identities = {
"vlm_schema_version": "1.0",
"movies": [],
"series": [
{
"path": str(tmp_path / "dl/Show.S01E01.mkv"),
"filename": "Show.S01E01.mkv",
"category": "series",
"title": "Show",
"season": 1,
"episodes": [1],
"confidence": 1.0,
"needs_review": False,
"video_metadata": {"resolution": "1080p", "size_bytes": 1000},
}
],
}
lookup = build_identity_lookup(identities)
op = FileOperation(
operation_type="move",
source_path=tmp_path / "dl/Show.S01E01.mkv",
destination_path=tmp_path / "lib/series/Show/Season 01/S01E01.mkv",
reason="organize",
has_conflict=False,
review_context={"title": "Show", "category": "series", "season": 1, "episode": 1},
)
plan = _minimal_plan([op])
rows = [
{
"index": "1",
"operation_type": "move",
"risk_flags": "",
"source_path": str(op.source_path),
"destination_path": str(op.destination_path),
"reason": op.reason,
}
]
enriched = enrich_review_rows(rows, plan, identity_lookup=lookup, library_root=tmp_path / "lib")
assert enriched[0]["title"] == "Show"
assert enriched[0]["quality_hint"]
assert enriched[0]["source_name"] == "Show.S01E01.mkv"
def test_check_review_requirements_missing_csv(tmp_path):
plan_path = tmp_path / "plan.json"
op = FileOperation(
operation_type="no-op",
source_path=tmp_path / "Series.S20E01.mkv",
destination_path=None,
reason="Series needs manual review (season exceeds configured threshold)",
has_conflict=False,
)
plan = _minimal_plan([op])
save_plan(plan, plan_path)
loaded = load_plan(plan_path)
errors = check_review_requirements(loaded, plan_path, tmp_path / "missing.csv")
assert len(errors) == 1
assert "not found" in errors[0]
def test_structure_preview_lines(tmp_path):
lib = tmp_path / "library"
op = FileOperation(
operation_type="move",
source_path=tmp_path / "a.mkv",
destination_path=lib / "series/Show/Season 01/S01E01.mkv",
reason="x",
has_conflict=False,
review_context={"title": "Show"},
)
lines = build_structure_preview_lines(_minimal_plan([op]), lib)
assert any("Show" in line for line in lines)
def test_prepare_review_rows_sample_safe(tmp_path):
ops = [
FileOperation(
operation_type="move",
source_path=tmp_path / f"f{i}.mkv",
destination_path=tmp_path / f"lib/f{i}.mkv",
reason="organize",
has_conflict=False,
)
for i in range(5)
]
plan = _minimal_plan(ops)
rows, counters = prepare_review_rows(plan, sample_safe=2)
assert counters["high_risk_operations"] == 0
assert len(rows) == 2
assert all(r["risk_flags"] == "spot_check" for r in rows)