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>
This commit is contained in:
2026-04-12 18:19:52 +09:00
parent 133df68b81
commit f215c11c32
23 changed files with 3917 additions and 583 deletions

View File

@@ -36,8 +36,25 @@ Apply credibility scoring:
- Relevance signals (0.15) - Relevance signals (0.15)
### Step 4: Output URL Manifest ### Step 4: Output URL Manifest
Generate JSON manifest for the crawler skill: Save discovered URLs as a manifest JSON, deduplicating against the existing repository:
```bash
# Create manifest from discovered URLs
uv run python scripts/discover.py create-manifest --topic "prompt engineering" --output manifest.json < urls.json
# Deduplicate against existing DB
uv run python scripts/discover.py dedup --manifest manifest.json
# Register a new source
uv run python scripts/discover.py register-source \
--name "Anthropic Docs" --type official_docs \
--url "https://docs.anthropic.com" --tier tier1_official --vendor anthropic
# List registered sources
uv run python scripts/discover.py list-sources --vendor anthropic
```
Manifest format:
```json ```json
{ {
"discovery_date": "2025-01-28T10:30:00", "discovery_date": "2025-01-28T10:30:00",
@@ -56,20 +73,31 @@ Generate JSON manifest for the crawler skill:
} }
``` ```
## Scripts
### `discover_sources.py`
Main discovery script. Usage:
```bash
python scripts/discover_sources.py --topic "prompt engineering" --vendors anthropic,openai --output manifest.json
```
## Output ## Output
- `manifest.json` → Handoff to `02-web-crawler-orchestrator` - `manifest.json` → Handoff to `02-web-crawler-orchestrator`
- Register new sources in `sources` table via `03-content-repository` - New sources registered in `sources` table via `register-source`
## Deduplication ## Deduplication
Before outputting: Before outputting:
- Normalize URLs (remove trailing slashes, query params) - Normalize URLs (remove trailing slashes, query params)
- Check against existing `documents` table - Check against existing `documents` table
- Merge duplicates, keeping highest credibility score - Merge duplicates, keeping highest credibility score
## Scripts
All scripts require the `refcurator` package. Run with `uv run python` from the skill directory.
| Command | Purpose |
|---------|---------|
| `discover.py create-manifest` | Create manifest from URL entries JSON |
| `discover.py dedup` | Deduplicate manifest against DB |
| `discover.py register-source` | Register a new source |
| `discover.py list-sources` | List registered sources |
## Integration
| From | To |
|------|-----|
| WebSearch results | → manifest.json |
| → manifest.json | web-crawler-orchestrator |
| → register-source | content-repository (sources table) |

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Reference Discovery CLI — manage source discovery and manifests.
Claude performs the actual web search. This script handles:
- Receiving search results and creating manifests
- Deduplicating URLs against the existing repository
- Registering new sources in the database
Usage:
python discover.py create-manifest --topic "prompt engineering" --output manifest.json < urls.json
python discover.py dedup --manifest manifest.json [--output deduped.json]
python discover.py register-source --name "Anthropic Docs" --type official_docs --url "https://docs.anthropic.com" --tier tier1_official --vendor anthropic
python discover.py list-sources [--vendor anthropic]
"""
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.manifest import write_manifest, read_manifest, dedup_manifest_urls
from refcurator.models import Manifest, ManifestURL
from refcurator.utils import normalize_url
console = Console()
@click.group()
def cli():
"""Reference Discovery — manage source manifests."""
pass
@cli.command("create-manifest")
@click.option("--topic", required=True, help="Discovery topic")
@click.option("--output", required=True, type=click.Path(), help="Output manifest path")
@click.option("--input", "input_file", type=click.Path(exists=True),
help="Input JSON file with URL entries (or pipe via stdin)")
def create_manifest(topic, output, input_file):
"""Create a manifest from discovered URLs.
Input format (JSON array):
[
{"url": "https://...", "title": "...", "credibility_score": 0.85, "source_type": "official_docs"},
...
]
"""
if input_file:
data = json.loads(Path(input_file).read_text())
elif not sys.stdin.isatty():
data = json.load(sys.stdin)
else:
console.print("[red]Error:[/red] Provide URLs via --input file or stdin")
sys.exit(1)
urls = [ManifestURL(**entry) for entry in data]
# Normalize and deduplicate
seen = {}
for u in urls:
norm = normalize_url(u.url)
if norm not in seen or (u.credibility_score or 0) > (seen[norm].credibility_score or 0):
seen[norm] = u
unique_urls = list(seen.values())
manifest = Manifest(
discovery_date=datetime.now().isoformat(),
topic=topic,
total_urls=len(unique_urls),
urls=unique_urls,
)
out_path = Path(output)
write_manifest(manifest, out_path)
console.print(f"[green]Created manifest:[/green] {len(unique_urls)} URLs → {output}")
@cli.command()
@click.option("--manifest", required=True, type=click.Path(exists=True), help="Manifest to dedup")
@click.option("--output", type=click.Path(), help="Output path (defaults to overwriting input)")
def dedup(manifest, output):
"""Deduplicate manifest URLs against existing repository."""
m = read_manifest(Path(manifest))
original_count = len(m.urls)
with db_session() as db:
existing = db.fetch_all("SELECT url FROM documents WHERE url IS NOT NULL")
existing_urls = {r["url"] for r in existing}
deduped = dedup_manifest_urls(m, existing_urls)
removed = original_count - len(deduped.urls)
out_path = Path(output) if output else Path(manifest)
write_manifest(deduped, out_path)
console.print(
f"[green]Deduped:[/green] {original_count}{len(deduped.urls)} URLs "
f"({removed} duplicates removed)"
)
@cli.command("register-source")
@click.option("--name", required=True, help="Source name")
@click.option("--type", "source_type", required=True,
type=click.Choice(["official_docs", "engineering_blog", "research_paper",
"github_repo", "community_guide", "pdf_document", "api_reference"]))
@click.option("--url", required=True, help="Base URL")
@click.option("--tier", default="tier3_community",
type=click.Choice(["tier1_official", "tier2_verified", "tier3_community"]))
@click.option("--vendor", help="Vendor name (e.g., anthropic, openai)")
def register_source(name, source_type, url, tier, vendor):
"""Register a new source in the repository."""
with db_session() as db:
# Check for existing source with same base_url
existing = db.fetch_one(
"SELECT source_id, source_name FROM sources WHERE base_url = %s",
(url,),
)
if existing:
console.print(
f"[yellow]Already registered:[/yellow] source_id={existing['source_id']} "
f"({existing['source_name']})"
)
return
source_id = db.insert_returning_id(
"""INSERT INTO sources (source_name, source_type, base_url, credibility_tier, vendor)
VALUES (%s, %s, %s, %s, %s)""",
(name, source_type, url, tier, vendor),
)
console.print(f"[green]Registered:[/green] source_id={source_id}{name}")
click.echo(json.dumps({"source_id": source_id, "name": name, "url": url}))
@cli.command("list-sources")
@click.option("--vendor", help="Filter by vendor")
@click.option("--tier", type=click.Choice(["tier1_official", "tier2_verified", "tier3_community"]),
help="Filter by credibility tier")
def list_sources(vendor, tier):
"""List registered sources."""
with db_session() as db:
if vendor:
rows = db.fetch_all(
"SELECT * FROM sources WHERE vendor = %s ORDER BY credibility_tier",
(vendor,),
)
elif tier:
rows = db.fetch_all(
"SELECT * FROM sources WHERE credibility_tier = %s ORDER BY vendor",
(tier,),
)
else:
rows = db.fetch_all("SELECT * FROM sources ORDER BY credibility_tier, vendor")
table = Table(title="Registered Sources")
table.add_column("ID", style="cyan")
table.add_column("Name")
table.add_column("Type")
table.add_column("Tier")
table.add_column("Vendor")
table.add_column("URL")
for r in rows:
table.add_row(
str(r.get("source_id", "")),
str(r.get("source_name", "")),
str(r.get("source_type", "")),
str(r.get("credibility_tier", "")),
str(r.get("vendor", "")),
str(r.get("base_url", ""))[:40],
)
console.print(table)
if __name__ == "__main__":
cli()

View File

@@ -1,115 +1,48 @@
# Web Crawler Orchestrator # Web Crawler Orchestrator
Orchestrates web crawling with intelligent backend selection. Automatically chooses the best crawler based on site characteristics. Orchestrates web crawling with intelligent backend selection. Claude performs actual crawling via Firecrawl MCP tools. This skill manages crawl results, selects crawlers, and tracks crawl metadata.
## Trigger Keywords ## Trigger Keywords
"crawl URLs", "fetch documents", "scrape pages", "download references" "crawl URLs", "fetch documents", "scrape pages", "download references"
## Intelligent Crawler Selection ## Intelligent Crawler Selection
Claude automatically selects the optimal crawler based on the request: ```bash
# Get crawler recommendation for a URL
uv run python scripts/crawl_mgr.py select-crawler --url "https://docs.anthropic.com"
```
| Crawler | Best For | Auto-Selected When | | Crawler | Best For | Auto-Selected When |
|---------|----------|-------------------| |---------|----------|-------------------|
| **Node.js** (default) | Small docs sites | ≤50 pages, static content | | **Firecrawl MCP** (default) | Dynamic sites, SPAs | React/Vue/Angular, JS-rendered |
| **Node.js** | Small docs sites | ≤50 pages, static content |
| **Python aiohttp** | Technical docs | ≤200 pages, needs SEO data | | **Python aiohttp** | Technical docs | ≤200 pages, needs SEO data |
| **Scrapy** | Enterprise crawls | >200 pages, multi-domain | | **Scrapy** | Enterprise crawls | >200 pages, multi-domain |
| **Firecrawl MCP** | Dynamic sites | SPAs, JS-rendered content |
### Decision Flow
```
[Crawl Request]
├─ Is it SPA/React/Vue/Angular? → Firecrawl MCP
├─ >200 pages or multi-domain? → Scrapy
├─ Needs SEO extraction? → Python aiohttp
└─ Default (small site) → Node.js
```
## Crawler Backends
### Node.js (Default)
Fast, lightweight crawler for small documentation sites.
```bash
cd ~/Project/our-seo-agent/util/js-crawler
node src/crawler.js <URL> --max-pages 50
```
### Python aiohttp
Async crawler with full SEO extraction.
```bash
cd ~/Project/our-seo-agent
python -m seo_agent.crawler --url <URL> --max-pages 100
```
### Scrapy
Enterprise-grade crawler with pipelines.
```bash
cd ~/Project/our-seo-agent
scrapy crawl seo_spider -a start_url=<URL> -a max_pages=500
```
### Firecrawl MCP
Use MCP tools for JavaScript-heavy sites:
```
firecrawl_scrape(url, formats=["markdown"], only_main_content=true)
firecrawl_crawl(url, max_depth=2, limit=50)
firecrawl_map(url, limit=100) # Discover URLs first
```
## Workflow ## Workflow
### Step 1: Analyze Target Site ### Step 1: Analyze Target Site
Determine site characteristics: Run `select-crawler` to determine site characteristics and get a recommendation.
- Is it a SPA? (React, Vue, Angular, Next.js)
- How many pages expected?
- Does it need JavaScript rendering?
- Is SEO data extraction needed?
### Step 2: Select Crawler ### Step 2: Execute Crawl
Based on analysis, select the appropriate backend. Use Firecrawl MCP tools directly:
```
firecrawl_map(url, limit=100) # Discover URLs
firecrawl_scrape(url, formats=["markdown"], only_main_content=true)
firecrawl_crawl(url, max_depth=2, limit=50)
```
### Step 3: Load URL Manifest ### Step 3: Store Crawl Results
```bash ```bash
# From reference-discovery output # Store crawled files and create result manifest
cat manifest.json | jq '.urls[].url' uv run python scripts/crawl_mgr.py store-result \
``` --raw-dir ~/Documents/reference-library/raw/ \
--crawler firecrawl \
--source-id 1 \
--output crawl_result.json
### Step 4: Execute Crawl # List recent crawls
uv run python scripts/crawl_mgr.py list-crawls --status completed
**For Node.js:**
```bash
cd ~/Project/our-seo-agent/util/js-crawler
for url in $(cat urls.txt); do
node src/crawler.js "$url" --max-pages 50
sleep 2
done
```
**For Firecrawl MCP (Claude Desktop/Code):**
Use the firecrawl MCP tools directly in conversation.
### Step 5: Save Raw Content
```
~/reference-library/raw/
└── 2025/01/
├── a1b2c3d4.md
└── b2c3d4e5.md
```
### Step 6: Generate Crawl Manifest
```json
{
"crawl_date": "2025-01-28T12:00:00",
"crawler_used": "nodejs",
"total_crawled": 45,
"total_failed": 5,
"documents": [...]
}
``` ```
## Rate Limiting ## Rate Limiting
@@ -129,31 +62,19 @@ All crawlers respect these limits:
| Access denied (403) | Log, mark as `failed` | | Access denied (403) | Log, mark as `failed` |
| JS rendering needed | Switch to Firecrawl | | JS rendering needed | Switch to Firecrawl |
## Site Type Detection
Indicators for automatic routing:
**SPA (→ Firecrawl):**
- URL contains `#/` or uses hash routing
- Page source shows React/Vue/Angular markers
- Content loads dynamically after initial load
**Static docs (→ Node.js/aiohttp):**
- Built with Hugo, Jekyll, MkDocs, Docusaurus, GitBook
- Clean HTML structure
- Server-side rendered
## Scripts ## Scripts
- `scripts/select_crawler.py` - Intelligent crawler selection | Command | Purpose |
- `scripts/crawl_with_nodejs.sh` - Node.js wrapper |---------|---------|
- `scripts/crawl_with_aiohttp.sh` - Python wrapper | `crawl_mgr.py select-crawler` | Recommend optimal crawler for a URL |
- `scripts/crawl_with_firecrawl.py` - Firecrawl MCP wrapper | `crawl_mgr.py store-result` | Store crawl results and create manifest |
| `crawl_mgr.py list-crawls` | List recent crawl records |
## Integration ## Integration
| From | To | | From | To |
|------|-----| |------|-----|
| reference-discovery | URL manifest input | | reference-discovery | URL manifest input |
| Firecrawl MCP | Raw crawled files |
| → | content-repository (crawl manifest + raw files) | | → | content-repository (crawl manifest + raw files) |
| quality-reviewer (deep_research) | Additional crawl requests | | quality-reviewer (deep_research) | Additional crawl requests |

View File

@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""Web Crawler Manager CLI — manage crawl results and crawler selection.
Claude performs actual crawling via Firecrawl MCP. This script handles:
- Storing crawl results metadata
- Selecting the optimal crawler backend
- Generating crawl result manifests
Usage:
python crawl_mgr.py store-result --manifest manifest.json --raw-dir ~/reference-library/raw/
python crawl_mgr.py select-crawler --url "https://docs.anthropic.com"
python crawl_mgr.py list-crawls [--status completed]
"""
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
import click
from rich.console import Console
from rich.table import Table
from refcurator.db import db_session
from refcurator.manifest import create_crawl_result, write_crawl_result, read_manifest
from refcurator.utils import count_tokens
console = Console()
# Known SPA frameworks and their indicators
SPA_INDICATORS = {
"react": ["react", "next.js", "nextjs", "gatsby", "remix"],
"vue": ["vue", "nuxt"],
"angular": ["angular"],
"svelte": ["svelte", "sveltekit"],
}
# Known static site generators
STATIC_INDICATORS = ["hugo", "jekyll", "mkdocs", "docusaurus", "gitbook", "sphinx",
"readthedocs", "vuepress", "docsify"]
@click.group()
def cli():
"""Web Crawler Manager — manage crawl results and backend selection."""
pass
@cli.command("store-result")
@click.option("--raw-dir", required=True, type=click.Path(exists=True),
help="Directory containing crawled raw files")
@click.option("--crawler", default="firecrawl",
type=click.Choice(["firecrawl", "nodejs", "aiohttp", "scrapy"]))
@click.option("--source-id", type=int, help="Source ID to associate documents with")
@click.option("--output", type=click.Path(), help="Output crawl result JSON path")
def store_result(raw_dir, crawler, source_id, output):
"""Store crawl results from raw files into the repository.
Scans raw_dir for .md files, creates document records, and generates a crawl result manifest.
"""
raw_path = Path(raw_dir)
md_files = sorted(raw_path.glob("**/*.md"))
if not md_files:
console.print(f"[yellow]No .md files found in {raw_dir}[/yellow]")
sys.exit(0)
entries = []
stored = 0
with db_session() as db:
for md_file in md_files:
content = md_file.read_text(errors="replace")
content_size = md_file.stat().st_size
# Extract title from first heading or filename
title = _extract_title(content, md_file.stem)
# Extract URL from frontmatter if available
url = _extract_url_from_content(content)
entry = {
"url": url or f"file://{md_file.resolve()}",
"title": title,
"raw_path": str(md_file.resolve()),
"content_size": content_size,
"status": "completed",
}
entries.append(entry)
if source_id:
db.insert_returning_id(
"""INSERT INTO documents
(source_id, title, url, doc_type, crawl_date,
crawl_method, crawl_status, raw_content_path, raw_content_size)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(source_id, title, url, "markdown", datetime.now().isoformat(),
crawler, "completed", str(md_file.resolve()), content_size),
)
stored += 1
result = create_crawl_result(entries, crawler)
if output:
write_crawl_result(result, Path(output))
console.print(f"[green]Crawl result written to {output}[/green]")
console.print(
f"[green]Processed {len(entries)} files[/green]"
+ (f", stored {stored} documents" if stored else "")
)
click.echo(result.model_dump_json(indent=2))
@cli.command("select-crawler")
@click.option("--url", required=True, help="Target URL to analyze")
@click.option("--page-count", type=int, help="Expected page count")
@click.option("--json-output", is_flag=True, help="Output as JSON")
def select_crawler(url, page_count, json_output):
"""Recommend the best crawler backend for a URL.
Analyzes URL patterns and site characteristics to suggest:
- firecrawl: SPAs, JS-rendered content
- nodejs: Small static docs sites (<=50 pages)
- aiohttp: Medium technical docs (<=200 pages)
- scrapy: Large enterprise sites (>200 pages)
"""
parsed = urlparse(url)
domain = parsed.netloc.lower()
path = parsed.path.lower()
recommendation = "firecrawl" # Default
reason = "Default recommendation for general crawling"
confidence = 0.7
# Check for SPA indicators in domain/path
url_lower = url.lower()
for framework, indicators in SPA_INDICATORS.items():
if any(ind in url_lower or ind in domain for ind in indicators):
recommendation = "firecrawl"
reason = f"Detected {framework} SPA framework — needs JS rendering"
confidence = 0.9
break
# Check for static site indicators
for gen in STATIC_INDICATORS:
if gen in url_lower or gen in domain:
if page_count and page_count <= 50:
recommendation = "nodejs"
reason = f"Static site ({gen}), small page count — lightweight crawler sufficient"
else:
recommendation = "aiohttp"
reason = f"Static site ({gen}), moderate size — async crawler recommended"
confidence = 0.85
break
# Page count override (takes precedence over default, not over SPA detection)
spa_detected = confidence >= 0.9 and recommendation == "firecrawl"
if page_count and not spa_detected:
if page_count > 200:
recommendation = "scrapy"
reason = f"Large site ({page_count} pages) — enterprise crawler needed"
confidence = 0.9
elif page_count <= 50:
recommendation = "nodejs"
reason = f"Small site ({page_count} pages) — lightweight crawler sufficient"
confidence = 0.8
# Known documentation platforms → firecrawl
doc_platforms = ["docs.", "developer.", "api.", "reference."]
if any(domain.startswith(p) for p in doc_platforms):
if recommendation == "firecrawl":
reason = "Documentation platform — Firecrawl handles dynamic docs well"
confidence = 0.85
result = {
"url": url,
"recommendation": recommendation,
"reason": reason,
"confidence": confidence,
"alternatives": _get_alternatives(recommendation),
}
if json_output:
click.echo(json.dumps(result, indent=2))
else:
console.print(f"\n[bold]Crawler Recommendation for:[/bold] {url}")
console.print(f" [green]Recommended:[/green] {recommendation}")
console.print(f" [dim]Reason:[/dim] {reason}")
console.print(f" [dim]Confidence:[/dim] {confidence:.0%}")
if result["alternatives"]:
console.print(f" [dim]Alternatives:[/dim] {', '.join(result['alternatives'])}")
@cli.command("list-crawls")
@click.option("--status", type=click.Choice(["pending", "completed", "failed", "stale"]),
help="Filter by crawl status")
@click.option("--limit", default=20, type=int, help="Max results")
def list_crawls(status, limit):
"""List recent crawl records."""
with db_session() as db:
if status:
rows = db.fetch_all(
"""SELECT doc_id, title, url, crawl_method, crawl_status, crawl_date
FROM documents WHERE crawl_status = %s
ORDER BY crawl_date DESC LIMIT %s""",
(status, limit),
)
else:
rows = db.fetch_all(
"""SELECT doc_id, title, url, crawl_method, crawl_status, crawl_date
FROM documents ORDER BY crawl_date DESC LIMIT %s""",
(limit,),
)
table = Table(title="Crawl Records")
table.add_column("ID", style="cyan")
table.add_column("Title")
table.add_column("Crawler")
table.add_column("Status")
table.add_column("Date")
for r in rows:
table.add_row(
str(r.get("doc_id", "")),
str(r.get("title", ""))[:40],
str(r.get("crawl_method", "")),
str(r.get("crawl_status", "")),
str(r.get("crawl_date", ""))[:10],
)
console.print(table)
def _extract_title(content: str, fallback: str) -> str:
"""Extract title from markdown content."""
for line in content.split("\n")[:10]:
line = line.strip()
if line.startswith("# ") and not line.startswith("##"):
return line[2:].strip()
return fallback.replace("-", " ").replace("_", " ").title()
def _extract_url_from_content(content: str) -> str | None:
"""Extract source URL from markdown frontmatter or content."""
# YAML frontmatter
if content.startswith("---"):
fm_end = content.find("---", 3)
if fm_end > 0:
fm = content[3:fm_end]
for line in fm.split("\n"):
if line.strip().startswith("url:") or line.strip().startswith("source:"):
url = line.split(":", 1)[1].strip().strip("'\"")
if url.startswith("http"):
return url
# Source: URL pattern in content
m = re.search(r"\*\*Source:\*\*\s*(https?://\S+)", content[:500])
if m:
return m.group(1)
return None
def _get_alternatives(primary: str) -> list[str]:
"""Get alternative crawler recommendations."""
all_crawlers = ["firecrawl", "nodejs", "aiohttp", "scrapy"]
return [c for c in all_crawlers if c != primary][:2]
if __name__ == "__main__":
cli()

View File

@@ -1,46 +1,42 @@
# Content Repository # Content Repository
MySQL storage management for the reference library. Handles document storage, version control, deduplication, and retrieval. MySQL storage management for the reference library. Handles document storage, version control, deduplication, and retrieval. Supports MySQL primary backend with JSON file fallback.
## Trigger Keywords ## Trigger Keywords
"store content", "save to database", "check duplicates", "version tracking", "document retrieval", "reference library DB" "store content", "save to database", "check duplicates", "version tracking", "document retrieval", "reference library DB"
## Prerequisites ## Prerequisites
- MySQL 8.0+ with utf8mb4 charset - `refcurator` package installed (`uv pip install -e shared/lib/`)
- Config file at `~/.config/reference-curator/db_config.yaml` - MySQL 8.0+ (optional — falls back to JSON file storage)
- Database `reference_library` initialized - Config at `~/.config/reference-curator/db_config.yaml` (optional)
## Database Setup ## Quick Start
```bash ```bash
# Initialize database # Store a document
mysql -u root -p < references/schema.sql uv run python scripts/repo.py store \
--source-id 1 --title "Prompt Engineering Guide" \
# Verify tables
mysql -u root -p reference_library -e "SHOW TABLES;"
```
## Core Scripts
### Store Document
```bash
python scripts/store_document.py \
--source-id 1 \
--title "Prompt Engineering Guide" \
--url "https://docs.anthropic.com/..." \ --url "https://docs.anthropic.com/..." \
--doc-type webpage \ --doc-type webpage --raw-path ~/reference-library/raw/abc123.md
--raw-path ~/reference-library/raw/2025/01/abc123.md
```
### Check Duplicate # Check for duplicates
```bash uv run python scripts/repo.py check-dup --url "https://docs.anthropic.com/..."
python scripts/check_duplicate.py --url "https://docs.anthropic.com/..."
```
### Query by Topic # Query by topic
```bash uv run python scripts/repo.py query-topic --topic-slug prompt-engineering --min-quality 0.80
python scripts/query_topic.py --topic-slug prompt-engineering --min-quality 0.80
# Get repository stats
uv run python scripts/repo.py stats
# Find stale documents (older than 30 days)
uv run python scripts/repo.py find-stale --days 30
# Get pending reviews
uv run python scripts/repo.py pending-reviews --output pending.json
# Get export-ready content
uv run python scripts/repo.py export-ready --min-score 0.85
``` ```
## Table Quick Reference ## Table Quick Reference
@@ -61,31 +57,17 @@ python scripts/query_topic.py --topic-slug prompt-engineering --min-quality 0.80
**review_status:** `pending``in_review``approved` | `needs_refactor` | `rejected` **review_status:** `pending``in_review``approved` | `needs_refactor` | `rejected`
## Common Queries
### Find Stale Documents
```bash
python scripts/find_stale.py --output stale_docs.json
```
### Get Pending Reviews
```bash
python scripts/pending_reviews.py --output pending.json
```
### Export-Ready Content
```bash
python scripts/export_ready.py --min-score 0.85 --output ready.json
```
## Scripts ## Scripts
- `scripts/store_document.py` - Store new document | Command | Purpose |
- `scripts/check_duplicate.py` - URL deduplication |---------|---------|
- `scripts/query_topic.py` - Query by topic | `repo.py store` | Store a new document |
- `scripts/find_stale.py` - Find stale documents | `repo.py check-dup` | URL deduplication check |
- `scripts/pending_reviews.py` - Get pending reviews | `repo.py query-topic` | Query documents by topic |
- `scripts/db_utils.py` - Database connection utilities | `repo.py find-stale` | Find stale documents |
| `repo.py pending-reviews` | Get pending reviews |
| `repo.py export-ready` | Get approved content ready for export |
| `repo.py stats` | Show repository statistics |
## Integration ## Integration

View File

@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""Content Repository CLI — CRUD operations for the reference library.
Usage:
python repo.py store --source-id 1 --title "Doc" --url "https://..." --doc-type webpage --raw-path /path/to/file
python repo.py check-dup --url "https://..."
python repo.py query-topic --topic-slug prompt-engineering [--min-quality 0.80]
python repo.py find-stale [--output stale.json]
python repo.py pending-reviews [--output pending.json]
python repo.py export-ready [--min-score 0.85] [--output ready.json]
python repo.py stats
"""
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.utils import url_hash, normalize_url
console = Console()
@click.group()
def cli():
"""Content Repository — manage documents in the reference library."""
pass
@cli.command()
@click.option("--source-id", required=True, type=int, help="Source ID from sources table")
@click.option("--title", required=True, help="Document title")
@click.option("--url", required=True, help="Document URL")
@click.option("--doc-type", required=True,
type=click.Choice(["webpage", "pdf", "markdown", "api_spec", "code_sample"]))
@click.option("--raw-path", required=True, type=click.Path(), help="Path to raw content file")
@click.option("--crawl-method", default="firecrawl",
type=click.Choice(["firecrawl", "scrapy", "aiohttp", "nodejs", "manual", "api"]))
@click.option("--language", default="en", type=click.Choice(["en", "ko", "mixed"]))
def store(source_id, title, url, doc_type, raw_path, crawl_method, language):
"""Store a new document in the repository."""
raw = Path(raw_path)
if not raw.is_file():
console.print(f"[red]Error:[/red] Raw file not found: {raw_path}")
sys.exit(1)
content_size = raw.stat().st_size
with db_session() as db:
# Check for duplicate
existing = db.fetch_one(
"SELECT doc_id, title FROM documents WHERE url_hash = %s",
(url_hash(url),),
)
if existing:
console.print(
f"[yellow]Duplicate:[/yellow] URL already stored as doc_id={existing['doc_id']} "
f"({existing['title']})"
)
sys.exit(0)
doc_id = db.insert_returning_id(
"""INSERT INTO documents
(source_id, title, url, doc_type, language, crawl_date,
crawl_method, crawl_status, raw_content_path, raw_content_size)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(source_id, title, url, doc_type, language, datetime.now().isoformat(),
crawl_method, "completed", str(raw.resolve()), content_size),
)
console.print(f"[green]Stored:[/green] doc_id={doc_id}{title}")
click.echo(json.dumps({"doc_id": doc_id, "title": title, "url": url}))
@cli.command("check-dup")
@click.option("--url", required=True, help="URL to check for duplicates")
def check_dup(url):
"""Check if a URL already exists in the repository."""
h = url_hash(url)
with db_session() as db:
existing = db.fetch_one(
"SELECT doc_id, title, url, crawl_status FROM documents WHERE url_hash = %s",
(h,),
)
if existing:
console.print(f"[yellow]Duplicate found:[/yellow] doc_id={existing['doc_id']}")
click.echo(json.dumps(existing, default=str))
else:
console.print("[green]No duplicate found.[/green]")
click.echo(json.dumps({"duplicate": False, "url": url}))
@cli.command("query-topic")
@click.option("--topic-slug", required=True, help="Topic slug to query")
@click.option("--min-quality", default=0.0, type=float, help="Minimum quality score")
@click.option("--output", type=click.Path(), help="Output JSON file path")
def query_topic(topic_slug, min_quality, output):
"""Query documents by topic."""
with db_session() as db:
rows = db.fetch_all(
"""SELECT d.doc_id, d.title, d.url, d.crawl_status,
dt.relevance_score, t.topic_name
FROM documents d
JOIN document_topics dt ON d.doc_id = dt.doc_id
JOIN topics t ON dt.topic_id = t.topic_id
WHERE t.topic_slug = %s
ORDER BY dt.relevance_score DESC""",
(topic_slug,),
)
if min_quality > 0:
# Filter by review score if available
rows = [r for r in rows if r.get("relevance_score", 0) >= min_quality]
if output:
Path(output).write_text(json.dumps(rows, indent=2, default=str))
console.print(f"[green]Wrote {len(rows)} results to {output}[/green]")
else:
_print_doc_table(rows, f"Topic: {topic_slug}")
@cli.command("find-stale")
@click.option("--days", default=30, type=int, help="Documents older than N days")
@click.option("--output", type=click.Path(), help="Output JSON file path")
def find_stale(days, output):
"""Find documents that may be outdated."""
with db_session() as db:
rows = db.fetch_all(
"""SELECT doc_id, title, url, crawl_date, crawl_status
FROM documents
WHERE crawl_status = %s
ORDER BY crawl_date ASC""",
("completed",),
)
# Filter by age
cutoff = datetime.now()
stale = []
for r in rows:
crawl_date = r.get("crawl_date")
if crawl_date:
if isinstance(crawl_date, str):
crawl_date = datetime.fromisoformat(crawl_date)
age_days = (cutoff - crawl_date).days
if age_days >= days:
r["age_days"] = age_days
stale.append(r)
if output:
Path(output).write_text(json.dumps(stale, indent=2, default=str))
console.print(f"[green]Found {len(stale)} stale documents, wrote to {output}[/green]")
else:
_print_doc_table(stale, f"Stale documents (>{days} days)")
@cli.command("pending-reviews")
@click.option("--output", type=click.Path(), help="Output JSON file path")
def pending_reviews(output):
"""Get documents pending quality review."""
with db_session() as db:
rows = db.fetch_all(
"""SELECT dc.distill_id, d.doc_id, d.title, d.url,
dc.token_count_distilled, dc.distill_date
FROM distilled_content dc
JOIN documents d ON dc.doc_id = d.doc_id
WHERE dc.review_status = %s
ORDER BY dc.distill_date ASC""",
("pending",),
)
if output:
Path(output).write_text(json.dumps(rows, indent=2, default=str))
console.print(f"[green]{len(rows)} pending reviews, wrote to {output}[/green]")
else:
table = Table(title="Pending Reviews")
table.add_column("distill_id", style="cyan")
table.add_column("doc_id")
table.add_column("Title")
table.add_column("Tokens", justify="right")
for r in rows:
table.add_row(
str(r.get("distill_id", "")),
str(r.get("doc_id", "")),
str(r.get("title", ""))[:50],
str(r.get("token_count_distilled", "")),
)
console.print(table)
@cli.command("export-ready")
@click.option("--min-score", default=0.80, type=float, help="Minimum quality score")
@click.option("--output", type=click.Path(), help="Output JSON file path")
def export_ready(min_score, output):
"""Get documents approved and ready for export."""
with db_session() as db:
rows = db.fetch_all(
"""SELECT d.doc_id, d.title, d.url,
dc.structured_content, dc.token_count_distilled,
rl.quality_score, rl.decision
FROM documents d
JOIN distilled_content dc ON d.doc_id = dc.doc_id
JOIN review_logs rl ON dc.distill_id = rl.distill_id
WHERE dc.review_status = %s
AND rl.decision = %s
AND rl.review_id = (
SELECT MAX(rl2.review_id)
FROM review_logs rl2
WHERE rl2.distill_id = dc.distill_id
)
ORDER BY rl.quality_score DESC""",
("approved", "approve"),
)
# Filter by min score
rows = [r for r in rows if (r.get("quality_score") or 0) >= min_score]
if output:
Path(output).write_text(json.dumps(rows, indent=2, default=str))
console.print(f"[green]{len(rows)} export-ready documents, wrote to {output}[/green]")
else:
table = Table(title=f"Export-Ready (score >= {min_score})")
table.add_column("doc_id", style="cyan")
table.add_column("Title")
table.add_column("Score", justify="right")
table.add_column("Tokens", justify="right")
for r in rows:
table.add_row(
str(r.get("doc_id", "")),
str(r.get("title", ""))[:50],
f"{r.get('quality_score', 0):.2f}",
str(r.get("token_count_distilled", "")),
)
console.print(table)
@cli.command()
def stats():
"""Show repository statistics."""
with db_session() as db:
doc_count = db.fetch_one("SELECT COUNT(*) as cnt FROM documents") or {"cnt": 0}
source_count = db.fetch_one("SELECT COUNT(*) as cnt FROM sources") or {"cnt": 0}
status_rows = db.fetch_all(
"""SELECT crawl_status, COUNT(*) as cnt
FROM documents
GROUP BY crawl_status"""
)
review_rows = db.fetch_all(
"""SELECT review_status, COUNT(*) as cnt
FROM distilled_content
GROUP BY review_status"""
)
topic_rows = db.fetch_all(
"""SELECT t.topic_name, COUNT(dt.doc_id) as cnt
FROM topics t
LEFT JOIN document_topics dt ON t.topic_id = dt.topic_id
GROUP BY t.topic_id
ORDER BY cnt DESC"""
)
console.print()
console.print(f"[bold]Reference Library Statistics[/bold]")
console.print(f" Sources: {source_count['cnt']}")
console.print(f" Documents: {doc_count['cnt']}")
console.print()
if status_rows:
table = Table(title="Documents by Crawl Status")
table.add_column("Status")
table.add_column("Count", justify="right")
for r in status_rows:
table.add_row(str(r["crawl_status"]), str(r["cnt"]))
console.print(table)
if review_rows:
table = Table(title="Distilled Content by Review Status")
table.add_column("Status")
table.add_column("Count", justify="right")
for r in review_rows:
table.add_row(str(r["review_status"]), str(r["cnt"]))
console.print(table)
if topic_rows:
table = Table(title="Documents by Topic")
table.add_column("Topic")
table.add_column("Documents", justify="right")
for r in topic_rows:
table.add_row(str(r["topic_name"]), str(r["cnt"]))
console.print(table)
result = {
"sources": source_count["cnt"],
"documents": doc_count["cnt"],
"by_status": {r["crawl_status"]: r["cnt"] for r in status_rows},
"by_review": {r["review_status"]: r["cnt"] for r in review_rows},
"by_topic": {r["topic_name"]: r["cnt"] for r in topic_rows},
}
click.echo(json.dumps(result, default=str))
def _print_doc_table(rows: list[dict], title: str):
"""Print a table of documents."""
table = Table(title=title)
table.add_column("doc_id", style="cyan")
table.add_column("Title")
table.add_column("URL")
table.add_column("Status")
for r in rows:
table.add_row(
str(r.get("doc_id", "")),
str(r.get("title", ""))[:40],
str(r.get("url", ""))[:50],
str(r.get("crawl_status", "")),
)
console.print(table)
if __name__ == "__main__":
cli()

View File

@@ -1,44 +1,58 @@
# Content Distiller # Content Distiller
Analyzes and distills raw crawled content into concise reference materials. Extracts key concepts, code snippets, and creates structured summaries. Analyzes and distills raw crawled content into concise reference materials. Claude performs the actual distillation (summarization, key concept extraction). Scripts handle data loading and storage.
## Trigger Keywords ## Trigger Keywords
"distill content", "summarize document", "extract key concepts", "process raw content", "create reference summary" "distill content", "summarize document", "extract key concepts", "process raw content", "create reference summary"
## Goals ## Goals
1. **Compress** - Reduce token count while preserving essential information 1. **Compress** Reduce token count while preserving essential information
2. **Structure** - Organize content for easy retrieval 2. **Structure** Organize content for easy retrieval
3. **Extract** - Pull out code snippets, key concepts, patterns 3. **Extract** Pull out code snippets, key concepts, patterns
4. **Annotate** - Add metadata for searchability 4. **Annotate** Add metadata for searchability
## Workflow ## Workflow
### Step 1: Load Raw Content ### Step 1: Load Pending Documents
```bash ```bash
python scripts/load_pending.py --output pending_docs.json uv run python scripts/distiller.py load-pending --output pending.json
``` ```
### Step 2: Analyze Content Structure ### Step 2: Analyze and Distill (Claude)
Identify document characteristics: For each pending document, Claude reads the raw content and creates:
- Has code blocks? - Executive summary (2-3 sentences)
- Has headers? - Key concepts with definitions
- Has tables?
- Estimated tokens?
### Step 3: Extract Key Components
```bash
python scripts/extract_components.py --doc-id 123 --output components.json
```
Extracts:
- Code snippets with language tags
- Key concepts and definitions
- Best practices
- Techniques and patterns - Techniques and patterns
- Code examples
- Best practices
### Step 3: Store Distilled Content
```bash
uv run python scripts/distiller.py store \
--doc-id 123 \
--content distilled.md \
--summary summary.txt \
--concepts concepts.json \
--snippets snippets.json \
--model claude-opus-4-6
```
### Step 4: Handle Refactor Requests
When quality-reviewer returns `refactor`, load context for re-distillation:
```bash
uv run python scripts/distiller.py refactor --distill-id 456 --output context.json
```
This outputs a context bundle with the current distilled content, raw source, and all review feedback.
### Step 5: View Distilled Content
```bash
uv run python scripts/distiller.py show --distill-id 456
```
## Distilled Output Template
### Step 4: Create Structured Summary
Output template:
```markdown ```markdown
# {title} # {title}
@@ -62,17 +76,6 @@ Output template:
{actionable recommendations} {actionable recommendations}
``` ```
### Step 5: Optimize for Tokens
Target: 25-35% of original token count
```bash
python scripts/optimize_content.py --doc-id 123 --target-ratio 0.30
```
### Step 6: Store Distilled Content
```bash
python scripts/store_distilled.py --doc-id 123 --content distilled.md
```
## Quality Metrics ## Quality Metrics
| Metric | Target | | Metric | Target |
@@ -82,20 +85,14 @@ python scripts/store_distilled.py --doc-id 123 --content distilled.md
| Code Snippet Retention | 100% of relevant examples | | Code Snippet Retention | 100% of relevant examples |
| Readability | Clear, scannable structure | | Readability | Clear, scannable structure |
## Handling Refactor Requests
When `quality-reviewer` returns `refactor`:
```bash
python scripts/refactor_content.py --distill-id 456 --instructions "Add more examples"
```
## Scripts ## Scripts
- `scripts/load_pending.py` - Load documents pending distillation | Command | Purpose |
- `scripts/extract_components.py` - Extract code, concepts, patterns |---------|---------|
- `scripts/optimize_content.py` - Token optimization | `distiller.py load-pending` | Load documents pending distillation |
- `scripts/store_distilled.py` - Save to database | `distiller.py store` | Save distilled content to DB |
- `scripts/refactor_content.py` - Handle refactor requests | `distiller.py refactor` | Load context for re-distillation |
| `distiller.py show` | Show distilled content details |
## Integration ## Integration

View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Content Distiller CLI — manage distillation data I/O.
Claude performs the actual distillation (summarization, extraction).
This script handles loading raw content from the DB and storing distilled output.
Usage:
python distiller.py load-pending [--output pending.json]
python distiller.py store --doc-id 123 --content distilled.md [--model claude-opus-4-6]
python distiller.py refactor --distill-id 456 [--output context.json]
python distiller.py show --distill-id 456
"""
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.utils import count_tokens
console = Console()
@click.group()
def cli():
"""Content Distiller — manage distillation data."""
pass
@cli.command("load-pending")
@click.option("--output", type=click.Path(), help="Output JSON file path")
@click.option("--limit", default=50, type=int, help="Max documents to load")
def load_pending(output, limit):
"""Load documents pending distillation.
Finds documents with crawl_status='completed' that have no distilled content yet.
"""
with db_session() as db:
rows = db.fetch_all(
"""SELECT d.doc_id, d.title, d.url, d.raw_content_path,
d.raw_content_size, d.doc_type,
s.source_name, s.credibility_tier
FROM documents d
JOIN sources s ON d.source_id = s.source_id
LEFT JOIN distilled_content dc ON d.doc_id = dc.doc_id
WHERE d.crawl_status = %s AND dc.distill_id IS NULL
ORDER BY s.credibility_tier ASC, d.crawl_date ASC
LIMIT %s""",
("completed", limit),
)
# Enrich with raw content preview
for row in rows:
raw_path = row.get("raw_content_path")
if raw_path and Path(raw_path).is_file():
content = Path(raw_path).read_text(errors="replace")
row["token_count_estimate"] = count_tokens(content)
row["content_preview"] = content[:200] + "..." if len(content) > 200 else content
else:
row["token_count_estimate"] = 0
row["content_preview"] = "[file not found]"
if output:
Path(output).write_text(json.dumps(rows, indent=2, default=str))
console.print(f"[green]{len(rows)} pending documents written to {output}[/green]")
else:
table = Table(title=f"Pending Distillation ({len(rows)} documents)")
table.add_column("doc_id", style="cyan")
table.add_column("Title")
table.add_column("Source")
table.add_column("Tier")
table.add_column("~Tokens", justify="right")
for r in rows:
table.add_row(
str(r.get("doc_id", "")),
str(r.get("title", ""))[:40],
str(r.get("source_name", ""))[:20],
str(r.get("credibility_tier", "")),
str(r.get("token_count_estimate", "")),
)
console.print(table)
click.echo(json.dumps({"count": len(rows)}, default=str))
@cli.command()
@click.option("--doc-id", required=True, type=int, help="Document ID to store distilled content for")
@click.option("--content", required=True, type=click.Path(exists=True),
help="Path to distilled markdown content")
@click.option("--summary", type=click.Path(exists=True), help="Path to summary text file")
@click.option("--concepts", type=click.Path(exists=True), help="Path to key concepts JSON")
@click.option("--snippets", type=click.Path(exists=True), help="Path to code snippets JSON")
@click.option("--model", default="claude-opus-4-6", help="Model used for distillation")
def store(doc_id, content, summary, concepts, snippets, model):
"""Store distilled content for a document."""
structured = Path(content).read_text(errors="replace")
token_distilled = count_tokens(structured)
summary_text = Path(summary).read_text() if summary else None
concepts_json = json.loads(Path(concepts).read_text()) if concepts else None
snippets_json = json.loads(Path(snippets).read_text()) if snippets else None
with db_session() as db:
# Get original token count
doc = db.fetch_one(
"SELECT raw_content_path, raw_content_size FROM documents WHERE doc_id = %s",
(doc_id,),
)
token_original = 0
if doc and doc.get("raw_content_path"):
raw_path = Path(doc["raw_content_path"])
if raw_path.is_file():
token_original = count_tokens(raw_path.read_text(errors="replace"))
# Check for existing distilled_content row (refactor case)
existing = db.fetch_one(
"SELECT distill_id, review_status FROM distilled_content WHERE doc_id = %s",
(doc_id,),
)
if existing and existing.get("review_status") in ("needs_refactor", "pending"):
# Update existing row instead of creating a new one
distill_id = existing["distill_id"]
db.execute(
"""UPDATE distilled_content
SET summary = %s, key_concepts = %s, code_snippets = %s,
structured_content = %s, token_count_original = %s,
token_count_distilled = %s, distill_model = %s,
distill_date = %s, review_status = %s
WHERE distill_id = %s""",
(summary_text,
json.dumps(concepts_json) if concepts_json else None,
json.dumps(snippets_json) if snippets_json else None,
structured, token_original, token_distilled, model,
datetime.now().isoformat(), "pending", distill_id),
)
action = "Updated"
else:
# First distillation for this document
distill_id = db.insert_returning_id(
"""INSERT INTO distilled_content
(doc_id, summary, key_concepts, code_snippets, structured_content,
token_count_original, token_count_distilled, distill_model, distill_date,
review_status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(doc_id, summary_text,
json.dumps(concepts_json) if concepts_json else None,
json.dumps(snippets_json) if snippets_json else None,
structured, token_original, token_distilled, model,
datetime.now().isoformat(), "pending"),
)
action = "Stored"
ratio = (token_distilled / token_original * 100) if token_original else 0
console.print(
f"[green]{action}:[/green] distill_id={distill_id} for doc_id={doc_id} "
f"({token_original}{token_distilled} tokens, {ratio:.0f}% compression)"
)
click.echo(json.dumps({
"distill_id": distill_id,
"doc_id": doc_id,
"token_original": token_original,
"token_distilled": token_distilled,
"compression_ratio": round(ratio, 2),
}))
@cli.command()
@click.option("--distill-id", required=True, type=int, help="Distilled content ID")
@click.option("--output", type=click.Path(), help="Output context JSON for re-distillation")
def refactor(distill_id, output):
"""Load existing distilled content + review feedback for re-distillation.
Outputs a context bundle that Claude can use to re-distill with improvements.
"""
with db_session() as db:
distilled = db.fetch_one(
"""SELECT dc.*, d.title, d.url, d.raw_content_path
FROM distilled_content dc
JOIN documents d ON dc.doc_id = d.doc_id
WHERE dc.distill_id = %s""",
(distill_id,),
)
if not distilled:
console.print(f"[red]Error:[/red] distill_id={distill_id} not found")
sys.exit(1)
# Get review feedback
reviews = db.fetch_all(
"""SELECT review_round, quality_score, decision, feedback, refactor_instructions
FROM review_logs
WHERE distill_id = %s
ORDER BY review_round ASC""",
(distill_id,),
)
# Load raw content if available
raw_content = ""
raw_path = distilled.get("raw_content_path")
if raw_path and Path(raw_path).is_file():
raw_content = Path(raw_path).read_text(errors="replace")
context = {
"distill_id": distill_id,
"doc_id": distilled.get("doc_id"),
"title": distilled.get("title"),
"url": distilled.get("url"),
"current_distilled": distilled.get("structured_content"),
"raw_content": raw_content,
"review_history": [
{
"round": r.get("review_round"),
"score": float(r["quality_score"]) if r.get("quality_score") else None,
"decision": r.get("decision"),
"feedback": r.get("feedback"),
"instructions": r.get("refactor_instructions"),
}
for r in reviews
],
}
if output:
Path(output).write_text(json.dumps(context, indent=2, default=str))
console.print(f"[green]Refactor context written to {output}[/green]")
else:
click.echo(json.dumps(context, indent=2, default=str))
@cli.command()
@click.option("--distill-id", required=True, type=int, help="Distilled content ID")
def show(distill_id):
"""Show details of a distilled content record."""
with db_session() as db:
row = db.fetch_one(
"""SELECT dc.*, d.title, d.url
FROM distilled_content dc
JOIN documents d ON dc.doc_id = d.doc_id
WHERE dc.distill_id = %s""",
(distill_id,),
)
if not row:
console.print(f"[red]Not found:[/red] distill_id={distill_id}")
sys.exit(1)
console.print(f"\n[bold]Distilled Content #{distill_id}[/bold]")
console.print(f" Document: {row.get('title')} (doc_id={row.get('doc_id')})")
console.print(f" URL: {row.get('url')}")
console.print(f" Review Status: {row.get('review_status')}")
console.print(f" Model: {row.get('distill_model')}")
console.print(f" Tokens: {row.get('token_count_original')}{row.get('token_count_distilled')}")
if row.get("summary"):
console.print(f"\n[bold]Summary:[/bold]\n{row['summary'][:500]}")
if __name__ == "__main__":
cli()

View File

@@ -1,103 +1,93 @@
# Quality Reviewer # Quality Reviewer
QA loop for reference library content. Scores distilled materials, routes decisions, and provides actionable feedback. Pre-distillation quality gate using Gemini CLI as an independent evaluator. Assesses raw crawled content before distillation to filter out low-quality sources early. Also supports manual scoring and routing for edge cases.
## Trigger Keywords ## Trigger Keywords
"review content", "quality check", "QA review", "assess distilled content", "check reference quality" "review content", "quality check", "QA review", "evaluate sources", "check reference quality"
## Decision Flow ## Primary Flow: Gemini Pre-Distillation Gate
``` ```
[Distilled Content] [Raw Crawled Content]
┌─────────────────┐ ┌────────────────────
Score Criteria │ → accuracy, completeness, clarity, PE quality, usability Gemini CLI Eval │ → relevance, authority, completeness, freshness, distill_value
└─────────────────┘ └────────────────────
├── ≥ 0.85 → APPROVE → markdown-exporter ├── ≥ 0.75 → APPROVE → proceed to distillation
├── 0.60-0.84 → REFACTOR → content-distiller ├── 0.50-0.74 → DEEP_RESEARCH → re-crawl for better sources
── 0.40-0.59DEEP_RESEARCH → web-crawler ── < 0.50REJECT → skip distillation entirely
└── < 0.40 → REJECT → archive
``` ```
## Scoring Criteria ## Evaluation Criteria (Gemini)
| Criterion | Weight | Checks | | Criterion | Weight | What It Checks |
|-----------|--------|--------| |-----------|--------|----------------|
| **Accuracy** | 0.25 | Factual correctness, up-to-date, attribution | | **Relevance** | 0.25 | Does content match the curation topic? |
| **Completeness** | 0.20 | Key concepts, examples, edge cases | | **Authority** | 0.25 | Official docs / research paper, or blog spam? |
| **Clarity** | 0.20 | Structure, concise language, logical flow | | **Completeness** | 0.20 | Full article, or nav fragment / error page / stub? |
| **PE Quality** | 0.25 | Techniques, before/after, explains why | | **Freshness** | 0.15 | Up-to-date or outdated information? |
| **Usability** | 0.10 | Easy reference, searchable, appropriate length | | **Distill Value** | 0.15 | Unique info worth summarizing, or redundant? |
## Workflow ## Workflow
### Step 1: Load Pending Reviews ### Step 1: Evaluate Single Document
```bash ```bash
python scripts/load_pending_reviews.py --output pending.json uv run python scripts/reviewer.py gemini-evaluate --doc-id 123 --topic "prompt engineering"
# With auto-logging of decision:
uv run python scripts/reviewer.py gemini-evaluate --doc-id 123 --topic "prompt engineering" --auto-approve
``` ```
### Step 2: Score Content ### Step 2: Batch Evaluate All Pending
```bash ```bash
python scripts/score_content.py --distill-id 123 --output assessment.json uv run python scripts/reviewer.py gemini-evaluate-pending --topic "prompt engineering" --auto-approve --limit 20
``` ```
### Step 3: Calculate Final Score ### Step 3: Manual Review (Edge Cases)
For documents where Gemini evaluation fails or needs human judgment:
```bash ```bash
python scripts/calculate_score.py --assessment assessment.json # Calculate score from manual assessment
uv run python scripts/reviewer.py calculate-score --assessment assessment.json
# Route based on score
uv run python scripts/reviewer.py route --score 0.78
# Log review decision
uv run python scripts/reviewer.py log-review \
--distill-id 123 --decision approve --score 0.85 \
--feedback "Manually verified"
``` ```
### Step 4: Route Decision ### Step 4: Review History
```bash ```bash
python scripts/route_decision.py --distill-id 123 --score 0.78 uv run python scripts/reviewer.py history --distill-id 123
``` ```
Outputs: ## Prerequisites
- `approve` → Ready for export
- `refactor` → Return to distiller with instructions
- `deep_research` → Need more sources (queries generated)
- `reject` → Archive with reason
### Step 5: Log Review - Gemini CLI: `npm install -g @google/gemini-cli`
```bash - Google auth: `gemini` (run once interactively to authenticate)
python scripts/log_review.py --distill-id 123 --decision refactor --instructions "Add more examples" - `refcurator` package installed
```
## PE Quality Checklist
When scoring `prompt_engineering_quality`:
- [ ] Demonstrates specific techniques (CoT, few-shot, etc.)
- [ ] Shows before/after examples
- [ ] Explains *why* techniques work
- [ ] Provides actionable patterns
- [ ] Includes edge cases and failure modes
- [ ] References authoritative sources
## Auto-Approve Rules
Tier 1 sources with score ≥ 0.80 may auto-approve:
```yaml
# In config
quality:
auto_approve_tier1_sources: true
auto_approve_min_score: 0.80
```
## Scripts ## Scripts
- `scripts/load_pending_reviews.py` - Get pending reviews | Command | Purpose |
- `scripts/score_content.py` - Multi-criteria scoring |---------|---------|
- `scripts/calculate_score.py` - Weighted average calculation | `reviewer.py gemini-evaluate` | Evaluate single doc via Gemini CLI |
- `scripts/route_decision.py` - Decision routing logic | `reviewer.py gemini-evaluate-pending` | Batch evaluate all pending docs |
- `scripts/log_review.py` - Log review to database | `reviewer.py calculate-score` | Manual weighted score calculation |
- `scripts/generate_feedback.py` - Generate refactor instructions | `reviewer.py route` | Decision routing from score |
| `reviewer.py log-review` | Log review decision to DB |
| `reviewer.py load-pending` | Get pending reviews |
| `reviewer.py history` | Show review history |
## Integration ## Integration
| From | Action | To | | From | Action | To |
|------|--------|-----| |------|--------|-----|
| content-distiller | Distilled content | → | | content-repository (raw docs) | Gemini evaluation | → |
| → | APPROVE | markdown-exporter | | → | APPROVE | content-distiller |
| → | REFACTOR + instructions | content-distiller | | → | DEEP_RESEARCH | web-crawler-orchestrator |
| → | DEEP_RESEARCH + queries | web-crawler-orchestrator | | → | REJECT | archive (skip distillation) |

View File

@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""Quality Reviewer CLI — scoring, routing, and review logging.
Claude performs qualitative assessment. This script handles:
- Loading pending reviews
- Calculating weighted quality scores
- Routing decisions based on thresholds
- Logging review decisions to the database
Usage:
python reviewer.py load-pending [--output pending.json]
python reviewer.py calculate-score --assessment assessment.json
python reviewer.py route --score 0.78
python reviewer.py log-review --distill-id 123 --decision refactor --score 0.78 [--feedback "..."]
python reviewer.py history --distill-id 123
"""
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.models import QAAssessment
console = Console()
# Thresholds from pipeline config
APPROVE_THRESHOLD = 0.85
REFACTOR_THRESHOLD = 0.60
DEEP_RESEARCH_THRESHOLD = 0.40
@click.group()
def cli():
"""Quality Reviewer — scoring, routing, and review logging."""
pass
@cli.command("load-pending")
@click.option("--output", type=click.Path(), help="Output JSON file path")
@click.option("--limit", default=50, type=int, help="Max results")
def load_pending(output, limit):
"""Load distilled content pending quality review."""
with db_session() as db:
rows = db.fetch_all(
"""SELECT dc.distill_id, d.doc_id, d.title, d.url,
dc.token_count_distilled, dc.distill_date,
dc.summary, s.credibility_tier, s.vendor
FROM distilled_content dc
JOIN documents d ON dc.doc_id = d.doc_id
JOIN sources s ON d.source_id = s.source_id
WHERE dc.review_status = %s
ORDER BY s.credibility_tier ASC, dc.distill_date ASC
LIMIT %s""",
("pending", limit),
)
if output:
Path(output).write_text(json.dumps(rows, indent=2, default=str))
console.print(f"[green]{len(rows)} pending reviews written to {output}[/green]")
else:
table = Table(title=f"Pending Reviews ({len(rows)})")
table.add_column("distill_id", style="cyan")
table.add_column("Title")
table.add_column("Tier")
table.add_column("Tokens", justify="right")
for r in rows:
table.add_row(
str(r.get("distill_id", "")),
str(r.get("title", ""))[:40],
str(r.get("credibility_tier", "")),
str(r.get("token_count_distilled", "")),
)
console.print(table)
click.echo(json.dumps({"count": len(rows)}, default=str))
@cli.command("calculate-score")
@click.option("--assessment", required=True, type=click.Path(exists=True),
help="Assessment JSON file with criteria scores")
@click.option("--json-output", is_flag=True, help="Output as JSON only")
def calculate_score(assessment, json_output):
"""Calculate weighted quality score from assessment criteria.
Input JSON format:
{
"accuracy": 0.90,
"completeness": 0.85,
"clarity": 0.95,
"prompt_engineering_quality": 0.88,
"usability": 0.82
}
Weights: accuracy 0.25, completeness 0.20, clarity 0.20,
prompt_engineering_quality 0.25, usability 0.10
"""
data = json.loads(Path(assessment).read_text())
qa = QAAssessment(**data)
result = {
"criteria": data,
"weighted_score": qa.weighted_score,
"decision": _route_score(qa.weighted_score),
}
if json_output:
click.echo(json.dumps(result, indent=2))
else:
console.print(f"\n[bold]Quality Assessment[/bold]")
for criterion, score in data.items():
bar = "" * int(score * 20) + "" * (20 - int(score * 20))
console.print(f" {criterion:<30} {bar} {score:.2f}")
console.print(f"\n [bold]Weighted Score:[/bold] {qa.weighted_score:.4f}")
console.print(f" [bold]Decision:[/bold] {_route_score(qa.weighted_score)}")
@cli.command()
@click.option("--score", required=True, type=float, help="Quality score to route")
@click.option("--tier", type=click.Choice(["tier1_official", "tier2_verified", "tier3_community"]),
help="Source credibility tier (for auto-approve)")
@click.option("--auto-approve", is_flag=True, help="Enable auto-approve for tier1 sources")
def route(score, tier, auto_approve):
"""Route a quality score to a decision.
Thresholds:
>= 0.85 → approve
0.60-0.84 → refactor
0.40-0.59 → deep_research
< 0.40 → reject
"""
decision = _route_score(score)
# Auto-approve tier1 sources with lower threshold
if auto_approve and tier == "tier1_official" and score >= 0.80:
decision = "approve"
result = {"score": score, "decision": decision}
if decision == "approve":
console.print(f"[green]APPROVE[/green] (score: {score:.2f})")
elif decision == "refactor":
console.print(f"[yellow]REFACTOR[/yellow] (score: {score:.2f})")
elif decision == "deep_research":
console.print(f"[blue]DEEP_RESEARCH[/blue] (score: {score:.2f})")
else:
console.print(f"[red]REJECT[/red] (score: {score:.2f})")
click.echo(json.dumps(result))
@cli.command("log-review")
@click.option("--distill-id", required=True, type=int, help="Distilled content ID")
@click.option("--decision", required=True,
type=click.Choice(["approve", "refactor", "deep_research", "reject"]))
@click.option("--score", required=True, type=float, help="Quality score")
@click.option("--assessment", type=click.Path(exists=True), help="Assessment JSON file")
@click.option("--feedback", help="Review feedback text")
@click.option("--instructions", help="Refactor instructions")
@click.option("--queries", type=click.Path(exists=True), help="Research queries JSON (for deep_research)")
@click.option("--reviewer", default="claude_review",
type=click.Choice(["auto_qa", "human", "claude_review"]))
def log_review(distill_id, decision, score, assessment, feedback, instructions, queries, reviewer):
"""Log a review decision for distilled content."""
assessment_json = json.loads(Path(assessment).read_text()) if assessment else None
queries_json = json.loads(Path(queries).read_text()) if queries else None
with db_session() as db:
# Get current review round
last_review = db.fetch_one(
"SELECT MAX(review_round) as max_round FROM review_logs WHERE distill_id = %s",
(distill_id,),
)
review_round = (last_review.get("max_round") or 0) + 1 if last_review else 1
review_id = db.insert_returning_id(
"""INSERT INTO review_logs
(distill_id, review_round, reviewer_type, quality_score, assessment,
decision, feedback, refactor_instructions, research_queries, reviewed_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(distill_id, review_round, reviewer, score,
json.dumps(assessment_json) if assessment_json else None,
decision, feedback, instructions,
json.dumps(queries_json) if queries_json else None,
datetime.now().isoformat()),
)
# Update distilled_content review_status
status_map = {
"approve": "approved",
"refactor": "needs_refactor",
"deep_research": "needs_refactor",
"reject": "rejected",
}
db.execute(
"UPDATE distilled_content SET review_status = %s WHERE distill_id = %s",
(status_map[decision], distill_id),
)
console.print(
f"[green]Logged:[/green] review_id={review_id}, round={review_round}, "
f"decision={decision}, score={score:.2f}"
)
click.echo(json.dumps({
"review_id": review_id,
"distill_id": distill_id,
"review_round": review_round,
"decision": decision,
"score": score,
}))
@cli.command()
@click.option("--distill-id", required=True, type=int, help="Distilled content ID")
def history(distill_id):
"""Show review history for a distilled content record."""
with db_session() as db:
reviews = db.fetch_all(
"""SELECT review_id, review_round, reviewer_type, quality_score,
decision, feedback, refactor_instructions, reviewed_at
FROM review_logs
WHERE distill_id = %s
ORDER BY review_round ASC""",
(distill_id,),
)
if not reviews:
console.print(f"[dim]No reviews found for distill_id={distill_id}[/dim]")
return
table = Table(title=f"Review History — distill_id={distill_id}")
table.add_column("Round", style="cyan")
table.add_column("Score", justify="right")
table.add_column("Decision")
table.add_column("Reviewer")
table.add_column("Feedback")
for r in reviews:
decision = str(r.get("decision", ""))
style = {"approve": "green", "refactor": "yellow",
"deep_research": "blue", "reject": "red"}.get(decision, "")
table.add_row(
str(r.get("review_round", "")),
f"{float(r['quality_score']):.2f}" if r.get("quality_score") else "",
f"[{style}]{decision}[/{style}]" if style else decision,
str(r.get("reviewer_type", "")),
str(r.get("feedback", ""))[:40] if r.get("feedback") else "",
)
console.print(table)
@cli.command("gemini-evaluate")
@click.option("--doc-id", required=True, type=int, help="Document ID to evaluate")
@click.option("--topic", required=True, help="Curation topic for relevance scoring")
@click.option("--auto-approve", is_flag=True, help="Auto-log decision based on Gemini verdict")
def gemini_evaluate(doc_id, topic, auto_approve):
"""Evaluate raw crawled content using Gemini CLI (pre-distillation gate).
Sends raw content to Gemini for independent quality assessment.
Scores: relevance, authority, completeness, freshness, distill_value.
"""
from refcurator.gemini import evaluate_content, is_available
if not is_available():
console.print("[red]Error:[/red] Gemini CLI not available. Install: npm install -g @google/gemini-cli")
sys.exit(1)
with db_session() as db:
doc = db.fetch_one(
"SELECT doc_id, title, url, raw_content_path FROM documents WHERE doc_id = %s",
(doc_id,),
)
if not doc:
console.print(f"[red]Error:[/red] doc_id={doc_id} not found")
sys.exit(1)
raw_path = doc.get("raw_content_path")
if not raw_path or not Path(raw_path).is_file():
console.print(f"[red]Error:[/red] Raw content file not found: {raw_path}")
sys.exit(1)
content = Path(raw_path).read_text(errors="replace")
url = doc.get("url", "")
console.print(f"[dim]Evaluating doc_id={doc_id}: {doc.get('title', '')}[/dim]")
console.print(f"[dim]Sending {len(content):,} chars to Gemini...[/dim]")
result = evaluate_content(content, topic, url)
if result is None:
console.print("[yellow]Warning:[/yellow] Gemini evaluation failed — manual review needed")
click.echo(json.dumps({"doc_id": doc_id, "status": "gemini_failed"}))
return
# Display results
console.print(f"\n[bold]Gemini Evaluation — doc_id={doc_id}[/bold]")
for criterion in ["relevance", "authority", "completeness", "freshness", "distill_value"]:
score = result.get(criterion, 0)
bar = "" * int(score * 20) + "" * (20 - int(score * 20))
console.print(f" {criterion:<15} {bar} {score:.2f}")
ws = result.get("weighted_score", 0)
verdict = result.get("verdict", "unknown")
reason = result.get("reason", "")
console.print(f"\n [bold]Weighted Score:[/bold] {ws:.4f}")
verdict_color = {"approve": "green", "reject": "red", "deep_research": "blue"}.get(verdict, "white")
console.print(f" [bold]Verdict:[/bold] [{verdict_color}]{verdict}[/{verdict_color}]")
if reason:
console.print(f" [dim]Reason:[/dim] {reason}")
if auto_approve:
# Map verdict to decision (no 'refactor' for raw content)
decision = verdict if verdict in ("approve", "reject", "deep_research") else "reject"
with db_session() as db:
# Log to review_logs (using doc_id context, not distill_id since pre-distillation)
# We create a placeholder distill_id = 0 entry or log against the document directly
review_id = db.insert_returning_id(
"""INSERT INTO review_logs
(distill_id, review_round, reviewer_type, quality_score, assessment,
decision, feedback, reviewed_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
(0, 1, "auto_qa", ws,
json.dumps(result),
decision, reason,
datetime.now().isoformat()),
)
console.print(f"\n[green]Auto-logged:[/green] review_id={review_id}, decision={decision}")
click.echo(json.dumps({
"doc_id": doc_id,
"evaluation": result,
"auto_logged": auto_approve,
}, default=str))
@cli.command("gemini-evaluate-pending")
@click.option("--topic", required=True, help="Curation topic for relevance scoring")
@click.option("--auto-approve", is_flag=True, help="Auto-log decisions")
@click.option("--limit", default=20, type=int, help="Max documents to evaluate")
def gemini_evaluate_pending(topic, auto_approve, limit):
"""Evaluate all crawled-but-not-evaluated documents using Gemini."""
from refcurator.gemini import evaluate_content, is_available
if not is_available():
console.print("[red]Error:[/red] Gemini CLI not available")
sys.exit(1)
with db_session() as db:
rows = db.fetch_all(
"""SELECT doc_id, title, url, raw_content_path
FROM documents
WHERE crawl_status = %s
ORDER BY doc_id ASC
LIMIT %s""",
("completed", limit),
)
if not rows:
console.print("[dim]No pending documents to evaluate.[/dim]")
return
console.print(f"[bold]Evaluating {len(rows)} documents with Gemini...[/bold]\n")
stats = {"approve": 0, "reject": 0, "deep_research": 0, "failed": 0}
for row in rows:
doc_id = row["doc_id"]
raw_path = row.get("raw_content_path")
if not raw_path or not Path(raw_path).is_file():
console.print(f" [yellow]Skip[/yellow] doc_id={doc_id}: raw file not found")
stats["failed"] += 1
continue
content = Path(raw_path).read_text(errors="replace")
result = evaluate_content(content, topic, row.get("url", ""))
if result is None:
console.print(f" [yellow]Failed[/yellow] doc_id={doc_id}: Gemini returned no result")
stats["failed"] += 1
continue
verdict = result.get("verdict", "reject")
ws = result.get("weighted_score", 0)
verdict_color = {"approve": "green", "reject": "red", "deep_research": "blue"}.get(verdict, "white")
console.print(
f" [{verdict_color}]{verdict:>13}[/{verdict_color}] "
f"({ws:.2f}) doc_id={doc_id}{row.get('title', '')[:40]}"
)
stats[verdict] = stats.get(verdict, 0) + 1
if auto_approve:
decision = verdict if verdict in ("approve", "reject", "deep_research") else "reject"
with db_session() as db:
db.insert_returning_id(
"""INSERT INTO review_logs
(distill_id, review_round, reviewer_type, quality_score, assessment,
decision, feedback, reviewed_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
(0, 1, "auto_qa", ws,
json.dumps(result),
decision, result.get("reason", ""),
datetime.now().isoformat()),
)
console.print(f"\n[bold]Results:[/bold]")
console.print(f" [green]Approved:[/green] {stats['approve']}")
console.print(f" [red]Rejected:[/red] {stats['reject']}")
console.print(f" [blue]Deep Research:[/blue] {stats['deep_research']}")
console.print(f" [yellow]Failed:[/yellow] {stats['failed']}")
click.echo(json.dumps(stats))
def _route_score(score: float) -> str:
"""Route a quality score to a decision based on thresholds."""
if score >= APPROVE_THRESHOLD:
return "approve"
elif score >= REFACTOR_THRESHOLD:
return "refactor"
elif score >= DEEP_RESEARCH_THRESHOLD:
return "deep_research"
else:
return "reject"
if __name__ == "__main__":
cli()

View File

@@ -9,57 +9,51 @@ Exports approved reference content as structured markdown files for project know
| Type | Format | Use Case | | Type | Format | Use Case |
|------|--------|----------| |------|--------|----------|
| `project_files` | Nested markdown | Claude Projects knowledge | | `project` | Nested markdown | Claude Projects knowledge |
| `fine_tuning` | JSONL | Model fine-tuning dataset | | `finetuning` | JSONL | Model fine-tuning dataset |
| `knowledge_base` | Flat markdown | Documentation |
## Workflow ## Workflow
### Step 1: Query Approved Content ### Step 1: Export Project Files
```bash ```bash
python scripts/query_approved.py --min-score 0.80 --output approved.json uv run python scripts/exporter.py project \
--output ~/reference-library/exports/ \
--min-score 0.80 \
--structure nested_by_topic
``` ```
### Step 2: Organize by Structure Output structure:
**Nested by Topic (default):**
``` ```
exports/ exports/
├── INDEX.md ├── INDEX.md
├── prompt-engineering/ ├── prompt-engineering/
│ ├── _index.md │ ├── _index.md
│ ├── 01-chain-of-thought.md │ ├── 00-chain-of-thought.md
│ └── 02-few-shot-prompting.md │ └── 01-few-shot-prompting.md
└── claude-models/ └── claude-models/
├── _index.md ├── _index.md
└── 01-model-comparison.md └── 00-model-comparison.md
``` ```
**Flat Structure:** ### Step 2: Generate INDEX
```
exports/
├── INDEX.md
├── prompt-engineering-chain-of-thought.md
└── claude-models-comparison.md
```
### Step 3: Generate Files
```bash ```bash
python scripts/export_project.py \ uv run python scripts/exporter.py index --output ~/reference-library/exports/INDEX.md
--structure nested_by_topic \
--output ~/reference-library/exports/ \
--include-metadata
``` ```
### Step 4: Generate INDEX ### Step 3: Add Cross-References
```bash ```bash
python scripts/generate_index.py --output ~/reference-library/exports/INDEX.md uv run python scripts/exporter.py crossrefs --input ~/reference-library/exports/
```
### Step 4: Verify Export
```bash
uv run python scripts/exporter.py verify --path ~/reference-library/exports/
``` ```
### Step 5: Fine-tuning Export (Optional) ### Step 5: Fine-tuning Export (Optional)
```bash ```bash
python scripts/export_finetuning.py \ uv run python scripts/exporter.py finetuning \
--output ~/reference-library/exports/fine_tuning.jsonl \ --output ~/reference-library/exports/training.jsonl \
--max-tokens 4096 --max-tokens 4096
``` ```
@@ -71,62 +65,25 @@ JSONL format:
{"role": "user", "content": "Explain {title}"}, {"role": "user", "content": "Explain {title}"},
{"role": "assistant", "content": "{structured_content}"} {"role": "assistant", "content": "{structured_content}"}
], ],
"metadata": {"source": "{url}", "topic": "{topic_slug}", "quality_score": 0.92} "metadata": {"source": "{url}", "quality_score": 0.92}
} }
``` ```
### Step 6: Log Export Job ### Step 6: Log Export Job
```bash ```bash
python scripts/log_export.py --name "January 2025 Export" --type project_files --docs 45 uv run python scripts/exporter.py log --name "April 2026 Export" --type project_files --docs 45
```
## Cross-Reference Generation
```bash
python scripts/add_crossrefs.py --input ~/reference-library/exports/
```
Links related documents based on overlapping key concepts.
## Output Verification
After export, verify:
- [ ] All files readable and valid markdown
- [ ] INDEX.md links resolve correctly
- [ ] No broken cross-references
- [ ] Total token count matches expectation
- [ ] No duplicate content
```bash
python scripts/verify_export.py --path ~/reference-library/exports/
``` ```
## Scripts ## Scripts
- `scripts/query_approved.py` - Get approved content from DB | Command | Purpose |
- `scripts/export_project.py` - Main export for project files |---------|---------|
- `scripts/export_finetuning.py` - JSONL export for fine-tuning | `exporter.py project` | Export as nested markdown files |
- `scripts/generate_index.py` - Generate INDEX.md | `exporter.py finetuning` | Export as JSONL training dataset |
- `scripts/add_crossrefs.py` - Add cross-references | `exporter.py index` | Generate INDEX.md table of contents |
- `scripts/log_export.py` - Log export job to DB | `exporter.py crossrefs` | Add cross-reference links |
- `scripts/verify_export.py` - Verify export integrity | `exporter.py verify` | Verify export integrity |
| `exporter.py log` | Log export job to DB |
## Configuration
```yaml
# ~/.config/reference-curator/export_config.yaml
output:
base_path: ~/reference-library/exports/
project_files:
structure: nested_by_topic
index_file: INDEX.md
include_metadata: true
fine_tuning:
format: jsonl
max_tokens_per_sample: 4096
quality:
min_score_for_export: 0.80
```
## Integration ## Integration

View File

@@ -0,0 +1,451 @@
#!/usr/bin/env python3
"""Markdown Exporter CLI — export approved content as structured files.
Usage:
python exporter.py project --output ~/reference-library/exports/ [--min-score 0.80]
python exporter.py finetuning --output ~/reference-library/exports/training.jsonl
python exporter.py index --output ~/reference-library/exports/INDEX.md
python exporter.py crossrefs --input ~/reference-library/exports/
python exporter.py verify --path ~/reference-library/exports/
python exporter.py log --name "Jan 2025 Export" --type project_files --docs 40
"""
import json
import re
import sys
from collections import defaultdict
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.utils import count_tokens, slugify
console = Console()
@click.group()
def cli():
"""Markdown Exporter — export approved references."""
pass
@cli.command()
@click.option("--output", required=True, type=click.Path(), help="Output directory path")
@click.option("--min-score", default=0.80, type=float, help="Minimum quality score")
@click.option("--structure", default="nested_by_topic",
type=click.Choice(["nested_by_topic", "flat"]))
@click.option("--include-metadata", is_flag=True, default=True, help="Include source metadata")
def project(output, min_score, structure, include_metadata):
"""Export approved content as markdown project files."""
out_dir = Path(output).expanduser()
out_dir.mkdir(parents=True, exist_ok=True)
with db_session() as db:
rows = db.fetch_all(
"""SELECT d.doc_id, d.title, d.url,
dc.structured_content, dc.summary, dc.key_concepts,
dc.token_count_distilled,
rl.quality_score,
s.credibility_tier, s.vendor, s.source_name
FROM documents d
JOIN distilled_content dc ON d.doc_id = dc.doc_id
JOIN review_logs rl ON dc.distill_id = rl.distill_id
JOIN sources s ON d.source_id = s.source_id
WHERE dc.review_status = %s
AND rl.decision = %s
AND rl.review_id = (
SELECT MAX(rl2.review_id)
FROM review_logs rl2
WHERE rl2.distill_id = dc.distill_id
)
ORDER BY rl.quality_score DESC""",
("approved", "approve"),
)
# Filter by min score
rows = [r for r in rows if (r.get("quality_score") or 0) >= min_score]
if not rows:
console.print("[yellow]No approved documents found above minimum score.[/yellow]")
return
# Get topic mappings
with db_session() as db:
topic_rows = db.fetch_all(
"""SELECT dt.doc_id, t.topic_name, t.topic_slug
FROM document_topics dt
JOIN topics t ON dt.topic_id = t.topic_id"""
)
doc_topics = defaultdict(list)
for tr in topic_rows:
doc_topics[tr["doc_id"]].append(tr)
exported = 0
total_tokens = 0
if structure == "nested_by_topic":
exported, total_tokens = _export_nested(rows, doc_topics, out_dir, include_metadata)
else:
exported, total_tokens = _export_flat(rows, doc_topics, out_dir, include_metadata)
console.print(
f"[green]Exported {exported} documents ({total_tokens:,} tokens) to {out_dir}[/green]"
)
@cli.command()
@click.option("--output", required=True, type=click.Path(), help="Output JSONL file path")
@click.option("--min-score", default=0.80, type=float, help="Minimum quality score")
@click.option("--max-tokens", default=4096, type=int, help="Max tokens per sample")
@click.option("--system-prompt", default="You are an expert on AI and prompt engineering.",
help="System prompt for training samples")
def finetuning(output, min_score, max_tokens, system_prompt):
"""Export approved content as JSONL fine-tuning dataset."""
out_path = Path(output).expanduser()
out_path.parent.mkdir(parents=True, exist_ok=True)
with db_session() as db:
rows = db.fetch_all(
"""SELECT d.doc_id, d.title, d.url,
dc.structured_content, dc.summary,
dc.token_count_distilled,
rl.quality_score
FROM documents d
JOIN distilled_content dc ON d.doc_id = dc.doc_id
JOIN review_logs rl ON dc.distill_id = rl.distill_id
WHERE dc.review_status = %s
AND rl.decision = %s
AND rl.review_id = (
SELECT MAX(rl2.review_id)
FROM review_logs rl2
WHERE rl2.distill_id = dc.distill_id
)
ORDER BY rl.quality_score DESC""",
("approved", "approve"),
)
rows = [r for r in rows if (r.get("quality_score") or 0) >= min_score]
count = 0
with open(out_path, "w") as f:
for row in rows:
content = row.get("structured_content", "")
if count_tokens(content) > max_tokens:
content = content[:max_tokens * 4] # Approximate truncation
sample = {
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Explain {row.get('title', 'this topic')}"},
{"role": "assistant", "content": content},
],
"metadata": {
"source": row.get("url"),
"quality_score": float(row["quality_score"]) if row.get("quality_score") else None,
"doc_id": row.get("doc_id"),
},
}
f.write(json.dumps(sample, ensure_ascii=False) + "\n")
count += 1
console.print(f"[green]Exported {count} samples to {out_path}[/green]")
@cli.command()
@click.option("--output", required=True, type=click.Path(), help="Output INDEX.md path")
@click.option("--exports-dir", type=click.Path(exists=True),
help="Exports directory to scan (defaults to parent of output)")
def index(output, exports_dir):
"""Generate INDEX.md with table of contents."""
out_path = Path(output).expanduser()
scan_dir = Path(exports_dir).expanduser() if exports_dir else out_path.parent
lines = [
"# Reference Library Index",
"",
f"*Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}*",
"",
]
# Scan for topic directories
topic_dirs = sorted([d for d in scan_dir.iterdir() if d.is_dir() and not d.name.startswith("_")])
if topic_dirs:
lines.append("## Topics\n")
for topic_dir in topic_dirs:
md_files = sorted(topic_dir.glob("*.md"))
if md_files:
topic_name = topic_dir.name.replace("-", " ").title()
lines.append(f"### {topic_name}\n")
for md_file in md_files:
if md_file.name.startswith("_"):
continue
title = _extract_md_title(md_file)
rel_path = md_file.relative_to(scan_dir)
lines.append(f"- [{title}]({rel_path})")
lines.append("")
# Scan for flat files
flat_files = sorted([f for f in scan_dir.glob("*.md")
if f.name not in ("INDEX.md", "_index.md")])
if flat_files:
lines.append("## Documents\n")
for md_file in flat_files:
title = _extract_md_title(md_file)
lines.append(f"- [{title}]({md_file.name})")
lines.append("")
out_path.write_text("\n".join(lines))
console.print(f"[green]Generated INDEX.md at {out_path}[/green]")
@cli.command()
@click.option("--input", "input_dir", required=True, type=click.Path(exists=True),
help="Exports directory to add cross-references to")
def crossrefs(input_dir):
"""Add cross-reference links between related documents."""
scan_dir = Path(input_dir).expanduser()
md_files = list(scan_dir.rglob("*.md"))
if not md_files:
console.print("[yellow]No markdown files found.[/yellow]")
return
# Build concept index: concept → list of (file, title)
concept_index: dict[str, list[tuple[Path, str]]] = defaultdict(list)
file_concepts: dict[Path, set[str]] = {}
for md_file in md_files:
if md_file.name in ("INDEX.md", "_index.md"):
continue
content = md_file.read_text(errors="replace")
title = _extract_md_title(md_file)
concepts = _extract_concepts(content)
file_concepts[md_file] = concepts
for concept in concepts:
concept_index[concept].append((md_file, title))
# Add cross-references
modified = 0
for md_file in md_files:
if md_file.name in ("INDEX.md", "_index.md"):
continue
my_concepts = file_concepts.get(md_file, set())
related: dict[str, str] = {} # title → relative path
for concept in my_concepts:
for other_file, other_title in concept_index.get(concept, []):
if other_file != md_file and other_title not in related:
try:
rel = other_file.relative_to(scan_dir)
except ValueError:
rel = other_file
related[other_title] = str(rel)
if related and len(related) <= 10:
content = md_file.read_text(errors="replace")
# Remove existing Related section if present
content = re.sub(r"\n## Related\n.*$", "", content, flags=re.DOTALL)
content = content.rstrip() + "\n\n## Related\n\n"
for title, path in sorted(related.items())[:5]:
content += f"- [{title}]({path})\n"
md_file.write_text(content)
modified += 1
console.print(f"[green]Added cross-references to {modified} files[/green]")
@cli.command()
@click.option("--path", required=True, type=click.Path(exists=True), help="Exports directory to verify")
def verify(path):
"""Verify export integrity."""
scan_dir = Path(path).expanduser()
md_files = list(scan_dir.rglob("*.md"))
issues = []
total_tokens = 0
total_files = len(md_files)
for md_file in md_files:
content = md_file.read_text(errors="replace")
tokens = count_tokens(content)
total_tokens += tokens
# Check for empty files
if len(content.strip()) < 10:
issues.append(f"Empty or near-empty: {md_file.relative_to(scan_dir)}")
# Check for broken internal links
for match in re.finditer(r"\[([^\]]+)\]\(([^)]+)\)", content):
link_path = match.group(2)
if link_path.startswith("http"):
continue
resolved = (md_file.parent / link_path).resolve()
if not resolved.exists():
issues.append(
f"Broken link in {md_file.relative_to(scan_dir)}: {link_path}"
)
# Check INDEX.md exists
if not (scan_dir / "INDEX.md").is_file():
issues.append("Missing INDEX.md")
# Report
console.print(f"\n[bold]Export Verification: {scan_dir}[/bold]")
console.print(f" Files: {total_files}")
console.print(f" Total tokens: {total_tokens:,}")
if issues:
console.print(f"\n[red]Issues ({len(issues)}):[/red]")
for issue in issues[:20]:
console.print(f" - {issue}")
else:
console.print(f"\n[green]All checks passed.[/green]")
click.echo(json.dumps({
"files": total_files,
"total_tokens": total_tokens,
"issues": len(issues),
"issue_details": issues[:20],
}))
@cli.command()
@click.option("--name", required=True, help="Export job name")
@click.option("--type", "export_type", required=True,
type=click.Choice(["project_files", "fine_tuning", "knowledge_base"]))
@click.option("--docs", required=True, type=int, help="Number of documents exported")
@click.option("--path", type=click.Path(), help="Export output path")
@click.option("--tokens", type=int, help="Total tokens exported")
def log(name, export_type, docs, path, tokens):
"""Log an export job to the database."""
with db_session() as db:
export_id = db.insert_returning_id(
"""INSERT INTO export_jobs
(export_name, export_type, output_path, total_documents, total_tokens,
status, started_at, completed_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
(name, export_type, path, docs, tokens,
"completed", datetime.now().isoformat(), datetime.now().isoformat()),
)
console.print(f"[green]Logged export:[/green] export_id={export_id}{name}")
# --- Helpers ---
def _export_nested(rows, doc_topics, out_dir, include_metadata):
"""Export documents in nested topic directory structure."""
exported = 0
total_tokens = 0
topic_docs = defaultdict(list)
for row in rows:
topics = doc_topics.get(row["doc_id"], [])
if topics:
for t in topics:
topic_docs[t["topic_slug"]].append(row)
else:
topic_docs["uncategorized"].append(row)
for topic_slug, docs in sorted(topic_docs.items()):
topic_dir = out_dir / topic_slug
topic_dir.mkdir(parents=True, exist_ok=True)
# Topic index
topic_name = topic_slug.replace("-", " ").title()
index_lines = [f"# {topic_name}\n"]
for i, doc in enumerate(docs):
filename = f"{i:02d}-{slugify(doc.get('title', 'untitled'))}.md"
content = _format_document(doc, include_metadata)
(topic_dir / filename).write_text(content)
index_lines.append(f"- [{doc.get('title', 'Untitled')}]({filename})")
exported += 1
total_tokens += doc.get("token_count_distilled") or count_tokens(content)
(topic_dir / "_index.md").write_text("\n".join(index_lines) + "\n")
return exported, total_tokens
def _export_flat(rows, doc_topics, out_dir, include_metadata):
"""Export documents as flat files."""
exported = 0
total_tokens = 0
for row in rows:
topics = doc_topics.get(row["doc_id"], [])
topic_prefix = topics[0]["topic_slug"] + "-" if topics else ""
filename = f"{topic_prefix}{slugify(row.get('title', 'untitled'))}.md"
content = _format_document(row, include_metadata)
(out_dir / filename).write_text(content)
exported += 1
total_tokens += row.get("token_count_distilled") or count_tokens(content)
return exported, total_tokens
def _format_document(row: dict, include_metadata: bool) -> str:
"""Format a document for export."""
lines = []
if include_metadata:
lines.extend([
f"# {row.get('title', 'Untitled')}",
"",
f"**Source:** {row.get('url', 'N/A')}",
f"**Tier:** {row.get('credibility_tier', 'N/A')} | "
f"**Vendor:** {row.get('vendor', 'N/A')} | "
f"**Score:** {float(row['quality_score']):.2f}" if row.get("quality_score") else "",
"",
"---",
"",
])
content = row.get("structured_content", "")
if content:
lines.append(content)
return "\n".join(lines)
def _extract_md_title(md_file: Path) -> str:
"""Extract title from a markdown file."""
try:
for line in md_file.read_text(errors="replace").split("\n")[:10]:
if line.startswith("# ") and not line.startswith("##"):
return line[2:].strip()
except Exception:
pass
return md_file.stem.replace("-", " ").title()
def _extract_concepts(content: str) -> set[str]:
"""Extract key concepts from markdown content for cross-referencing."""
concepts = set()
# Extract from ## Key Concepts section
m = re.search(r"## Key Concepts?\n(.*?)(?=\n##|\Z)", content, re.DOTALL)
if m:
for line in m.group(1).split("\n"):
line = line.strip().lstrip("- *")
if line and ":" in line:
concept = line.split(":")[0].strip("*").strip()
if 2 < len(concept) < 50:
concepts.add(concept.lower())
# Extract bold terms
for m in re.finditer(r"\*\*([^*]{3,40})\*\*", content):
concepts.add(m.group(1).lower())
return concepts
if __name__ == "__main__":
cli()

View File

@@ -1,6 +1,6 @@
# Pipeline Orchestrator # Pipeline Orchestrator
Coordinates the full 6-skill reference curation workflow with QA loop handling. Coordinates the full 6-skill reference curation workflow with QA loop handling. Manages pipeline state (init, advance, pause, resume, complete) while Claude orchestrates the actual stage execution.
## Trigger Keywords ## Trigger Keywords
"curate references", "full pipeline", "run curation", "reference-curator-pipeline" "curate references", "full pipeline", "run curation", "reference-curator-pipeline"
@@ -22,257 +22,144 @@ Coordinates the full 6-skill reference curation workflow with QA loop handling.
crawler ─┘ crawler ─┘
``` ```
## Input Detection ## Pipeline State Management
Parse input to determine mode: ### Initialize a Run
```bash
```python uv run python scripts/pipeline.py init \
def detect_input_mode(input_value): --input "prompt engineering" --type topic \
if input_value.endswith('.json') and os.path.exists(input_value): --options '{"max_sources": 10, "auto_approve": true}'
return 'manifest'
elif input_value.startswith('http://') or input_value.startswith('https://'):
return 'urls'
else:
return 'topic'
``` ```
## Pipeline Execution ### Advance to Next Stage
```bash
uv run python scripts/pipeline.py advance \
--run-id 1 --stage crawling \
--stats '{"sources_discovered": 8}'
```
### Stage 1: Reference Discovery (Topic Mode Only) ### Pause on Error
```bash
uv run python scripts/pipeline.py pause \
--run-id 1 --error "Crawl timeout on page 45" --stage crawling
```
### Resume from Pause
```bash
uv run python scripts/pipeline.py resume --run-id 1
```
### Complete Pipeline
```bash
uv run python scripts/pipeline.py complete \
--run-id 1 --export-path ~/reference-library/exports/ --export-count 40
```
### Check Status
```bash
uv run python scripts/pipeline.py status --run-id 1
uv run python scripts/pipeline.py status --all
```
## QA Loop Tracking
```bash ```bash
# Skip if input mode is 'urls' or 'manifest' # Track a refactor iteration for a document
if mode == 'topic': uv run python scripts/pipeline.py track-iteration \
/reference-discovery "$TOPIC" --max-sources $MAX_SOURCES --run-id 1 --doc-id 42 --action refactor
# Output: manifest.json
# Track a deep research iteration
uv run python scripts/pipeline.py track-iteration \
--run-id 1 --doc-id 42 --action deep_research
```
Returns one of:
- `re_distill` — proceed with refactor
- `re_crawl_and_distill` — proceed with deep research
- `needs_manual_review` — max iterations exceeded
| Decision | Max Iterations |
|----------|----------------|
| REFACTOR | 3 |
| DEEP_RESEARCH | 2 |
| Combined total | 5 |
## Pipeline Execution Flow
### Stage 1: Reference Discovery (Topic Mode Only)
```
If mode == 'topic':
→ Claude runs WebSearch
→ discover.py create-manifest
→ discover.py dedup
→ pipeline.py advance --stage crawling
``` ```
### Stage 2: Web Crawler ### Stage 2: Web Crawler
```
```bash → Claude uses Firecrawl MCP tools
# From manifest or URLs → crawl_mgr.py store-result
/web-crawler $INPUT --max-pages $MAX_PAGES → pipeline.py advance --stage storing
# Output: crawled files in ~/reference-library/raw/
``` ```
### Stage 3: Content Repository ### Stage 3: Content Repository
```
```bash → repo.py store (for each crawled doc)
/content-repository store → pipeline.py advance --stage evaluating
# Output: documents stored in MySQL or file-based storage
``` ```
### Stage 4: Content Distiller ### Stage 4: Gemini Quality Gate (Pre-Distillation)
```
```bash → reviewer.py gemini-evaluate-pending --topic "$TOPIC" --auto-approve
/content-distiller all-pending → APPROVE: proceed to distillation
# Output: distilled content records → DEEP_RESEARCH: pipeline.py track-iteration → crawler (re-crawl)
→ REJECT: skip document entirely
→ pipeline.py advance --stage distilling
``` ```
### Stage 5: Quality Reviewer ### Stage 5: Content Distiller (Approved Only)
```
```bash → distiller.py load-pending
if auto_approve: → Claude distills each approved document
/quality-reviewer all-pending --auto-approve --threshold $THRESHOLD → distiller.py store
else: → pipeline.py advance --stage exporting
/quality-reviewer all-pending
``` ```
Handle QA decisions:
- **APPROVE**: Add to export queue
- **REFACTOR**: Re-run distiller with feedback (track iteration count)
- **DEEP_RESEARCH**: Run crawler for additional sources, then distill
- **REJECT**: Archive with reason
### Stage 6: Markdown Exporter ### Stage 6: Markdown Exporter
```bash
/markdown-exporter $EXPORT_FORMAT
# Output: files in ~/reference-library/exports/
``` ```
→ exporter.py project
## State Management → exporter.py index
→ exporter.py crossrefs
### Initialize Pipeline State → exporter.py verify
→ pipeline.py complete
```python
def init_pipeline_state(run_id, input_value, options):
state = {
"run_id": run_id,
"run_type": detect_input_mode(input_value),
"input_value": input_value,
"status": "running",
"current_stage": "discovery",
"options": options,
"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
},
"started_at": datetime.now().isoformat()
}
save_state(run_id, state)
return state
```
### MySQL State (Preferred)
```sql
INSERT INTO pipeline_runs (run_type, input_value, options)
VALUES ('topic', 'Claude system prompts', '{"max_sources": 10}');
```
### File-Based Fallback
```
~/reference-library/pipeline_state/run_XXX/
├── state.json # Current stage and stats
├── manifest.json # Discovered sources
├── crawl_results.json # Crawled document paths
├── review_log.json # QA decisions per document
└── errors.log # Any errors encountered
```
## QA Loop Logic
```python
MAX_REFACTOR_ITERATIONS = 3
MAX_DEEP_RESEARCH_ITERATIONS = 2
MAX_TOTAL_ITERATIONS = 5
def handle_qa_decision(doc_id, decision, iteration_counts):
refactor_count = iteration_counts.get('refactor', 0)
research_count = iteration_counts.get('deep_research', 0)
total = refactor_count + research_count
if total >= MAX_TOTAL_ITERATIONS:
return 'needs_manual_review'
if decision == 'refactor':
if refactor_count >= MAX_REFACTOR_ITERATIONS:
return 'needs_manual_review'
iteration_counts['refactor'] = refactor_count + 1
return 're_distill'
if decision == 'deep_research':
if research_count >= MAX_DEEP_RESEARCH_ITERATIONS:
return 'needs_manual_review'
iteration_counts['deep_research'] = research_count + 1
return 're_crawl_and_distill'
return decision # approve or reject
``` ```
## Checkpoint Strategy ## Checkpoint Strategy
Save checkpoint after each stage completes:
| Stage | Checkpoint | Resume Point | | Stage | Checkpoint | Resume Point |
|-------|------------|--------------| |-------|------------|--------------|
| discovery | `manifest.json` created | → crawler | | discovery | manifest.json created | → crawler |
| crawl | `crawl_results.json` | → repository | | crawl | crawl_result.json | → repository |
| store | DB records or file list | → distiller | | store | DB records | → distiller |
| distill | distilled_content records | → reviewer | | distill | distilled_content records | → reviewer |
| review | review_logs records | → exporter or loop | | review | review_logs records | → exporter or loop |
| export | final export complete | Done | | export | final export complete | Done |
## Progress Reporting ## Scripts
Report progress to user at key checkpoints: | Command | Purpose |
|---------|---------|
``` | `pipeline.py init` | Initialize a new pipeline run |
[Pipeline] Stage 1/6: Discovery - Found 8 sources | `pipeline.py advance` | Advance to next stage |
[Pipeline] Stage 2/6: Crawling - 45/50 pages complete | `pipeline.py pause` | Pause on error |
[Pipeline] Stage 3/6: Storing - 45 documents saved | `pipeline.py resume` | Resume from pause |
[Pipeline] Stage 4/6: Distilling - 45 documents processed | `pipeline.py complete` | Mark pipeline complete |
[Pipeline] Stage 5/6: Reviewing - 40 approved, 3 refactored, 2 rejected | `pipeline.py status` | Show run status |
[Pipeline] Stage 6/6: Exporting - 40 documents exported | `pipeline.py track-iteration` | Track QA loop iterations |
[Pipeline] Complete! See ~/reference-library/exports/
```
## Error Handling
```python
def handle_stage_error(stage, error, state):
state['status'] = 'paused'
state['error_message'] = str(error)
state['error_stage'] = stage
save_state(state['run_id'], state)
# Log to errors.log
log_error(state['run_id'], stage, error)
# Report to user
return f"Pipeline paused at {stage}: {error}. Resume with run_id {state['run_id']}"
```
## Resume Pipeline
```python
def resume_pipeline(run_id):
state = load_state(run_id)
if state['status'] != 'paused':
return f"Pipeline {run_id} is {state['status']}, cannot resume"
stage = state['current_stage']
state['status'] = 'running'
state['error_message'] = None
save_state(run_id, state)
# Resume from failed stage
return execute_from_stage(stage, state)
```
## Output Summary
On completion, generate summary:
```json
{
"run_id": 123,
"status": "completed",
"duration_minutes": 15,
"stats": {
"sources_discovered": 5,
"pages_crawled": 45,
"documents_stored": 45,
"documents_distilled": 45,
"approved": 40,
"refactored": 8,
"deep_researched": 2,
"rejected": 3,
"needs_manual_review": 2
},
"exports": {
"format": "project_files",
"path": "~/reference-library/exports/",
"document_count": 40
},
"errors": []
}
```
## Integration Points
| Skill | Called By | Provides |
|-------|-----------|----------|
| reference-discovery | Orchestrator | manifest.json |
| web-crawler | Orchestrator | Raw crawled files |
| content-repository | Orchestrator | Stored documents |
| content-distiller | Orchestrator, QA loop | Distilled content |
| quality-reviewer | Orchestrator | QA decisions |
| markdown-exporter | Orchestrator | Final exports |
## Configuration ## Configuration
Read from `~/.config/reference-curator/pipeline_config.yaml`: Reads from `~/.config/reference-curator/pipeline_config.yaml`:
```yaml ```yaml
pipeline: pipeline:
@@ -288,9 +175,4 @@ qa_loop:
export: export:
default_format: project_files default_format: project_files
include_rejected: false
state:
backend: mysql # or 'file'
state_directory: ~/reference-library/pipeline_state/
``` ```

View File

@@ -0,0 +1,339 @@
#!/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()

View File

@@ -51,6 +51,7 @@ COMMANDS=(
"content-distiller" "content-distiller"
"quality-reviewer" "quality-reviewer"
"markdown-exporter" "markdown-exporter"
"reference-curator-pipeline"
) )
# ============================================================================ # ============================================================================
@@ -541,6 +542,16 @@ check_status() {
print_info "MySQL not configured or client not installed" print_info "MySQL not configured or client not installed"
fi fi
# Check Python package
print_substep "Python Package (refcurator)"
if uv run python3 -c "import refcurator; print(refcurator.__version__)" &>/dev/null; then
local pkg_version=$(uv run python3 -c "import refcurator; print(refcurator.__version__)" 2>/dev/null)
print_success "refcurator $pkg_version installed"
else
print_warning "refcurator package not installed — scripts will not work"
all_ok=false
fi
# Check Claude Code commands # Check Claude Code commands
print_substep "Claude Code Commands ($CLAUDE_COMMANDS_DIR)" print_substep "Claude Code Commands ($CLAUDE_COMMANDS_DIR)"
for cmd in "${COMMANDS[@]}"; do for cmd in "${COMMANDS[@]}"; do
@@ -670,11 +681,43 @@ install() {
setup_database setup_database
fi fi
install_python_package
register_commands register_commands
register_skills register_skills
post_install post_install
} }
# ============================================================================
# Install Python Package
# ============================================================================
install_python_package() {
print_step "Step 5b: Installing Python Package (refcurator)"
print_substep "Checking for uv package manager"
if command -v uv &> /dev/null; then
print_success "uv found: $(uv --version 2>/dev/null)"
else
print_warning "uv not found — install with: curl -LsSf https://astral.sh/uv/install.sh | sh"
print_info "Skipping Python package installation"
return 0
fi
print_substep "Installing refcurator package"
local pkg_dir="$SCRIPT_DIR/shared/lib"
if [[ -f "$pkg_dir/pyproject.toml" ]]; then
if uv pip install -e "$pkg_dir" 2>/dev/null; then
print_success "refcurator package installed (editable mode)"
else
print_warning "Failed to install refcurator — scripts may not work"
print_info "Try manually: uv pip install -e $pkg_dir"
fi
else
print_error "pyproject.toml not found at $pkg_dir"
fi
}
# ============================================================================ # ============================================================================
# Minimal Installation (Firecrawl only) # Minimal Installation (Firecrawl only)
# ============================================================================ # ============================================================================
@@ -712,6 +755,7 @@ EOF
install_configs install_configs
create_directories create_directories
install_python_package
register_commands register_commands
register_skills register_skills
post_install post_install

View File

@@ -0,0 +1,34 @@
[project]
name = "refcurator"
version = "1.0.0"
description = "Reference Curator — data management for the reference curation pipeline"
requires-python = ">=3.12"
license = "MIT"
authors = [
{ name = "Andrew Yim", email = "andrew@ourdigital.org" },
]
dependencies = [
"pymysql>=1.1.0",
"click>=8.0",
"pydantic>=2.0",
"pyyaml>=6.0",
"rich>=13.0",
"python-dotenv>=1.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.4",
]
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.ruff]
line-length = 100
target-version = "py312"

View File

@@ -0,0 +1,3 @@
"""Reference Curator — data management for the reference curation pipeline."""
__version__ = "1.0.0"

View File

@@ -0,0 +1,133 @@
"""Configuration loading for the reference curator pipeline.
Loads YAML configs from ~/.config/reference-curator/ with env var substitution.
Falls back to bundled defaults in shared/config/.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any
import yaml
from dotenv import load_dotenv
# Load user env if present
_env_file = Path.home() / ".reference-curator.env"
if _env_file.is_file():
load_dotenv(_env_file)
# Config search paths (user override → bundled defaults)
USER_CONFIG_DIR = Path.home() / ".config" / "reference-curator"
BUNDLED_CONFIG_DIR = Path(__file__).resolve().parents[4] / "config" # shared/config/
# Default storage paths
DEFAULT_LIBRARY_PATH = Path(
os.environ.get("REFERENCE_LIBRARY_PATH", "~/Documents/reference-library")
).expanduser()
DEFAULT_STATE_DIR = DEFAULT_LIBRARY_PATH / "pipeline_state"
def _expand_env_vars(value: str) -> str:
"""Expand ${VAR:-default} patterns in a string."""
def _replace(match: re.Match) -> str:
var_expr = match.group(1)
if ":-" in var_expr:
var_name, default = var_expr.split(":-", 1)
return os.environ.get(var_name, default)
return os.environ.get(var_expr, match.group(0))
return re.sub(r"\$\{([^}]+)}", _replace, value)
def _expand_recursive(obj: Any) -> Any:
"""Recursively expand env vars in a parsed YAML structure."""
if isinstance(obj, str):
expanded = _expand_env_vars(obj)
# Expand ~ in path-like strings
if expanded.startswith("~"):
expanded = str(Path(expanded).expanduser())
return expanded
if isinstance(obj, dict):
return {k: _expand_recursive(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_expand_recursive(item) for item in obj]
return obj
def load_config(name: str) -> dict:
"""Load a YAML config file by name (without extension).
Searches user config dir first, then bundled defaults.
Expands ${VAR:-default} env var patterns in all string values.
Args:
name: Config file name without .yaml extension
(e.g., "db_config", "pipeline_config", "crawl_config", "export_config")
Returns:
Parsed and expanded config dict.
Raises:
FileNotFoundError: If config file not found in any search path.
"""
filename = f"{name}.yaml"
for config_dir in [USER_CONFIG_DIR, BUNDLED_CONFIG_DIR]:
config_path = config_dir / filename
if config_path.is_file():
with open(config_path) as f:
raw = yaml.safe_load(f) or {}
return _expand_recursive(raw)
raise FileNotFoundError(
f"Config '{filename}' not found in {USER_CONFIG_DIR} or {BUNDLED_CONFIG_DIR}"
)
def get_db_config() -> dict:
"""Load database configuration."""
return load_config("db_config")
def get_pipeline_config() -> dict:
"""Load pipeline orchestrator configuration."""
return load_config("pipeline_config")
def get_crawl_config() -> dict:
"""Load crawler configuration."""
return load_config("crawl_config")
def get_export_config() -> dict:
"""Load export configuration."""
return load_config("export_config")
def get_library_path() -> Path:
"""Get the reference library base path."""
return DEFAULT_LIBRARY_PATH
def get_state_dir() -> Path:
"""Get the pipeline state directory."""
try:
cfg = get_pipeline_config()
state_dir = cfg.get("state", {}).get("state_directory")
if state_dir:
return Path(state_dir).expanduser()
except FileNotFoundError:
pass
return DEFAULT_STATE_DIR
def get_state_backend() -> str:
"""Get the state backend type: 'mysql' or 'file'."""
try:
cfg = get_pipeline_config()
return cfg.get("state", {}).get("backend", "file")
except FileNotFoundError:
return "file"

View File

@@ -0,0 +1,389 @@
"""Database abstraction layer with MySQL and file-based backends.
Usage:
from refcurator.db import get_backend
db = get_backend()
rows = db.fetch_all("SELECT * FROM documents WHERE crawl_status = %s", ("pending",))
doc_id = db.insert_returning_id("INSERT INTO documents (...) VALUES (...)", params)
"""
from __future__ import annotations
import json
import logging
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Optional, Protocol, Sequence
from refcurator.config import get_db_config, get_state_backend, get_state_dir
logger = logging.getLogger("refcurator.db")
class DatabaseBackend(Protocol):
"""Protocol for database backends."""
def execute(self, sql: str, params: Sequence = ()) -> int:
"""Execute a statement, return affected row count."""
...
def fetch_one(self, sql: str, params: Sequence = ()) -> Optional[dict]:
"""Fetch a single row as dict."""
...
def fetch_all(self, sql: str, params: Sequence = ()) -> list[dict]:
"""Fetch all rows as list of dicts."""
...
def insert_returning_id(self, sql: str, params: Sequence = ()) -> int:
"""Insert a row and return the auto-generated ID."""
...
def close(self) -> None:
"""Close the connection."""
...
class MySQLBackend:
"""MySQL backend using PyMySQL."""
def __init__(self, config: dict | None = None):
import pymysql
import pymysql.cursors
if config is None:
config = get_db_config().get("mysql", {})
self._conn = pymysql.connect(
host=config.get("host", "localhost"),
port=int(config.get("port", 3306)),
user=config.get("user", "root"),
password=config.get("password", ""),
database=config.get("database", "reference_library"),
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
)
def execute(self, sql: str, params: Sequence = ()) -> int:
with self._conn.cursor() as cur:
return cur.execute(sql, params)
def fetch_one(self, sql: str, params: Sequence = ()) -> Optional[dict]:
with self._conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchone()
def fetch_all(self, sql: str, params: Sequence = ()) -> list[dict]:
with self._conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchall()
def insert_returning_id(self, sql: str, params: Sequence = ()) -> int:
with self._conn.cursor() as cur:
cur.execute(sql, params)
return cur.lastrowid
def close(self) -> None:
self._conn.close()
class FileBackend:
"""JSON file-based backend for use without MySQL.
Stores data as JSON arrays in the state directory.
Supports basic CRUD but not complex queries or JOINs.
"""
def __init__(self, state_dir: Path | None = None):
self._dir = state_dir or get_state_dir()
self._dir.mkdir(parents=True, exist_ok=True)
self._cache: dict[str, list[dict]] = {}
self._counters: dict[str, int] = {}
self._load_counters()
def _table_path(self, table: str) -> Path:
return self._dir / f"{table}.json"
def _load_table(self, table: str) -> list[dict]:
if table not in self._cache:
path = self._table_path(table)
if path.is_file():
self._cache[table] = json.loads(path.read_text())
else:
self._cache[table] = []
return self._cache[table]
def _save_table(self, table: str) -> None:
path = self._table_path(table)
path.write_text(json.dumps(self._cache.get(table, []), indent=2, default=str))
def _load_counters(self) -> None:
counter_path = self._dir / "_counters.json"
if counter_path.is_file():
self._counters = json.loads(counter_path.read_text())
def _save_counters(self) -> None:
counter_path = self._dir / "_counters.json"
counter_path.write_text(json.dumps(self._counters, indent=2))
def _next_id(self, table: str) -> int:
current = self._counters.get(table, 0)
self._counters[table] = current + 1
self._save_counters()
return current + 1
# --- Protocol methods ---
# These provide basic support for the most common operations.
# Complex SQL is not supported; use MySQL for full functionality.
def execute(self, sql: str, params: Sequence = ()) -> int:
"""Basic INSERT/UPDATE/DELETE support via SQL pattern matching."""
sql_lower = sql.strip().lower()
table = _extract_table_name(sql)
if sql_lower.startswith("insert"):
return self._handle_insert(table, sql, params)
elif sql_lower.startswith("update"):
return self._handle_update(table, sql, params)
elif sql_lower.startswith("delete"):
return self._handle_delete(table, sql, params)
logger.warning("FileBackend: unsupported SQL operation: %s", sql[:60])
return 0
def fetch_one(self, sql: str, params: Sequence = ()) -> Optional[dict]:
rows = self.fetch_all(sql, params)
return rows[0] if rows else None
def fetch_all(self, sql: str, params: Sequence = ()) -> list[dict]:
table = _extract_table_name(sql)
if not table:
logger.warning("FileBackend: cannot extract table from: %s", sql[:60])
return []
# Hard-fail on SQL patterns that FileBackend cannot handle correctly
_reject_unsupported_sql(sql)
rows = self._load_table(table)
# Basic WHERE clause filtering
conditions = _extract_where_conditions(sql, params)
if conditions:
rows = [r for r in rows if _matches_conditions(r, conditions)]
# Basic ORDER BY
order_col = _extract_order_by(sql)
if order_col:
desc = "desc" in sql.lower().split("order by")[-1].lower()
rows = sorted(rows, key=lambda r: r.get(order_col, ""), reverse=desc)
# Basic LIMIT
limit = _extract_limit(sql)
if limit is not None:
rows = rows[:limit]
return rows
def insert_returning_id(self, sql: str, params: Sequence = ()) -> int:
table = _extract_table_name(sql)
self._handle_insert(table, sql, params)
return self._counters.get(table, 0)
def close(self) -> None:
pass # No connection to close
# --- Internal handlers ---
def _handle_insert(self, table: str, sql: str, params: Sequence) -> int:
columns = _extract_insert_columns(sql)
if not columns or len(columns) != len(params):
logger.warning("FileBackend: column/param mismatch for INSERT into %s", table)
return 0
row = dict(zip(columns, params))
pk = _primary_key_for(table)
if pk and pk not in row:
row[pk] = self._next_id(table)
rows = self._load_table(table)
rows.append(row)
self._cache[table] = rows
self._save_table(table)
return 1
def _handle_update(self, table: str, sql: str, params: Sequence) -> int:
rows = self._load_table(table)
set_cols = _extract_set_columns(sql)
conditions = _extract_where_conditions(sql, params[len(set_cols):])
set_values = list(params[:len(set_cols)])
count = 0
for row in rows:
if _matches_conditions(row, conditions):
for col, val in zip(set_cols, set_values):
row[col] = val
count += 1
if count > 0:
self._save_table(table)
return count
def _handle_delete(self, table: str, sql: str, params: Sequence) -> int:
rows = self._load_table(table)
conditions = _extract_where_conditions(sql, params)
before = len(rows)
self._cache[table] = [r for r in rows if not _matches_conditions(r, conditions)]
self._save_table(table)
return before - len(self._cache[table])
class UnsupportedQueryError(Exception):
"""Raised when FileBackend encounters SQL it cannot handle correctly."""
pass
def _reject_unsupported_sql(sql: str) -> None:
"""Raise UnsupportedQueryError if the SQL uses patterns FileBackend cannot handle.
FileBackend only supports single-table SELECT with simple WHERE col = %s.
JOINs, subqueries, aggregates, and GROUP BY would return wrong results silently.
"""
import re
sql_upper = sql.upper()
unsupported = []
if re.search(r"\bJOIN\b", sql_upper):
unsupported.append("JOIN")
if re.search(r"\bGROUP\s+BY\b", sql_upper):
unsupported.append("GROUP BY")
if re.search(r"\b(MAX|MIN|SUM|AVG|COUNT)\s*\(", sql_upper):
unsupported.append("aggregate functions")
if re.search(r"\bLEFT\s+JOIN\b", sql_upper):
unsupported.append("LEFT JOIN")
if re.search(r"\(\s*SELECT\b", sql_upper):
unsupported.append("subquery")
if unsupported:
raise UnsupportedQueryError(
f"FileBackend cannot execute queries with {', '.join(unsupported)}. "
f"Configure MySQL or use 'backend: mysql' in pipeline_config.yaml. "
f"Query: {sql[:80]}..."
)
# --- SQL parsing helpers (minimal, covers common patterns) ---
def _extract_table_name(sql: str) -> str:
"""Extract the primary table name from a SQL statement."""
import re
sql_clean = sql.strip()
# INSERT INTO table
m = re.search(r"(?:insert\s+into|update|delete\s+from|from)\s+(\w+)", sql_clean, re.I)
if m:
return m.group(1)
return ""
def _extract_insert_columns(sql: str) -> list[str]:
"""Extract column names from INSERT INTO table (col1, col2, ...)."""
import re
m = re.search(r"\(([^)]+)\)\s*VALUES", sql, re.I)
if m:
return [c.strip() for c in m.group(1).split(",")]
return []
def _extract_set_columns(sql: str) -> list[str]:
"""Extract column names from UPDATE ... SET col1 = %s, col2 = %s."""
import re
m = re.search(r"SET\s+(.+?)(?:\s+WHERE|$)", sql, re.I | re.S)
if m:
return [c.strip().split("=")[0].strip() for c in m.group(1).split(",")]
return []
def _extract_where_conditions(sql: str, params: Sequence) -> list[tuple[str, Any]]:
"""Extract simple col = %s conditions from WHERE clause."""
import re
m = re.search(r"WHERE\s+(.+?)(?:\s+ORDER|\s+LIMIT|$)", sql, re.I | re.S)
if not m:
return []
where_clause = m.group(1)
cols = re.findall(r"(\w+)\s*=\s*%s", where_clause)
# Map to params (taking from the end of params for UPDATE, all for SELECT)
return list(zip(cols, params[-len(cols):] if cols else []))
def _extract_order_by(sql: str) -> str:
"""Extract the first ORDER BY column."""
import re
m = re.search(r"ORDER\s+BY\s+(\w+)", sql, re.I)
return m.group(1) if m else ""
def _extract_limit(sql: str) -> int | None:
"""Extract LIMIT value."""
import re
m = re.search(r"LIMIT\s+(\d+)", sql, re.I)
return int(m.group(1)) if m else None
def _matches_conditions(row: dict, conditions: list[tuple[str, Any]]) -> bool:
"""Check if a row matches all WHERE conditions."""
return all(str(row.get(col)) == str(val) for col, val in conditions)
def _primary_key_for(table: str) -> str:
"""Return the auto-increment primary key name for a table."""
pk_map = {
"sources": "source_id",
"documents": "doc_id",
"distilled_content": "distill_id",
"review_logs": "review_id",
"topics": "topic_id",
"export_jobs": "export_id",
"pipeline_runs": "run_id",
"pipeline_iteration_tracker": "tracker_id",
"crawl_schedule": "schedule_id",
"change_detection": "change_id",
}
return pk_map.get(table, "")
# --- Factory ---
def get_backend(backend_type: str | None = None) -> DatabaseBackend:
"""Create and return the appropriate database backend.
Args:
backend_type: 'mysql' or 'file'. If None, reads from pipeline config.
Returns:
A DatabaseBackend instance.
"""
if backend_type is None:
backend_type = get_state_backend()
if backend_type == "mysql":
return MySQLBackend()
return FileBackend()
@contextmanager
def db_session(backend_type: str | None = None):
"""Context manager for database sessions.
Usage:
with db_session() as db:
rows = db.fetch_all("SELECT * FROM documents")
"""
db = get_backend(backend_type)
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,182 @@
"""Gemini CLI wrapper for independent content quality evaluation.
Uses the Gemini CLI (google/gemini-cli) to evaluate raw crawled content
before distillation, providing third-party quality assessment.
Requires: `npm install -g @google/gemini-cli` and Google auth configured.
"""
from __future__ import annotations
import json
import logging
import re
import subprocess
from typing import Optional
logger = logging.getLogger("refcurator.gemini")
GEMINI_CMD = "gemini"
TIMEOUT_SECONDS = 60
EVALUATION_PROMPT = """You are evaluating a raw reference document for inclusion in a curated knowledge base.
Topic: {topic}
Source URL: {source_url}
Score each criterion from 0.0 to 1.0:
- relevance: Does this content actually relate to the topic "{topic}"?
- authority: Is this from an authoritative, official source (official docs, research paper) or low-quality (scraped blog, forum post, SEO spam)?
- completeness: Is this a complete article with substance, or a navigation fragment, error page, stub, or boilerplate?
- freshness: Does the information appear current and not outdated? Look for version numbers, dates, deprecated APIs.
- distill_value: Does this contain unique, valuable information worth summarizing, or is it redundant with what official docs already cover?
Return ONLY a valid JSON object with no markdown formatting, no code fences, no explanation:
{{"relevance": 0.0, "authority": 0.0, "completeness": 0.0, "freshness": 0.0, "distill_value": 0.0, "verdict": "approve", "reason": "brief explanation"}}
The verdict must be one of: "approve", "reject", "deep_research"
- approve: source is worth distilling (score >= 0.75 typical)
- reject: not worth distilling (low quality, irrelevant, or fragment)
- deep_research: partially relevant but needs supplementary sources"""
def is_available() -> bool:
"""Check if the Gemini CLI is installed and authenticated."""
try:
result = subprocess.run(
[GEMINI_CMD, "--help"],
capture_output=True, timeout=10,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def evaluate_content(
content: str,
topic: str,
source_url: str = "",
timeout: int = TIMEOUT_SECONDS,
) -> Optional[dict]:
"""Evaluate raw content using Gemini CLI.
Args:
content: Raw document content (markdown/text)
topic: The curation topic for relevance scoring
source_url: Original URL of the content
timeout: Subprocess timeout in seconds
Returns:
Parsed evaluation dict with scores, verdict, and reason.
Returns None if Gemini is unavailable or evaluation fails.
"""
# Truncate very long content to avoid overwhelming the model
max_chars = 50_000
if len(content) > max_chars:
content = content[:max_chars] + "\n\n[... content truncated for evaluation ...]"
prompt = EVALUATION_PROMPT.format(topic=topic, source_url=source_url)
try:
result = subprocess.run(
[GEMINI_CMD, prompt],
input=content,
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode != 0:
logger.warning("Gemini CLI failed (exit %d): %s", result.returncode, result.stderr[:200])
return None
return _parse_response(result.stdout)
except FileNotFoundError:
logger.warning("Gemini CLI not found. Install with: npm install -g @google/gemini-cli")
return None
except subprocess.TimeoutExpired:
logger.warning("Gemini CLI timed out after %ds", timeout)
return None
except Exception as e:
logger.warning("Gemini evaluation failed: %s", e)
return None
def _parse_response(output: str) -> Optional[dict]:
"""Parse Gemini's response, handling markdown-wrapped JSON."""
text = output.strip()
# Try direct JSON parse first
try:
return _validate_evaluation(json.loads(text))
except json.JSONDecodeError:
pass
# Try extracting JSON from markdown code fences
m = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
if m:
try:
return _validate_evaluation(json.loads(m.group(1).strip()))
except json.JSONDecodeError:
pass
# Try finding a JSON object anywhere in the output
m = re.search(r"\{[^{}]*\"relevance\"[^{}]*\}", text, re.DOTALL)
if m:
try:
return _validate_evaluation(json.loads(m.group(0)))
except json.JSONDecodeError:
pass
logger.warning("Could not parse Gemini response as JSON: %s", text[:200])
return None
def _validate_evaluation(data: dict) -> Optional[dict]:
"""Validate that the evaluation has required fields and reasonable values."""
required_scores = ["relevance", "authority", "completeness", "freshness", "distill_value"]
for field in required_scores:
if field not in data:
logger.warning("Missing required field: %s", field)
return None
score = data[field]
if not isinstance(score, (int, float)) or score < 0 or score > 1:
logger.warning("Invalid score for %s: %s", field, score)
return None
if "verdict" not in data or data["verdict"] not in ("approve", "reject", "deep_research"):
data["verdict"] = _derive_verdict(data)
if "reason" not in data:
data["reason"] = ""
# Add weighted score
data["weighted_score"] = round(
data["relevance"] * 0.25
+ data["authority"] * 0.25
+ data["completeness"] * 0.20
+ data["freshness"] * 0.15
+ data["distill_value"] * 0.15,
4,
)
return data
def _derive_verdict(data: dict) -> str:
"""Derive verdict from scores if Gemini didn't provide one."""
score = (
data.get("relevance", 0) * 0.25
+ data.get("authority", 0) * 0.25
+ data.get("completeness", 0) * 0.20
+ data.get("freshness", 0) * 0.15
+ data.get("distill_value", 0) * 0.15
)
if score >= 0.75:
return "approve"
elif score >= 0.50:
return "deep_research"
else:
return "reject"

View File

@@ -0,0 +1,90 @@
"""Manifest I/O for reference discovery and crawl results."""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from refcurator.models import CrawlResult, CrawlResultEntry, Manifest, ManifestURL
from refcurator.utils import normalize_url
def read_manifest(path: Path) -> Manifest:
"""Read a manifest JSON file."""
data = json.loads(path.read_text())
return Manifest(**data)
def write_manifest(manifest: Manifest, path: Path) -> None:
"""Write a manifest to a JSON file."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(manifest.model_dump_json(indent=2))
def merge_manifests(manifests: list[Manifest]) -> Manifest:
"""Merge multiple manifests, deduplicating URLs."""
seen: dict[str, ManifestURL] = {}
topic_parts = []
for m in manifests:
if m.topic:
topic_parts.append(m.topic)
for url_entry in m.urls:
normalized = normalize_url(url_entry.url)
existing = seen.get(normalized)
if existing is None or (
url_entry.credibility_score
and (existing.credibility_score or 0) < url_entry.credibility_score
):
seen[normalized] = url_entry
urls = list(seen.values())
return Manifest(
discovery_date=datetime.now().isoformat(),
topic=" + ".join(topic_parts) if topic_parts else None,
total_urls=len(urls),
urls=urls,
)
def dedup_manifest_urls(manifest: Manifest, existing_urls: set[str]) -> Manifest:
"""Remove URLs already in the existing set (normalized comparison)."""
existing_normalized = {normalize_url(u) for u in existing_urls}
filtered = [u for u in manifest.urls if normalize_url(u.url) not in existing_normalized]
return Manifest(
discovery_date=manifest.discovery_date,
topic=manifest.topic,
total_urls=len(filtered),
urls=filtered,
)
def read_crawl_result(path: Path) -> CrawlResult:
"""Read a crawl result JSON file."""
data = json.loads(path.read_text())
return CrawlResult(**data)
def write_crawl_result(result: CrawlResult, path: Path) -> None:
"""Write a crawl result to a JSON file."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(result.model_dump_json(indent=2))
def create_crawl_result(
entries: list[dict],
crawler: str = "firecrawl",
) -> CrawlResult:
"""Create a CrawlResult from a list of crawl entry dicts."""
docs = [CrawlResultEntry(**e) for e in entries]
completed = [d for d in docs if d.status == "completed"]
failed = [d for d in docs if d.status != "completed"]
return CrawlResult(
crawl_date=datetime.now().isoformat(),
crawler_used=crawler,
total_crawled=len(completed),
total_failed=len(failed),
documents=docs,
)

View File

@@ -0,0 +1,355 @@
"""Pydantic v2 models matching the reference_library MySQL schema."""
from __future__ import annotations
from datetime import date, datetime
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, Field, computed_field
# --- Enums matching MySQL ENUMs ---
class SourceType(str, Enum):
official_docs = "official_docs"
engineering_blog = "engineering_blog"
research_paper = "research_paper"
github_repo = "github_repo"
community_guide = "community_guide"
pdf_document = "pdf_document"
api_reference = "api_reference"
class CredibilityTier(str, Enum):
tier1_official = "tier1_official"
tier2_verified = "tier2_verified"
tier3_community = "tier3_community"
class DocType(str, Enum):
webpage = "webpage"
pdf = "pdf"
markdown = "markdown"
api_spec = "api_spec"
code_sample = "code_sample"
class Language(str, Enum):
en = "en"
ko = "ko"
mixed = "mixed"
class CrawlMethod(str, Enum):
firecrawl = "firecrawl"
scrapy = "scrapy"
aiohttp = "aiohttp"
nodejs = "nodejs"
manual = "manual"
api = "api"
class CrawlStatus(str, Enum):
pending = "pending"
completed = "completed"
failed = "failed"
stale = "stale"
class ReviewStatus(str, Enum):
pending = "pending"
in_review = "in_review"
approved = "approved"
needs_refactor = "needs_refactor"
rejected = "rejected"
class ReviewerType(str, Enum):
auto_qa = "auto_qa"
human = "human"
claude_review = "claude_review"
gemini_review = "gemini_review"
class Decision(str, Enum):
approve = "approve"
refactor = "refactor"
deep_research = "deep_research"
reject = "reject"
class PipelineStatus(str, Enum):
running = "running"
completed = "completed"
failed = "failed"
paused = "paused"
class PipelineStage(str, Enum):
discovery = "discovery"
crawling = "crawling"
storing = "storing"
evaluating = "evaluating"
distilling = "distilling"
exporting = "exporting"
class RunType(str, Enum):
topic = "topic"
urls = "urls"
manifest = "manifest"
class ExportType(str, Enum):
project_files = "project_files"
fine_tuning = "fine_tuning"
training_dataset = "training_dataset"
knowledge_base = "knowledge_base"
class OutputFormat(str, Enum):
markdown = "markdown"
jsonl = "jsonl"
parquet = "parquet"
sqlite = "sqlite"
class Frequency(str, Enum):
daily = "daily"
weekly = "weekly"
biweekly = "biweekly"
monthly = "monthly"
on_demand = "on_demand"
class ChangeType(str, Enum):
content_updated = "content_updated"
url_moved = "url_moved"
deleted = "deleted"
new_version = "new_version"
class FinalDecision(str, Enum):
approved = "approved"
rejected = "rejected"
needs_manual_review = "needs_manual_review"
# --- Core Table Models ---
class Source(BaseModel):
source_id: Optional[int] = None
source_name: str
source_type: SourceType
base_url: Optional[str] = None
credibility_tier: CredibilityTier = CredibilityTier.tier3_community
vendor: Optional[str] = None
is_active: bool = True
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Document(BaseModel):
doc_id: Optional[int] = None
source_id: int
title: str
url: Optional[str] = None
url_hash: Optional[str] = None # Generated column in MySQL
doc_type: DocType
language: Language = Language.en
original_publish_date: Optional[date] = None
last_modified_date: Optional[date] = None
crawl_date: Optional[datetime] = None
crawl_method: CrawlMethod = CrawlMethod.firecrawl
crawl_status: CrawlStatus = CrawlStatus.pending
raw_content_path: Optional[str] = None
raw_content_size: Optional[int] = None
version: int = 1
previous_version_id: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class DistilledContent(BaseModel):
distill_id: Optional[int] = None
doc_id: int
summary: Optional[str] = None
key_concepts: Optional[list[dict[str, Any]]] = None
code_snippets: Optional[list[dict[str, Any]]] = None
structured_content: Optional[str] = None
token_count_original: Optional[int] = None
token_count_distilled: Optional[int] = None
distill_model: Optional[str] = None
distill_date: Optional[datetime] = None
review_status: ReviewStatus = ReviewStatus.pending
@computed_field
@property
def compression_ratio(self) -> Optional[float]:
if self.token_count_original and self.token_count_distilled:
return round(self.token_count_distilled / self.token_count_original * 100, 2)
return None
class ReviewLog(BaseModel):
review_id: Optional[int] = None
distill_id: int
review_round: int = 1
reviewer_type: ReviewerType
quality_score: Optional[float] = None
assessment: Optional[dict[str, float]] = None
decision: Decision
feedback: Optional[str] = None
refactor_instructions: Optional[str] = None
research_queries: Optional[list[str]] = None
reviewed_at: Optional[datetime] = None
class Topic(BaseModel):
topic_id: Optional[int] = None
topic_name: str
topic_slug: str
parent_topic_id: Optional[int] = None
description: Optional[str] = None
class DocumentTopic(BaseModel):
doc_id: int
topic_id: int
relevance_score: float = 1.0
class ExportJob(BaseModel):
export_id: Optional[int] = None
export_name: str
export_type: ExportType
output_format: OutputFormat = OutputFormat.markdown
topic_filter: Optional[list[int]] = None
date_range_start: Optional[date] = None
date_range_end: Optional[date] = None
min_quality_score: float = 0.80
output_path: Optional[str] = None
total_documents: Optional[int] = None
total_tokens: Optional[int] = None
status: str = "pending"
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
error_message: Optional[str] = None
created_at: Optional[datetime] = None
class PipelineRun(BaseModel):
run_id: Optional[int] = None
run_type: RunType
input_value: str
status: PipelineStatus = PipelineStatus.running
current_stage: PipelineStage = PipelineStage.discovery
options: Optional[dict[str, Any]] = None
stats: Optional[dict[str, int]] = Field(default_factory=lambda: {
"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,
})
export_path: Optional[str] = None
export_document_count: Optional[int] = None
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
error_message: Optional[str] = None
error_stage: Optional[str] = None
class PipelineIterationTracker(BaseModel):
tracker_id: Optional[int] = None
run_id: int
doc_id: int
refactor_count: int = 0
deep_research_count: int = 0
final_decision: Optional[FinalDecision] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# --- Non-DB Models (manifest/crawl/assessment) ---
class ManifestURL(BaseModel):
url: str
title: Optional[str] = None
credibility_tier: Optional[str] = None
credibility_score: Optional[float] = None
source_type: Optional[str] = None
vendor: Optional[str] = None
class Manifest(BaseModel):
discovery_date: Optional[str] = None
topic: Optional[str] = None
total_urls: int = 0
urls: list[ManifestURL] = Field(default_factory=list)
class CrawlResultEntry(BaseModel):
url: str
title: Optional[str] = None
raw_path: str
content_size: int = 0
status: str = "completed"
error: Optional[str] = None
class CrawlResult(BaseModel):
crawl_date: Optional[str] = None
crawler_used: str = "firecrawl"
total_crawled: int = 0
total_failed: int = 0
documents: list[CrawlResultEntry] = Field(default_factory=list)
class QAAssessment(BaseModel):
"""Legacy model for post-distillation Claude self-review (deprecated)."""
accuracy: float = 0.0
completeness: float = 0.0
clarity: float = 0.0
prompt_engineering_quality: float = 0.0
usability: float = 0.0
@computed_field
@property
def weighted_score(self) -> float:
return round(
self.accuracy * 0.25
+ self.completeness * 0.20
+ self.clarity * 0.20
+ self.prompt_engineering_quality * 0.25
+ self.usability * 0.10,
4,
)
class SourceQAAssessment(BaseModel):
"""Pre-distillation source quality assessment via Gemini."""
relevance: float = 0.0
authority: float = 0.0
completeness: float = 0.0
freshness: float = 0.0
distill_value: float = 0.0
verdict: str = ""
reason: str = ""
@computed_field
@property
def weighted_score(self) -> float:
return round(
self.relevance * 0.25
+ self.authority * 0.25
+ self.completeness * 0.20
+ self.freshness * 0.15
+ self.distill_value * 0.15,
4,
)

View File

@@ -0,0 +1,75 @@
"""Common utilities for the reference curator pipeline."""
from __future__ import annotations
import hashlib
import logging
import re
import unicodedata
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
def normalize_url(url: str) -> str:
"""Normalize a URL for deduplication.
- Lowercase scheme and host
- Remove trailing slashes
- Sort query parameters
- Remove common tracking params (utm_*, ref, fbclid)
- Remove fragment
"""
parsed = urlparse(url)
scheme = parsed.scheme.lower()
netloc = parsed.netloc.lower()
path = parsed.path.rstrip("/") or "/"
# Sort query params, removing tracking params
tracking_params = {"utm_source", "utm_medium", "utm_campaign", "utm_content",
"utm_term", "ref", "fbclid", "gclid", "mc_cid", "mc_eid"}
params = parse_qs(parsed.query, keep_blank_values=True)
filtered = {k: v for k, v in sorted(params.items()) if k not in tracking_params}
query = urlencode(filtered, doseq=True)
return urlunparse((scheme, netloc, path, "", query, ""))
def url_hash(url: str) -> str:
"""SHA-256 hash of normalized URL. Matches the url_hash column in schema.sql."""
return hashlib.sha256(normalize_url(url).encode()).hexdigest()
def slugify(text: str) -> str:
"""Convert text to a URL/folder-friendly slug.
>>> slugify("Prompt Engineering Best Practices")
'prompt-engineering-best-practices'
"""
text = unicodedata.normalize("NFKD", text)
text = text.encode("ascii", "ignore").decode()
text = text.lower()
text = re.sub(r"[^a-z0-9]+", "-", text)
text = text.strip("-")
return text or "untitled"
def count_tokens(text: str) -> int:
"""Approximate token count using chars/4 heuristic.
Good enough for compression ratio calculations without requiring tiktoken.
"""
return max(1, len(text) // 4)
def setup_logging(level: str = "INFO", run_id: int | None = None) -> logging.Logger:
"""Configure and return a logger for the reference curator pipeline."""
logger = logging.getLogger("refcurator")
if not logger.handlers:
handler = logging.StreamHandler()
fmt = "[refcurator]"
if run_id:
fmt += f" [run:{run_id}]"
fmt += " %(levelname)s: %(message)s"
handler.setFormatter(logging.Formatter(fmt))
logger.addHandler(handler)
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
return logger