refactor review-plan safety and validation

This commit is contained in:
windyboy
2026-04-07 11:00:47 +08:00
parent fb128c70d6
commit 0a6bddcc7e
19 changed files with 1246 additions and 628 deletions
-8
View File
@@ -568,14 +568,6 @@ def review_plan_cmd(
if not _review_plan_tui_streams_ok():
click.echo("Error: --tui requires an interactive terminal (TTY)", err=True)
sys.exit(1)
try:
from textual.app import App as _TextualApp # noqa: F401
except ImportError:
click.echo(
'Error: Textual is not installed. Install with: uv pip install -e ".[tui]"',
err=True,
)
sys.exit(1)
click.echo(f"Loading plan: {input}")
execution_plan = load_plan(input)
+13 -3
View File
@@ -9,6 +9,10 @@ from vlm.models import MovieIdentity, SeriesIdentity
from vlm.utils import is_sample_path
class DuplicateResolutionError(ValueError):
"""Raised when a duplicate group cannot be resolved deterministically."""
def choose_keep_index(
items: list[tuple[Path, Union[MovieIdentity, SeriesIdentity]]],
strategy: str,
@@ -38,14 +42,20 @@ def choose_keep_index(
if strategy == "first_seen":
return 0
if strategy == "by_quality":
if quality_comparison is None or len(quality_comparison) != len(items):
return 0 # Fallback to first if quality data missing/mismatched
if quality_comparison is None:
raise DuplicateResolutionError(
"by_quality strategy requires quality comparison data"
)
if len(quality_comparison) != len(items):
raise DuplicateResolutionError(
"by_quality strategy requires quality data aligned with duplicate items"
)
return _by_quality_index(items, quality_comparison)
if strategy == "by_reputation":
return _by_reputation_index(items, quality_comparison=quality_comparison)
if strategy == "by_reputation_quality_time":
return _by_reputation_quality_time_index(items, quality_comparison=quality_comparison)
return 0
raise DuplicateResolutionError(f"Unsupported duplicate strategy: {strategy}")
def _parse_resolution_tier(resolution: Optional[str], path: Path) -> int:
+70 -20
View File
@@ -108,7 +108,25 @@ class ExecutionEngine:
# Execute all operations
results = []
for i, operation in enumerate(plan.operations):
result = self.execute_operation(operation, mode)
try:
result = self.execute_operation(operation, mode)
except Exception as exc: # pragma: no cover - defensive containment
error_msg = (
f"Unexpected failure during {operation.operation_type}: {exc}"
)
self.logger.exception(
error_msg,
extra={
"operation_type": "execute",
"file_path": f" - {operation.source_path}",
},
)
result = OperationResult(
operation=operation,
success=False,
error_message=error_msg,
executed_at=utc_now(),
)
results.append(result)
# Update transaction and state logs in execute mode
@@ -183,6 +201,53 @@ class ExecutionEngine:
return results, summary, rollback_log
def _validate_library_root_boundaries(
self,
operation: FileOperation,
executed_at: datetime,
) -> OperationResult | None:
"""Reject move/rename operations that escape the configured library root."""
if not self.config or operation.operation_type not in ("move", "rename"):
return None
if not is_within_root(operation.source_path, self.config.library_root):
error_msg = f"Unsafe source outside library root: {operation.source_path}"
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="execute",
file_path=operation.source_path,
)
return OperationResult(
operation=operation,
success=False,
error_message=error_msg,
executed_at=executed_at,
)
if operation.destination_path and not is_within_root(
operation.destination_path, self.config.library_root
):
error_msg = (
f"Unsafe destination outside library root: {operation.destination_path}"
)
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="execute",
file_path=operation.source_path,
)
return OperationResult(
operation=operation,
success=False,
error_message=error_msg,
executed_at=executed_at,
)
return None
def execute_operation(
self,
operation: FileOperation,
@@ -277,6 +342,10 @@ class ExecutionEngine:
executed_at=executed_at
)
boundary_error = self._validate_library_root_boundaries(operation, executed_at)
if boundary_error is not None:
return boundary_error
# Execute based on mode
if mode == "dry-run":
return self._simulate_operation(operation, executed_at)
@@ -352,25 +421,6 @@ class ExecutionEngine:
# Create destination directory if needed
if operation.destination_path:
if self.config and not is_within_root(
operation.destination_path, self.config.library_root
):
error_msg = (
f"Unsafe destination outside library root: {operation.destination_path}"
)
log_operation(
self.logger,
logging.ERROR,
error_msg,
operation_type="execute",
file_path=operation.source_path
)
return OperationResult(
operation=operation,
success=False,
error_message=error_msg,
executed_at=executed_at
)
operation.destination_path.parent.mkdir(parents=True, exist_ok=True)
# Perform the move/rename operation
+97 -15
View File
@@ -1,8 +1,8 @@
"""Unified I/O layer for inventory and identities data."""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Union
@@ -10,16 +10,19 @@ from vlm.models import (
AnalysisJSON,
AnalysisCompletenessRecord,
AnalysisDuplicateRecord,
ExecutionPlan,
FileOperation,
MovieIdentity,
MovieIdentityRecord,
ParsedIdentitiesJSON,
PlanJSON,
PlanOperationRecord,
SeriesIdentity,
SeriesIdentityRecord,
VideoFile,
)
from vlm.utils import utc_now
from vlm.utils import ensure_utc, utc_now
# Re-export scanner CSV functions so CLI and others use a single I/O entry point
from vlm.scanner import load_inventory_csv, save_inventory_csv
__all__ = [
@@ -31,6 +34,11 @@ __all__ = [
"save_identities_json",
"load_analysis_json",
"save_analysis_json",
"validate_plan_json",
"execution_plan_from_record",
"execution_plan_to_record",
"load_execution_plan",
"save_execution_plan",
"identities_to_plan_input",
"identities_to_analysis_input",
]
@@ -222,7 +230,21 @@ def _validate_analysis_json(data: object) -> AnalysisJSON:
return mapping # type: ignore[return-value]
def validate_plan_json(data: object) -> dict:
def _validate_plan_operation_record(record: object, *, label: str) -> PlanOperationRecord:
operation = _ensure_dict(record, label)
_ensure_str(operation.get("operation_type"), f"{label}.operation_type")
_ensure_str(operation.get("source_path"), f"{label}.source_path")
if operation.get("destination_path") is not None:
_ensure_str(operation.get("destination_path"), f"{label}.destination_path")
_ensure_str(operation.get("reason"), f"{label}.reason")
_ensure_bool(operation.get("has_conflict"), f"{label}.has_conflict")
if "conflict_reason" in operation and operation["conflict_reason"] is not None:
_ensure_str(operation["conflict_reason"], f"{label}.conflict_reason")
return operation # type: ignore[return-value]
def validate_plan_json(data: object) -> PlanJSON:
"""Validate the on-disk execution plan schema."""
mapping = _ensure_dict(data, "plan JSON")
_ensure_str(mapping.get("vlm_schema_version"), "plan JSON.vlm_schema_version")
@@ -237,18 +259,78 @@ def validate_plan_json(data: object) -> dict:
_ensure_dict(mapping["metadata"], "plan JSON.metadata")
operations = _ensure_list(mapping.get("operations", []), "plan JSON.operations")
for idx, item in enumerate(operations):
operation = _ensure_dict(item, f"plan JSON.operations[{idx}]")
_ensure_str(operation.get("operation_type"), f"plan JSON.operations[{idx}].operation_type")
_ensure_str(operation.get("source_path"), f"plan JSON.operations[{idx}].source_path")
if operation.get("destination_path") is not None:
_ensure_str(operation.get("destination_path"), f"plan JSON.operations[{idx}].destination_path")
_ensure_str(operation.get("reason"), f"plan JSON.operations[{idx}].reason")
_ensure_bool(operation.get("has_conflict"), f"plan JSON.operations[{idx}].has_conflict")
if "conflict_reason" in operation and operation["conflict_reason"] is not None:
_ensure_str(operation["conflict_reason"], f"plan JSON.operations[{idx}].conflict_reason")
mapping["operations"] = [
_validate_plan_operation_record(item, label=f"plan JSON.operations[{idx}]")
for idx, item in enumerate(operations)
]
return mapping # type: ignore[return-value]
def execution_plan_to_record(plan: ExecutionPlan) -> PlanJSON:
"""Serialize a typed execution plan into the canonical JSON record."""
plan_record: PlanJSON = {
"vlm_schema_version": "1.0",
"plan_id": plan.plan_id,
"created_at": plan.created_at.isoformat(),
"operations": [
{
"operation_type": op.operation_type,
"source_path": str(op.source_path),
"destination_path": str(op.destination_path) if op.destination_path else None,
"reason": op.reason,
"has_conflict": op.has_conflict,
"conflict_reason": op.conflict_reason,
}
for op in plan.operations
],
"summary": plan.summary,
"summary_by_reason": plan.summary_by_reason,
"human_summary": plan.human_summary,
"metadata": plan.metadata,
}
return validate_plan_json(plan_record)
def execution_plan_from_record(data: object) -> ExecutionPlan:
"""Construct a typed execution plan after schema validation."""
plan_dict = validate_plan_json(data)
operations = [
FileOperation(
operation_type=op["operation_type"],
source_path=Path(op["source_path"]),
destination_path=Path(op["destination_path"]) if op["destination_path"] else None,
reason=op["reason"],
has_conflict=op["has_conflict"],
conflict_reason=op.get("conflict_reason"),
)
for op in plan_dict["operations"]
]
created_at = ensure_utc(datetime.fromisoformat(plan_dict["created_at"]))
return ExecutionPlan(
plan_id=plan_dict["plan_id"],
created_at=created_at,
operations=operations,
summary=plan_dict["summary"],
summary_by_reason=plan_dict.get("summary_by_reason", {}),
human_summary=plan_dict.get("human_summary", ""),
metadata=plan_dict.get("metadata", {}),
)
def load_execution_plan(path: Path) -> ExecutionPlan:
"""Load an execution plan from disk through the validated typed boundary."""
return execution_plan_from_record(load_json_file(path))
def save_execution_plan(plan: ExecutionPlan, path: Path) -> None:
"""Persist an execution plan via the canonical validated JSON record."""
save_json_file(execution_plan_to_record(plan), path)
return mapping
def load_analysis_json(path: Path) -> AnalysisJSON:
+20
View File
@@ -363,3 +363,23 @@ class AnalysisJSON(TypedDict, total=False):
completeness: list[AnalysisCompletenessRecord]
duplicates: list[AnalysisDuplicateRecord]
class PlanOperationRecord(TypedDict, total=False):
operation_type: str
source_path: str
destination_path: str | None
reason: str
has_conflict: bool
conflict_reason: str | None
class PlanJSON(TypedDict, total=False):
vlm_schema_version: str
plan_id: str
created_at: str
operations: list[PlanOperationRecord]
summary: dict[str, int]
summary_by_reason: dict[str, int]
human_summary: str
metadata: dict[str, object]
+110 -70
View File
@@ -4,17 +4,24 @@ This module generates structured execution plans that specify how video files
should be organized based on their parsed identities and configuration templates.
"""
import json
import re
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
from vlm.config import Config
from vlm.duplicate_resolve import choose_keep_index
from vlm.io import validate_plan_json
from vlm.utils import ensure_utc, is_sample_path, is_within_root, sanitize_path_component, utc_now
from vlm.duplicate_resolve import DuplicateResolutionError, choose_keep_index
from vlm.io import (
load_execution_plan,
save_execution_plan,
)
from vlm.utils import (
canonical_path_str,
is_sample_path,
is_within_root,
sanitize_path_component,
utc_now,
)
from vlm.models import (
ExecutionPlan,
FileOperation,
@@ -32,6 +39,42 @@ NO_OP_REASON_SEASON_OUT_OF_RANGE = "Series needs manual review (season exceeds c
NO_OP_REASON_EPISODE_OUT_OF_RANGE = "Series needs manual review (episode exceeds configured threshold)"
def _normalized_path_key(path_value: str | Path) -> str:
"""Normalize path-like values for duplicate-group matching."""
text = str(path_value).strip()
if not text:
return ""
return canonical_path_str(Path(text.replace("\\", "/")))
def _build_duplicate_quality_lookup(quality_comparison: list[dict]) -> dict[str, dict]:
"""Index duplicate quality entries by canonicalized path."""
lookup: dict[str, dict] = {}
for quality in quality_comparison:
quality_path = quality.get("path")
if isinstance(quality_path, str) and quality_path.strip():
lookup[_normalized_path_key(quality_path)] = quality
return lookup
def _mark_duplicate_group_manual_review(
operations: list[FileOperation],
indices: list[int],
message: str,
) -> None:
"""Convert unresolved duplicate operations into explicit manual-review no-ops."""
for index in indices:
current = operations[index]
operations[index] = FileOperation(
operation_type="no-op",
source_path=current.source_path,
destination_path=None,
reason=f"Duplicate group needs manual review: {message}",
has_conflict=False,
conflict_reason=None,
)
def _analyze_directory_impact(operations: list[FileOperation]) -> dict:
"""Analyze which directories will be emptied by the plan."""
# Get all source directories that have files being moved/renamed
@@ -99,17 +142,31 @@ def generate_plan(
metadata: dict = {}
validation_snapshot: dict[str, object] = {}
duplicate_resolution_issues: list[dict[str, object]] = []
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", []))
validation_snapshot["completeness_seasons_with_gaps"] = len(analysis_data.get("completeness", []))
if config.duplicate_keep != "manual":
path_to_index = {str(vf.path): i for i, (vf, _) in enumerate(identities)}
path_to_index = {
_normalized_path_key(vf.path): i for i, (vf, _) in enumerate(identities)
}
for dup in analysis_data.get("duplicates", []):
paths = dup.get("files", [])
indices = [path_to_index[p] for p in paths if p in path_to_index]
path_to_qc = {qc.get("path"): qc for qc in dup.get("quality_comparison", [])}
normalized_paths = [
_normalized_path_key(path)
for path in paths
if isinstance(path, str) and path.strip()
]
indices = [
path_to_index[path_key]
for path_key in normalized_paths
if path_key in path_to_index
]
path_to_qc = _build_duplicate_quality_lookup(
dup.get("quality_comparison", [])
)
items = []
valid_indices = []
for i in indices:
@@ -125,11 +182,25 @@ def generate_plan(
if not items:
continue
quality_list = [
path_to_qc.get(str(p), {}) for p, _ in items
path_to_qc.get(_normalized_path_key(path), {}) for path, _ in items
]
keep_idx = choose_keep_index(
items, config.duplicate_keep, quality_comparison=quality_list
)
try:
if config.duplicate_keep == "by_quality" and any(not qc for qc in quality_list):
raise DuplicateResolutionError(
"missing quality comparison entries for one or more duplicate items"
)
keep_idx = choose_keep_index(
items, config.duplicate_keep, quality_comparison=quality_list
)
except DuplicateResolutionError as exc:
issue = {
"strategy": config.duplicate_keep,
"reason": str(exc),
"files": [str(path) for path, _ in items],
}
duplicate_resolution_issues.append(issue)
_mark_duplicate_group_manual_review(operations, valid_indices, str(exc))
continue
if keep_idx is None:
continue
keep_identity_index = valid_indices[keep_idx]
@@ -165,14 +236,18 @@ def generate_plan(
validation_snapshot["emptied_directories"] = [str(d) for d in directory_analysis["emptied_directories"]]
validation_snapshot["directory_warning"] = True
summary = _generate_summary(operations)
summary_by_reason = _generate_summary_by_reason(operations)
human_summary = _generate_human_summary(operations, summary, summary_by_reason, metadata)
if duplicate_resolution_issues:
validation_snapshot["duplicate_resolution_issues"] = duplicate_resolution_issues
metadata["duplicate_resolution_issues"] = duplicate_resolution_issues
if validation_snapshot:
validation_snapshot["captured_at"] = utc_now().isoformat()
metadata["validation_snapshot"] = validation_snapshot
summary = _generate_summary(operations)
summary_by_reason = _generate_summary_by_reason(operations)
human_summary = _generate_human_summary(operations, summary, summary_by_reason, metadata)
return ExecutionPlan(
plan_id=str(uuid.uuid4()),
created_at=utc_now(),
@@ -539,10 +614,25 @@ def _generate_human_summary(
if conflicts > 0:
parts.append(f"冲突 {conflicts} 条。")
if metadata:
dup = metadata.get("duplicate_groups_considered", 0)
gaps = metadata.get("completeness_seasons_with_gaps", 0)
validation = metadata.get("validation_snapshot", {}) if isinstance(metadata.get("validation_snapshot"), dict) else {}
dup = metadata.get(
"duplicate_groups_considered",
validation.get("duplicate_groups_considered", 0),
)
gaps = metadata.get(
"completeness_seasons_with_gaps",
validation.get("completeness_seasons_with_gaps", 0),
)
if dup or gaps:
parts.append(f"依据 analysis:重复组 {dup} 个;剧集缺口 {gaps} 季。")
resolution_issues = metadata.get(
"duplicate_resolution_issues",
validation.get("duplicate_resolution_issues", []),
)
if resolution_issues:
parts.append(
f"重复组中有 {len(resolution_issues)} 个因决策依据不足已转人工复核。"
)
quarantine_lines = _build_quarantine_recommendation_lines(operations)
if quarantine_lines:
parts.append("删除建议(仅隔离建议,执行删除前请人工复核):")
@@ -598,31 +688,8 @@ def save_plan(plan: ExecutionPlan, output_path: Path) -> None:
plan: ExecutionPlan to save
output_path: Path where the JSON file should be saved
"""
# Convert ExecutionPlan to dictionary
plan_dict = {
"vlm_schema_version": "1.0",
"plan_id": plan.plan_id,
"created_at": plan.created_at.isoformat(),
"operations": [
{
"operation_type": op.operation_type,
"source_path": str(op.source_path),
"destination_path": str(op.destination_path) if op.destination_path else None,
"reason": op.reason,
"has_conflict": op.has_conflict,
"conflict_reason": op.conflict_reason
}
for op in plan.operations
],
"summary": plan.summary,
"summary_by_reason": plan.summary_by_reason,
"human_summary": plan.human_summary,
"metadata": plan.metadata,
}
# Write to JSON file with indentation for human readability
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(validate_plan_json(plan_dict), f, indent=2, ensure_ascii=False)
save_execution_plan(plan, output_path)
def load_plan(input_path: Path) -> ExecutionPlan:
@@ -642,34 +709,7 @@ def load_plan(input_path: Path) -> ExecutionPlan:
json.JSONDecodeError: If the file contains invalid JSON
KeyError: If required fields are missing from the JSON
"""
with open(input_path, 'r', encoding='utf-8') as f:
plan_dict = validate_plan_json(json.load(f))
# Reconstruct FileOperation objects
operations = [
FileOperation(
operation_type=op["operation_type"],
source_path=Path(op["source_path"]),
destination_path=Path(op["destination_path"]) if op["destination_path"] else None,
reason=op["reason"],
has_conflict=op["has_conflict"],
conflict_reason=op.get("conflict_reason")
)
for op in plan_dict["operations"]
]
# Reconstruct ExecutionPlan (normalize naive datetime to UTC for backward compatibility)
created_at = ensure_utc(datetime.fromisoformat(plan_dict["created_at"]))
return ExecutionPlan(
plan_id=plan_dict["plan_id"],
created_at=created_at,
operations=operations,
summary=plan_dict["summary"],
summary_by_reason=plan_dict.get("summary_by_reason", {}),
human_summary=plan_dict.get("human_summary", ""),
metadata=plan_dict.get("metadata", {}),
)
return load_execution_plan(input_path)
def apply_review_to_plan(plan: ExecutionPlan, csv_path: Path) -> ExecutionPlan:
"""Update a plan's operations based on a modified review CSV.
+12 -1
View File
@@ -126,7 +126,18 @@ class QuarantineManager:
operation_type="quarantine",
file_path=file_path
)
raise ValueError(error_msg)
return OperationResult(
operation=FileOperation(
operation_type="quarantine",
source_path=file_path,
destination_path=None,
reason=reason or "Unsupported quarantine category",
has_conflict=False,
),
success=False,
error_message=error_msg,
executed_at=executed_at,
)
# Get the actual category directory name from the file path
# (not the category name, which may differ due to category mappings)
+335 -319
View File
@@ -5,14 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
from textual.geometry import Size
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable, Footer, Static
from vlm.plan_review import save_review_csv
from vlm.review_display import (
build_csv_rows,
@@ -21,14 +13,21 @@ from vlm.review_display import (
risk_flags_to_labels,
)
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
def _index_from_row_key(row_key) -> int: # noqa: ANN001 - RowKey | str
if isinstance(row_key, str):
return int(row_key)
val = getattr(row_key, "value", None)
if val is None:
return int(row_key)
return int(val)
MISSING_TEXTUAL_MESSAGE = 'Textual is not installed. Install with: uv pip install -e ".[tui]"'
@dataclass(frozen=True)
@@ -43,346 +42,363 @@ class ReviewTUIContext:
summary_text: str
class SummaryScreen(Screen):
"""Migration summary; Enter continues, q aborts."""
if TEXTUAL_IMPORT_ERROR is None:
BINDINGS = [
Binding("enter", "continue_", "继续", show=True),
Binding("q", "quit", "退出", show=True),
]
def _index_from_row_key(row_key) -> int: # noqa: ANN001 - RowKey | str
if isinstance(row_key, str):
return int(row_key)
val = getattr(row_key, "value", None)
if val is None:
return int(row_key)
return int(val)
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self._ctx = ctx
def compose(self) -> ComposeResult:
stats_lines = [
"---",
"计划统计",
f" 总操作: {self._ctx.counters['total_operations']}",
f" 高危: {self._ctx.counters['high_risk_operations']}",
f" manual_review: {self._ctx.counters['manual_review']}",
f" sample_source: {self._ctx.counters['sample_source']}",
f" high_season: {self._ctx.counters['high_season']}",
f" high_episode: {self._ctx.counters['high_episode']}",
f" conflicts: {self._ctx.counters['conflicts']}",
"",
f"将要写入: {self._ctx.output_csv}",
"",
"默认仅审核标记为高危的操作(与 CSV 行一致)。",
"",
"Enter 进入审核 · q 退出",
class SummaryScreen(Screen):
"""Migration summary; Enter continues, q aborts."""
BINDINGS = [
Binding("enter", "continue_", "继续", show=True),
Binding("q", "quit", "退出", show=True),
]
body = self._ctx.summary_text + "\n\n" + "\n".join(stats_lines)
yield ScrollableContainer(Static(body, id="summary_body"))
yield Footer()
def action_continue_(self) -> None:
self.dismiss(True)
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self._ctx = ctx
def action_quit(self) -> None:
self.dismiss(False)
def compose(self) -> ComposeResult:
stats_lines = [
"---",
"计划统计",
f" 总操作: {self._ctx.counters['total_operations']}",
f" 高危: {self._ctx.counters['high_risk_operations']}",
f" manual_review: {self._ctx.counters['manual_review']}",
f" sample_source: {self._ctx.counters['sample_source']}",
f" high_season: {self._ctx.counters['high_season']}",
f" high_episode: {self._ctx.counters['high_episode']}",
f" conflicts: {self._ctx.counters['conflicts']}",
"",
f"将要写入: {self._ctx.output_csv}",
"",
"默认仅审核标记为高危的操作(与 CSV 行一致)。",
"",
"Enter 进入审核 · q 退出",
]
body = self._ctx.summary_text + "\n\n" + "\n".join(stats_lines)
yield ScrollableContainer(Static(body, id="summary_body"))
yield Footer()
def action_continue_(self) -> None:
self.dismiss(True)
def action_quit(self) -> None:
self.dismiss(False)
class ConfirmDiscardScreen(ModalScreen[bool]):
"""Confirm discarding unsaved edits."""
class ConfirmDiscardScreen(ModalScreen[bool]):
"""Confirm discarding unsaved edits."""
BINDINGS = [
Binding("y", "yes", show=False),
Binding("n", "no", show=False),
]
BINDINGS = [
Binding("y", "yes", show=False),
Binding("n", "no", show=False),
]
def compose(self) -> ComposeResult:
yield Container(
Static("未保存的修改将丢失。放弃? (y / n)", id="confirm_text"),
id="confirm_box",
)
def action_yes(self) -> None:
self.dismiss(True)
def action_no(self) -> None:
self.dismiss(False)
DEFAULT_CSS = """
ConfirmDiscardScreen {
align: center middle;
}
#confirm_box {
width: auto;
height: auto;
padding: 1 2;
border: thick $primary;
background: $surface;
}
"""
class ReviewMainScreen(Screen):
"""High-risk table + detail pane."""
BINDINGS = [
Binding("up", "cursor_up", show=False),
Binding("down", "cursor_down", show=False),
Binding("k", "cursor_up", show=False),
Binding("j", "cursor_down", show=False),
Binding("a", "keep_row", "保留", show=True),
Binding("r", "reject_row", "驳回", show=True),
Binding("u", "undo_row", "撤销", show=True),
Binding("s", "save", "保存", show=True),
Binding("q", "request_quit", "退出", show=True),
]
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self.ctx = ctx
self._by_index: dict[int, dict[str, str]] = {
int(r["index"]): r for r in ctx.rows
}
self.initial_op_by_index: dict[int, str] = {
int(r["index"]): r["operation_type"] for r in ctx.rows
}
self.op_by_index: dict[int, str] = dict(self.initial_op_by_index)
def compose(self) -> ComposeResult:
plan_s = str(self.ctx.plan_input)
if len(plan_s) > 72:
plan_s = plan_s[:35] + "" + plan_s[-34:]
hdr = (
f"{plan_s} · 高危 {self.ctx.counters['high_risk_operations']}"
f" / {self.ctx.counters['total_operations']}"
)
yield Static(hdr, id="header_line")
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 退出",
id="footer_line",
)
yield Footer()
DEFAULT_CSS = """
#header_line {
dock: top;
padding: 0 1;
background: $primary-darken-2;
color: $text;
}
#footer_line {
dock: bottom;
padding: 0 1;
background: $panel;
color: $text-muted;
}
#body {
layout: horizontal;
height: 1fr;
}
#body.vertical-split {
layout: vertical;
height: 1fr;
}
#review_table {
width: 1fr;
min-height: 5;
}
#body.vertical-split #review_table {
height: 40%;
}
#detail_scroll {
width: 1fr;
min-height: 5;
border-left: solid $primary-darken-3;
padding: 0 1;
}
#body.vertical-split #detail_scroll {
border-left: none;
border-top: solid $primary-darken-3;
height: 1fr;
}
"""
def on_mount(self) -> None:
table = self.query_one("#review_table", DataTable)
table.cursor_type = "row"
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")
for r in self.ctx.rows:
idx = int(r["index"])
sym = self._symbol_for(idx)
table.add_row(
sym,
str(idx),
self.op_by_index[idx],
risk_flags_to_labels(r["risk_flags"]),
Path(r["source_path"]).name,
key=str(idx),
)
self._apply_body_layout(self.app.size)
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 行为一致)。"
def compose(self) -> ComposeResult:
yield Container(
Static("未保存的修改将丢失。放弃? (y / n)", id="confirm_text"),
id="confirm_box",
)
def _table(self) -> DataTable:
return self.query_one("#review_table", DataTable)
def action_yes(self) -> None:
self.dismiss(True)
def _symbol_for(self, index: int) -> str:
return review_row_status_symbol(
self.op_by_index,
self.initial_op_by_index,
index,
)
def action_no(self) -> None:
self.dismiss(False)
def _apply_body_layout(self, app_size: Size) -> None:
body = self.query_one("#body", Horizontal)
if app_size.width < 100:
body.add_class("vertical-split")
else:
body.remove_class("vertical-split")
DEFAULT_CSS = """
ConfirmDiscardScreen {
align: center middle;
}
#confirm_box {
width: auto;
height: auto;
padding: 1 2;
border: thick $primary;
background: $surface;
}
"""
def on_resize(self, event) -> None: # noqa: ANN001 - textual Resize
self._apply_body_layout(self.app.size)
def _current_index(self) -> int | None:
table = self._table()
if table.row_count == 0:
return None
row_index = table.cursor_coordinate.row
row = table.ordered_rows[row_index]
return _index_from_row_key(row.key)
class ReviewMainScreen(Screen):
"""High-risk table + detail pane."""
@on(DataTable.RowHighlighted) # type: ignore[misc]
def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if event.data_table.id != "review_table":
return
idx = _index_from_row_key(event.row_key)
self._refresh_detail(idx)
BINDINGS = [
Binding("up", "cursor_up", show=False),
Binding("down", "cursor_down", show=False),
Binding("k", "cursor_up", show=False),
Binding("j", "cursor_down", show=False),
Binding("a", "keep_row", "保留", show=True),
Binding("r", "reject_row", "驳回", show=True),
Binding("u", "undo_row", "撤销", show=True),
Binding("s", "save", "保存", show=True),
Binding("q", "request_quit", "退出", show=True),
]
def _refresh_detail(self, index: int) -> None:
r = self._by_index[index]
op = self.op_by_index[index]
paths = format_paths_for_detail(
r["source_path"],
r.get("destination_path", ""),
self.ctx.library_root,
)
risk_cn = risk_flags_to_labels(r["risk_flags"], max_len=120)
summary = (
f"#{index} · {op} · {Path(r['source_path']).name}"
+ (f" · {risk_cn}" if risk_cn else "")
)
text = (
f"{summary}\n\n"
f"变更\n{paths}\n\n"
f"依据\n{r['reason']}\n\n"
f"标记\n{r['risk_flags']}"
)
self.query_one("#detail_text", Static).update(text)
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self.ctx = ctx
self._by_index: dict[int, dict[str, str]] = {
int(r["index"]): r for r in ctx.rows
}
self.initial_op_by_index: dict[int, str] = {
int(r["index"]): r["operation_type"] for r in ctx.rows
}
self.op_by_index: dict[int, str] = dict(self.initial_op_by_index)
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)
table.update_cell(key, "op", self.op_by_index[index])
def compose(self) -> ComposeResult:
plan_s = str(self.ctx.plan_input)
if len(plan_s) > 72:
plan_s = plan_s[:35] + "" + plan_s[-34:]
hdr = (
f"{plan_s} · 高危 {self.ctx.counters['high_risk_operations']}"
f" / {self.ctx.counters['total_operations']}"
)
yield Static(hdr, id="header_line")
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 退出",
id="footer_line",
)
yield Footer()
def _update_dirty_header(self) -> None:
dirty = self._is_dirty()
hdr = self.query_one("#header_line", Static)
plan_s = str(self.ctx.plan_input)
if len(plan_s) > 72:
plan_s = plan_s[:35] + "" + plan_s[-34:]
star = " *" if dirty else ""
hdr.update(
f"{plan_s}{star} · 高危 {self.ctx.counters['high_risk_operations']}"
f" / {self.ctx.counters['total_operations']}"
)
DEFAULT_CSS = """
#header_line {
dock: top;
padding: 0 1;
background: $primary-darken-2;
color: $text;
}
#footer_line {
dock: bottom;
padding: 0 1;
background: $panel;
color: $text-muted;
}
#body {
layout: horizontal;
height: 1fr;
}
#body.vertical-split {
layout: vertical;
height: 1fr;
}
#review_table {
width: 1fr;
min-height: 5;
}
#body.vertical-split #review_table {
height: 40%;
}
#detail_scroll {
width: 1fr;
min-height: 5;
border-left: solid $primary-darken-3;
padding: 0 1;
}
#body.vertical-split #detail_scroll {
border-left: none;
border-top: solid $primary-darken-3;
height: 1fr;
}
"""
def _is_dirty(self) -> bool:
return self.op_by_index != self.initial_op_by_index
def on_mount(self) -> None:
table = self.query_one("#review_table", DataTable)
table.cursor_type = "row"
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")
def action_cursor_up(self) -> None:
if self._table().row_count:
self._table().action_cursor_up()
for r in self.ctx.rows:
idx = int(r["index"])
sym = self._symbol_for(idx)
table.add_row(
sym,
str(idx),
self.op_by_index[idx],
risk_flags_to_labels(r["risk_flags"]),
Path(r["source_path"]).name,
key=str(idx),
)
self._apply_body_layout(self.app.size)
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 行为一致)。"
)
def action_cursor_down(self) -> None:
if self._table().row_count:
self._table().action_cursor_down()
def _table(self) -> DataTable:
return self.query_one("#review_table", DataTable)
def action_keep_row(self) -> None:
idx = self._current_index()
if idx is None:
return
self.op_by_index[idx] = self.initial_op_by_index[idx]
self._refresh_row_cells(idx)
self._refresh_detail(idx)
self._update_dirty_header()
def _symbol_for(self, index: int) -> str:
return review_row_status_symbol(
self.op_by_index,
self.initial_op_by_index,
index,
)
def action_reject_row(self) -> None:
idx = self._current_index()
if idx is None:
return
self.op_by_index[idx] = "no-op"
self._refresh_row_cells(idx)
self._refresh_detail(idx)
self._update_dirty_header()
def _apply_body_layout(self, app_size: Size) -> None:
body = self.query_one("#body", Horizontal)
if app_size.width < 100:
body.add_class("vertical-split")
else:
body.remove_class("vertical-split")
def action_undo_row(self) -> None:
self.action_keep_row()
def on_resize(self, event) -> None: # noqa: ANN001 - textual Resize
self._apply_body_layout(self.app.size)
def action_save(self) -> None:
out_rows = build_csv_rows(self.ctx.rows, self.op_by_index)
save_review_csv(out_rows, self.ctx.output_csv)
self.dismiss("saved")
def _current_index(self) -> int | None:
table = self._table()
if table.row_count == 0:
return None
row_index = table.cursor_coordinate.row
row = table.ordered_rows[row_index]
return _index_from_row_key(row.key)
def action_request_quit(self) -> None:
if not self._is_dirty():
self.dismiss("aborted")
return
@on(DataTable.RowHighlighted) # type: ignore[misc]
def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if event.data_table.id != "review_table":
return
idx = _index_from_row_key(event.row_key)
self._refresh_detail(idx)
def after_confirm(confirmed: bool | None) -> None:
if confirmed:
def _refresh_detail(self, index: int) -> None:
r = self._by_index[index]
op = self.op_by_index[index]
paths = format_paths_for_detail(
r["source_path"],
r.get("destination_path", ""),
self.ctx.library_root,
)
risk_cn = risk_flags_to_labels(r["risk_flags"], max_len=120)
summary = (
f"#{index} · {op} · {Path(r['source_path']).name}"
+ (f" · {risk_cn}" if risk_cn else "")
)
text = (
f"{summary}\n\n"
f"变更\n{paths}\n\n"
f"依据\n{r['reason']}\n\n"
f"标记\n{r['risk_flags']}"
)
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)
table.update_cell(key, "op", self.op_by_index[index])
def _update_dirty_header(self) -> None:
dirty = self._is_dirty()
hdr = self.query_one("#header_line", Static)
plan_s = str(self.ctx.plan_input)
if len(plan_s) > 72:
plan_s = plan_s[:35] + "" + plan_s[-34:]
star = " *" if dirty else ""
hdr.update(
f"{plan_s}{star} · 高危 {self.ctx.counters['high_risk_operations']}"
f" / {self.ctx.counters['total_operations']}"
)
def _is_dirty(self) -> bool:
return self.op_by_index != self.initial_op_by_index
def action_cursor_up(self) -> None:
if self._table().row_count:
self._table().action_cursor_up()
def action_cursor_down(self) -> None:
if self._table().row_count:
self._table().action_cursor_down()
def action_keep_row(self) -> None:
idx = self._current_index()
if idx is None:
return
self.op_by_index[idx] = self.initial_op_by_index[idx]
self._refresh_row_cells(idx)
self._refresh_detail(idx)
self._update_dirty_header()
def action_reject_row(self) -> None:
idx = self._current_index()
if idx is None:
return
self.op_by_index[idx] = "no-op"
self._refresh_row_cells(idx)
self._refresh_detail(idx)
self._update_dirty_header()
def action_undo_row(self) -> None:
self.action_keep_row()
def action_save(self) -> None:
out_rows = build_csv_rows(self.ctx.rows, self.op_by_index)
save_review_csv(out_rows, self.ctx.output_csv)
self.dismiss("saved")
def action_request_quit(self) -> None:
if not self._is_dirty():
self.dismiss("aborted")
return
self.app.push_screen(ConfirmDiscardScreen(), callback=after_confirm)
def after_confirm(confirmed: bool | None) -> None:
if confirmed:
self.dismiss("aborted")
self.app.push_screen(ConfirmDiscardScreen(), callback=after_confirm)
class PlanReviewApp(App):
"""Application shell: summary screen then review screen."""
class PlanReviewApp(App):
"""Application shell: summary screen then review screen."""
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self.ctx = ctx
def __init__(self, ctx: ReviewTUIContext) -> None:
super().__init__()
self.ctx = ctx
def on_mount(self) -> None:
self.push_screen(SummaryScreen(self.ctx), self._after_summary)
def on_mount(self) -> None:
self.push_screen(SummaryScreen(self.ctx), self._after_summary)
def _after_summary(self, result: bool | None) -> None:
if not result:
self.exit(return_code=1)
return
self.push_screen(ReviewMainScreen(self.ctx), self._after_main)
def _after_summary(self, result: bool | None) -> None:
if not result:
self.exit(return_code=1)
return
self.push_screen(ReviewMainScreen(self.ctx), self._after_main)
def _after_main(self, result: str | None) -> None:
if result == "saved":
self.exit(return_code=0)
else:
self.exit(return_code=1)
def _after_main(self, result: str | None) -> None:
if result == "saved":
self.exit(return_code=0)
else:
self.exit(return_code=1)
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
"""Block until the user finishes the TUI. Returns process exit code."""
app = PlanReviewApp(ctx)
app.run()
code = app.return_code
return 0 if code is None else code
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
"""Block until the user finishes the TUI. Returns process exit code."""
app = PlanReviewApp(ctx)
app.run()
code = app.return_code
return 0 if code is None else code
else:
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
"""Raise a friendly error when the optional Textual dependency is missing."""
raise RuntimeError(MISSING_TEXTUAL_MESSAGE) from TEXTUAL_IMPORT_ERROR
+27 -5
View File
@@ -155,6 +155,29 @@ def _discover_video_paths(root: Path, video_extensions: list[str]) -> list[Path]
return _discover_video_paths_recursive(root, video_extensions)
def _log_find_nonzero_exit(returncode: int, stderr_text: str, discovered_count: int) -> None:
"""Log the explicit contract for non-zero `find` exits.
Contract: if `find` emits partial stdout before failing, keep those paths and
continue with a warning. If no paths were emitted, return an empty result and
log that scan discovery was incomplete.
"""
stderr_suffix = f": {stderr_text}" if stderr_text else ""
if discovered_count > 0:
logger.warning(
"find exited with code %s; using %s partial scan result(s)%s",
returncode,
discovered_count,
stderr_suffix,
)
else:
logger.warning(
"find exited with code %s and produced no scan results%s",
returncode,
stderr_suffix,
)
def _discover_video_paths_with_find(root: Path, video_extensions: list[str]) -> list[Path]:
"""Discover matching video files using the system `find` command."""
normalized_extensions = [ext.lower() for ext in video_extensions if ext]
@@ -175,11 +198,6 @@ def _discover_video_paths_with_find(root: Path, video_extensions: list[str]) ->
)
stdout, stderr = process.communicate()
if process.returncode != 0:
stderr_text = stderr.decode(errors="replace").strip()
if stderr_text:
logger.warning(f"find reported issues while scanning: {stderr_text}")
discovered_paths: list[Path] = []
for path_bytes in stdout.split(b"\0"):
if not path_bytes:
@@ -189,6 +207,10 @@ def _discover_video_paths_with_find(root: Path, video_extensions: list[str]) ->
continue
discovered_paths.append(file_path)
if process.returncode != 0:
stderr_text = stderr.decode(errors="replace").strip()
_log_find_nonzero_exit(process.returncode, stderr_text, len(discovered_paths))
return discovered_paths