feat: Introduce AI estimation and quote generation scripts, update pricing logic and PDF components, add new documentation, and clean up temporary files.
This commit is contained in:
537
scripts/ai-estimate.ts
Normal file
537
scripts/ai-estimate.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { CheerioCrawler, RequestQueue } from 'crawlee';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { URL } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
import axios from 'axios';
|
||||
import { FileCacheAdapter } from '../src/utils/cache/file-adapter.js';
|
||||
|
||||
import { initialState } from '../src/logic/pricing/constants.js';
|
||||
|
||||
async function main() {
|
||||
const OPENROUTER_KEY = process.env.OPENROUTER_KEY;
|
||||
if (!OPENROUTER_KEY) {
|
||||
console.error('❌ Error: OPENROUTER_KEY not found in environment.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let briefing = '';
|
||||
let targetUrl: string | null = null;
|
||||
let comments: string | null = null;
|
||||
let cacheKey: string | null = null;
|
||||
|
||||
let jsonStatePath: string | null = null;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--url') {
|
||||
targetUrl = args[++i];
|
||||
} else if (arg === '--comments' || arg === '--notes') {
|
||||
comments = args[++i];
|
||||
} else if (arg === '--cache-key') {
|
||||
cacheKey = args[++i];
|
||||
} else if (arg === '--json') {
|
||||
jsonStatePath = args[++i];
|
||||
} else if (!arg.startsWith('--')) {
|
||||
briefing = arg;
|
||||
}
|
||||
}
|
||||
|
||||
if (briefing && briefing.startsWith('@')) {
|
||||
const rawPath = briefing.substring(1);
|
||||
const filePath = rawPath.startsWith('/') ? rawPath : path.resolve(process.cwd(), rawPath);
|
||||
briefing = await fs.readFile(filePath, 'utf8');
|
||||
}
|
||||
|
||||
// Discovery ONLY if not provided
|
||||
if (!targetUrl && briefing) {
|
||||
const urlMatch = briefing.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch) {
|
||||
targetUrl = urlMatch[0];
|
||||
console.log(`🔗 Discovered URL in briefing: ${targetUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!briefing && !targetUrl && !comments && !jsonStatePath) {
|
||||
console.error('❌ Usage: npm run ai-estimate -- "Briefing text" [--url https://example.com] [--comments "Manual notes"]');
|
||||
console.error(' Or: npm run ai-estimate -- @briefing.txt [--url https://example.com]');
|
||||
console.error(' Or: npm run ai-estimate -- --json path/to/state.json');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const clearCache = process.argv.includes('--clear-cache');
|
||||
if (clearCache) {
|
||||
console.log('🧹 Clearing cache...');
|
||||
const cacheFiles = await fs.readdir(path.join(process.cwd(), '.cache'));
|
||||
for (const file of cacheFiles) {
|
||||
if (file.startsWith('ai_est_')) {
|
||||
await fs.unlink(path.join(process.cwd(), '.cache', file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cache = new FileCacheAdapter({ prefix: 'ai_est_' });
|
||||
const finalCacheKey = cacheKey || `${briefing}_${targetUrl}_${comments}`;
|
||||
|
||||
// 1. Crawl if URL provided
|
||||
let crawlContext = '';
|
||||
if (targetUrl) {
|
||||
console.log(`🔍 Crawling ${targetUrl} for context...`);
|
||||
const cachedCrawl = await cache.get<string>(`crawl_${targetUrl}`);
|
||||
if (cachedCrawl && !clearCache) {
|
||||
console.log('📦 Using cached crawl results.');
|
||||
crawlContext = cachedCrawl;
|
||||
} else {
|
||||
crawlContext = await performCrawl(targetUrl);
|
||||
await cache.set(`crawl_${targetUrl}`, crawlContext, 86400); // 24h cache
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AI Prompting
|
||||
console.log('🤖 Consultating Gemini 3 Flash...');
|
||||
const cachedAi = !clearCache ? await cache.get<any>(finalCacheKey) : null;
|
||||
let formState: any;
|
||||
let usage: { prompt: number, completion: number, cost: number } = { prompt: 0, completion: 0, cost: 0 };
|
||||
|
||||
// Load Context Documents
|
||||
const principles = await fs.readFile(path.resolve(process.cwd(), 'docs/PRINCIPLES.md'), 'utf8');
|
||||
const techStandards = await fs.readFile(path.resolve(process.cwd(), 'docs/TECH.md'), 'utf8');
|
||||
const tone = await fs.readFile(path.resolve(process.cwd(), 'docs/TONE.md'), 'utf8');
|
||||
|
||||
if (jsonStatePath) {
|
||||
console.log(`📂 Loading state from JSON: ${jsonStatePath}`);
|
||||
const rawJson = await fs.readFile(path.resolve(process.cwd(), jsonStatePath), 'utf8');
|
||||
formState = JSON.parse(rawJson);
|
||||
} else if (cachedAi) {
|
||||
console.log('📦 Using cached AI response.');
|
||||
formState = cachedAi;
|
||||
} else {
|
||||
const result = await getAiEstimation(briefing, crawlContext, comments, OPENROUTER_KEY, principles, techStandards, tone);
|
||||
formState = result.state;
|
||||
usage = result.usage;
|
||||
await cache.set(finalCacheKey, formState);
|
||||
}
|
||||
|
||||
// 3. Save Data & Generate PDF
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const jsonOutDir = path.resolve(process.cwd(), 'out/estimations/json');
|
||||
if (!existsSync(jsonOutDir)) await fs.mkdir(jsonOutDir, { recursive: true });
|
||||
|
||||
const finalJsonPath = path.join(jsonOutDir, `${formState.companyName || 'unknown'}_${timestamp}.json`);
|
||||
await fs.writeFile(finalJsonPath, JSON.stringify(formState, null, 2));
|
||||
|
||||
const tempJsonPath = path.resolve(process.cwd(), '.cache', `temp_state_${Date.now()}.json`);
|
||||
await fs.writeFile(tempJsonPath, JSON.stringify(formState, null, 2));
|
||||
|
||||
console.log(`📦 Saved detailed state to: ${finalJsonPath}`);
|
||||
console.log('📄 Generating PDF estimation...');
|
||||
try {
|
||||
execSync(`npx tsx ./scripts/generate-quote.ts --input ${tempJsonPath}`, { stdio: 'inherit' });
|
||||
} finally {
|
||||
// await fs.unlink(tempJsonPath);
|
||||
}
|
||||
|
||||
console.log('\n✨ AI Estimation Complete!');
|
||||
if (usage.prompt > 0) {
|
||||
console.log('--------------------------------------------------');
|
||||
console.log('📊 ACCUMULATED API USAGE (SUM OF 6 PASSES)');
|
||||
console.log(` Model: google/gemini-3-flash-preview`);
|
||||
console.log(` Total Prompt: ${usage.prompt.toLocaleString()}`);
|
||||
console.log(` Total Completion: ${usage.completion.toLocaleString()}`);
|
||||
console.log(` Total Tokens: ${(usage.prompt + usage.completion).toLocaleString()}`);
|
||||
console.log(` Total Cost (USD): $${usage.cost.toFixed(6)}`);
|
||||
console.log('--------------------------------------------------\n');
|
||||
}
|
||||
}
|
||||
|
||||
async function performCrawl(url: string): Promise<string> {
|
||||
const pages: { url: string, content: string, type: string }[] = [];
|
||||
const origin = new URL(url).origin;
|
||||
|
||||
const crawler = new CheerioCrawler({
|
||||
maxRequestsPerCrawl: 20,
|
||||
async requestHandler({ $, request, enqueueLinks }) {
|
||||
const title = $('title').text();
|
||||
const urlObj = new URL(request.url);
|
||||
const urlPath = urlObj.pathname.toLowerCase();
|
||||
|
||||
let type = 'other';
|
||||
if (urlPath === '/' || urlPath === '') type = 'home';
|
||||
else if (urlPath.includes('service') || urlPath.includes('leistung')) type = 'service';
|
||||
else if (urlPath.includes('blog') || urlPath.includes('news') || urlPath.includes('aktuelles') || urlPath.includes('magazin')) type = 'blog';
|
||||
else if (urlPath.includes('contact') || urlPath.includes('kontakt')) type = 'contact';
|
||||
else if (urlPath.includes('job') || urlPath.includes('karriere') || urlPath.includes('career') || urlPath.includes('human-resources')) type = 'career';
|
||||
else if (urlPath.includes('portfolio') || urlPath.includes('referenz') || urlPath.includes('projekt') || urlPath.includes('case-study')) type = 'portfolio';
|
||||
else if (urlPath.includes('legal') || urlPath.includes('impressum') || urlPath.includes('datenschutz') || urlPath.includes('privacy')) type = 'legal';
|
||||
|
||||
const h1s = $('h1').map((_, el) => $(el).text()).get();
|
||||
const navLinks = $('nav a').map((_, el) => $(el).text().trim()).get().filter(t => t.length > 0);
|
||||
const bodyText = $('body').text().replace(/\s+/g, ' ').substring(0, 50000);
|
||||
const html = $.html();
|
||||
const hexColors = html.match(/#(?:[0-9a-fA-F]{3}){1,2}\b/g) || [];
|
||||
const uniqueColors = Array.from(new Set(hexColors)).slice(0, 5);
|
||||
|
||||
pages.push({
|
||||
url: request.url,
|
||||
type,
|
||||
content: `Title: ${title}\nType: ${type}\nHeadings: ${h1s.join(', ')}\nNav: ${navLinks.join(', ')}\nColors: ${uniqueColors.join(', ')}\nText: ${bodyText}`
|
||||
});
|
||||
|
||||
await enqueueLinks({
|
||||
limit: 15,
|
||||
transformRequestFunction: (req) => {
|
||||
const reqUrl = new URL(req.url);
|
||||
if (reqUrl.origin !== origin) return false;
|
||||
// Skip assets
|
||||
if (reqUrl.pathname.match(/\.(pdf|zip|jpg|png|svg|webp)$/i)) return false;
|
||||
return req;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await crawler.run([url]);
|
||||
|
||||
const typeCounts = pages.reduce((acc, p) => {
|
||||
acc[p.type] = (acc[p.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
let summary = `\nCrawl Summary: Identified ${pages.length} pages total on ${origin}.\n`;
|
||||
summary += Object.entries(typeCounts).map(([type, count]) => `- ${type}: ${count}`).join('\n') + '\n\n';
|
||||
|
||||
return summary + pages.map(p => `--- PAGE: ${p.url} ---\n${p.content}`).join('\n\n');
|
||||
}
|
||||
|
||||
async function getAiEstimation(briefing: string, crawlContext: string, comments: string | null, apiKey: string, principles: string, techStandards: string, tone: string) {
|
||||
let usage = { prompt: 0, completion: 0, cost: 0 };
|
||||
const addUsage = (data: any) => {
|
||||
if (data?.usage) {
|
||||
usage.prompt += data.usage.prompt_tokens || 0;
|
||||
usage.completion += data.usage.completion_tokens || 0;
|
||||
// OpenRouter provides 'cost' field in USD (as per documentation)
|
||||
// If missing, we use a fallback calculation for transparency
|
||||
if (data.usage.cost !== undefined) {
|
||||
usage.cost += data.usage.cost;
|
||||
} else {
|
||||
// Fallback: Gemini 3 Flash Flash pricing (~$0.1 / 1M prompt, ~$0.4 / 1M completion)
|
||||
usage.cost += (data.usage.prompt_tokens || 0) * (0.1 / 1000000) + (data.usage.completion_tokens || 0) * (0.4 / 1000000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 1. PASS 1: Fact Extraction
|
||||
console.log(' ↳ Pass 1: Fact Extraction...');
|
||||
const pass1SystemPrompt = `
|
||||
You are a precision extraction engine. Analyze the briefing and extract ONLY the raw facts.
|
||||
Tone: Literal, non-interpretive.
|
||||
Output language: GERMAN (Strict).
|
||||
|
||||
### OBJECTIVES:
|
||||
- Extract companyName (Strictly the name, no descriptors).
|
||||
- Extract companyAddress (Full address if found).
|
||||
- Extract personName (Primary contact if found).
|
||||
- Extract **websiteTopic**: This MUST be a single, short branch name (e.g., "Kabeltiefbau", "Logistik", "Anwaltskanzlei"). ABSOLUTELY NO SENTENCES. If the briefing says "Group-Homepage for X", extract ONLY "X".
|
||||
- Map to internal IDs for selectedPages, features, functions, apiSystems, assets.
|
||||
- Identify if isRelaunch is true (briefing mentions existing site or URL).
|
||||
- For all textual values (deadline, websiteTopic, targetAudience etc.): USE GERMAN.
|
||||
- **multilang**: ONLY if the briefing mentions multiple target languages (e.g., DE/EN). If only one language is mentioned, do NOT use multilang.
|
||||
- **maps**: If "Google Maps" or location maps are mentioned or implicit (Contact page).
|
||||
- **CRITICAL**: Do NOT include "social" in apiSystems unless the user explicitly wants to SYNC/POST content to social media. "Existing social media links" are NOT apiSystems.
|
||||
- **CRITICAL**: "Video Player", "Cookie Banner", "Animations" are NOT features. They are visual/base. Do NOT map them to features.
|
||||
|
||||
### CATEGORY MAPPING (IDs ONLY):
|
||||
- **selectedPages**: [Home, About, Services, Contact, Landing, Legal]
|
||||
- **features**: [blog_news, products, jobs, refs, events]
|
||||
- **functions**: [search, filter, pdf, forms, members, calendar, multilang, chat]
|
||||
- **apiSystems**: [crm_erp, payment, marketing, ecommerce, maps, social, analytics]
|
||||
- **assets**: [existing_website, logo, styleguide, content_concept, media, icons, illustrations, fonts]
|
||||
|
||||
### OUTPUT FORMAT (Strict JSON):
|
||||
{
|
||||
"companyName": string,
|
||||
"companyAddress": string,
|
||||
"personName": string,
|
||||
"websiteTopic": string,
|
||||
"isRelaunch": boolean,
|
||||
"selectedPages": string[],
|
||||
"features": string[],
|
||||
"functions": string[],
|
||||
"apiSystems": string[],
|
||||
"assets": string[],
|
||||
"deadline": string (GERMAN),
|
||||
"targetAudience": "B2B" | "B2C" | "Internal" | string (GERMAN),
|
||||
"expectedAdjustments": "low" | "medium" | "high" | string (GERMAN),
|
||||
"employeeCount": string
|
||||
}
|
||||
`;
|
||||
const pass1UserPrompt = `BRIEFING:\n${briefing}\n\nCOMMENTS:\n${comments}\n\nCRAWL:\n${crawlContext}`;
|
||||
const p1Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: pass1SystemPrompt }, { role: 'user', content: pass1UserPrompt }],
|
||||
response_format: { type: 'json_object' }
|
||||
}, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } });
|
||||
addUsage(p1Resp.data);
|
||||
const facts = JSON.parse(p1Resp.data.choices[0].message.content);
|
||||
|
||||
// 2. PASS 2: Feature Deep-Dive
|
||||
console.log(' ↳ Pass 2: Feature Deep-Dive...');
|
||||
const pass2SystemPrompt = `
|
||||
You are a detail-oriented Solution Architect.
|
||||
For EVERY item selected in Pass 1 (pages, features, functions, apiSystems), write a specific justification and technical scope.
|
||||
|
||||
### RULES:
|
||||
1. **CONCRETE & SPECIFIC**: Do NOT say "Implementation of X". Say "Displaying X with Y filters".
|
||||
2. **NO EFFECTS**: Do not mention "fade-ins", "animations" or "visual styling". Focus on FUNCTION.
|
||||
3. **ABSOLUTE RULE**: EVERYTHING MUST BE GERMAN.
|
||||
4. **TRANSPARENCY**: Explain exactly what the USER gets.
|
||||
5. **API NOTE**: For 'media' or 'video', explicitly state "Upload & Integration" (NO STREAMING).
|
||||
|
||||
### INPUT (from Pass 1):
|
||||
${JSON.stringify(facts, null, 2)}
|
||||
|
||||
### OUTPUT FORMAT (Strict JSON):
|
||||
{
|
||||
"pageDetails": { "Home": string, ... },
|
||||
"featureDetails": { "blog_news": string, ... },
|
||||
"functionDetails": { "search": string, ... },
|
||||
"apiDetails": { "crm_erp": string, ... }
|
||||
}
|
||||
`;
|
||||
const p2Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: pass2SystemPrompt }, { role: 'user', content: briefing }],
|
||||
response_format: { type: 'json_object' }
|
||||
}, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } });
|
||||
addUsage(p2Resp.data);
|
||||
const details = JSON.parse(p2Resp.data.choices[0].message.content);
|
||||
|
||||
// 3. PASS 3: Strategic Content
|
||||
console.log(' ↳ Pass 3: Strategic Content...');
|
||||
const pass3SystemPrompt = `
|
||||
You are a high-end Digital Architect. Analyze the BRIEFING.
|
||||
ABSOLUTE RULE: OUTPUT MUST BE 100% GERMAN.
|
||||
|
||||
### TONE & COMMUNICATION PRINCIPLES (MANDATORY):
|
||||
${tone}
|
||||
|
||||
### OBJECTIVE:
|
||||
1. **briefingSummary**: Summarize the project's essence for the CUSTOMER.
|
||||
- FOLLOW PRINCIPLE 1 & 5: Clear, direct, no marketing fluff, no "partnership talk".
|
||||
- Focus purely on the CUSTOMER'S goal: What are they building, why does it matter to their business, and what is the outcome?
|
||||
- Keep it 2-3 professional, direct sentences.
|
||||
2. **designVision**: A solid, grounded, and high-quality description of the look & feel.
|
||||
- FOLLOW PRINCIPLE 1 & 3: Fact-based, professional, high density of information.
|
||||
- **NO ARROGANCE**: Eliminate all "high-end", "world-class", "dominance" language. Be humble and precise.
|
||||
- **SIMPLE & CLEAR**: Use simple German. No buzzwords. "Solid Industrial Design" instead of "Technocratic Sovereignty".
|
||||
- 3-4 sentences of deep analysis.
|
||||
|
||||
### OUTPUT FORMAT (Strict JSON):
|
||||
{
|
||||
"briefingSummary": string,
|
||||
"designVision": string
|
||||
}
|
||||
`;
|
||||
const p3Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: pass3SystemPrompt }, { role: 'user', content: briefing }],
|
||||
response_format: { type: 'json_object' }
|
||||
}, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } });
|
||||
addUsage(p3Resp.data);
|
||||
const strategy = JSON.parse(p3Resp.data.choices[0].message.content);
|
||||
|
||||
// 4. PASS 4: Information Architecture (Sitemap)
|
||||
console.log(' ↳ Pass 4: Information Architecture...');
|
||||
const pass4SystemPrompt = `
|
||||
You are a Senior UX Architect. Design a hierarchical sitemap following the 'Industrial Logic' principle.
|
||||
EVERYTHING MUST BE IN GERMAN.
|
||||
|
||||
### SITEMAP RULES:
|
||||
1. **HIERARCHY**: Build a logical tree. Group by category (e.g., "Kern-Präsenz", "Lösungen", "Vertrauen", "Rechtliches").
|
||||
2. **INTENT**: Each page MUST have a title and a brief functional conversion intent (desc).
|
||||
3. **COMPREHENSIVENESS**: Ensure all 'selectedPages' and 'features' from Pass 1 are represented.
|
||||
4. **LANGUAGE**: STRICT GERMAN TITLES. Do NOT use "Home", "About", "Services". Use "Startseite", "Über uns", "Leistungen".
|
||||
|
||||
### DATA CONTEXT:
|
||||
${JSON.stringify({ facts, strategy }, null, 2)}
|
||||
|
||||
### OUTPUT FORMAT (Strict JSON):
|
||||
{
|
||||
"websiteTopic": string,
|
||||
"sitemap": [ { "category": string, "pages": [ { "title": string, "desc": string } ] } ]
|
||||
}
|
||||
`;
|
||||
const p4Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: pass4SystemPrompt }, { role: 'user', content: briefing }],
|
||||
response_format: { type: 'json_object' }
|
||||
}, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } });
|
||||
addUsage(p4Resp.data);
|
||||
const ia = JSON.parse(p4Resp.data.choices[0].message.content);
|
||||
|
||||
// 5. PASS 5: Position Synthesis & Pricing Transparency
|
||||
console.log(' ↳ Pass 5: Position Synthesis...');
|
||||
const pass5SystemPrompt = `
|
||||
You are a Senior Solution Architect. Your goal is ABSOLUTE TRANSPARENCY for the customer.
|
||||
Each position in the quote must be perfectly justified and detailed.
|
||||
|
||||
### POSITION TITLES:
|
||||
"Basis Website Setup", "Individuelle Seiten", "System-Module", "Logik-Funktionen", "Schnittstellen (API)", "Inhaltsverwaltung (CMS)".
|
||||
|
||||
### MAPPING RULES (STRICTLY BASED ON PRICING.MD):
|
||||
- **Basis Website Setup**: Includes Infrastructure, Hosting Setup, Basic SEO, Cookie-Consent (!), Design-Template.
|
||||
- **System-Module (Features)**: ONLY closed data systems: [Blog, News, Products, Jobs, Cases/References, Events].
|
||||
- NEVER put "Video Player", "Cookies", "Animations" here.
|
||||
- **Logik-Funktionen**: Functional logic like: Search, Filter, Forms, PDF-Export, Multi-lang.
|
||||
- **Schnittstellen (API)**: REAL Data Syncs (CRM, ERP). DO NOT include Tracking, Google Maps, or simple Video embedding here. Basic embedding is "Basis Website Setup".
|
||||
- **Sorglos-Betrieb (Hosting)**: Hosting & Maintenance.
|
||||
|
||||
### RULES FOR positionDescriptions:
|
||||
1. **ZERO GENERALIZATION**: Do NOT say "Verschiedene Funktionen".
|
||||
2. **ITEMIZED SYNTHESIS**: Mention EVERY component selected in Pass 1.
|
||||
3. **BREVITY & DENSITY**: Max 1-2 short sentences. Focus on TASKS not RESULTS.
|
||||
4. **STYLE**: Direct, engineering-grade, no fluff.
|
||||
5. **LANGUAGE**: 100% GERMAN.
|
||||
6. **SPECIFIC - PAGES**: For "Individuelle Seiten", list the pages as a comma-separated list (e.g. "Umfasst: Startseite, Über uns, Leistungen, Kontakt, Impressum").
|
||||
7. **SPECIFIC - API**: Video Uploads, Google Maps, and Tracking are NOT APIs. If video/maps are standard embedding, do NOT put them in "Schnittstellen".
|
||||
8. **SPECIFIC - HOSTING**: Always append: "Inkl. 20GB Speicher. Auto-Erweiterung +10€/10GB."
|
||||
9. **SPECIFIC - LOGIC**: Describe the ACTUAL logic.
|
||||
- BAD: "Erweiterte Formulare", "Logikfunktionen"
|
||||
- GOOD: "Anfrage-Strecken mit Validierung", "Filterung nach Kategorie", "Mehrsprachigkeits-Routing".
|
||||
|
||||
### FORBIDDEN PHRASES (STRICT BLOCKLIST):
|
||||
- "Erweiterte Formulare" (INSTEAD USE: "Komplexe Anfrage-Logik" or "Valide Formular-Systeme")
|
||||
- "Verschiedene Funktionen"
|
||||
- "Allgemeine Logik"
|
||||
- "Optimierte Darstellung"
|
||||
|
||||
10. **NO "MARKETING LINGO"**: Never say "avoids branding" or "maximizes performance". Say "Implements HTML5 Video Player". ALWAYS DESCRIBE THE TASK.
|
||||
|
||||
### DATA CONTEXT:
|
||||
${JSON.stringify({ facts, details, strategy, ia }, null, 2)}
|
||||
|
||||
### OUTPUT FORMAT (Strict JSON):
|
||||
{
|
||||
"positionDescriptions": { "Basis Website Setup": string, ... }
|
||||
}
|
||||
`;
|
||||
const p5Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: pass5SystemPrompt }, { role: 'user', content: briefing }],
|
||||
response_format: { type: 'json_object' }
|
||||
}, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } });
|
||||
addUsage(p5Resp.data);
|
||||
const positionsData = JSON.parse(p5Resp.data.choices[0].message.content);
|
||||
|
||||
// 6. PASS 6: Reflection & Hardening
|
||||
console.log(' ↳ Pass 6: Reflection & Nuance Check...');
|
||||
const pass6SystemPrompt = `
|
||||
You are a senior supervisor. Compare the CURRENT_STATE against the RAW_BRIEFING.
|
||||
Your goal is to catch missed nuances, specific customer wishes, and technical details.
|
||||
|
||||
### CHECKLIST:
|
||||
1. **SPECIFICS**: Did we miss names, technical terms (kV, HDD, etc.), or specific vendor refs?
|
||||
2. **CONSISTENCY**: Do the positionDescriptions match the counts of features/functions in Pass 1?
|
||||
3. **DEADLINE**: Is there a specific month? (e.g. April/Mai). If yes, set "deadline" field.
|
||||
4. **LANGUAGE**: ABSOLUTE RULE: EVERYTHING MUST BE GERMAN.
|
||||
5. **CONFLICT CHECK**: If 'languagesList' has only 1 item, REMOVE 'multilang' from 'functions'.
|
||||
6. Refactor 'dontKnows' into a 'gridDontKnows' object for missing technical facts.
|
||||
|
||||
### CURRENT_STATE:
|
||||
${JSON.stringify({ facts, strategy, ia, positionsData }, null, 2)}
|
||||
`;
|
||||
const p6Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
|
||||
model: 'google/gemini-3-flash-preview',
|
||||
messages: [{ role: 'system', content: pass6SystemPrompt }, { role: 'user', content: `RAW_BRIEFING:\n${briefing}\n\nEnhance the state. Return ONLY the delta or the corrected fields.` }],
|
||||
response_format: { type: 'json_object' }
|
||||
}, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } });
|
||||
addUsage(p6Resp.data);
|
||||
const reflection = JSON.parse(p6Resp.data.choices[0].message.content);
|
||||
|
||||
let finalState = {
|
||||
...initialState,
|
||||
...facts,
|
||||
...strategy,
|
||||
...ia,
|
||||
...positionsData,
|
||||
...reflection,
|
||||
statusQuo: facts.isRelaunch ? 'Relaunch' : 'Neuentwicklung'
|
||||
};
|
||||
|
||||
// Flatten if AI nested everything under "0", "state" or "state.0"
|
||||
if (finalState["0"]) finalState = { ...finalState, ...finalState["0"] };
|
||||
if ((finalState as any).state) {
|
||||
const nestedState = (finalState as any).state;
|
||||
finalState = { ...finalState, ...nestedState };
|
||||
if (nestedState["0"]) finalState = { ...finalState, ...nestedState["0"] };
|
||||
}
|
||||
|
||||
// Normalization Layer: Map hallucinated German keys back to internal keys
|
||||
const normalizationMap: Record<string, string> = {
|
||||
"Briefing-Zusammenfassung": "briefingSummary",
|
||||
"Design-Vision": "designVision",
|
||||
"Zusammenfassung": "briefingSummary",
|
||||
"Vision": "designVision",
|
||||
"BRIEFING_SUMMARY": "briefingSummary",
|
||||
"DESIGN_VISION": "designVision"
|
||||
};
|
||||
|
||||
Object.entries(normalizationMap).forEach(([gerKey, intKey]) => {
|
||||
if (finalState[gerKey] && !finalState[intKey]) {
|
||||
if (typeof finalState[gerKey] === 'object' && !Array.isArray(finalState[gerKey])) {
|
||||
finalState[intKey] = Object.values(finalState[gerKey]).join('\n\n');
|
||||
} else {
|
||||
finalState[intKey] = finalState[gerKey];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sitemap Normalization (German keys to internal)
|
||||
if (Array.isArray(finalState.sitemap)) {
|
||||
finalState.sitemap = finalState.sitemap.map((cat: any) => ({
|
||||
category: cat.category || cat.kategorie || cat.Kategorie || cat.title || "Allgemein",
|
||||
pages: (cat.pages || cat.seiten || cat.Seiten || []).map((page: any) => ({
|
||||
title: page.title || page.titel || page.Titel || "Seite",
|
||||
desc: page.desc || page.beschreibung || page.Beschreibung || page.description || ""
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
// Position Descriptions Normalization
|
||||
if (finalState.positionDescriptions) {
|
||||
const normalized: Record<string, string> = {};
|
||||
Object.entries(finalState.positionDescriptions).forEach(([key, value]) => {
|
||||
const normalizedKey = key === 'titel' || key === 'Title' ? 'title' : key;
|
||||
const normalizedValue = typeof value === 'object' ? (value as any).beschreibung || (value as any).description || JSON.stringify(value) : value;
|
||||
normalized[normalizedKey] = normalizedValue as string;
|
||||
});
|
||||
finalState.positionDescriptions = normalized;
|
||||
}
|
||||
|
||||
// Normalize final state
|
||||
if (Array.isArray(finalState.positionDescriptions)) {
|
||||
const normalized: Record<string, string> = {};
|
||||
finalState.positionDescriptions.forEach((item: any) => {
|
||||
const key = item.feature || item.id || item.title || item.pos;
|
||||
if (key) normalized[key] = item.description || item.desc;
|
||||
});
|
||||
finalState.positionDescriptions = normalized;
|
||||
}
|
||||
|
||||
if (finalState.sitemap && !Array.isArray(finalState.sitemap)) {
|
||||
if (finalState.sitemap.categories) finalState.sitemap = finalState.sitemap.categories;
|
||||
else if (finalState.sitemap.sitemap) finalState.sitemap = finalState.sitemap.sitemap;
|
||||
else {
|
||||
const entries = Object.entries(finalState.sitemap);
|
||||
if (entries.every(([_, v]) => Array.isArray(v))) {
|
||||
finalState.sitemap = entries.map(([category, pages]) => ({ category, pages }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { state: finalState, usage };
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
154
scripts/generate-quote.ts
Normal file
154
scripts/generate-quote.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as readline from 'node:readline/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createElement } from 'react';
|
||||
import { renderToFile } from '@react-pdf/renderer';
|
||||
import { calculatePositions } from '../src/logic/pricing/calculator.js';
|
||||
import { CombinedQuotePDF } from '../src/components/CombinedQuotePDF.js';
|
||||
import { initialState, PRICING } from '../src/logic/pricing/constants.js';
|
||||
import { getTechDetails, getPrinciples } from '../src/logic/content-provider.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const isInteractive = args.includes('--interactive') || args.includes('-I');
|
||||
const inputPath = args.find((_, i) => args[i - 1] === '--input' || args[i - 1] === '-i');
|
||||
const outputPath = args.find((_, i) => args[i - 1] === '--output' || args[i - 1] === '-o');
|
||||
|
||||
let state = { ...initialState };
|
||||
|
||||
if (inputPath) {
|
||||
const rawData = fs.readFileSync(path.resolve(process.cwd(), inputPath), 'utf8');
|
||||
const diskState = JSON.parse(rawData);
|
||||
state = { ...state, ...diskState };
|
||||
}
|
||||
|
||||
if (isInteractive) {
|
||||
state = await runWizard(state);
|
||||
}
|
||||
|
||||
// Final confirmation of data needed for PDF
|
||||
if (!state.name || !state.email) {
|
||||
console.warn('⚠️ Missing recipient name or email. Document might look incomplete.');
|
||||
}
|
||||
|
||||
const totalPrice = calculateTotal(state);
|
||||
const monthlyPrice = calculateMonthly(state);
|
||||
const totalPagesCount = (state.selectedPages?.length || 0) + (state.otherPages?.length || 0) + (state.otherPagesCount || 0);
|
||||
|
||||
const finalOutputPath = outputPath || generateDefaultPath(state);
|
||||
const outputDir = path.dirname(finalOutputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Resolve assets for the PDF
|
||||
const assetsDir = path.resolve(process.cwd(), 'src/assets');
|
||||
const headerIcon = path.join(assetsDir, 'logo/Icon White Transparent.png');
|
||||
const footerLogo = path.join(assetsDir, 'logo/Logo Black Transparent.png');
|
||||
|
||||
console.log(`🚀 Generating PDF: ${finalOutputPath}`);
|
||||
|
||||
const estimationProps = {
|
||||
state,
|
||||
totalPrice,
|
||||
monthlyPrice,
|
||||
totalPagesCount,
|
||||
pricing: PRICING,
|
||||
headerIcon,
|
||||
footerLogo
|
||||
};
|
||||
|
||||
await renderToFile(
|
||||
createElement(CombinedQuotePDF as any, {
|
||||
estimationProps,
|
||||
techDetails: getTechDetails(),
|
||||
principles: getPrinciples()
|
||||
}) as any,
|
||||
finalOutputPath
|
||||
);
|
||||
|
||||
console.log('✅ Done!');
|
||||
}
|
||||
|
||||
async function runWizard(state: any) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
console.log('\n--- Mintel Quote Generator Wizard ---\n');
|
||||
|
||||
const ask = async (q: string, def?: string) => {
|
||||
const answer = await rl.question(`${q}${def ? ` [${def}]` : ''}: `);
|
||||
return answer || def || '';
|
||||
};
|
||||
|
||||
const selectOne = async (q: string, options: { id: string, label: string }[]) => {
|
||||
console.log(`\n${q}:`);
|
||||
options.forEach((opt, i) => console.log(`${i + 1}) ${opt.label}`));
|
||||
const answer = await rl.question('Selection (number): ');
|
||||
const idx = parseInt(answer) - 1;
|
||||
return options[idx]?.id || options[0].id;
|
||||
};
|
||||
|
||||
state.name = await ask('Recipient Name', state.name);
|
||||
state.email = await ask('Recipient Email', state.email);
|
||||
state.companyName = await ask('Company Name', state.companyName);
|
||||
|
||||
state.projectType = await selectOne('Project Type', [
|
||||
{ id: 'website', label: 'Website' },
|
||||
{ id: 'web-app', label: 'Web App' }
|
||||
]);
|
||||
|
||||
if (state.projectType === 'website') {
|
||||
state.websiteTopic = await ask('Website Topic', state.websiteTopic);
|
||||
// Simplified for now, in a real tool we'd loop through all options
|
||||
}
|
||||
|
||||
rl.close();
|
||||
return state;
|
||||
}
|
||||
|
||||
function calculateTotal(state: any) {
|
||||
// Basic duplication of logic from ContactForm for consistency
|
||||
if (state.projectType !== 'website') return 0;
|
||||
const totalPagesCount = (state.selectedPages?.length || 0) + (state.otherPages?.length || 0) + (state.otherPagesCount || 0);
|
||||
let total = PRICING.BASE_WEBSITE;
|
||||
total += totalPagesCount * PRICING.PAGE;
|
||||
total += ((state.features?.length || 0) + (state.otherFeatures?.length || 0) + (state.otherFeaturesCount || 0)) * PRICING.FEATURE;
|
||||
total += ((state.functions?.length || 0) + (state.otherFunctions?.length || 0) + (state.otherFunctionsCount || 0)) * PRICING.FUNCTION;
|
||||
total += ((state.apiSystems?.length || 0) + (state.otherTech?.length || 0) + (state.otherTechCount || 0)) * PRICING.API_INTEGRATION;
|
||||
|
||||
if (state.cmsSetup) {
|
||||
total += PRICING.CMS_SETUP;
|
||||
total += ((state.features?.length || 0) + (state.otherFeatures?.length || 0) + (state.otherFeaturesCount || 0)) * PRICING.CMS_CONNECTION_PER_FEATURE;
|
||||
}
|
||||
|
||||
const languagesCount = state.languagesList?.length || 1;
|
||||
if (languagesCount > 1) {
|
||||
total *= (1 + (languagesCount - 1) * 0.2);
|
||||
}
|
||||
return Math.round(total);
|
||||
}
|
||||
|
||||
function calculateMonthly(state: any) {
|
||||
if (state.projectType !== 'website') return 0;
|
||||
return PRICING.HOSTING_MONTHLY + ((state.storageExpansion || 0) * PRICING.STORAGE_EXPANSION_MONTHLY);
|
||||
}
|
||||
|
||||
function generateDefaultPath(state: any) {
|
||||
const now = new Date();
|
||||
const month = now.toISOString().slice(0, 7);
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
const company = (state.companyName || state.name || 'Unknown').replace(/[^a-z0-9]/gi, '_');
|
||||
return path.join(process.cwd(), 'out', 'estimations', month, `${day}_${company}_${state.projectType}.pdf`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('❌ Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user