refactor: consolidate skill docs, add anti-drift tests, and apply audit fixes

DLO-13: Restructure vlm-library-workflow skill as safety contract layer.
- Rewrite SKILL.md (69 lines): safety contract, execution threshold semantics,
  six-step high-risk loop, decision rules, phase skeleton
- Delete redundant references (cli-reference, workflow, command-recipes, dev-guide)
- Add triage.md (failure mapping + preflight) and dev-map.md (module→test mapping)
- Add tests/test_docs_consistency.py: 78 parametrized tests verifying documented
  vlm commands exist in CLI registry
- Add CSV path mismatch test to test_plan_review.py (4th safety gate path)
- Delete vlm-expert.skill (Gemini package, 7 months stale) and README Gemini section

DLO-2 audit fixes: rate limiter injection, symmetric quarantine categories,
review-plan safety gates, parser improvements, planner validation.

CLI modularization: commands/ directory with one module per command group.
This commit is contained in:
windyboy
2026-09-25 13:50:09 +08:00
parent c7a55190d7
commit dfa18ed405
35 changed files with 1116 additions and 621 deletions
+100 -280
View File
@@ -1,22 +1,25 @@
"""Report CLI command implementations."""
"""Report CLI commands."""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import click
from vlm.cli_helpers import command_error, resolve_legacy_default_input_path
from vlm.context import CLIContext
from vlm.cli_helpers import (
command_error,
default_artifact_path,
emit_report,
optional_plan_summary,
resolve_legacy_default_input_path,
run_command,
)
from vlm.context import CLIContext, pass_context
from vlm.io import load_analysis_json, load_inventory_csv
from vlm.models import DuplicateGroup, MovieIdentity, SeasonCompleteness, SeriesIdentity, VideoFile
from vlm.plan_render import preferred_plan_summary
from vlm.planner import load_plan
from vlm.reports import (
completeness_from_analysis,
duplicate_groups_from_analysis,
generate_completeness_report,
generate_duplicate_report,
generate_inventory_report,
@@ -24,290 +27,107 @@ from vlm.reports import (
)
def report_inventory_cmd(
ctx: CLIContext,
format: str,
input: Path,
output: Optional[Path],
def _run_inventory(ctx: CLIContext, format: str, input: Path, output: Optional[Path]) -> None:
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
click.echo(f"Loading inventory from: {input}")
video_files = load_inventory_csv(input)
click.echo(f"Loaded {len(video_files)} files\n")
report_format = "csv" if format == "text" else format
content = generate_inventory_report(video_files, report_format, ctx.config.library_root)
emit_report(content, output)
ctx.logger.info("inventory report: %s files, format=%s", len(video_files), format)
def _run_completeness(
ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]
) -> None:
"""Generate inventory report."""
config = ctx.config
logger = ctx.logger
try:
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
click.echo(f"Loading inventory from: {input}")
video_files = load_inventory_csv(input)
click.echo(f"Loaded {len(video_files)} files")
click.echo()
click.echo(f"Generating inventory report in {format} format...")
report_format = "csv" if format == "text" else format
report_content = generate_inventory_report(video_files, report_format, config.library_root)
if output:
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, "w", encoding="utf-8") as f:
f.write(report_content)
click.echo(f"Report saved to: {output}")
else:
click.echo()
click.echo(report_content)
logger.info("Generated inventory report in %s format with %s files", format, len(video_files))
except FileNotFoundError:
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
except Exception as e:
command_error(
ctx,
f"Error generating inventory report: {e}",
f"Inventory report generation failed: {e}",
exc_info=True,
)
def report_completeness_cmd(
ctx: CLIContext,
format: str,
input: Path,
output: Optional[Path],
plan: Optional[Path],
) -> None:
"""Generate completeness report."""
config = ctx.config
logger = ctx.logger
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
plan_summary = None
if plan:
if not plan.exists():
click.echo(f"Error: Plan file not found: {plan}", err=True)
sys.exit(1)
execution_plan = load_plan(plan)
plan_summary = preferred_plan_summary(execution_plan)
try:
click.echo(f"Loading analysis from: {input}")
analysis_data = load_analysis_json(input)
completeness_list = analysis_data.get("completeness", [])
season_completeness = []
for c in completeness_list:
season_completeness.append(
SeasonCompleteness(
series_title=c["series_title"],
season=c["season"],
episodes_found=c["episodes_found"],
episodes_missing=c["episodes_missing"],
)
)
click.echo(f"Loaded {len(season_completeness)} series with gaps")
click.echo()
click.echo(f"Generating completeness report in {format} format...")
report_content = generate_completeness_report(
season_completeness, format, config.library_root, plan_summary=plan_summary
)
if output:
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, "w", encoding="utf-8") as f:
f.write(report_content)
click.echo(f"Report saved to: {output}")
else:
click.echo()
click.echo(report_content)
logger.info(
"Generated completeness report in %s format with %s series",
format,
len(season_completeness),
)
except FileNotFoundError:
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
except json.JSONDecodeError as e:
command_error(
ctx,
f"Error: Failed to parse JSON file: {e}",
f"JSON parsing failed: {e}",
exc_info=True,
)
except Exception as e:
command_error(
ctx,
f"Error generating completeness report: {e}",
f"Completeness report generation failed: {e}",
exc_info=True,
)
plan_summary = optional_plan_summary(plan)
click.echo(f"Loading analysis from: {input}")
analysis_data = load_analysis_json(input)
seasons = completeness_from_analysis(analysis_data)
click.echo(f"Loaded {len(seasons)} series with gaps\n")
content = generate_completeness_report(
seasons, format, ctx.config.library_root, plan_summary=plan_summary
)
emit_report(content, output)
ctx.logger.info("completeness report: %s series", len(seasons))
def report_duplicates_cmd(
ctx: CLIContext,
format: str,
input: Path,
output: Optional[Path],
plan: Optional[Path],
def _run_duplicates(
ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]
) -> None:
"""Generate duplicate report."""
config = ctx.config
logger = ctx.logger
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
plan_summary = None
if plan:
if not plan.exists():
click.echo(f"Error: Plan file not found: {plan}", err=True)
sys.exit(1)
execution_plan = load_plan(plan)
plan_summary = preferred_plan_summary(execution_plan)
try:
click.echo(f"Loading analysis from: {input}")
analysis_data = load_analysis_json(input)
duplicates_list = analysis_data.get("duplicates", [])
duplicate_groups = []
for d in duplicates_list:
identity_data = d["identity"]
if identity_data["type"] == "movie":
identity = MovieIdentity(
title=identity_data["title"],
year=identity_data.get("year"),
confidence=1.0,
needs_review=False,
original_filename="",
)
else:
identity = SeriesIdentity(
title=identity_data["title"],
season=identity_data.get("season"),
episodes=identity_data.get("episodes", []),
confidence=1.0,
needs_review=False,
original_filename="",
)
quality_by_path = {
str(item.get("path", "")): item for item in d.get("quality_comparison", [])
}
files = []
for file_path in d["files"]:
quality = quality_by_path.get(str(file_path), {})
files.append(
VideoFile(
path=Path(file_path),
filename=Path(file_path).name,
size_bytes=int(quality.get("size_bytes", 0) or 0),
modified_timestamp=datetime.now(timezone.utc),
category="",
resolution=quality.get("resolution"),
codec=quality.get("codec"),
duration_seconds=quality.get("duration_seconds"),
bitrate_kbps=quality.get("bitrate_kbps"),
)
)
duplicate_groups.append(
DuplicateGroup(
identity=identity,
files=files,
quality_comparison=d["quality_comparison"],
)
)
click.echo(f"Loaded {len(duplicate_groups)} duplicate groups")
click.echo()
click.echo(f"Generating duplicate report in {format} format...")
report_content = generate_duplicate_report(
duplicate_groups, format, config.library_root, plan_summary=plan_summary
)
if output:
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, "w", encoding="utf-8") as f:
f.write(report_content)
click.echo(f"Report saved to: {output}")
else:
click.echo()
click.echo(report_content)
logger.info(
"Generated duplicate report in %s format with %s groups",
format,
len(duplicate_groups),
)
except FileNotFoundError:
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
except json.JSONDecodeError as e:
command_error(
ctx,
f"Error: Failed to parse JSON file: {e}",
f"JSON parsing failed: {e}",
exc_info=True,
)
except Exception as e:
command_error(
ctx,
f"Error generating duplicate report: {e}",
f"Duplicate report generation failed: {e}",
exc_info=True,
)
plan_summary = optional_plan_summary(plan)
click.echo(f"Loading analysis from: {input}")
analysis_data = load_analysis_json(input)
groups = duplicate_groups_from_analysis(analysis_data)
click.echo(f"Loaded {len(groups)} duplicate groups\n")
content = generate_duplicate_report(
groups, format, ctx.config.library_root, plan_summary=plan_summary
)
emit_report(content, output)
ctx.logger.info("duplicate report: %s groups", len(groups))
def report_summary_cmd(ctx: CLIContext, input: Path, output: Optional[Path]) -> None:
"""Generate summary report."""
config = ctx.config
logger = ctx.logger
def _run_summary(ctx: CLIContext, input: Path, output: Optional[Path]) -> None:
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
click.echo(f"Loading inventory from: {input}")
video_files = load_inventory_csv(input)
click.echo(f"Loaded {len(video_files)} files\n")
content = generate_summary_report(video_files, ctx.config.library_root)
emit_report(content, output)
ctx.logger.info("summary report: %s files", len(video_files))
try:
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
click.echo(f"Loading inventory from: {input}")
video_files = load_inventory_csv(input)
@click.group()
@pass_context
def report(ctx: CLIContext):
"""Generate inventory, completeness, duplicate, and summary reports."""
click.echo(f"Loaded {len(video_files)} files")
click.echo()
click.echo("Generating summary report...")
report_content = generate_summary_report(video_files, config.library_root)
@report.command("inventory")
@click.option("--format", type=click.Choice(["csv", "json", "text"], case_sensitive=False), default="text")
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
@click.option("--output", type=click.Path(path_type=Path), default=None)
@pass_context
def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
"""List discovered files with metadata."""
run_command(ctx, lambda: _run_inventory(ctx, format, input, output), stage="inventory report")
if output:
output.parent.mkdir(parents=True, exist_ok=True)
with open(output, "w", encoding="utf-8") as f:
f.write(report_content)
click.echo(f"Report saved to: {output}")
else:
click.echo()
click.echo(report_content)
logger.info("Generated summary report with %s files", len(video_files))
@report.command("completeness")
@click.option("--format", type=click.Choice(["text", "json"], case_sensitive=False), default="text")
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json"))
@click.option("--output", type=click.Path(path_type=Path), default=None)
@click.option("--plan", type=click.Path(path_type=Path), default=None)
@pass_context
def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]):
"""Show series with episode gaps."""
run_command(
ctx, lambda: _run_completeness(ctx, format, input, output, plan),
stage="completeness report", json_errors=True,
)
except FileNotFoundError:
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
except Exception as e:
command_error(
ctx,
f"Error generating summary report: {e}",
f"Summary report generation failed: {e}",
exc_info=True,
)
@report.command("duplicates")
@click.option("--format", type=click.Choice(["text", "json"], case_sensitive=False), default="text")
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json"))
@click.option("--output", type=click.Path(path_type=Path), default=None)
@click.option("--plan", type=click.Path(path_type=Path), default=None)
@pass_context
def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]):
"""Show duplicate groups with quality comparison."""
run_command(
ctx, lambda: _run_duplicates(ctx, format, input, output, plan),
stage="duplicate report", json_errors=True,
)
@report.command("summary")
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
@click.option("--output", type=click.Path(path_type=Path), default=None)
@pass_context
def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]):
"""Show library statistics."""
run_command(ctx, lambda: _run_summary(ctx, input, output), stage="summary report")