// ============================================================================ // Pipeline Orchestrator // Runs all steps sequentially, tracks state, supports re-running individual steps. // ============================================================================ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { validateEstimation } from "./validators.js"; import { executeSynthesize } from "./steps/05-synthesize.js"; import { executeCritique } from "./steps/06-critique.js"; import type { PipelineConfig, PipelineInput, EstimationState, StepResult, } from "./types.js"; export interface PipelineCallbacks { onStepStart?: (stepId: string, stepName: string) => void; onStepComplete?: (stepId: string, result: StepResult) => void; onStepError?: (stepId: string, error: string) => void; } /** * The main estimation pipeline orchestrator. * Runs steps sequentially, persists state between steps, supports re-entry. */ export class EstimationPipeline { private config: PipelineConfig; private state: EstimationState; private callbacks: PipelineCallbacks; constructor(config: PipelineConfig, callbacks: PipelineCallbacks = {}) { this.config = config; this.callbacks = callbacks; this.state = this.createInitialState(); } private createInitialState(): EstimationState { return { concept: null as any, // Will be set in run() usage: { totalPromptTokens: 0, totalCompletionTokens: 0, totalCost: 0, perStep: [], }, }; } /** * Run the full estimation pipeline from a completed project concept. */ async run(input: PipelineInput): Promise { this.state.concept = input.concept; this.state.budget = input.budget; // Ensure output directories await fs.mkdir(this.config.outputDir, { recursive: true }); // Step 5: Position synthesis await this.runStep("05-synthesize", "Position Descriptions", async () => { const result = await executeSynthesize(this.state, this.config); if (result.success) this.state.positionDescriptions = result.data; return result; }); // Step 6: Quality critique await this.runStep( "06-critique", "Quality Gate (Industrial Critic)", async () => { const result = await executeCritique(this.state, this.config); if (result.success) { this.state.critiquePassed = result.data.passed; this.state.critiqueErrors = result.data.errors?.map((e: any) => `${e.field}: ${e.issue}`) || []; // Apply corrections if (result.data.corrections) { const corrections = result.data.corrections; // Note: We only correct the positionDescriptions since briefing/design/sitemap are locked in the concept phase. // If the critique suggests changes to those, it should be a warning or failure. if (corrections.positionDescriptions) { this.state.positionDescriptions = { ...this.state.positionDescriptions, ...corrections.positionDescriptions, }; } } } return result; }, ); // Step 7: Deterministic validation await this.runStep("07-validate", "Deterministic Validation", async () => { // Build the merged form state first this.state.formState = this.buildFormState(); const validationResult = validateEstimation(this.state); this.state.validationResult = validationResult; if (!validationResult.passed) { console.log("\n⚠️ Validation Issues:"); for (const error of validationResult.errors) { console.log(` ❌ [${error.code}] ${error.message}`); } } if (validationResult.warnings.length > 0) { console.log("\n⚡ Warnings:"); for (const warning of validationResult.warnings) { console.log(` ⚡ [${warning.code}] ${warning.message}`); if (warning.suggestion) console.log(` → ${warning.suggestion}`); } } return { success: true, data: validationResult, usage: { step: "07-validate", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0, }, }; }); // Save final state await this.saveState(); return this.state; } /** * Run a single step with callbacks and error handling. */ private async runStep( stepId: string, stepName: string, executor: () => Promise, ): Promise { this.callbacks.onStepStart?.(stepId, stepName); console.log(`\n📍 ${stepName}...`); try { const result = await executor(); if (result.usage) { this.state.usage.perStep.push(result.usage); this.state.usage.totalPromptTokens += result.usage.promptTokens; this.state.usage.totalCompletionTokens += result.usage.completionTokens; this.state.usage.totalCost += result.usage.cost; } if (result.success) { const cost = result.usage?.cost ? ` ($${result.usage.cost.toFixed(4)})` : ""; const duration = result.usage?.durationMs ? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]` : ""; console.log(` ✅ ${stepName} complete${cost}${duration}`); this.callbacks.onStepComplete?.(stepId, result); } else { console.error(` ❌ ${stepName} failed: ${result.error}`); this.callbacks.onStepError?.(stepId, result.error || "Unknown error"); throw new Error(result.error); } } catch (err) { const errorMsg = (err as Error).message; this.callbacks.onStepError?.(stepId, errorMsg); throw err; } } /** * Build the final FormState compatible with @mintel/pdf. */ private buildFormState(): Record { const facts = this.state.concept.auditedFacts || {}; return { projectType: "website", ...facts, briefingSummary: this.state.concept.strategy.briefingSummary || "", designVision: this.state.concept.strategy.designVision || "", sitemap: this.state.concept.architecture.sitemap || [], positionDescriptions: this.state.positionDescriptions || {}, websiteTopic: this.state.concept.architecture.websiteTopic || facts.websiteTopic || "", statusQuo: facts.isRelaunch ? "Relaunch" : "Neuentwicklung", name: facts.personName || "", email: facts.email || "", }; } /** * Save the full state to disk for later re-use. */ private async saveState(): Promise { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const companyName = this.state.concept.auditedFacts?.companyName || "unknown"; // Save full state const stateDir = path.join(this.config.outputDir, "json"); await fs.mkdir(stateDir, { recursive: true }); const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`); await fs.writeFile( statePath, JSON.stringify(this.state.formState, null, 2), ); console.log(`\n📦 Saved state to: ${statePath}`); // Save full pipeline state (for debugging / re-entry) const debugPath = path.join( stateDir, `${companyName}_${timestamp}_debug.json`, ); await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2)); // Print usage summary console.log("\n──────────────────────────────────────────────"); console.log("📊 PIPELINE USAGE SUMMARY"); console.log("──────────────────────────────────────────────"); for (const step of this.state.usage.perStep) { if (step.cost > 0) { console.log( ` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`, ); } } console.log("──────────────────────────────────────────────"); console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`); console.log( ` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`, ); console.log("──────────────────────────────────────────────\n"); } /** Get the current state (for CLI inspection). */ getState(): EstimationState { return this.state; } /** Load a saved state from JSON. */ async loadState(jsonPath: string): Promise { const raw = await fs.readFile(jsonPath, "utf8"); const formState = JSON.parse(raw); this.state.formState = formState; } }