/** * convex/lib/audit.ts * * Externe Clients und reine Helfer für die Audit-Pipeline. Wird nur aus * Convex *Actions* aufgerufen (fetch). Alles dependency-frei (kein "use node"), * damit audits.ts Queries/Mutations/Actions in einer Datei halten kann. */ // ---- Kosten (Quelle: PitchFast_Pricing_Modell.xlsx) -------------------------- export const AUDIT_COSTS = { llmInputPerMTokUsd: 0.75, llmOutputPerMTokUsd: 4.5, screenshotUsd: 0.0085, jinaUsd: 0.003, }; export function estimateAuditCostUsd(p: { inputTokens: number; outputTokens: number; screenshots: number; }): number { return ( (p.inputTokens / 1_000_000) * AUDIT_COSTS.llmInputPerMTokUsd + (p.outputTokens / 1_000_000) * AUDIT_COSTS.llmOutputPerMTokUsd + p.screenshots * AUDIT_COSTS.screenshotUsd + AUDIT_COSTS.jinaUsd ); } // ---- ScreenshotOne ----------------------------------------------------------- const SCREENSHOTONE_URL = "https://api.screenshotone.com/take"; /** Einen Screenshot holen (PNG-Bytes). Cookie-Banner/Ads werden gefiltert. */ export async function takeScreenshot(args: { accessKey: string; url: string; device: "desktop" | "mobile"; }): Promise { const params = new URLSearchParams({ access_key: args.accessKey, url: args.url, format: "png", full_page: "true", block_cookie_banners: "true", block_ads: "true", block_banners_by_heuristics: "true", viewport_width: args.device === "mobile" ? "390" : "1280", viewport_height: args.device === "mobile" ? "844" : "900", device_scale_factor: args.device === "mobile" ? "2" : "1", }); const res = await fetch(`${SCREENSHOTONE_URL}?${params.toString()}`); if (!res.ok) { throw new Error(`ScreenshotOne ${res.status}: ${(await res.text()).slice(0, 200)}`); } return new Uint8Array(await res.arrayBuffer()); } export function toDataUrl(bytes: Uint8Array): string { let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return `data:image/png;base64,${btoa(bin)}`; } // ---- Jina AI Reader (HTML → Markdown) ---------------------------------------- const COMMON_SUBPATHS = ["kontakt", "impressum", "leistungen", "ueber-uns"]; const MARKDOWN_CHAR_CAP = 12000; /** Startseite + (falls vorhanden) relevante Unterseiten als Markdown. */ export async function fetchSiteMarkdown(args: { jinaKey?: string; baseUrl: string; }): Promise<{ markdown: string; pages: string[] }> { const headers: Record = { "X-Return-Format": "markdown" }; if (args.jinaKey) headers.Authorization = `Bearer ${args.jinaKey}`; const base = args.baseUrl.replace(/\/$/, ""); const urls = [base, ...COMMON_SUBPATHS.map((p) => `${base}/${p}`)]; const pages: string[] = []; let combined = ""; for (const u of urls) { if (combined.length >= MARKDOWN_CHAR_CAP) break; try { const res = await fetch(`https://r.jina.ai/${u}`, { headers }); if (!res.ok) continue; const md = await res.text(); if (md && md.trim().length > 50) { pages.push(u); combined += `\n\n## Seite: ${u}\n${md}`; } } catch { // Unterseite existiert nicht / Fehler → überspringen } } return { markdown: combined.slice(0, MARKDOWN_CHAR_CAP), pages }; } // ---- PageSpeed Insights ------------------------------------------------------ export async function fetchPageSpeed(args: { googleKey: string; url: string; }): Promise { const params = new URLSearchParams({ url: args.url, key: args.googleKey, strategy: "mobile", }); params.append("category", "performance"); params.append("category", "accessibility"); const res = await fetch( `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?${params.toString()}`, ); if (!res.ok) throw new Error(`PageSpeed ${res.status}`); const data = await res.json(); const lh = data.lighthouseResult ?? {}; const audits = lh.audits ?? {}; return { performanceScore: lh.categories?.performance?.score ?? null, accessibilityScore: lh.categories?.accessibility?.score ?? null, lcp: audits["largest-contentful-paint"]?.displayValue ?? null, cls: audits["cumulative-layout-shift"]?.displayValue ?? null, inp: audits["interaction-to-next-paint"]?.displayValue ?? null, }; } // ---- Struktur-Check (regex, dependency-frei) --------------------------------- export function parseStructuralSignals(html: string, url: string) { const m = (re: RegExp) => (html.match(re)?.[1] ?? "").trim(); return { title: m(/]*>([^<]*)<\/title>/i) || null, metaDescription: m(/]+name=["']description["'][^>]+content=["']([^"']*)["']/i) || m(/]+content=["']([^"']*)["'][^>]+name=["']description["']/i) || null, h1Count: (html.match(/]/gi) ?? []).length, hasViewport: /]+name=["']viewport["']/i.test(html), isHttps: url.startsWith("https://"), }; } // ---- Multimodale Auswertung (OpenRouter, GPT-5.4 mini) ----------------------- const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; const MODEL = "openai/gpt-5.4-mini"; // OpenRouter-Slug ggf. verifizieren export function buildSystemPrompt(skillsRegistry: string): string { return [ "Du bist der Audit-Agent von PitchFast. Du beurteilst die Website eines lokalen", "Dienstleisters anhand der folgenden Skill-Registry. Wähle alle zutreffenden", "Skills, deren benötigte Eingaben vorliegen, und erzeuge Findings.", "", "VOICE: Ich-Form, auf Augenhöhe, respektvoll, ohne Floskeln, ohne Dringlichkeit,", "ohne Preise. Beobachtung statt Urteil. Übersetze jeden Befund in Kundennutzen.", "Severity ist INTERN — niemals im öffentlichen Text nennen, keine Scores/Ampeln.", "", "Gib AUSSCHLIESSLICH valides JSON zurück, ohne Markdown-Fences, exakt in diesem Schema:", `{ "findings": [{"skill_id": string, "observation": string, "customer_benefit": string, "public_phrasing": string, "severity": 1|2|3, "evidence": string}], "finalSummary": string, // intern, vollständig "publicAuditText": string, // extern, voice-konform, wenige konkrete Punkte "emailSubject": string, "emailBody": string, // Ich-Form, persönlich, ohne Preise "phoneScript": string, "ctaType": string, // z.B. "anruf" | "termin" | "rueckruf" "usedSkills": [string] }`, "", "=== SKILL-REGISTRY (skills.md) ===", skillsRegistry, ].join("\n"); } export async function runMultimodalAnalysis(args: { openRouterKey: string; systemPrompt: string; textContext: string; desktopDataUrl: string; mobileDataUrl: string; }): Promise<{ json: any; inputTokens: number; outputTokens: number }> { const res = await fetch(OPENROUTER_URL, { method: "POST", headers: { Authorization: `Bearer ${args.openRouterKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: MODEL, response_format: { type: "json_object" }, messages: [ { role: "system", content: args.systemPrompt }, { role: "user", content: [ { type: "text", text: args.textContext }, { type: "image_url", image_url: { url: args.desktopDataUrl } }, { type: "image_url", image_url: { url: args.mobileDataUrl } }, ], }, ], }), }); if (!res.ok) { throw new Error(`OpenRouter ${res.status}: ${(await res.text()).slice(0, 300)}`); } const data = await res.json(); const content: string = data.choices?.[0]?.message?.content ?? "{}"; const clean = content.replace(/```json|```/g, "").trim(); let json: any; try { json = JSON.parse(clean); } catch { throw new Error("LLM-Antwort war kein valides JSON"); } return { json, inputTokens: data.usage?.prompt_tokens ?? 0, outputTokens: data.usage?.completion_tokens ?? 0, }; }