feat: migrate npm registry from Verdaccio to Gitea Packages
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 35s
Monorepo Pipeline / 🧪 Test (push) Failing after 35s
Monorepo Pipeline / 🏗️ Build (push) Failing after 12s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped

This commit is contained in:
2026-02-27 00:12:00 +01:00
parent efd1341762
commit 5da88356a8
69 changed files with 5397 additions and 114 deletions

View File

@@ -0,0 +1,40 @@
import { config as dotenvConfig } from 'dotenv';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { EstimationPipeline } from './pipeline.js';
dotenvConfig({ path: path.resolve(process.cwd(), '../../.env') });
const briefing = await fs.readFile(
path.resolve(process.cwd(), '../../data/briefings/etib.txt'),
'utf8',
);
console.log(`Briefing loaded: ${briefing.length} chars`);
const pipeline = new EstimationPipeline(
{
openrouterKey: process.env.OPENROUTER_API_KEY || '',
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), '../../out/estimations'),
crawlDir: path.resolve(process.cwd(), '../../data/crawls'),
},
{
onStepStart: (id, name) => console.log(`[CB] Starting: ${id}`),
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
},
);
try {
const result = await pipeline.run({
briefing,
url: 'https://www.e-tib.com',
});
console.log('\n✨ Pipeline complete!');
console.log('Validation:', result.validationResult?.passed ? 'PASSED' : 'FAILED');
} catch (err: any) {
console.error('\n❌ Pipeline failed:', err.message);
console.error(err.stack);
}

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env node
// ============================================================================
// @mintel/estimation-engine — CLI Entry Point
// ============================================================================
import { Command } from "commander";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { existsSync } from "node:fs";
import { config as dotenvConfig } from "dotenv";
import { EstimationPipeline } from "./pipeline.js";
import type { ProjectConcept } from "@mintel/concept-engine";
// Load .env from monorepo root
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
dotenvConfig({ path: path.resolve(process.cwd(), ".env") });
const program = new Command();
program
.name("estimate")
.description("AI-powered project estimation engine")
.version("1.0.0");
program
.command("run")
.description("Run the financial estimation pipeline from a concept file")
.argument("<concept-file>", "Path to the ProjectConcept JSON file")
.option("--budget <budget>", "Budget constraint (e.g. '15.000 €')")
.option("--output <dir>", "Output directory", "../../out/estimations")
.action(async (conceptFile: string, options: any) => {
const openrouterKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!openrouterKey) {
console.error("❌ OPENROUTER_API_KEY not found in environment.");
process.exit(1);
}
const filePath = path.resolve(process.cwd(), conceptFile);
if (!existsSync(filePath)) {
console.error(`❌ Concept file not found: ${filePath}`);
process.exit(1);
}
console.log(`📄 Loading concept from: ${filePath}`);
const rawConcept = await fs.readFile(filePath, "utf8");
const concept = JSON.parse(rawConcept) as ProjectConcept;
const pipeline = new EstimationPipeline(
{
openrouterKey,
outputDir: path.resolve(process.cwd(), options.output),
crawlDir: "" // No longer needed here
},
{
onStepStart: (id, name) => { },
onStepComplete: (id, result) => { },
},
);
try {
const result = await pipeline.run({
concept,
budget: options.budget,
});
console.log("\n✨ Estimation complete!");
if (result.validationResult && !result.validationResult.passed) {
console.log(`\n⚠ ${result.validationResult.errors.length} validation issues found.`);
console.log(" Review the output JSON and re-run problematic steps.");
}
} catch (err) {
console.error(`\n❌ Pipeline failed: ${(err as Error).message}`);
process.exit(1);
}
});
program.parse();

View File

@@ -0,0 +1,9 @@
// ============================================================================
// @mintel/estimation-engine — Public API
// ============================================================================
export { EstimationPipeline } from "./pipeline.js";
export type { PipelineCallbacks } from "./pipeline.js";
export { validateEstimation } from "./validators.js";
export { llmRequest, llmJsonRequest, cleanJson } from "./llm-client.js";
export * from "./types.js";

