Surface audit generations on dashboard audits
This commit is contained in:
225
v2_elemente/audit.ts
Normal file
225
v2_elemente/audit.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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<Uint8Array> {
|
||||
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<string, string> = { "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<any> {
|
||||
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[^>]*>([^<]*)<\/title>/i) || null,
|
||||
metaDescription:
|
||||
m(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) ||
|
||||
m(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i) ||
|
||||
null,
|
||||
h1Count: (html.match(/<h1[\s>]/gi) ?? []).length,
|
||||
hasViewport: /<meta[^>]+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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user