Files
our-claude-skills/custom-skills/90-reference-curator/07-pipeline-orchestrator/code/scripts/pipeline.py
Andrew Yim f215c11c32 feat(reference-curator): implement Python scripts + Gemini quality gate
Build the refcurator shared Python package and 7 CLI scripts that were
previously specification-only. Add Gemini CLI as an independent pre-distillation
quality evaluator, replacing the circular Claude-self-review pattern.

Key changes:
- shared/lib/src/refcurator/: 7-module package (config, db, models, utils,
  manifest, gemini) with PyMySQL + JSON file dual backend
- 7 Click CLI scripts: discover, crawl_mgr, repo, distiller, reviewer,
  exporter, pipeline — each with subcommands for data management
- Gemini quality gate: evaluates raw content BEFORE distillation using
  5 criteria (relevance, authority, completeness, freshness, distill_value)
- Pipeline reordered: discovery → crawl → store → evaluate → distill → export
- Bug fixes from Codex adversarial review:
  - FileBackend now hard-fails on JOIN/aggregate/GROUP BY queries
  - Exporter uses MAX(review_id) to prevent shipping stale approvals
  - Distiller updates existing rows on refactor instead of forking
- Updated all 7 CLAUDE.md directives with real script references
- install.sh updated with refcurator package install step

51/51 E2E tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:22:28 +09:00

340 lines
13 KiB
Python