View File

@@ -0,0 +1,128 @@
// ============================================================================
// LLM Client — Unified interface with model routing via OpenRouter
// ============================================================================
import axios from "axios";
interface LLMRequestOptions {
model: string;
systemPrompt: string;
userPrompt: string;
jsonMode?: boolean;
apiKey: string;
}
interface LLMResponse {
content: string;
usage: {
promptTokens: number;
completionTokens: number;
cost: number;
};
}
/**
* Clean raw LLM output to parseable JSON.
* Handles markdown fences, control chars, trailing commas.
*/
export function cleanJson(str: string): string {
let cleaned = str.replace(/```json\n?|```/g, "").trim();
cleaned = cleaned.replace(
/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
" ",
);
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
return cleaned;
}
/**
* Send a request to an LLM via OpenRouter.
*/
export async function llmRequest(options: LLMRequestOptions): Promise<LLMResponse> {
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
const startTime = Date.now();
const resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: 120000,
},
);
const content = resp.data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`LLM returned no content. Model: ${model}`);
}
let cost = 0;
const usage = resp.data.usage || {};
if (usage.cost !== undefined) {
cost = usage.cost;
} else {
// Fallback estimation
cost =
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
}
return {
content,
usage: {
promptTokens: usage.prompt_tokens || 0,
completionTokens: usage.completion_tokens || 0,
cost,
},
};
}
/**
* Send a request and parse the response as JSON.
*/
export async function llmJsonRequest<T = any>(
options: LLMRequestOptions,
): Promise<{ data: T; usage: LLMResponse["usage"] }> {
const response = await llmRequest({ ...options, jsonMode: true });
const cleaned = cleanJson(response.content);
let parsed: T;
try {
parsed = JSON.parse(cleaned);
} catch (e) {
throw new Error(
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
);
}
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
const unwrapped = unwrapResponse(parsed);
return { data: unwrapped as T, usage: response.usage };
}
/**
* Recursively unwrap common LLM wrapping patterns.
*/
function unwrapResponse(obj: any): any {
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
const keys = Object.keys(obj);
if (keys.length === 1) {
const key = keys[0];
if (key === "0" || key === "state" || key === "facts" || key === "result" || key === "data") {
return unwrapResponse(obj[key]);
}
}
return obj;
}

View File

