live progress for scraping
This commit is contained in:
parent
a6a4cb9f89
commit
f9bf790bb3
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import type { Job, JobStatus } from '../shared/types';
|
import type { Job, JobStatus } from '../shared/types';
|
||||||
import { Header, Stats, JobList, ToastContainer, Toast } from './components';
|
import { Header, Stats, JobList, ToastContainer, Toast, PipelineProgress } from './components';
|
||||||
import * as api from './api';
|
import * as api from './api';
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
@ -158,6 +158,8 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="container" style={{ paddingBottom: 'var(--space-12)' }}>
|
<main className="container" style={{ paddingBottom: 'var(--space-12)' }}>
|
||||||
|
<PipelineProgress isRunning={isPipelineRunning} />
|
||||||
|
|
||||||
<Stats stats={stats} />
|
<Stats stats={stats} />
|
||||||
|
|
||||||
<JobList
|
<JobList
|
||||||
|
|||||||
241
orchestrator/src/client/components/PipelineProgress.tsx
Normal file
241
orchestrator/src/client/components/PipelineProgress.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Live pipeline progress display component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface PipelineProgress {
|
||||||
|
step: 'idle' | 'crawling' | 'importing' | 'scoring' | 'processing' | 'completed' | 'failed';
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
jobsDiscovered: number;
|
||||||
|
jobsScored: number;
|
||||||
|
jobsProcessed: number;
|
||||||
|
totalToProcess: number;
|
||||||
|
currentJob?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
employer: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineProgressProps {
|
||||||
|
isRunning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepLabels: Record<PipelineProgress['step'], string> = {
|
||||||
|
idle: 'Ready',
|
||||||
|
crawling: 'Crawling Jobs',
|
||||||
|
importing: 'Importing',
|
||||||
|
scoring: 'Scoring Jobs',
|
||||||
|
processing: 'Generating Resumes',
|
||||||
|
completed: 'Complete',
|
||||||
|
failed: 'Failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepColors: Record<PipelineProgress['step'], string> = {
|
||||||
|
idle: 'var(--color-muted)',
|
||||||
|
crawling: 'var(--color-info)',
|
||||||
|
importing: 'var(--color-info)',
|
||||||
|
scoring: 'var(--color-warning)',
|
||||||
|
processing: 'var(--color-primary-500)',
|
||||||
|
completed: 'var(--color-success)',
|
||||||
|
failed: 'var(--color-error)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning }) => {
|
||||||
|
const [progress, setProgress] = useState<PipelineProgress | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRunning) {
|
||||||
|
setProgress(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to SSE endpoint
|
||||||
|
const eventSource = new EventSource('/api/pipeline/progress');
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
setProgress(data);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
}, [isRunning]);
|
||||||
|
|
||||||
|
if (!isRunning && !progress) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = progress?.step || 'idle';
|
||||||
|
const isActive = progress && step !== 'idle' && step !== 'completed' && step !== 'failed';
|
||||||
|
|
||||||
|
// Calculate overall progress percentage
|
||||||
|
let percentage = 0;
|
||||||
|
if (progress) {
|
||||||
|
switch (step) {
|
||||||
|
case 'crawling':
|
||||||
|
percentage = 10;
|
||||||
|
break;
|
||||||
|
case 'importing':
|
||||||
|
percentage = 20;
|
||||||
|
break;
|
||||||
|
case 'scoring':
|
||||||
|
if (progress.jobsScored > 0) {
|
||||||
|
percentage = 20 + (progress.jobsScored / Math.max(progress.jobsDiscovered, 1)) * 30;
|
||||||
|
} else {
|
||||||
|
percentage = 25;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'processing':
|
||||||
|
if (progress.totalToProcess > 0) {
|
||||||
|
percentage = 50 + (progress.jobsProcessed / progress.totalToProcess) * 50;
|
||||||
|
} else {
|
||||||
|
percentage = 55;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'completed':
|
||||||
|
percentage = 100;
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
percentage = 100;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pipeline-progress" style={{
|
||||||
|
background: 'var(--glass-background)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
padding: 'var(--space-6)',
|
||||||
|
marginBottom: 'var(--space-6)',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-4)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
{isActive && (
|
||||||
|
<div className="spinner" style={{ width: '16px', height: '16px' }} />
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
color: stepColors[step],
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 'var(--font-sm)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
{stepLabels[step]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'var(--color-muted)', fontSize: 'var(--font-xs)' }}>
|
||||||
|
{Math.round(percentage)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div style={{
|
||||||
|
height: '6px',
|
||||||
|
background: 'var(--color-surface-elevated)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${percentage}%`,
|
||||||
|
background: step === 'failed'
|
||||||
|
? 'var(--color-error)'
|
||||||
|
: 'linear-gradient(90deg, var(--color-primary-500), var(--color-primary-400))',
|
||||||
|
borderRadius: '3px',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{progress && (
|
||||||
|
<div style={{ marginBottom: 'var(--space-3)' }}>
|
||||||
|
<p style={{ color: 'var(--color-text)', margin: 0 }}>
|
||||||
|
{progress.message}
|
||||||
|
</p>
|
||||||
|
{progress.detail && (
|
||||||
|
<p style={{
|
||||||
|
color: 'var(--color-muted)',
|
||||||
|
fontSize: 'var(--font-sm)',
|
||||||
|
margin: 'var(--space-1) 0 0 0',
|
||||||
|
}}>
|
||||||
|
{progress.detail}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{progress && (step === 'scoring' || step === 'processing' || step === 'completed') && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'var(--space-6)',
|
||||||
|
paddingTop: 'var(--space-3)',
|
||||||
|
borderTop: '1px solid var(--glass-border)',
|
||||||
|
fontSize: 'var(--font-sm)',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--color-muted)' }}>Discovered: </span>
|
||||||
|
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
|
||||||
|
{progress.jobsDiscovered}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{progress.jobsScored > 0 && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--color-muted)' }}>Scored: </span>
|
||||||
|
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
|
||||||
|
{progress.jobsScored}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{progress.totalToProcess > 0 && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--color-muted)' }}>Processed: </span>
|
||||||
|
<span style={{ color: 'var(--color-text)', fontWeight: '500' }}>
|
||||||
|
{progress.jobsProcessed}/{progress.totalToProcess}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{step === 'failed' && progress?.error && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 'var(--space-3)',
|
||||||
|
padding: 'var(--space-3)',
|
||||||
|
background: 'rgba(var(--color-error-rgb), 0.1)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
fontSize: 'var(--font-sm)',
|
||||||
|
}}>
|
||||||
|
{progress.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,4 +5,5 @@ export { ScoreIndicator } from './ScoreIndicator';
|
|||||||
export { JobCard } from './JobCard';
|
export { JobCard } from './JobCard';
|
||||||
export { JobList } from './JobList';
|
export { JobList } from './JobList';
|
||||||
export { ToastContainer, type Toast } from './Toast';
|
export { ToastContainer, type Toast } from './Toast';
|
||||||
|
export { PipelineProgress } from './PipelineProgress';
|
||||||
export * from './Icons';
|
export * from './Icons';
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { Router, Request, Response } from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import * as jobsRepo from '../repositories/jobs.js';
|
import * as jobsRepo from '../repositories/jobs.js';
|
||||||
import * as pipelineRepo from '../repositories/pipeline.js';
|
import * as pipelineRepo from '../repositories/pipeline.js';
|
||||||
import { runPipeline, processJob, getPipelineStatus } from '../pipeline/index.js';
|
import { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
|
||||||
import { createNotionEntry } from '../services/notion.js';
|
import { createNotionEntry } from '../services/notion.js';
|
||||||
import { clearDatabase } from '../db/clear.js';
|
import { clearDatabase } from '../db/clear.js';
|
||||||
import type { JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
|
import type { JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
|
||||||
@ -198,6 +198,36 @@ apiRouter.get('/pipeline/status', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/pipeline/progress - Server-Sent Events endpoint for live progress
|
||||||
|
*/
|
||||||
|
apiRouter.get('/pipeline/progress', (req: Request, res: Response) => {
|
||||||
|
// Set headers for SSE
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no'); // Disable Nginx buffering
|
||||||
|
|
||||||
|
// Send initial progress
|
||||||
|
const sendProgress = (data: unknown) => {
|
||||||
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscribe to progress updates
|
||||||
|
const unsubscribe = subscribeToProgress(sendProgress);
|
||||||
|
|
||||||
|
// Send heartbeat every 30 seconds to keep connection alive
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
res.write(': heartbeat\n\n');
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Cleanup on close
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/pipeline/runs - Get recent pipeline runs
|
* GET /api/pipeline/runs - Get recent pipeline runs
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from './orchestrator.js';
|
export * from './orchestrator.js';
|
||||||
|
export * from './progress.js';
|
||||||
|
|||||||
@ -14,11 +14,12 @@ import { readFile } from 'fs/promises';
|
|||||||
import { join, dirname } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { runCrawler } from '../services/crawler.js';
|
import { runCrawler } from '../services/crawler.js';
|
||||||
import { scoreAndRankJobs } from '../services/scorer.js';
|
import { scoreAndRankJobs, scoreJobSuitability } from '../services/scorer.js';
|
||||||
import { generateSummary } from '../services/summary.js';
|
import { generateSummary } from '../services/summary.js';
|
||||||
import { generatePdf } from '../services/pdf.js';
|
import { generatePdf } from '../services/pdf.js';
|
||||||
import * as jobsRepo from '../repositories/jobs.js';
|
import * as jobsRepo from '../repositories/jobs.js';
|
||||||
import * as pipelineRepo from '../repositories/pipeline.js';
|
import * as pipelineRepo from '../repositories/pipeline.js';
|
||||||
|
import { progressHelpers, resetProgress } from './progress.js';
|
||||||
import type { Job, PipelineConfig } from '../../shared/types.js';
|
import type { Job, PipelineConfig } from '../../shared/types.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@ -54,6 +55,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
isPipelineRunning = true;
|
isPipelineRunning = true;
|
||||||
|
resetProgress();
|
||||||
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
|
||||||
// Create pipeline run record
|
// Create pipeline run record
|
||||||
@ -69,39 +71,60 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
|||||||
|
|
||||||
// Step 2: Run crawler
|
// Step 2: Run crawler
|
||||||
console.log('\n🕷️ Running crawler...');
|
console.log('\n🕷️ Running crawler...');
|
||||||
|
progressHelpers.startCrawling();
|
||||||
const crawlerResult = await runCrawler();
|
const crawlerResult = await runCrawler();
|
||||||
|
|
||||||
if (!crawlerResult.success) {
|
if (!crawlerResult.success) {
|
||||||
throw new Error(`Crawler failed: ${crawlerResult.error}`);
|
throw new Error(`Crawler failed: ${crawlerResult.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progressHelpers.crawlingComplete(crawlerResult.jobs.length);
|
||||||
|
|
||||||
// Step 3: Import discovered jobs
|
// Step 3: Import discovered jobs
|
||||||
console.log('\n💾 Importing jobs to database...');
|
console.log('\n💾 Importing jobs to database...');
|
||||||
const { created, skipped } = await jobsRepo.bulkCreateJobs(crawlerResult.jobs);
|
const { created, skipped } = await jobsRepo.bulkCreateJobs(crawlerResult.jobs);
|
||||||
console.log(` Created: ${created}, Skipped (duplicates): ${skipped}`);
|
console.log(` Created: ${created}, Skipped (duplicates): ${skipped}`);
|
||||||
|
|
||||||
|
progressHelpers.importComplete(created, skipped);
|
||||||
|
|
||||||
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
|
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
|
||||||
jobsDiscovered: created,
|
jobsDiscovered: created,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: Get unprocessed jobs and score them
|
// Step 4: Get unprocessed jobs and score them
|
||||||
console.log('\n🎯 Scoring jobs for suitability...');
|
console.log('\n🎯 Scoring jobs for suitability...');
|
||||||
const unprocessedJobs = await jobsRepo.getJobsForProcessing(50); // Get more than topN for ranking
|
const unprocessedJobs = await jobsRepo.getJobsForProcessing(50);
|
||||||
const rankedJobs = await scoreAndRankJobs(unprocessedJobs, profile);
|
|
||||||
|
|
||||||
// Update scores in database
|
// Score jobs with progress updates
|
||||||
for (const job of rankedJobs) {
|
const scoredJobs: Array<Job & { suitabilityScore: number; suitabilityReason: string }> = [];
|
||||||
|
for (let i = 0; i < unprocessedJobs.length; i++) {
|
||||||
|
const job = unprocessedJobs[i];
|
||||||
|
progressHelpers.scoringJob(i + 1, unprocessedJobs.length, job.title);
|
||||||
|
|
||||||
|
const { score, reason } = await scoreJobSuitability(job, profile);
|
||||||
|
scoredJobs.push({
|
||||||
|
...job,
|
||||||
|
suitabilityScore: score,
|
||||||
|
suitabilityReason: reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update score in database
|
||||||
await jobsRepo.updateJob(job.id, {
|
await jobsRepo.updateJob(job.id, {
|
||||||
suitabilityScore: job.suitabilityScore,
|
suitabilityScore: score,
|
||||||
suitabilityReason: job.suitabilityReason,
|
suitabilityReason: reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by score
|
||||||
|
scoredJobs.sort((a, b) => b.suitabilityScore - a.suitabilityScore);
|
||||||
|
|
||||||
// Step 5: Pick top N jobs above threshold
|
// Step 5: Pick top N jobs above threshold
|
||||||
const topJobs = rankedJobs
|
const topJobs = scoredJobs
|
||||||
.filter(j => j.suitabilityScore >= mergedConfig.minSuitabilityScore)
|
.filter(j => j.suitabilityScore >= mergedConfig.minSuitabilityScore)
|
||||||
.slice(0, mergedConfig.topN);
|
.slice(0, mergedConfig.topN);
|
||||||
|
|
||||||
|
progressHelpers.scoringComplete(scoredJobs.length, topJobs.length);
|
||||||
|
|
||||||
console.log(`\n📊 Selected ${topJobs.length} top jobs for processing:`);
|
console.log(`\n📊 Selected ${topJobs.length} top jobs for processing:`);
|
||||||
for (const job of topJobs) {
|
for (const job of topJobs) {
|
||||||
console.log(` - ${job.title} @ ${job.employer} (score: ${job.suitabilityScore})`);
|
console.log(` - ${job.title} @ ${job.employer} (score: ${job.suitabilityScore})`);
|
||||||
@ -110,15 +133,24 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
|||||||
// Step 6: Process each top job
|
// Step 6: Process each top job
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
|
|
||||||
for (const job of topJobs) {
|
for (let i = 0; i < topJobs.length; i++) {
|
||||||
|
const job = topJobs[i];
|
||||||
console.log(`\n📝 Processing: ${job.title} @ ${job.employer}`);
|
console.log(`\n📝 Processing: ${job.title} @ ${job.employer}`);
|
||||||
|
|
||||||
|
progressHelpers.processingJob(i + 1, topJobs.length, {
|
||||||
|
id: job.id,
|
||||||
|
title: job.title,
|
||||||
|
employer: job.employer,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Mark as processing
|
// Mark as processing
|
||||||
await jobsRepo.updateJob(job.id, { status: 'processing' });
|
await jobsRepo.updateJob(job.id, { status: 'processing' });
|
||||||
|
|
||||||
// Generate tailored summary
|
// Generate tailored summary
|
||||||
console.log(' Generating summary...');
|
console.log(' Generating summary...');
|
||||||
|
progressHelpers.generatingSummary({ title: job.title, employer: job.employer });
|
||||||
|
|
||||||
const summaryResult = await generateSummary(
|
const summaryResult = await generateSummary(
|
||||||
job.jobDescription || '',
|
job.jobDescription || '',
|
||||||
profile
|
profile
|
||||||
@ -136,6 +168,8 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
|||||||
|
|
||||||
// Generate PDF
|
// Generate PDF
|
||||||
console.log(' Generating PDF...');
|
console.log(' Generating PDF...');
|
||||||
|
progressHelpers.generatingPdf({ title: job.title, employer: job.employer });
|
||||||
|
|
||||||
const pdfResult = await generatePdf(
|
const pdfResult = await generatePdf(
|
||||||
job.id,
|
job.id,
|
||||||
summaryResult.summary!,
|
summaryResult.summary!,
|
||||||
@ -150,10 +184,11 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
|||||||
// Mark as ready
|
// Mark as ready
|
||||||
await jobsRepo.updateJob(job.id, {
|
await jobsRepo.updateJob(job.id, {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
pdfPath: pdfResult.pdfPath ?? null,
|
pdfPath: pdfResult.pdfPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
processed++;
|
processed++;
|
||||||
|
progressHelpers.jobComplete(processed, topJobs.length);
|
||||||
console.log(` ✅ Ready for review!`);
|
console.log(` ✅ Ready for review!`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -173,6 +208,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
|||||||
console.log(` Jobs discovered: ${created}`);
|
console.log(` Jobs discovered: ${created}`);
|
||||||
console.log(` Jobs processed: ${processed}`);
|
console.log(` Jobs processed: ${processed}`);
|
||||||
|
|
||||||
|
progressHelpers.complete(created, processed);
|
||||||
isPipelineRunning = false;
|
isPipelineRunning = false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -190,6 +226,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
|||||||
errorMessage: message,
|
errorMessage: message,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
progressHelpers.failed(message);
|
||||||
isPipelineRunning = false;
|
isPipelineRunning = false;
|
||||||
|
|
||||||
console.error('\n❌ Pipeline failed:', message);
|
console.error('\n❌ Pipeline failed:', message);
|
||||||
@ -250,7 +287,7 @@ export async function processJob(jobId: string): Promise<{
|
|||||||
// Mark as ready
|
// Mark as ready
|
||||||
await jobsRepo.updateJob(job.id, {
|
await jobsRepo.updateJob(job.id, {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
pdfPath: pdfResult.pdfPath ?? null,
|
pdfPath: pdfResult.pdfPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(' ✅ Done!');
|
console.log(' ✅ Done!');
|
||||||
|
|||||||
177
orchestrator/src/server/pipeline/progress.ts
Normal file
177
orchestrator/src/server/pipeline/progress.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Pipeline progress tracking with Server-Sent Events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PipelineStep =
|
||||||
|
| 'idle'
|
||||||
|
| 'crawling'
|
||||||
|
| 'importing'
|
||||||
|
| 'scoring'
|
||||||
|
| 'processing'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
export interface PipelineProgress {
|
||||||
|
step: PipelineStep;
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
jobsDiscovered: number;
|
||||||
|
jobsScored: number;
|
||||||
|
jobsProcessed: number;
|
||||||
|
totalToProcess: number;
|
||||||
|
currentJob?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
employer: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event emitter for progress updates
|
||||||
|
type ProgressListener = (progress: PipelineProgress) => void;
|
||||||
|
const listeners: Set<ProgressListener> = new Set();
|
||||||
|
|
||||||
|
let currentProgress: PipelineProgress = {
|
||||||
|
step: 'idle',
|
||||||
|
message: 'Ready',
|
||||||
|
jobsDiscovered: 0,
|
||||||
|
jobsScored: 0,
|
||||||
|
jobsProcessed: 0,
|
||||||
|
totalToProcess: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current progress and notify all listeners.
|
||||||
|
*/
|
||||||
|
export function updateProgress(update: Partial<PipelineProgress>): void {
|
||||||
|
currentProgress = { ...currentProgress, ...update };
|
||||||
|
|
||||||
|
// Notify all listeners
|
||||||
|
for (const listener of listeners) {
|
||||||
|
try {
|
||||||
|
listener(currentProgress);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in progress listener:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current progress state.
|
||||||
|
*/
|
||||||
|
export function getProgress(): PipelineProgress {
|
||||||
|
return { ...currentProgress };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to progress updates.
|
||||||
|
*/
|
||||||
|
export function subscribeToProgress(listener: ProgressListener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
|
||||||
|
// Send current state immediately
|
||||||
|
listener(currentProgress);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset progress to idle state.
|
||||||
|
*/
|
||||||
|
export function resetProgress(): void {
|
||||||
|
currentProgress = {
|
||||||
|
step: 'idle',
|
||||||
|
message: 'Ready',
|
||||||
|
jobsDiscovered: 0,
|
||||||
|
jobsScored: 0,
|
||||||
|
jobsProcessed: 0,
|
||||||
|
totalToProcess: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create progress updates for each step.
|
||||||
|
*/
|
||||||
|
export const progressHelpers = {
|
||||||
|
startCrawling: () => updateProgress({
|
||||||
|
step: 'crawling',
|
||||||
|
message: 'Fetching jobs from sources...',
|
||||||
|
detail: 'Running Crawlee crawler',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
jobsDiscovered: 0,
|
||||||
|
jobsScored: 0,
|
||||||
|
jobsProcessed: 0,
|
||||||
|
totalToProcess: 0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
crawlingComplete: (jobsFound: number) => updateProgress({
|
||||||
|
step: 'importing',
|
||||||
|
message: `Found ${jobsFound} jobs, importing to database...`,
|
||||||
|
detail: 'Deduplicating and saving',
|
||||||
|
jobsDiscovered: jobsFound,
|
||||||
|
}),
|
||||||
|
|
||||||
|
importComplete: (created: number, skipped: number) => updateProgress({
|
||||||
|
step: 'scoring',
|
||||||
|
message: `Imported ${created} new jobs (${skipped} duplicates). Scoring...`,
|
||||||
|
detail: 'Using AI to evaluate job fit',
|
||||||
|
}),
|
||||||
|
|
||||||
|
scoringJob: (index: number, total: number, title: string) => updateProgress({
|
||||||
|
step: 'scoring',
|
||||||
|
message: `Scoring jobs (${index}/${total})...`,
|
||||||
|
detail: title,
|
||||||
|
jobsScored: index,
|
||||||
|
}),
|
||||||
|
|
||||||
|
scoringComplete: (totalScored: number, topN: number) => updateProgress({
|
||||||
|
step: 'processing',
|
||||||
|
message: `Scored ${totalScored} jobs. Processing top ${topN}...`,
|
||||||
|
detail: 'Generating tailored resumes',
|
||||||
|
jobsScored: totalScored,
|
||||||
|
totalToProcess: topN,
|
||||||
|
}),
|
||||||
|
|
||||||
|
processingJob: (index: number, total: number, job: { id: string; title: string; employer: string }) => updateProgress({
|
||||||
|
step: 'processing',
|
||||||
|
message: `Processing job ${index}/${total}...`,
|
||||||
|
detail: `${job.title} @ ${job.employer}`,
|
||||||
|
jobsProcessed: index - 1,
|
||||||
|
totalToProcess: total,
|
||||||
|
currentJob: job,
|
||||||
|
}),
|
||||||
|
|
||||||
|
generatingSummary: (job: { title: string; employer: string }) => updateProgress({
|
||||||
|
detail: `Generating summary for ${job.title}...`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
generatingPdf: (job: { title: string; employer: string }) => updateProgress({
|
||||||
|
detail: `Generating PDF for ${job.title}...`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
jobComplete: (index: number, total: number) => updateProgress({
|
||||||
|
jobsProcessed: index,
|
||||||
|
detail: `Completed ${index}/${total} jobs`,
|
||||||
|
}),
|
||||||
|
|
||||||
|
complete: (discovered: number, processed: number) => updateProgress({
|
||||||
|
step: 'completed',
|
||||||
|
message: `Pipeline complete! Discovered ${discovered} jobs, processed ${processed}.`,
|
||||||
|
detail: 'Ready for review',
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
currentJob: undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
failed: (error: string) => updateProgress({
|
||||||
|
step: 'failed',
|
||||||
|
message: 'Pipeline failed',
|
||||||
|
detail: error,
|
||||||
|
error,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user