#!/usr/bin/env python3
"""Pipeline Orchestrator CLI — manage pipeline runs and state.
This script provides state management for the 6-stage pipeline.
Claude orchestrates the actual stages via slash commands.
Usage:
python pipeline.py init --input "prompt engineering" --type topic [--options '{"max_sources": 10}']
python pipeline.py advance --run-id 1 --stage crawling [--stats '{"pages_crawled": 45}']
python pipeline.py pause --run-id 1 --error "Crawl timeout" --stage crawling
python pipeline.py resume --run-id 1
python pipeline.py complete --run-id 1 [--export-path ~/reference-library/exports/]
python pipeline.py status [--run-id 1]
python pipeline.py track-iteration --run-id 1 --doc-id 42 --action refactor
"""
import json
import sys
from datetime import datetime
from pathlib import Path
import click
from rich.console import Console
from rich.table import Table
from refcurator.db import db_session
from refcurator.config import get_pipeline_config
console = Console()
# QA loop limits
MAX_REFACTOR = 3
MAX_DEEP_RESEARCH = 2
MAX_TOTAL = 5
@click.group()
def cli():
"""Pipeline Orchestrator — manage pipeline run state."""
pass
@cli.command()
@click.option("--input", "input_value", required=True, help="Topic, URL(s), or manifest path")
@click.option("--type", "run_type", required=True,
type=click.Choice(["topic", "urls", "manifest"]))
@click.option("--options", type=str, help="Pipeline options as JSON string")
def init(input_value, run_type, options):
"""Initialize a new pipeline run."""
opts = json.loads(options) if options else {}
# Merge with config defaults
try:
cfg = get_pipeline_config()
defaults = cfg.get("pipeline", {})
for key in ("max_sources", "max_pages", "auto_approve", "approval_threshold"):
if key not in opts and key in defaults:
opts[key] = defaults[key]
except FileNotFoundError:
pass
stats = {
"sources_discovered": 0,
"pages_crawled": 0,
"documents_stored": 0,
"documents_distilled": 0,
"approved": 0,
"refactored": 0,
"deep_researched": 0,
"rejected": 0,
"needs_manual_review": 0,
}
# Determine starting stage
start_stage = "discovery" if run_type == "topic" else "crawling"
with db_session() as db:
run_id = db.insert_returning_id(
"""INSERT INTO pipeline_runs
(run_type, input_value, status, current_stage, options, stats, started_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(run_type, input_value, "running", start_stage,
json.dumps(opts), json.dumps(stats), datetime.now().isoformat()),
)
console.print(f"[green]Pipeline initialized:[/green] run_id={run_id}")
console.print(f" Type: {run_type}")
console.print(f" Input: {input_value}")
console.print(f" Starting stage: {start_stage}")
click.echo(json.dumps({"run_id": run_id, "status": "running", "stage": start_stage}))
@cli.command()
@click.option("--run-id", required=True, type=int, help="Pipeline run ID")
@click.option("--stage", required=True,
type=click.Choice(["discovery", "crawling", "storing", "evaluating",
"distilling", "exporting"]))
@click.option("--stats", type=str, help="Stats update as JSON string (merged with existing)")
def advance(run_id, stage, stats):
"""Advance pipeline to the next stage."""
stats_update = json.loads(stats) if stats else {}
with db_session() as db:
run = db.fetch_one(
"SELECT * FROM pipeline_runs WHERE run_id = %s", (run_id,)
)
if not run:
console.print(f"[red]Error:[/red] run_id={run_id} not found")
sys.exit(1)
if run["status"] != "running":
console.print(f"[red]Error:[/red] Pipeline is {run['status']}, not running")
sys.exit(1)
# Merge stats
current_stats = json.loads(run["stats"]) if isinstance(run["stats"], str) else (run["stats"] or {})
current_stats.update(stats_update)
db.execute(
"""UPDATE pipeline_runs
SET current_stage = %s, stats = %s
WHERE run_id = %s""",
(stage, json.dumps(current_stats), run_id),
)
stage_num = ["discovery", "crawling", "storing", "evaluating", "distilling", "exporting"].index(stage) + 1
console.print(f"[green]Pipeline advanced:[/green] Stage {stage_num}/6 — {stage}")
click.echo(json.dumps({"run_id": run_id, "stage": stage, "stats": current_stats}))
@cli.command()
@click.option("--run-id", required=True, type=int, help="Pipeline run ID")
@click.option("--error", required=True, help="Error message")
@click.option("--stage", required=True, help="Stage where error occurred")
def pause(run_id, error, stage):
"""Pause pipeline due to error."""
with db_session() as db:
db.execute(
"""UPDATE pipeline_runs
SET status = %s, error_message = %s, error_stage = %s
WHERE run_id = %s""",
("paused", error, stage, run_id),
)
console.print(f"[yellow]Pipeline paused:[/yellow] run_id={run_id} at {stage}")
console.print(f" Error: {error}")
console.print(f" Resume with: python pipeline.py resume --run-id {run_id}")
@cli.command()
@click.option("--run-id", required=True, type=int, help="Pipeline run ID to resume")
def resume(run_id):
"""Resume a paused pipeline."""
with db_session() as db:
run = db.fetch_one(
"SELECT * FROM pipeline_runs WHERE run_id = %s", (run_id,)
)
if not run:
console.print(f"[red]Error:[/red] run_id={run_id} not found")
sys.exit(1)
if run["status"] != "paused":
console.print(f"[red]Error:[/red] Pipeline is {run['status']}, not paused")
sys.exit(1)
db.execute(
"""UPDATE pipeline_runs
SET status = %s, error_message = %s
WHERE run_id = %s""",
("running", None, run_id),
)
stage = run["current_stage"]
console.print(f"[green]Pipeline resumed:[/green] run_id={run_id}, continuing from {stage}")
click.echo(json.dumps({"run_id": run_id, "status": "running", "resume_stage": stage}))
@cli.command()
@click.option("--run-id", required=True, type=int, help="Pipeline run ID")
@click.option("--export-path", type=click.Path(), help="Export output path")
@click.option("--export-count", type=int, help="Number of documents exported")
def complete(run_id, export_path, export_count):
"""Mark pipeline as completed."""
with db_session() as db:
db.execute(
"""UPDATE pipeline_runs
SET status = %s, completed_at = %s, export_path = %s, export_document_count = %s
WHERE run_id = %s""",
("completed", datetime.now().isoformat(), export_path, export_count, run_id),
)
run = db.fetch_one("SELECT * FROM pipeline_runs WHERE run_id = %s", (run_id,))
stats = json.loads(run["stats"]) if isinstance(run["stats"], str) else (run["stats"] or {})
console.print(f"\n[bold green]Pipeline Complete![/bold green] run_id={run_id}")
console.print(f" Sources: {stats.get('sources_discovered', 0)}")
console.print(f" Crawled: {stats.get('pages_crawled', 0)}")
console.print(f" Stored: {stats.get('documents_stored', 0)}")
console.print(f" Approved: {stats.get('approved', 0)}")
console.print(f" Rejected: {stats.get('rejected', 0)}")
if export_path:
console.print(f" Exports: {export_path}")
click.echo(json.dumps({"run_id": run_id, "status": "completed", "stats": stats}, default=str))
@cli.command()
@click.option("--run-id", type=int, help="Show specific run (or latest if omitted)")
@click.option("--all", "show_all", is_flag=True, help="Show all runs")
def status(run_id, show_all):
"""Show pipeline run status."""
with db_session() as db:
if run_id:
rows = db.fetch_all(
"SELECT * FROM pipeline_runs WHERE run_id = %s", (run_id,)
)
elif show_all:
rows = db.fetch_all(
"SELECT * FROM pipeline_runs ORDER BY started_at DESC LIMIT %s", (20,)
)
else:
rows = db.fetch_all(
"SELECT * FROM pipeline_runs ORDER BY started_at DESC LIMIT %s", (1,)
)
if not rows:
console.print("[dim]No pipeline runs found.[/dim]")
return
for run in rows:
stats = json.loads(run["stats"]) if isinstance(run["stats"], str) else (run["stats"] or {})
status_color = {
"running": "green", "completed": "blue",
"failed": "red", "paused": "yellow",
}.get(run["status"], "white")
console.print(f"\n[bold]Pipeline Run #{run['run_id']}[/bold]")
console.print(f" Status: [{status_color}]{run['status']}[/{status_color}]")
console.print(f" Type: {run['run_type']}")
console.print(f" Input: {run['input_value']}")
console.print(f" Stage: {run['current_stage']}")
console.print(f" Started: {run['started_at']}")
if run.get("error_message"):
console.print(f" [red]Error:[/red] {run['error_message']} (at {run.get('error_stage')})")
if stats:
table = Table(title="Pipeline Stats", show_header=False)
table.add_column("Metric")
table.add_column("Value", justify="right")
for k, v in stats.items():
if v:
table.add_row(k.replace("_", " ").title(), str(v))
console.print(table)
@cli.command("track-iteration")
@click.option("--run-id", required=True, type=int, help="Pipeline run ID")
@click.option("--doc-id", required=True, type=int, help="Document ID")
@click.option("--action", required=True, type=click.Choice(["refactor", "deep_research"]),
help="QA loop action")
def track_iteration(run_id, doc_id, action):
"""Track QA loop iteration for a document.
Returns the routing decision:
- 're_distill': proceed with refactor
- 're_crawl_and_distill': proceed with deep research
- 'needs_manual_review': max iterations exceeded
"""
with db_session() as db:
tracker = db.fetch_one(
"""SELECT * FROM pipeline_iteration_tracker
WHERE run_id = %s AND doc_id = %s""",
(run_id, doc_id),
)
if tracker:
refactor_count = tracker.get("refactor_count", 0)
deep_research_count = tracker.get("deep_research_count", 0)
else:
refactor_count = 0
deep_research_count = 0
total = refactor_count + deep_research_count
# Check limits
if total >= MAX_TOTAL:
result = "needs_manual_review"
elif action == "refactor" and refactor_count >= MAX_REFACTOR:
result = "needs_manual_review"
elif action == "deep_research" and deep_research_count >= MAX_DEEP_RESEARCH:
result = "needs_manual_review"
elif action == "refactor":
refactor_count += 1
result = "re_distill"
else:
deep_research_count += 1
result = "re_crawl_and_distill"
# Upsert tracker
if tracker:
db.execute(
"""UPDATE pipeline_iteration_tracker
SET refactor_count = %s, deep_research_count = %s,
final_decision = %s
WHERE run_id = %s AND doc_id = %s""",
(refactor_count, deep_research_count,
"needs_manual_review" if result == "needs_manual_review" else None,
run_id, doc_id),
)
else:
db.insert_returning_id(
"""INSERT INTO pipeline_iteration_tracker
(run_id, doc_id, refactor_count, deep_research_count, final_decision)
VALUES (%s, %s, %s, %s, %s)""",
(run_id, doc_id, refactor_count, deep_research_count,
"needs_manual_review" if result == "needs_manual_review" else None),
)
console.print(
f"[{'yellow' if result == 'needs_manual_review' else 'green'}]"
f"Iteration tracked:[/] doc_id={doc_id}, {action} "
f"(refactor: {refactor_count}/{MAX_REFACTOR}, "
f"research: {deep_research_count}/{MAX_DEEP_RESEARCH}) → {result}"
)
click.echo(json.dumps({
"run_id": run_id,
"doc_id": doc_id,
"action": action,
"result": result,
"refactor_count": refactor_count,
"deep_research_count": deep_research_count,
"total_iterations": refactor_count + deep_research_count,
}))
if __name__ == "__main__":
cli()