@@ -0,0 +1,228 @@
// ============================================================================
// 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 { existsSync } from "node:fs";
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,
StepUsage,
} 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<EstimationState> {
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<StepResult>,
): Promise<void> {
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<string, any> {
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<void> {
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<void> {
const raw = await fs.readFile(jsonPath, "utf8");
const formState = JSON.parse(raw);
this.state.formState = formState;
}
}

View File

@@ -0,0 +1,95 @@
// ============================================================================
// Step 05: Synthesize — Position Descriptions (Gemini Pro)
// ============================================================================
import { llmJsonRequest } from "../llm-client.js";
import type { EstimationState, StepResult, PipelineConfig } from "../types.js";
import { DEFAULT_MODELS } from "../types.js";
export async function executeSynthesize(
state: EstimationState,
config: PipelineConfig,
): Promise<StepResult> {
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
const startTime = Date.now();
if (!state.concept?.auditedFacts || !state.concept?.architecture?.sitemap) {
return { success: false, error: "Missing audited facts or sitemap." };
}
const facts = state.concept.auditedFacts;
// Determine which positions are required
const requiredPositions = [
"Das technische Fundament",
(facts.selectedPages?.length || 0) + (facts.otherPages?.length || 0) > 0
? "Individuelle Seiten"
: null,
facts.features?.length > 0 ? "System-Module (Features)" : null,
facts.functions?.length > 0 ? "Logik-Funktionen" : null,
facts.apiSystems?.length > 0 ? "Schnittstellen (API)" : null,
facts.cmsSetup ? "Inhalts-Verwaltung" : null,
facts.multilang ? "Mehrsprachigkeit" : null,
"Inhaltliche Initial-Pflege",
"Sorglos Betrieb",
].filter(Boolean);
const systemPrompt = `
You are a Senior Solution Architect. Write position descriptions for a professional B2B quote.
### REQUIRED POSITIONS (STRICT — ONLY DESCRIBE THESE):
${requiredPositions.map((p) => `"${p}"`).join(", ")}
### RULES (STRICT):
1. NO FIRST PERSON: NEVER "Ich", "Mein", "Wir", "Unser". Lead with nouns or passive verbs.
2. QUANTITY PARITY: Description MUST list EXACTLY the number of items matching 'qty'.
3. CMS GUARD: If cmsSetup=false, do NOT mention "CMS", "Inhaltsverwaltung". Use "Plattform-Struktur".
4. TONE: "Erstellung von...", "Anbindung der...", "Bereitstellung von...". Technical, high-density.
5. PAGES: List actual page names. NO implementation notes in parentheses.
6. HARD SPECIFICS: Use industry terms from the briefing (e.g. "Kabeltiefbau", "110 kV").
7. KEYS: Return EXACTLY the keys from REQUIRED POSITIONS.
8. NO AGB: NEVER mention "AGB" or "Geschäftsbedingungen".
9. Sorglos Betrieb: "Inklusive 1 Jahr technischer Betrieb, Hosting, SSL, Sicherheits-Updates, Monitoring und techn. Support."
10. Inhaltliche Initial-Pflege: Refers to DATENSÄTZE (datasets like products, references), NOT Seiten.
Use "Datensätze" in the description, not "Seiten".
11. Mehrsprachigkeit: This is a +20% markup on the subtotal. NOT an API. NOT a Schnittstelle.
### EXAMPLES:
- GOOD: "Erstellung der Seiten: Startseite, Über uns, Leistungen, Kontakt."
- GOOD: "Native API-Anbindung an Google Maps mit individueller Standort-Visualisierung."
- BAD: "Ich richte dir das CMS ein."
- BAD: "Verschiedene Funktionen" (too generic — name the things!)
### DATA CONTEXT:
${JSON.stringify({ facts, sitemap: state.concept.architecture.sitemap, strategy: { briefingSummary: state.concept.strategy.briefingSummary } }, null, 2)}
### OUTPUT FORMAT:
{
"positionDescriptions": { "Das technische Fundament": string, ... }
}
`;
try {
const { data, usage } = await llmJsonRequest({
model: models.pro,
systemPrompt,
userPrompt: state.concept.briefing,
apiKey: config.openrouterKey,
});
return {
success: true,
data: data.positionDescriptions || data,
usage: {
step: "05-synthesize",
model: models.pro,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
cost: usage.cost,
durationMs: Date.now() - startTime,
},
};
} catch (err) {
return { success: false, error: `Synthesize step failed: ${(err as Error).message}` };
}
}

View File

@@ -0,0 +1,99 @@
// ============================================================================
// Step 06: Critique — Industrial Critic Quality Gate (Claude Opus)
// ============================================================================
import { llmJsonRequest } from "../llm-client.js";
import type { EstimationState, StepResult, PipelineConfig } from "../types.js";
import { DEFAULT_MODELS } from "../types.js";
export async function executeCritique(
state: EstimationState,
config: PipelineConfig,
): Promise<StepResult> {
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
const startTime = Date.now();
const currentState = {
facts: state.concept?.auditedFacts,
briefingSummary: state.concept?.strategy?.briefingSummary,
designVision: state.concept?.strategy?.designVision,
sitemap: state.concept?.architecture?.sitemap,
positionDescriptions: state.positionDescriptions,
siteProfile: state.concept?.siteProfile
? {
existingFeatures: state.concept.siteProfile.existingFeatures,
services: state.concept.siteProfile.services,
externalDomains: state.concept.siteProfile.externalDomains,
navigation: state.concept.siteProfile.navigation,
totalPages: state.concept.siteProfile.totalPages,
}
: null,
};
const systemPrompt = `
You are the "Industrial Critic" — the final quality gate for a professional B2B estimation.
Your job is to find EVERY error, hallucination, and inconsistency before this goes to the client.
### CRITICAL ERROR CHECKLIST (FAIL IF ANY FOUND):
1. HALLUCINATION: FAIL if names, software versions, or details not in the BRIEFING are used.
- "Sie", "Ansprechpartner" for personName when an actual name exists = FAIL.
2. LOGIC CONFLICT: FAIL if isRelaunch=true but text claims "no website exists".
3. IMPLEMENTATION FLUFF: FAIL if "React", "Next.js", "TypeScript", "Tailwind" are mentioned.
4. GENERICISM: FAIL if text could apply to ANY company. Must use specific industry terms.
5. NAMEN-VERBOT: FAIL if personal names in briefingSummary or designVision.
6. CMS-LEAKAGE: FAIL if cmsSetup=false but descriptions mention "CMS", "Inhaltsverwaltung".
7. AGB BAN: FAIL if "AGB" or "Geschäftsbedingungen" appear anywhere.
8. LENGTH: briefingSummary ~6 sentences, designVision ~4 sentences. Shorten if too wordy.
9. LEGAL SAFETY: FAIL if "rechtssicher" is used. Use "Standard-konform" instead.
10. BULLSHIT DETECTOR: FAIL if jargon like "SEO-Standards zur Fachkräftesicherung",
"B2B-Nutzerströme", "Digitale Konvergenzstrategie" or similar meaningless buzzwords are used.
The text must make SENSE to a construction industry CEO.
11. PAGE STRUCTURE: FAIL if the sitemap contains:
- Videos as pages (Messefilm, Imagefilm)
- Internal functions as pages (Verwaltung)
- Entities with their own domains as sub-pages (check externalDomains!)
12. SORGLOS-BETRIEB: FAIL if not mentioned in the summary or position descriptions.
13. TONE: FAIL if "wir/unser" or "Ich/Mein" in position descriptions. FAIL if marketing fluff.
14. MULTILANG: FAIL if Mehrsprachigkeit is described as an API or Schnittstelle.
15. INITIAL-PFLEGE: FAIL if described in terms of "Seiten" instead of "Datensätze".
### MISSION:
Return corrected fields ONLY for fields with issues. If everything passes, return empty corrections.
### OUTPUT FORMAT:
{
"passed": boolean,
"errors": [{ "field": string, "issue": string, "severity": "critical" | "warning" }],
"corrections": {
"briefingSummary"?: string,
"designVision"?: string,
"positionDescriptions"?: Record<string, string>,
"sitemap"?: array
}
}
`;
try {
const { data, usage } = await llmJsonRequest({
model: models.opus,
systemPrompt,
userPrompt: `BRIEFING_TRUTH:\n${state.concept?.briefing}\n\nCURRENT_STATE:\n${JSON.stringify(currentState, null, 2)}`,
apiKey: config.openrouterKey,
});
return {
success: true,
data,
usage: {
step: "06-critique",
model: models.opus,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
cost: usage.cost,
durationMs: Date.now() - startTime,
},
};
} catch (err) {
return { success: false, error: `Critique step failed: ${(err as Error).message}` };
}
}

View File

@@ -0,0 +1,113 @@
// ============================================================================
// @mintel/estimation-engine — Core Type Definitions
// ============================================================================
import type { ProjectConcept, SitemapCategory } from "@mintel/concept-engine";
/** Configuration for the estimation pipeline */
export interface PipelineConfig {
openrouterKey: string;
zyteApiKey?: string;
outputDir: string;
crawlDir: string;
modelsOverride?: Partial<ModelConfig>;
}
/** Model routing configuration */
export interface ModelConfig {
flash: string;
pro: string;
opus: string;
}
export const DEFAULT_MODELS: ModelConfig = {
flash: "google/gemini-3-flash-preview",
pro: "google/gemini-3.1-pro-preview",
opus: "anthropic/claude-opus-4-6",
};
/** Input for the estimation pipeline */
export interface PipelineInput {
concept: ProjectConcept;
budget?: string;
}
/** State that flows through all pipeline steps */
export interface EstimationState {
// Input
concept: ProjectConcept;
budget?: string;
// Step 5 output: Position Synthesis
positionDescriptions?: Record<string, string>;
// Step 6 output: Critique
critiquePassed?: boolean;
critiqueErrors?: string[];
// Step 7 output: Validation
validationResult?: ValidationResult;
// Final merged form state for PDF generation
formState?: Record<string, any>;
// Cost tracking
usage: UsageStats;
}
export interface UsageStats {
totalPromptTokens: number;
totalCompletionTokens: number;
totalCost: number;
perStep: StepUsage[];
}
export interface StepUsage {
step: string;
model: string;
promptTokens: number;
completionTokens: number;
cost: number;
durationMs: number;
}
/** Result of a single pipeline step */
export interface StepResult<T = any> {
success: boolean;
data?: T;
error?: string;
usage?: StepUsage;
}
/** Validation result from the deterministic validator */
export interface ValidationResult {
passed: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
}
export interface ValidationError {
code: string;
message: string;
field?: string;
expected?: any;
actual?: any;
}
export interface ValidationWarning {
code: string;
message: string;
suggestion?: string;
}
/** Step definition for the pipeline */
export interface PipelineStep {
id: string;
name: string;
description: string;
model: "flash" | "pro" | "opus" | "none";
execute: (
state: EstimationState,
config: PipelineConfig,
) => Promise<StepResult>;
}

View File

@@ -0,0 +1,380 @@
// ============================================================================
// Validators — Deterministic Math & Logic Checks (NO LLM!)
// Catches all the issues reported by the user programmatically.
// ============================================================================
import type { EstimationState, ValidationResult, ValidationError, ValidationWarning } from "./types.js";
/**
* Run all deterministic validation checks on the final estimation state.
*/
export function validateEstimation(state: EstimationState): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
if (!state.formState) {
return { passed: false, errors: [{ code: "NO_FORM_STATE", message: "No form state available for validation." }], warnings: [] };
}
const fs = state.formState;
// 1. PAGE COUNT PARITY
validatePageCountParity(fs, errors);
// 2. SORGLOS-BETRIEB IN SUMMARY
validateSorglosBetrieb(fs, errors, warnings);
// 3. NO VIDEOS AS PAGES
validateNoVideosAsPages(fs, errors);
// 4. EXTERNAL DOMAINS NOT AS PAGES
validateExternalDomains(fs, state.concept?.siteProfile, errors);
// 5. SERVICE COVERAGE
validateServiceCoverage(fs, state.concept?.siteProfile, warnings);
// 6. EXISTING FEATURE DETECTION
validateExistingFeatures(fs, state.concept?.siteProfile, warnings);
// 7. MULTILANG LABEL CORRECTNESS
validateMultilangLabeling(fs, errors);
// 8. INITIAL-PFLEGE UNITS
validateInitialPflegeUnits(fs, warnings);
// 9. SITEMAP vs PAGE LIST CONSISTENCY
validateSitemapConsistency(fs, errors);
return {
passed: errors.length === 0,
errors,
warnings,
};
}
/**
* 1. Page count: the "Individuelle Seiten" position description must mention
* roughly the same number of pages as the sitemap contains.
* "er berechnet 15 Seiten nennt aber nur 11"
*
* NOTE: fs.pages (from auditedFacts) is a conceptual list of page groups
* (e.g. "Leistungen") while the sitemap expands those into sub-pages.
* Therefore we do NOT compare fs.pages.length to the sitemap count.
* Instead, we verify that the position description text lists the right count.
*/
function validatePageCountParity(
fs: Record<string, any>,
errors: ValidationError[],
): void {
// Count pages listed in the sitemap (the source of truth)
let sitemapPageCount = 0;
if (Array.isArray(fs.sitemap)) {
for (const cat of fs.sitemap) {
sitemapPageCount += (cat.pages || []).length;
}
}
if (sitemapPageCount === 0) return;
// Extract page names mentioned in the "Individuelle Seiten" position description
const positions = fs.positionDescriptions || {};
const pagesDesc = positions["Individuelle Seiten"] || positions["2. Individuelle Seiten"] || "";
if (!pagesDesc) return;
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
// Count distinct page names mentioned (split by comma)
// We avoid splitting by "&" or "und" because actual page names like
// "Wartung & Störungsdienst" or "Genehmigungs- und Ausführungsplanung" contain them.
const afterColon = descStr.includes(":") ? descStr.split(":").slice(1).join(":") : descStr;
const segments = afterColon
.split(/,/)
.map((s: string) => s.replace(/\.$/, "").trim())
.filter((s: string) => s.length > 2);
// Handle consolidated references like "Leistungen (6 Unterseiten)" or "(inkl. Messen)"
let mentionedCount = 0;
for (const seg of segments) {
const subPageMatch = seg.match(/\((\d+)\s+(?:Unter)?[Ss]eiten?\)/);
if (subPageMatch) {
mentionedCount += parseInt(subPageMatch[1], 10);
} else if (seg.match(/\(inkl\.\s+/)) {
// "Unternehmen (inkl. Messen)" = 2 pages
mentionedCount += 2;
} else {
mentionedCount += 1;
}
}
if (mentionedCount > 0 && Math.abs(mentionedCount - sitemapPageCount) > 2) {
errors.push({
code: "PAGE_COUNT_MISMATCH",
message: `Seiten-Beschreibung nennt ~${mentionedCount} Seiten, aber ${sitemapPageCount} Seiten in der Sitemap.`,
field: "positionDescriptions.Individuelle Seiten",
expected: sitemapPageCount,
actual: mentionedCount,
});
}
}
/**
* 2. Sorglos-Betrieb must be included in summary.
* "Zusammenfassung der Schätzung hat Sorglos-Betrieb nicht miteingenommen"
*/
function validateSorglosBetrieb(
fs: Record<string, any>,
errors: ValidationError[],
warnings: ValidationWarning[],
): void {
const positions = fs.positionDescriptions || {};
const hasPosition = Object.keys(positions).some(
(k) =>
k.toLowerCase().includes("sorglos") ||
k.toLowerCase().includes("betrieb") ||
k.toLowerCase().includes("pflege"),
);
if (!hasPosition) {
errors.push({
code: "MISSING_SORGLOS_BETRIEB",
message: "Der Sorglos-Betrieb fehlt in den Position-Beschreibungen.",
field: "positionDescriptions",
});
}
}
/**
* 3. Videos must not be treated as separate pages.
* "Er hat Videos als eigene Seite aufgenommen"
*/
function validateNoVideosAsPages(
fs: Record<string, any>,
errors: ValidationError[],
): void {
const allPages = [...(fs.selectedPages || []), ...(fs.otherPages || [])];
const sitemapPages = Array.isArray(fs.sitemap)
? fs.sitemap.flatMap((cat: any) => (cat.pages || []).map((p: any) => p.title))
: [];
const allPageNames = [...allPages, ...sitemapPages];
const videoKeywords = ["video", "film", "messefilm", "imagefilm", "clip"];
for (const pageName of allPageNames) {
const lower = (typeof pageName === "string" ? pageName : "").toLowerCase();
if (videoKeywords.some((kw) => lower.includes(kw) && !lower.includes("leistung"))) {
errors.push({
code: "VIDEO_AS_PAGE",
message: `"${pageName}" ist ein Video-Asset, keine eigene Seite.`,
field: "sitemap",
});
}
}
}
/**
* 4. External sister-company domains must not be proposed as sub-pages.
* "er hat ingenieursgesellschaft als seite integriert, die haben aber eine eigene website"
*/
function validateExternalDomains(
fs: Record<string, any>,
siteProfile: any,
errors: ValidationError[],
): void {
if (!siteProfile?.externalDomains?.length) return;
const sitemapPages = Array.isArray(fs.sitemap)
? fs.sitemap.flatMap((cat: any) => (cat.pages || []).map((p: any) => p.title || ""))
: [];
for (const extDomain of siteProfile.externalDomains) {
// Extract base name (e.g. "etib-ing" from "etib-ing.com")
const baseName = extDomain.replace(/^www\./, "").split(".")[0].toLowerCase();
for (const pageTitle of sitemapPages) {
const lower = pageTitle.toLowerCase();
// Check if the page title references the external company
if (lower.includes(baseName) || (lower.includes("ingenieur") && extDomain.includes("ing"))) {
errors.push({
code: "EXTERNAL_DOMAIN_AS_PAGE",
message: `"${pageTitle}" hat eine eigene Website (${extDomain}) und darf nicht als Unterseite vorgeschlagen werden.`,
field: "sitemap",
});
}
}
}
}
/**
* 5. Services from the existing site should be covered.
* "er hat leistungen ausgelassen die ganz klar auf der kompetenz seite genannt werden"
*/
function validateServiceCoverage(
fs: Record<string, any>,
siteProfile: any,
warnings: ValidationWarning[],
): void {
if (!siteProfile?.services?.length) return;
const allContent = JSON.stringify(fs).toLowerCase();
for (const service of siteProfile.services) {
const keywords = service
.toLowerCase()
.split(/[\s,&-]+/)
.filter((w: string) => w.length > 4);
const isCovered = keywords.some((kw: string) => allContent.includes(kw));
if (!isCovered && service.length > 5) {
warnings.push({
code: "MISSING_SERVICE",
message: `Bestehende Leistung "${service}" ist nicht in der Schätzung berücksichtigt.`,
suggestion: `Prüfen ob "${service}" im Briefing gewünscht ist und ggf. in die Seitenstruktur aufnehmen.`,
});
}
}
}
/**
* 6. Existing features (search, forms) must be acknowledged.
* "er hat die suchfunktion nicht bemerkt, die gibts schon auf der seite"
*/
function validateExistingFeatures(
fs: Record<string, any>,
siteProfile: any,
warnings: ValidationWarning[],
): void {
if (!siteProfile?.existingFeatures?.length) return;
const functions = fs.functions || [];
const features = fs.features || [];
const allSelected = [...functions, ...features];
for (const existingFeature of siteProfile.existingFeatures) {
if (existingFeature === "cookie-consent") continue; // Standard, don't flag
if (existingFeature === "video") continue; // Usually an asset, not a feature
const isMapped = allSelected.some(
(f: string) => f.toLowerCase() === existingFeature.toLowerCase(),
);
if (!isMapped) {
warnings.push({
code: "EXISTING_FEATURE_IGNORED",
message: `Die bestehende Suchfunktion/Feature "${existingFeature}" wurde auf der aktuellen Website erkannt, aber nicht in der Schätzung berücksichtigt.`,
suggestion: `"${existingFeature}" als Function oder Feature aufnehmen, da es bereits existiert und der Kunde es erwartet.`,
});
}
}
}
/**
* 7. Multilang +20% must not be labeled as API.
* "die +20% beziehen sich nicht auf API"
*/
function validateMultilangLabeling(
fs: Record<string, any>,
errors: ValidationError[],
): void {
const positions = fs.positionDescriptions || {};
for (const [key, desc] of Object.entries(positions)) {
if (key.toLowerCase().includes("api") || key.toLowerCase().includes("schnittstelle")) {
const descStr = typeof desc === "string" ? desc : "";
if (
descStr.toLowerCase().includes("mehrsprach") ||
descStr.toLowerCase().includes("multilang") ||
descStr.toLowerCase().includes("20%")
) {
errors.push({
code: "MULTILANG_WRONG_POSITION",
message: `Mehrsprachigkeit (+20%) ist unter "${key}" eingeordnet, gehört aber nicht zu API/Schnittstellen.`,
field: key,
});
}
}
}
}
/**
* 8. Initial-Pflege should refer to "Datensätze" not "Seiten".
* "Initialpflege => 100€/Stk => damit sind keine Seiten sondern Datensätze"
*/
function validateInitialPflegeUnits(
fs: Record<string, any>,
warnings: ValidationWarning[],
): void {
const positions = fs.positionDescriptions || {};
for (const [key, desc] of Object.entries(positions)) {
if (key.toLowerCase().includes("pflege") || key.toLowerCase().includes("initial")) {
const descStr = typeof desc === "string" ? desc : "";
if (descStr.toLowerCase().includes("seiten") && !descStr.toLowerCase().includes("datensätz")) {
warnings.push({
code: "INITIALPFLEGE_WRONG_UNIT",
message: `"${key}" spricht von "Seiten", aber gemeint sind Datensätze (z.B. Produkte, Referenzen).`,
suggestion: `Beschreibung auf "Datensätze" statt "Seiten" ändern.`,
});
}
}
}
}
/**
* 9. Position descriptions must match calculated quantities.
*/
function validatePositionDescriptionsMath(
fs: Record<string, any>,
errors: ValidationError[],
): void {
const positions = fs.positionDescriptions || {};
// Check pages description mentions correct count
const pagesDesc = positions["Individuelle Seiten"] || positions["2. Individuelle Seiten"] || "";
if (pagesDesc) {
// Use the sitemap as the authoritative source of truth for page count
let sitemapPageCount = 0;
if (Array.isArray(fs.sitemap)) {
for (const cat of fs.sitemap) {
sitemapPageCount += (cat.pages || []).length;
}
}
// Count how many page names are mentioned in the description
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
const mentionedPages = descStr.split(/,|und|&/).filter((s: string) => s.trim().length > 2);
if (sitemapPageCount > 0 && mentionedPages.length > 0 && Math.abs(mentionedPages.length - sitemapPageCount) > 2) {
errors.push({
code: "PAGES_DESC_COUNT_MISMATCH",
message: `Seiten-Beschreibung nennt ~${mentionedPages.length} Seiten, aber ${sitemapPageCount} in der Sitemap.`,
field: "positionDescriptions.Individuelle Seiten",
expected: sitemapPageCount,
actual: mentionedPages.length,
});
}
}
}
/**
* 10. Sitemap categories should be consistent with selected pages/features.
*/
function validateSitemapConsistency(
fs: Record<string, any>,
errors: ValidationError[],
): void {
if (!Array.isArray(fs.sitemap)) return;
const sitemapTitles = fs.sitemap
.flatMap((cat: any) => (cat.pages || []).map((p: any) => (p.title || "").toLowerCase()));
// Check for "Verwaltung" page (hallucinated management page)
for (const title of sitemapTitles) {
if (title.includes("verwaltung") && !title.includes("inhalt")) {
errors.push({
code: "HALLUCINATED_MANAGEMENT_PAGE",
message: `"Verwaltung" als Seite ist vermutlich halluziniert. Verwaltung ist typischerweise eine interne Funktion, keine öffentliche Webseite.`,
field: "sitemap",
});
}
}
}

View File

@@ -0,0 +1,14 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}