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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user