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
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:
40
packages/estimation-engine/src/_test_pipeline.ts
Normal file
40
packages/estimation-engine/src/_test_pipeline.ts
Normal 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);
|
||||
}
|
||||
78
packages/estimation-engine/src/cli.ts
Normal file
78
packages/estimation-engine/src/cli.ts
Normal 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();
|
||||
9
packages/estimation-engine/src/index.ts
Normal file
9
packages/estimation-engine/src/index.ts
Normal 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";
|
||||
128
packages/estimation-engine/src/llm-client.ts
Normal file
128
packages/estimation-engine/src/llm-client.ts
Normal 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;
|
||||
}
|
||||
228
packages/estimation-engine/src/pipeline.ts
Normal file
228
packages/estimation-engine/src/pipeline.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
95
packages/estimation-engine/src/steps/05-synthesize.ts
Normal file
95
packages/estimation-engine/src/steps/05-synthesize.ts
Normal 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}` };
|
||||
}
|
||||
}
|
||||
99
packages/estimation-engine/src/steps/06-critique.ts
Normal file
99
packages/estimation-engine/src/steps/06-critique.ts
Normal 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}` };
|
||||
}
|
||||
}
|
||||
113
packages/estimation-engine/src/types.ts
Normal file
113
packages/estimation-engine/src/types.ts
Normal 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>;
|
||||
}
|
||||
380
packages/estimation-engine/src/validators.ts
Normal file
380
packages/estimation-engine/src/validators.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/estimation-engine/tsconfig.json
Normal file
14
packages/estimation-engine/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user