Pipeline Progress Check
SkillMediaCheck pipeline processing progress, all phases including OCR, translation, image extraction, enrichment, and more. Shows real page-level verification, not just job counters. Use when asked "how's it going?", "progress?", "status?", "now?", or any progress check.
Use Pipeline Progress Check in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add Pipeline Progress Check and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the Pipeline Progress Check skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; Ahel provides instructions and does not run this skill.
No other account needed.
Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
What this skill tells your AI
The instructions your AI receives, as published by embassy-of-the-free-mind/sourcelibrary-v2 in .claude/skills/progress/SKILL.md and read by Ahel’s review.
Report on processing progress with verified page-level data across ALL pipeline phases. Job counters lie — always cross-reference against actual page records.
Also remind the user: full pipeline dashboard is live at https://sourcelibrary.org/analytics (Pipeline tab).
Critical Diagnostic Rules
- Never trust job counters alone. A job showing
status: 'processing'withprogress.completed: 0for hours is STUCK, not "in progress". - Never attribute global throughput to a specific operation. Translation throughput from
pages.translation.updated_atincludes ALL sources (pipeline cron, Lambda workers, backfill jobs). Filter by book IDs to attribute correctly. - Spot-check actual pages. For any "processing" job, check if its pages actually have
translation.dataorocr.data. This is ground truth. - Flag stale jobs explicitly. Any job in
processingfor >2 hours with 0 progress is stuck. Say so clearly.
Snapshot-First Approach (PREFERRED)
A pre-computed snapshot exists at system_config._id: 'enrichment_snapshot' (updated every 2h by Hetzner cron). Always try this first — it's a single document read vs 15+ slow aggregations.
const snapshot = await db.collection('system_config').findOne({ _id: 'enrichment_snapshot' });
If the snapshot exists and computed_at is within the last 3 hours, use it directly. Display the computed_at timestamp so the user knows how fresh the data is.
The snapshot contains: funnel, enrichment, milestones, gallery, active_jobs, stuck_jobs, throughput, paused, paused_phases, and computation_ms.
Only fall back to live queries if:
- The snapshot doesn't exist
- The snapshot is older than 3 hours
- The user explicitly asks for "live" or "fresh" data
For live queries, prefer running the enrichment-snapshot script directly (node scripts/workers/enrichment-snapshot.mjs) rather than inline queries — it writes the snapshot and you can read it back.
What to Check (Live Fallback)
Run a single MongoDB script that reports ALL of the following. Use set -a; source .env.production.local; set +a; node -e "..." to run.
1. Pipeline Funnel (bird's eye)
// Pipeline status distribution — full lifecycle
const funnel = await db.collection('books').aggregate([
{ $match: { 'pipeline_auto.status': { $exists: true } } },
{ $group: { _id: '$pipeline_auto.status', count: { $sum: 1 } } },
]).toArray();
IMPORTANT: Display in lifecycle order, not sorted by count. Use this fixed order (skip stages with 0 books):
queued → archiving → archive_complete → ocr_submitted → ocr_complete →
metadata_enriched → ft_verifying → ft_verified → translate_submitted →
translate_complete → enriching → enriched → chapters → chapters_complete →
images_submitted → images_complete → complete
Then separately: needs_attention, failed, plus any other statuses (e.g. empty_shell, paused, replaced_by_*).
This matches the Pipeline Funnel chart in the /analytics dashboard.
2. Enrichment Coverage (all phases)
// Single aggregation for all enrichment phases
const enrichment = await db.collection('books').aggregate([
{ $match: { status: { $ne: 'deleted' }, pages_count: { $gt: 0 } } },
{ $group: {
_id: null,
total: { $sum: 1 },
has_ocr: { $sum: { $cond: [{ $gt: ['$pages_ocr', 0] }, 1, 0] } },
has_translation: { $sum: { $cond: [{ $gt: ['$pages_translated', 0] }, 1, 0] } },
has_metadata: { $sum: { $cond: [{ $ifNull: ['$ai_metadata.enriched_at', false] }, 1, 0] } },
has_ft_verification: { $sum: { $cond: [{ $ifNull: ['$translation_verification.verified_at', false] }, 1, 0] } },
has_summary: { $sum: { $cond: [{ $ifNull: ['$index.generatedAt', false] }, 1, 0] } },
has_chapters: { $sum: { $cond: [{ $ifNull: ['$chapters', false] }, 1, 0] } },
has_collections: { $sum: { $cond: [{ $ifNull: ['$collection_scores', false] }, 1, 0] } },
has_quality_score: { $sum: { $cond: [{ $ifNull: ['$quality_score', false] }, 1, 0] } },
has_faceted_tags: { $sum: { $cond: [{ $ifNull: ['$faceted_tags', false] }, 1, 0] } },
has_author_entity: { $sum: { $cond: [{ $ifNull: ['$author_entity_id', false] }, 1, 0] } },
fully_translated: { $sum: { $cond: [{ $and: [{ $gt: ['$pages_translated', 0] }, { $gte: ['$pages_translated', { $subtract: [{ $ifNull: ['$pages_ocr', 0] }, { $ifNull: ['$pages_blank', 0] }] }] }] }, 1, 0] } },
pipeline_complete: { $sum: { $cond: [{ $eq: ['$pipeline_auto.status', 'complete'] }, 1, 0] } },
}}
]).toArray();
2b. First Translation Milestones
// First translations into English (root-level boolean, set by Stage 2 verification)
const firstTranslations = await db.collection('books').countDocuments({
is_first_translation: true
});
console.log(`First translations into English: ${firstTranslations}`);
// >90% translated books
const near90 = await db.collection('books').countDocuments({
status: { $ne: 'deleted' },
pages_count: { $gt: 0 },
pages_translated: { $gt: 0 },
$expr: { $gte: ['$pages_translated', { $multiply: [{ $subtract: [{ $ifNull: ['$pages_ocr', 0] }, { $ifNull: ['$pages_blank', 0] }] }, 0.9] }] }
});
console.log(`>90% translated: ${near90}`);
IMPORTANT: The first-translation flag is is_first_translation at the book root level (NOT translation_verification.is_first_translation). It's a boolean derived from the translation_verification.disposition field — true for confirmed_first, first_complete_translation, and first_modern_translation.
3. Image Extraction & Gallery
// Books with extracted images — use gallery_images (70K docs), NOT pages.distinct (times out on Atlas)
const booksWithImages = await db.collection('gallery_images').distinct('book_id');
console.log(`Books with extracted images: ${booksWithImages.length}`);
// Gallery images count
const galleryCount = await db.collection('gallery_images').estimatedDocumentCount();
console.log(`Gallery images: ${galleryCount}`);
// Image extraction jobs
const imageJobs = await db.collection('jobs').aggregate([
{ $match: { type: 'image_extraction', status: { $in: ['pending', 'processing'] } } },
{ $group: { _id: '$status', count: { $sum: 1 }, pages: { $sum: '$progress.total' }, done: { $sum: '$progress.completed' } } }
]).toArray();
4. Active Jobs by Type (all types)
const activeJobs = await db.collection('jobs').aggregate([
{ $match: { status: { $in: ['pending', 'processing'] } } },
{ $group: {
_id: { type: '$type', note: '$note' },
count: { $sum: 1 },
totalPages: { $sum: '$progress.total' },
completedPages: { $sum: '$progress.completed' },
failedPages: { $sum: '$progress.failed' },
oldest: { $min: '$created_at' }
}}
]).toArray();
5. Stuck Job Detection (CRITICAL)
const twoHoursAgo = new Date(Date.now() - 2 * 3600000);
const stuckJobs = await db.collection('jobs').countDocuments({
status: 'processing',
'progress.completed': 0,
created_at: { $lt: twoHoursAgo }
});
// If stuckJobs > 0, these are DEAD. Report them prominently.
6. Real Throughput (page-level verification)
// Translation throughput - last 1h, 3h, 6h
for (const hours of [1, 3, 6]) {
const since = new Date(Date.now() - hours * 3600000);
const count = await db.collection('pages').countDocuments({
'translation.updated_at': { $gte: since }
});
console.log(`Translations last ${hours}h: ${count} (${Math.round(count/hours)}/hr)`);
}
// OCR throughput
for (const hours of [1, 3, 6]) {
const since = new Date(Date.now() - hours * 3600000);
const count = await db.collection('pages').countDocuments({
'ocr.updated_at': { $gte: since }
});
console.log(`OCR last ${hours}h: ${count} (${Math.round(count/hours)}/hr)`);
}
7. Backfill-Specific Progress (when applicable)
For any named backfill operation (identified by note field on jobs), verify progress against actual pages:
// Get a sample of "processing" backfill jobs
const sampleJobs = await db.collection('jobs').find({
note: 'near-complete backfill', // or whatever the current operation is
status: 'processing'
}).limit(5).toArray();
// For each, verify pages actually got translated
for (const job of sampleJobs) {
const bookPages = await db.collection('pages').countDocuments({
book_id: job.book_id,
'translation.data': { $exists: true, $ne: '' }
});
const totalPages = await db.collection('pages').countDocuments({ book_id: job.book_id });
console.log(`${job.book_title?.substring(0,40)} — ${bookPages}/${totalPages} pages actually translated, job says ${job.progress.completed}/${job.progress.total}`);
}
8. ETA Calculation
// Only use VERIFIED throughput (page-level, not job-level)
const oneHourAgo = new Date(Date.now() - 3600000);
const hourlyRate = await db.collection('pages').countDocuments({
'translation.updated_at': { $gte: oneHourAgo }
});
// Remaining work
const remaining = activeJobs
.filter(j => j._id.type === 'translation')
.reduce((sum, j) => sum + (j.totalPages - j.completedPages - j.failedPages), 0);
const etaHours = hourlyRate > 0 ? remaining / hourlyRate : Infinity;
9. Pause Status
const control = await db.collection('system_config').findOne({ _id: 'processing_control' });
if (control?.paused) console.log('PIPELINE PAUSED');
if (control?.paused_phases?.length) console.log('Paused phases: ' + control.paused_phases.join(', '));
10. Batch API Health (IMPORTANT)
The batch-collector writes system_config._id: 'batch_health' every 10 minutes with real Gemini-side state. Always read this.
const batchHealth = await db.collection('system_config').findOne({ _id: 'batch_health' });
if (batchHealth) {
const age = ((Date.now() - new Date(batchHealth.updated_at).getTime()) / 60000).toFixed(0);
console.log(`Batch health (${age}m old):`);
console.log(` Gemini active: ${batchHealth.geminiActive} (by key: ${batchHealth.geminiActiveByKey?.join(', ')})`);
console.log(` DB active: ${batchHealth.dbActive} | Zombies: ${batchHealth.dbZombies}`);
console.log(` Completions: ${batchHealth.recentCompletions1h}/1h, ${batchHealth.recentCompletions6h}/6h`);
console.log(` OCR pages saved: ${batchHealth.recentPagesSaved1h}/1h, ${batchHealth.recentPagesSaved6h}/6h`);
console.log(` Healthy: ${batchHealth.healthy}`);
if (batchHealth.issues?.length) console.log(` Issues: ${batchHealth.issues.join('; ')}`);
}
Key signals:
healthy: false= something needs attention. Checkissuesarray.geminiActive > 0butrecentCompletions1h === 0= pipeline may be frozendbZombies > 0= jobs created but never submitted (auto-cleaned if >1h old)orphansCancelled > 0= drift detected and auto-fixed
Output Format
Present results as a concise status report:
Pipeline Status — [timestamp]
Dashboard: https://sourcelibrary.org/analytics (Pipeline tab)
Pipeline Funnel (in lifecycle order, skip 0s):
archiving: 13,869
archive_complete: 12,014
ocr_submitted: 118
metadata_enriched: 5,914
translate_submitted: 7
translate_complete: 19
complete: 3,713
---
needs_attention: 2,624
failed: 57
other: 50 (empty_shell, paused, replaced_by_*)
Milestones:
First translations (EN): X books
>90% translated: X books
Fully translated: X books
Enrichment coverage (of Y total books):
OCR: X (Z%)
Translation: X (Z%)
Metadata: X (Z%)
FT Verification: X (Z%)
Summary & Index: X (Z%)
Chapters: X (Z%)
Collections: X (Z%)
Quality Score: X (Z%)
Image Extraction: X books | Gallery: N images
Faceted Tags: X (Z%)
Author Entities: X (Z%)
Pipeline Complete: X (Z%)
Active jobs:
Translation (backfill): X jobs, Y/Z pages done [rate/hr, ETA]
Translation (pipeline): X jobs, Y/Z pages done
OCR: X jobs
Image Extraction: X jobs
...
STUCK JOBS: X jobs processing with 0 progress for >2h ← only if > 0
Throughput (verified from pages):
Translation: X/hr (1h), Y/hr (3h), Z/hr (6h)
OCR: X/hr (1h), Y/hr (3h)
Spot check (5 random processing jobs):
"Book Title" — 45/50 pages translated (job says 43/50)
"Book Title" — 0/30 pages translated (job says 0/30) STUCK
Batch API Health (updated Xm ago):
Gemini active: X (key0: X, key1: X, key2: X) ← real Gemini state
DB active: X | Zombies: X
Completions: X/1h, X/6h | OCR pages: X/1h, X/6h
Status: HEALTHY (or: ISSUES — list problems)
Pauses: none (or list)
Common Issues
- Jobs stuck at 0 progress: SQS messages consumed but Lambda never processed them. Cancel stuck jobs, clear
book.joblocks, re-submit. - Throughput looks good but backfill isn't moving: You're seeing pipeline cron throughput, not backfill. Filter by backfill book IDs.
pages_translatedstale:sync-page-countscron runs every 6h. For real-time count, query pages directly.- Book.job lock prevents re-submission: Clear with
$unset: { job: '' }on affected books. - Image extraction stuck: Check if
imagesphase is inpaused_phases. Also check Lambda CloudWatch for image-extraction-processor errors. - Enrichment coverage low for summaries/chapters: These run via Hetzner
enrich-worker.mjs(every 5 min, flock/tmp/sl-enrich.lock). Checktail /var/log/sourcelibrary/enrich.logon Hetzner, NOT the old Vercelenrich-bookscron (deprecated since ~March 2026).
Signals
- GitHub stars
- 21
- Forks
- 4
- Last commit
- Oct 2026
Advanced
- Item type
- skill
- Key
progress-embassy-of-the-free-mind- Source
- github.com/embassy-of-the-free-mind/sourcelibrary-v2
github.com/embassy-of-the-free-mind/sourcelibrary-v2
Related picks
Skill · thedaviddias
The pick for JavaScriptmodern-javascript-patterns
Skill · wshobson
The pick for JavaScriptdeploy-to-vercel
Skill · vercel-labs
The pick for Vercelvercel-deploy
Skill · davila7
The pick for Vercelcreate-vps
Skill · shannhk
The pick for Hetznerhetzner-cloud
Skill · laguagu
The pick for Hetzner