Files
dl-organizer/src/vlm/plan_review.py
T

594 lines
20 KiB
Python
Raw Normal View History

2026-02-13 13:36:39 +08:00
"""Plan review helpers for flagging high-risk operations before execution."""
from __future__ import annotations
import csv
import random
2026-02-13 13:36:39 +08:00
import re
from pathlib import Path
from typing import Any
2026-02-13 13:36:39 +08:00
from vlm.models import ExecutionPlan, FileOperation, MovieIdentity, SeriesIdentity
from vlm.review_display import display_path
from vlm.utils import canonical_path_str, format_size, is_sample_path, utc_now
REVIEW_APPLIED_AT_KEY = "review_applied_at"
REVIEW_CSV_PATH_KEY = "review_csv_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")
2026-02-13 13:36:39 +08:00
def normalized_path_key(path_value: str | Path) -> str:
"""Normalize path-like values for duplicate-group and lookup matching."""
text = str(path_value).strip()
if not text:
return ""
return canonical_path_str(Path(text.replace("\\", "/")))
2026-02-13 13:36:39 +08:00
def _extract_season_episode(operation: FileOperation) -> tuple[int | None, int | None]:
season: int | None = None
episode: int | None = None
for text in [
str(operation.destination_path) if operation.destination_path else "",
operation.reason,
operation.source_path.name,
]:
if season is None:
m = re.search(r"S(\d{1,3})E(\d{1,3})", text, re.IGNORECASE)
if m:
season = int(m.group(1))
episode = int(m.group(2))
else:
m = re.search(r"Season\s+(\d{1,3})", text, re.IGNORECASE)
if m:
season = int(m.group(1))
if episode is None:
m = re.search(r"E(\d{1,3})", text, re.IGNORECASE)
if m:
episode = int(m.group(1))
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 = normalized_path_key(file_path)
if not path_key:
continue
path_to_group[path_key] = group_id
for qc in quality_list:
if isinstance(qc, dict) and normalized_path_key(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(normalized_path_key(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 _risk_flags_for_operation(
op: FileOperation,
*,
season_threshold: int = 20,
episode_threshold: int = 40,
) -> list[str]:
"""Return risk flag keys for an operation, or empty if not flagged."""
if op.operation_type == "no-op" and op.reason.casefold().startswith("modified via manual review"):
return []
flags: list[str] = []
reason_l = op.reason.casefold()
if "manual review" in reason_l:
flags.append("manual_review")
if is_sample_path(op.source_path):
flags.append("sample_source")
season, episode = _extract_season_episode(op)
if season is not None and season >= season_threshold:
flags.append("high_season")
if episode is not None and episode >= episode_threshold:
flags.append("high_episode")
if op.has_conflict:
flags.append("conflict")
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")
return flags
def _is_actionable_high_risk(op: FileOperation, flags: list[str]) -> bool:
"""True when review is required before confirmed execute (would modify files)."""
return bool(flags) and op.operation_type != "no-op"
2026-02-13 13:36:39 +08:00
def review_plan(
plan: ExecutionPlan,
season_threshold: int = 20,
episode_threshold: int = 40,
) -> tuple[list[dict[str, str]], dict[str, int]]:
"""Build review rows and aggregate risk counters."""
rows: list[dict[str, str]] = []
counters = {
"total_operations": len(plan.operations),
"high_risk_operations": 0,
"review_export_rows": 0,
2026-02-13 13:36:39 +08:00
"manual_review": 0,
"sample_source": 0,
"high_season": 0,
"high_episode": 0,
"conflicts": 0,
}
for idx, op in enumerate(plan.operations, start=1):
flags = _risk_flags_for_operation(
op,
season_threshold=season_threshold,
episode_threshold=episode_threshold,
)
if not flags:
continue
if "manual_review" in flags:
2026-02-13 13:36:39 +08:00
counters["manual_review"] += 1
if "sample_source" in flags:
2026-02-13 13:36:39 +08:00
counters["sample_source"] += 1
if "high_season" in flags:
2026-02-13 13:36:39 +08:00
counters["high_season"] += 1
if "high_episode" in flags:
2026-02-13 13:36:39 +08:00
counters["high_episode"] += 1
if "conflict" in flags:
2026-02-13 13:36:39 +08:00
counters["conflicts"] += 1
counters["review_export_rows"] += 1
if _is_actionable_high_risk(op, flags):
2026-02-13 13:36:39 +08:00
counters["high_risk_operations"] += 1
rows.append(
{
"index": str(idx),
"operation_type": op.operation_type,
"risk_flags": "|".join(flags),
"source_path": str(op.source_path),
"destination_path": str(op.destination_path) if op.destination_path else "",
"reason": op.reason,
}
)
2026-02-13 13:36:39 +08:00
return rows, counters
def _load_review_csv_by_index(csv_path: Path) -> dict[int, dict[str, str]]:
"""Load manual review CSV rows keyed by 1-based operation index."""
indexed: dict[int, dict[str, str]] = {}
with open(csv_path, "r", encoding="utf-8", newline="") as fh:
reader = csv.DictReader(fh)
for row in reader:
try:
indexed[int(row["index"])] = row
except (KeyError, ValueError, TypeError):
continue
return indexed
def _actionable_high_risk_indices(
plan: ExecutionPlan,
*,
season_threshold: int = 20,
episode_threshold: int = 40,
) -> list[int]:
"""Return 1-based indices of operations that block execute until reviewed."""
indices: list[int] = []
for idx, op in enumerate(plan.operations, start=1):
flags = _risk_flags_for_operation(
op,
season_threshold=season_threshold,
episode_threshold=episode_threshold,
)
if _is_actionable_high_risk(op, flags):
indices.append(idx)
return indices
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 not applied."""
actionable = _actionable_high_risk_indices(
plan,
season_threshold=season_threshold,
episode_threshold=episode_threshold,
)
if not actionable:
return []
errors: list[str] = []
count = len(actionable)
if not review_csv.is_file():
errors.append(
f"Review required: {count} actionable high-risk operation(s) but review CSV not found: {review_csv}. "
f"Run: vlm review-plan --input {plan_path}"
)
return errors
if not plan.metadata.get(REVIEW_APPLIED_AT_KEY):
errors.append(
f"Review required: {count} actionable high-risk operation(s) but plan was not updated via apply-review. "
f"Run: vlm apply-review --plan {plan_path} --csv {review_csv}"
)
return errors
try:
csv_rows = _load_review_csv_by_index(review_csv)
except OSError as exc:
errors.append(f"Could not read review CSV: {exc}")
return errors
missing = [idx for idx in actionable if idx not in csv_rows]
if missing:
preview = ", ".join(str(i) for i in missing[:8])
suffix = f" (and {len(missing) - 8} more)" if len(missing) > 8 else ""
errors.append(
f"Review CSV missing rows for actionable operation index(es): {preview}{suffix}. "
f"Re-run: vlm review-plan --input {plan_path}"
)
recorded_csv = plan.metadata.get(REVIEW_CSV_PATH_KEY)
if recorded_csv:
try:
if Path(recorded_csv).resolve() != review_csv.resolve():
errors.append(
f"Review CSV path does not match last apply-review ({recorded_csv}). "
f"Re-run apply-review with --csv {review_csv}"
)
except OSError:
pass
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
2026-02-13 13:36:39 +08:00
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)
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")
2026-02-13 13:36:39 +08:00
writer.writeheader()
writer.writerows(rows)