From ff18fc202e4a2a7c383fb853c447cb7dbf1ba018 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Mon, 8 Jun 2026 08:33:15 +0200 Subject: [PATCH] Fix MVP audit evidence pipeline --- ... Port-audit-pipeline-fully-into-the-MVP.md | 46 +++++ ...5 - Show-audit-evidence-on-detail-pages.md | 43 ++++ components/audits/audit-detail.tsx | 154 ++++++++++++++ components/settings/operations-readiness.tsx | 3 +- convex/auditGeneration.ts | 35 +++- convex/auditGenerationAction.ts | 13 +- convex/audits.ts | 190 +++++++++++++++++- lib/ai/local-audit-skill-registry.ts | 144 +++++++++++++ lib/operational-readiness.ts | 3 +- tests/audit-evidence.test.ts | 15 +- tests/audit-generation-action-source.test.ts | 15 +- ...udit-generation-persistence-source.test.ts | 44 ++++ tests/audit-skill-registry-v3.test.ts | 19 +- tests/audit-skills-schema.test.ts | 49 ++++- tests/audit-skills-ui.test.ts | 47 +++++ tests/ops-quality-source.test.ts | 3 + 16 files changed, 771 insertions(+), 52 deletions(-) create mode 100644 backlog/tasks/task-44 - Port-audit-pipeline-fully-into-the-MVP.md create mode 100644 backlog/tasks/task-45 - Show-audit-evidence-on-detail-pages.md create mode 100644 lib/ai/local-audit-skill-registry.ts diff --git a/backlog/tasks/task-44 - Port-audit-pipeline-fully-into-the-MVP.md b/backlog/tasks/task-44 - Port-audit-pipeline-fully-into-the-MVP.md new file mode 100644 index 0000000..a7d8e25 --- /dev/null +++ b/backlog/tasks/task-44 - Port-audit-pipeline-fully-into-the-MVP.md @@ -0,0 +1,46 @@ +--- +id: TASK-44 +title: Port audit pipeline fully into the MVP +status: In Progress +assignee: [] +created_date: '2026-06-07 21:16' +updated_date: '2026-06-07 21:34' +labels: [] +dependencies: [] +priority: high +ordinal: 46000 +--- + +## Description + + +Remove runtime dependencies on v2 reference files, bundle the v3 audit skill registry into the MVP, and ensure audit generation consumes website enrichment evidence from the lead's latest successful enrichment run. + + +## Acceptance Criteria + +- [x] #1 Audit generation no longer reads or imports v2_elemente at runtime +- [x] #2 MVP v3 audit skills are bundled through a production-owned source and selected during audit evidence building +- [x] #3 Audit generation evidence includes crawl pages, technical checks, and screenshots from the latest successful website_enrichment run for the same lead +- [ ] #4 ScreenshotOne remains optional only until configured and no missing-key warning appears after the corrected Convex env is present +- [x] #5 Regression tests and local verification commands pass + + +## Implementation Plan + + +1. Add RED tests for bundled MVP skill registry and no v2 runtime dependency +2. Add RED tests for audit evidence loading latest successful website enrichment data by lead +3. Implement bundled MVP v3 skill registry and wire audit generation action to it +4. Implement lead-based enrichment evidence lookup with audit-run screenshot fallback +5. Clarify readiness copy for Next vs Convex env scope +6. Run focused and full verification without closing the backlog task + + +## Implementation Notes + + +Implemented GREEN slice after RED tests: added bundled MVP v3 audit skill registry, rewired auditGenerationAction away from v2_elemente runtime file reads, loaded latest successful website_enrichment evidence by lead in getAuditGenerationEvidence, preserved audit-run ScreenshotOne captures, and clarified settings readiness copy for Next.js vs Convex Action env scope. Focused tests passed for registry, audit evidence, action source, persistence source, and ops quality. + +Masked Convex env check confirms SCREENSHOTONE_API_KEY is present in the dev deployment. AC #4 remains open until a fresh live audit run confirms no ScreenshotOne missing-key warning in Run Events. + diff --git a/backlog/tasks/task-45 - Show-audit-evidence-on-detail-pages.md b/backlog/tasks/task-45 - Show-audit-evidence-on-detail-pages.md new file mode 100644 index 0000000..a916b2c --- /dev/null +++ b/backlog/tasks/task-45 - Show-audit-evidence-on-detail-pages.md @@ -0,0 +1,43 @@ +--- +id: TASK-45 +title: Show audit evidence on detail pages +status: In Progress +assignee: [] +created_date: '2026-06-07 21:50' +updated_date: '2026-06-07 22:01' +labels: [] +dependencies: [] +priority: high +ordinal: 47000 +--- + +## Description + + +Fix the audit detail view so stored checked pages and compact website-enrichment evidence are visible instead of only showing the page count. + + +## Acceptance Criteria + +- [x] #1 Audit detail query returns ordered checked-page evidence with crawl, technical, and screenshot summaries +- [x] #2 Audit detail UI renders a compact Geprüfte Seiten section between overview and skills +- [x] #3 Fallback rows render checkedPages even when enrichment evidence is missing +- [x] #4 Public audit and outreach flows remain unchanged +- [x] #5 Regression tests and local verification pass + + +## Implementation Plan + + +1. Add RED tests for getDetail sourceSummaries checked-page evidence +2. Add RED tests for AuditDetail compact evidence rendering +3. Extend audits.getDetail with bounded lead/enrichment evidence summaries +4. Render compact checked-page evidence card in AuditDetail +5. Run focused and full verification without closing the task + + +## Implementation Notes + + +Implemented getDetail sourceSummaries.checkedPages with latest successful website_enrichment evidence by lead, bounded crawl/technical/screenshot joins, storage URL resolution, and checkedPages fallback rows. AuditDetail now renders a compact Geprüfte Seiten card between overview and skills. Verification passed: focused tests, pnpm test, app tsc, lint, git diff --check, convex codegen dry-run/typecheck, and convex dev --once. Browser plugin reached login because its session is unauthenticated; Arc/local authenticated session should show the deployed query after reload. + diff --git a/components/audits/audit-detail.tsx b/components/audits/audit-detail.tsx index e2f61fd..5cf842a 100644 --- a/components/audits/audit-detail.tsx +++ b/components/audits/audit-detail.tsx @@ -38,9 +38,40 @@ type SkillAwareAudit = { internalSummary?: string | null; }; +type CheckedPageScreenshot = { + id: Id<"_storage">; + url: string; + viewport: "desktop" | "mobile"; + sourceUrl: string; + width: number; + height: number; + createdAt: number; +}; + +type CheckedPageEvidence = { + url: string; + sourceUrl: string | null; + finalUrl: string | null; + pageKind: string | null; + title: string | null; + metaDescription: string | null; + headings: string[]; + visibleTextExcerpt: string | null; + hasContactFormSignal: boolean | null; + hasContactCtaSignal: boolean | null; + usesHttps: boolean | null; + missingMetaDescription: boolean | null; + brokenInternalLinkCount: number | null; + screenshots: CheckedPageScreenshot[]; + createdAt: number | null; +}; + type AuditDetailResult = { audit: SkillAwareAudit; lead: LeadContext | null; + sourceSummaries: { + checkedPages: CheckedPageEvidence[]; + }; } | null; const statusText: Record = { @@ -54,6 +85,42 @@ function getStatusLabel(status: SkillAwareAudit["status"]) { return statusText[status] ?? "Unbekannt"; } +function getPageKindLabel(pageKind: string | null) { + const labels: Record = { + contact: "Kontakt", + homepage: "Startseite", + imprint: "Impressum", + other: "Unterseite", + service: "Leistung", + }; + + return pageKind ? labels[pageKind] ?? pageKind : "Geprüft"; +} + +function signalText(value: boolean | null, positive: string, negative: string) { + if (value === null) { + return "Unbekannt"; + } + + return value ? positive : negative; +} + +function metaSignalText(page: CheckedPageEvidence) { + if (page.metaDescription) { + return "Vorhanden"; + } + + if (page.missingMetaDescription === true) { + return "Fehlt"; + } + + if (page.missingMetaDescription === false) { + return "Vorhanden"; + } + + return "Unbekannt"; +} + function leadSummary(lead: LeadContext | null | undefined) { if (!lead) { return "Kein Lead-Kontext gespeichert"; @@ -90,6 +157,10 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) { const lead = result?.lead; const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]); + const checkedPageEvidence = useMemo( + () => result?.sourceSummaries.checkedPages ?? [], + [result], + ); if (result === null) { return ( @@ -149,6 +220,89 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) { + + + Geprüfte Seiten + + Kompakte Evidence aus Website-Enrichment und Screenshot-Erfassung. + + + + {checkedPageEvidence.length === 0 ? ( +

Keine Seiten-Evidence gespeichert

+ ) : ( +
    + {checkedPageEvidence.map((page, index) => ( +
  • +
    +
    +

    + {page.title ?? page.finalUrl ?? page.sourceUrl ?? page.url} +

    +

    + {page.finalUrl ?? page.sourceUrl ?? page.url} +

    +
    + {getPageKindLabel(page.pageKind)} +
    + + {page.visibleTextExcerpt ? ( +

    + {page.visibleTextExcerpt} +

    + ) : null} + +
    + + Meta: {metaSignalText(page)} + + + Kontaktformular:{" "} + {signalText(page.hasContactFormSignal, "Signal", "Kein Signal")} + + + CTA: {signalText(page.hasContactCtaSignal, "Signal", "Kein Signal")} + + + Interne Links: {page.brokenInternalLinkCount ?? "Unbekannt"} + +
    + + {page.screenshots.length > 0 ? ( +
    + {page.screenshots.map((screenshot) => ( +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + {`${screenshot.viewport +
    + + {screenshot.viewport === "desktop" ? "Desktop" : "Mobil"} + + {screenshot.sourceUrl} +
    +
    + ))} +
    + ) : null} +
  • + ))} +
+ )} +
+
+ Verwendete Skills diff --git a/components/settings/operations-readiness.tsx b/components/settings/operations-readiness.tsx index 3c419b4..6ecf1dc 100644 --- a/components/settings/operations-readiness.tsx +++ b/components/settings/operations-readiness.tsx @@ -21,7 +21,8 @@ export function OperationsReadiness({ rows }: OperationsReadinessProps) { Integrationsstatus - Diese Übersicht zeigt nur fehlende Variablennamen und keine Secret-Werte. + Diese Übersicht zeigt nur fehlende Variablennamen der Next.js-Runtime. + Convex-Action-Env bitte zusätzlich über Run-Events oder CLI prüfen. diff --git a/convex/auditGeneration.ts b/convex/auditGeneration.ts index a613912..cbc70ee 100644 --- a/convex/auditGeneration.ts +++ b/convex/auditGeneration.ts @@ -252,32 +252,49 @@ export const getAuditGenerationEvidence = internalQuery({ return null; } - const runIdFilter = { - table: "by_runId" as const, - value: args.runId, - }; const leadIdFilter = { table: "by_leadId" as const, value: lead._id, }; + const latestSuccessfulEnrichmentRun = await ctx.db + .query("agentRuns") + .withIndex("by_type_and_status_and_leadId", (q) => + q + .eq("type", "website_enrichment") + .eq("status", "succeeded") + .eq("leadId", lead._id), + ) + .order("desc") + .take(1); + const enrichmentEvidenceRunId = + latestSuccessfulEnrichmentRun[0]?._id ?? args.runId; + const crawlPagesByRun = await ctx.db .query("websiteCrawlPages") - .withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value)) + .withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId)) .order("desc") .take(40); const technicalChecksByRun = await ctx.db .query("websiteTechnicalChecks") - .withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value)) + .withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId)) .order("desc") .take(80); - const screenshotsByRun = await ctx.db + const auditCaptureScreenshotsByRun = await ctx.db .query("websiteCrawlScreenshots") - .withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value)) + .withIndex("by_runId", (q) => q.eq("runId", args.runId)) .order("desc") .take(20); + const enrichmentScreenshotsByRun = + enrichmentEvidenceRunId === args.runId + ? [] + : await ctx.db + .query("websiteCrawlScreenshots") + .withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId)) + .order("desc") + .take(20); const pageSpeedByRun = run.auditId ? await ctx.db @@ -293,7 +310,7 @@ export const getAuditGenerationEvidence = internalQuery({ const crawlPages = crawlPagesByRun; const technicalChecks = technicalChecksByRun; - const screenshots = screenshotsByRun; + const screenshots = [...auditCaptureScreenshotsByRun, ...enrichmentScreenshotsByRun]; return { lead: { diff --git a/convex/auditGenerationAction.ts b/convex/auditGenerationAction.ts index 0b3c75f..b6a5f6b 100644 --- a/convex/auditGenerationAction.ts +++ b/convex/auditGenerationAction.ts @@ -1,9 +1,9 @@ "use node"; -import { join } from "node:path"; import { type DataContent, generateObject } from "ai"; import { createOpenRouterProvider } from "../lib/ai/openrouter-provider"; import { resolveModelProfile } from "../lib/ai/model-profiles"; +import { loadLocalAuditSkillRegistry } from "../lib/ai/local-audit-skill-registry"; import { auditClassificationSchema, auditSummarySchema, @@ -26,10 +26,7 @@ import { type JinaReaderPageInput, type ScreenshotOneRequest, } from "../lib/external-audit-services"; -import { - loadSkillsRegistry, - type AuditUsedSkill, -} from "../lib/skills-registry"; +import { type AuditUsedSkill } from "../lib/skills-registry"; import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { @@ -455,11 +452,9 @@ async function appendRunEvent( async function loadAuditSkillRegistry( ctx: ActionCtx, runId: Id<"agentRuns">, -): Promise>> { +): Promise> { try { - return await loadSkillsRegistry( - join(process.cwd(), "v2_elemente", "skills.md"), - ); + return loadLocalAuditSkillRegistry(); } catch (error) { const safeErrorSummary = messageFromError(error); try { diff --git a/convex/audits.ts b/convex/audits.ts index 5ea3282..6b8d987 100644 --- a/convex/audits.ts +++ b/convex/audits.ts @@ -6,6 +6,7 @@ import type { Doc, Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; export const AUDIT_REVIEW_NOTICE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; +const DETAIL_EVIDENCE_LIMIT = 50; const auditStatus = v.union( v.literal("draft"), @@ -103,6 +104,73 @@ const latestGenerationStage = (stages: Doc<"auditGenerations">[]) => { return [...stages].sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null; }; +const normalizeComparableAuditUrl = (value: string | null | undefined) => { + const trimmed = value?.trim(); + if (!trimmed) { + return ""; + } + + const normalizeParsedUrl = (parsedUrl: URL) => { + const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, ""); + const pathname = parsedUrl.pathname.replace(/\/+$/, ""); + return `${hostname}${pathname}${parsedUrl.search}`.toLowerCase(); + }; + + try { + return normalizeParsedUrl(new URL(trimmed)); + } catch { + try { + return normalizeParsedUrl(new URL(`https://${trimmed}`)); + } catch { + return trimmed + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/^www\./, "") + .replace(/\/+$/, ""); + } + } +}; + +const setIfPresent = ( + target: Map, + url: string | null | undefined, + value: T, +) => { + const key = normalizeComparableAuditUrl(url); + if (key && !target.has(key)) { + target.set(key, value); + } +}; + +const findByUrl = (source: Map, ...urls: Array) => { + for (const url of urls) { + const key = normalizeComparableAuditUrl(url); + if (key && source.has(key)) { + return source.get(key) ?? null; + } + } + + return null; +}; + +const fallbackCheckedPageEvidence = (url: string) => ({ + url, + sourceUrl: null, + finalUrl: null, + pageKind: null, + title: null, + metaDescription: null, + headings: [], + visibleTextExcerpt: null, + hasContactFormSignal: null, + hasContactCtaSignal: null, + usesHttps: null, + missingMetaDescription: null, + brokenInternalLinkCount: null, + screenshots: [], + createdAt: null, +}); + const toIsoDate = (timestamp: number | undefined, fallback: number) => { return new Date(timestamp ?? fallback).toISOString(); }; @@ -212,7 +280,127 @@ export const getDetail = query({ } const lead = await ctx.db.get(audit.leadId); - return { audit, lead }; + const latestSuccessfulEnrichmentRun = await ctx.db + .query("agentRuns") + .withIndex("by_type_and_status_and_leadId", (q) => + q + .eq("type", "website_enrichment") + .eq("status", "succeeded") + .eq("leadId", audit.leadId), + ) + .order("desc") + .take(1); + const enrichmentRunId = latestSuccessfulEnrichmentRun[0]?._id ?? null; + + const crawlPages = enrichmentRunId + ? await ctx.db + .query("websiteCrawlPages") + .withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId)) + .order("desc") + .take(DETAIL_EVIDENCE_LIMIT) + : []; + const technicalChecks = enrichmentRunId + ? await ctx.db + .query("websiteTechnicalChecks") + .withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId)) + .order("desc") + .take(DETAIL_EVIDENCE_LIMIT) + : []; + const crawlScreenshots = enrichmentRunId + ? await ctx.db + .query("websiteCrawlScreenshots") + .withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId)) + .order("desc") + .take(DETAIL_EVIDENCE_LIMIT) + : []; + + const pagesByUrl = new Map>(); + for (const page of crawlPages) { + setIfPresent(pagesByUrl, page.sourceUrl, page); + setIfPresent(pagesByUrl, page.finalUrl, page); + } + + const checksByUrl = new Map>(); + for (const checks of technicalChecks) { + setIfPresent(checksByUrl, checks.sourceUrl, checks); + setIfPresent(checksByUrl, checks.finalUrl, checks); + } + + const screenshotsByUrl = new Map< + string, + Array<{ + id: Id<"_storage">; + url: string; + viewport: Doc<"websiteCrawlScreenshots">["viewport"]; + sourceUrl: string; + width: number; + height: number; + createdAt: number; + }> + >(); + for (const screenshot of crawlScreenshots) { + const url = await ctx.storage.getUrl(screenshot.storageId); + if (!url) { + continue; + } + + const key = normalizeComparableAuditUrl(screenshot.sourceUrl); + if (!key) { + continue; + } + + const current = screenshotsByUrl.get(key) ?? []; + current.push({ + id: screenshot.storageId, + url, + viewport: screenshot.viewport, + sourceUrl: screenshot.sourceUrl, + width: screenshot.width, + height: screenshot.height, + createdAt: screenshot.createdAt, + }); + screenshotsByUrl.set(key, current); + } + + const checkedPages = audit.checkedPages.map((checkedUrl) => { + const page = findByUrl(pagesByUrl, checkedUrl); + if (!page) { + return fallbackCheckedPageEvidence(checkedUrl); + } + + const checks = findByUrl(checksByUrl, checkedUrl, page.sourceUrl, page.finalUrl); + const screenshots = [ + ...( + findByUrl(screenshotsByUrl, checkedUrl, page.sourceUrl, page.finalUrl) ?? [] + ), + ].sort((a, b) => b.createdAt - a.createdAt); + + return { + url: checkedUrl, + sourceUrl: page.sourceUrl, + finalUrl: page.finalUrl, + pageKind: page.pageKind, + title: page.title ?? null, + metaDescription: page.metaDescription ?? null, + headings: page.headings.slice(0, DETAIL_EVIDENCE_LIMIT), + visibleTextExcerpt: page.visibleTextExcerpt ?? null, + hasContactFormSignal: page.hasContactFormSignal, + hasContactCtaSignal: page.hasContactCtaSignal, + usesHttps: checks?.usesHttps ?? null, + missingMetaDescription: checks?.missingMetaDescription ?? null, + brokenInternalLinkCount: checks?.brokenInternalLinkCount ?? null, + screenshots, + createdAt: page.createdAt, + }; + }); + + return { + audit, + lead, + sourceSummaries: { + checkedPages, + }, + }; }, }); diff --git a/lib/ai/local-audit-skill-registry.ts b/lib/ai/local-audit-skill-registry.ts new file mode 100644 index 0000000..2d51ba4 --- /dev/null +++ b/lib/ai/local-audit-skill-registry.ts @@ -0,0 +1,144 @@ +import { parseSkillsRegistry } from "../skills-registry"; + +export const LOCAL_AUDIT_SKILL_REGISTRY_SOURCE = [ + "## visual-design", + "", + "```yaml", + "id: visual-design", + "title: Visueller Gesamteindruck & Zeitgemäßheit", + "applies_when: website_exists", + "inputs: [desktop_screenshot, mobile_screenshot]", + "outputs: findings", + "```", + "", + "Beurteile den ersten visuellen Eindruck: wirkt der Auftritt zeitgemäß oder veraltet?", + "Achte auf visuelle Hierarchie, Weißraum, Typografie (Lesbarkeit, Schriftmischung),", + "Farbkontraste, Bildqualität und Konsistenz. Konkrete Beobachtungen statt", + "Geschmacksurteilen — z. B. „kleine Schrift mit geringem Zeilenabstand erschwert das", + "Lesen auf dem Smartphone\", nicht „sieht altbacken aus\". Kundennutzen: ein moderner,", + "ruhiger Auftritt schafft Vertrauen, bevor der erste Satz gelesen wird.", + "", + "## first-impression-clarity", + "", + "```yaml", + "id: first-impression-clarity", + "title: Klarheit über dem Falz", + "applies_when: website_exists", + "inputs: [desktop_screenshot, mobile_screenshot, markdown]", + "outputs: findings", + "```", + "", + "Prüfe, ob im sichtbaren Bereich (ohne Scrollen) sofort klar wird: Was macht der", + "Betrieb, für wen, wo? Fehlt eine klare Überschrift, ein Leistungsversprechen", + "oder der Ort, muss ein Besucher raten. Kundennutzen: Besucher entscheiden in Sekunden,", + "ob sie bleiben — Klarheit hält sie auf der Seite.", + "", + "## contact-conversion", + "", + "```yaml", + "id: contact-conversion", + "title: Kontaktaufnahme & Handlungsaufforderung", + "applies_when: website_exists", + "inputs: [mobile_screenshot, markdown, dom]", + "outputs: findings", + "```", + "", + "Wie leicht kann ein Interessent Kontakt aufnehmen? Sind Telefonnummer, E-Mail bzw.", + "Formular und Öffnungszeiten leicht auffindbar — besonders mobil und ohne langes", + "Scrollen? Ist die Telefonnummer auf dem Smartphone klickbar (tel:)? Gibt es eine", + "klare nächste Handlung (anrufen, schreiben, Termin)? Kundennutzen: jede", + "Reibung weniger ist eine Anfrage mehr.", + "", + "## mobile-usability", + "", + "```yaml", + "id: mobile-usability", + "title: Mobile Nutzbarkeit", + "applies_when: has_mobile_screenshot", + "inputs: [mobile_screenshot, pagespeed]", + "outputs: findings", + "```", + "", + "Beurteile die mobile Darstellung: bricht Text oder Layout um, sind Tap-Ziele groß", + "genug, ist die Schrift ohne Zoom lesbar, verdecken Banner Inhalte? Nutze", + "PageSpeed-Mobile-Signale ergänzend. Kundennutzen: der Großteil lokaler Suchen passiert", + "am Handy — hier entscheidet sich, ob aus Interesse eine Anfrage wird.", + "", + "## trust-signals", + "", + "```yaml", + "id: trust-signals", + "title: Vertrauenssignale & Seriosität", + "applies_when: website_exists", + "inputs: [desktop_screenshot, markdown, dom]", + "outputs: findings", + "```", + "", + "Welche Vertrauenssignale sind vorhanden oder fehlen? Echte Fotos statt Stockbilder,", + "Team/Über-uns, Referenzen oder Bewertungen, vollständiges Impressum, sichtbare", + "Erreichbarkeit, gültiges HTTPS. Kundennutzen: lokale Kunden beauftragen, wem sie", + "vertrauen — sichtbare Seriosität senkt die Hemmschwelle.", + "", + "## conversion-copy", + "", + "```yaml", + "id: conversion-copy", + "title: Texte & Ansprache", + "applies_when: website_exists", + "inputs: [markdown]", + "outputs: findings", + "```", + "", + "Sind die Texte klar, nutzenorientiert und auf die Zielgruppe zugeschnitten — oder", + "generisch, fachsprachlich oder leer? Wird beschrieben, was der Betrieb leistet und", + "welches Problem er löst? Achte auf Verständlichkeit und Tonalität (Deutsch, lokal).", + "Kundennutzen: verständliche Texte holen mehr Besucher in eine Anfrage.", + "", + "## local-seo-basics", + "", + "```yaml", + "id: local-seo-basics", + "title: Lokale Auffindbarkeit (Grundlagen)", + "applies_when: website_exists", + "inputs: [dom, markdown]", + "outputs: findings", + "```", + "", + "Prüfe Title-Tag und Meta-Description (vorhanden, aussagekräftig, mit Ort?),", + "Überschriftenstruktur (genau eine sinnvolle H1?), sowie die Konsistenz von Name,", + "Adresse, Telefon (NAP) und ob der Ort/Einzugsbereich textlich auftaucht.", + "Kundennutzen: wer lokal gefunden wird, bekommt Anfragen aus der Region — ohne Werbebudget.", + "", + "## performance-experience", + "", + "```yaml", + "id: performance-experience", + "title: Tempo & Ladeerlebnis", + "applies_when: has_pagespeed", + "inputs: [pagespeed]", + "outputs: findings", + "```", + "", + "Übersetze PageSpeed-Rohdaten (LCP, CLS, INP, Gesamt-Score) in ein erlebbares Bild,", + "ohne Scores zu nennen. Beispiel: „Auf dem Smartphone erscheinen die ersten Inhalte", + "spürbar verzögert.\" Kundennutzen: schnelle Seiten halten Besucher — langsame verlieren", + "sie, bevor sie etwas gesehen haben.", + "", + "## accessibility-basics", + "", + "```yaml", + "id: accessibility-basics", + "title: Zugänglichkeit (Grundlagen)", + "applies_when: website_exists", + "inputs: [desktop_screenshot, dom]", + "outputs: findings", + "```", + "", + "Niedrigschwellige Barrieren: ausreichende Farbkontraste, lesbare Schriftgrößen,", + "sinnvolle Alt-Texte bei zentralen Bildern, bedienbare Menüs. Kundennutzen: gut", + "zugängliche Seiten erreichen mehr Menschen — und wirken professioneller.", +].join("\n"); + +export function loadLocalAuditSkillRegistry() { + return parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE); +} diff --git a/lib/operational-readiness.ts b/lib/operational-readiness.ts index 69cc400..8f3720a 100644 --- a/lib/operational-readiness.ts +++ b/lib/operational-readiness.ts @@ -43,7 +43,8 @@ export const integrationReadinessDefinitions: IntegrationReadinessDefinition[] = id: "screenshotone", label: "ScreenshotOne", requiredEnv: ["SCREENSHOTONE_API_KEY"], - errorSurface: "Screenshot-Erfassung zeigt API-, Quota- und Rendering-Fehler.", + errorSurface: + "Convex-Run-Events der Audit-Generierung zeigen fehlende Keys, API-, Quota- und Rendering-Fehler.", }, { id: "smtp", diff --git a/tests/audit-evidence.test.ts b/tests/audit-evidence.test.ts index c361fdc..86d1b6a 100644 --- a/tests/audit-evidence.test.ts +++ b/tests/audit-evidence.test.ts @@ -1,12 +1,11 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import test from "node:test"; import { buildAuditEvidenceInput, type SkillRegistryEntryEvidence, } from "../lib/ai/audit-evidence"; +import { LOCAL_AUDIT_SKILL_REGISTRY_SOURCE } from "../lib/ai/local-audit-skill-registry"; import { parseSkillsRegistry } from "../lib/skills-registry"; const SAMPLE_SKILL_REGISTRY: SkillRegistryEntryEvidence[] = [ @@ -340,11 +339,7 @@ test("buildAuditEvidenceInput selects deterministic skills and supports design/u }); test("buildAuditEvidenceInput prioritizes local-audit v3 skills before cap", () => { - const source = readFileSync( - join(process.cwd(), "v2_elemente", "skills.md"), - "utf8", - ); - const skillRegistry = parseSkillsRegistry(source); + const skillRegistry = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE); assert.equal( skillRegistry.some((skill) => skill.id === "visual-design" && !skill.category), @@ -448,11 +443,7 @@ test("buildAuditEvidenceInput prioritizes local-audit v3 skills before cap", () }); test("buildAuditEvidenceInput gates v3 skills when declared inputs are missing", () => { - const source = readFileSync( - join(process.cwd(), "v2_elemente", "skills.md"), - "utf8", - ); - const skillRegistry = parseSkillsRegistry(source); + const skillRegistry = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE); const actual = buildAuditEvidenceInput({ lead: { diff --git a/tests/audit-generation-action-source.test.ts b/tests/audit-generation-action-source.test.ts index 79654dd..46d1d94 100644 --- a/tests/audit-generation-action-source.test.ts +++ b/tests/audit-generation-action-source.test.ts @@ -182,16 +182,21 @@ test("action calls generateObject with required schemas", () => { } }); -test("action loads v3 skill registry from v2 source for evidence input", () => { +test("action loads v3 skill registry from bundled MVP source for evidence input", () => { assert.equal( - hasPattern(actionSource, /import\s*{[\s\S]*loadSkillsRegistry[\s\S]*}\s*from\s*["']\.\.\/lib\/skills-registry["']/), + hasPattern(actionSource, /import\s*{[\s\S]*loadLocalAuditSkillRegistry[\s\S]*}\s*from\s*["']\.\.\/lib\/ai\/local-audit-skill-registry["']/), true, - "Action should import loadSkillsRegistry from the shared registry parser.", + "Action should import the bundled MVP skill registry loader.", ); assert.equal( - hasPattern(actionSource, /loadSkillsRegistry\(\s*(?:join\()?[\s\S]*v2_elemente[\s\S]*skills\.md[\s\S]*\)/), + hasPattern(actionSource, /loadLocalAuditSkillRegistry\(\s*\)/), true, - "Action should load the v3 registry from v2_elemente/skills.md.", + "Action should load the v3 registry from a bundled MVP module.", + ); + assert.doesNotMatch( + actionSource, + /v2_elemente|process\.cwd\(\)|loadSkillsRegistry\(|node:path/, + "Action should not read v2 reference files or filesystem paths at runtime.", ); assert.equal( hasPattern(actionSource, /skillRegistry:\s*\[\s*\]/), diff --git a/tests/audit-generation-persistence-source.test.ts b/tests/audit-generation-persistence-source.test.ts index d476068..b4c70e9 100644 --- a/tests/audit-generation-persistence-source.test.ts +++ b/tests/audit-generation-persistence-source.test.ts @@ -224,6 +224,50 @@ test("persistAuditGenerationResult inserts into auditGenerations", () => { ); }); +test("getAuditGenerationEvidence loads latest successful website enrichment evidence by lead", () => { + const evidenceSource = extractExportSource("getAuditGenerationEvidence"); + + assert.equal( + hasPattern( + evidenceSource, + /query\("agentRuns"\)[\s\S]*withIndex\("by_type_and_status_and_leadId"[\s\S]*eq\("type",\s*"website_enrichment"\)[\s\S]*eq\("status",\s*"succeeded"\)[\s\S]*eq\("leadId",\s*lead\._id\)[\s\S]*order\("desc"\)[\s\S]*take\(1\)/, + ), + true, + "Evidence query should locate the latest successful website_enrichment run for the same lead.", + ); + assert.equal( + hasPattern( + evidenceSource, + /const\s+enrichmentEvidenceRunId\s*=\s*latestSuccessfulEnrichmentRun\[0\]\?\._id\s*\?\?\s*args\.runId/, + ), + true, + "Evidence query should fall back to the audit run only when no enrichment run exists.", + ); + for (const table of [ + "websiteCrawlPages", + "websiteTechnicalChecks", + ]) { + assert.equal( + hasPattern( + evidenceSource, + new RegExp( + `query\\("${table}"\\)[\\s\\S]*withIndex\\("by_runId"[\\s\\S]*eq\\("runId",\\s*enrichmentEvidenceRunId\\)`, + ), + ), + true, + `${table} should be loaded from the enrichment evidence run.`, + ); + } + assert.equal( + hasPattern( + evidenceSource, + /const\s+screenshots\s*=\s*\[\s*\.\.\.auditCaptureScreenshotsByRun,\s*\.\.\.enrichmentScreenshotsByRun\s*\]/, + ), + true, + "Evidence query should include audit-run ScreenshotOne captures and enrichment screenshots.", + ); +}); + test("truncateWithMarker is byte-capped and marker-safe in persistence", () => { assert.equal( hasPattern(auditGenerationSource, /const markerBytes = byteLength\(TRUNCATION_MARKER\);/), diff --git a/tests/audit-skill-registry-v3.test.ts b/tests/audit-skill-registry-v3.test.ts index 7acb58b..fd1153a 100644 --- a/tests/audit-skill-registry-v3.test.ts +++ b/tests/audit-skill-registry-v3.test.ts @@ -1,14 +1,11 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import test from "node:test"; +import { LOCAL_AUDIT_SKILL_REGISTRY_SOURCE } from "../lib/ai/local-audit-skill-registry"; import { parseSkillsRegistry, toAuditUsedSkill } from "../lib/skills-registry"; -test("parseSkillsRegistry parses v3 yaml metablocks from v2 source", async () => { - const source = await readFile(join(process.cwd(), "v2_elemente", "skills.md"), "utf8"); - - const parsed = parseSkillsRegistry(source); +test("parseSkillsRegistry parses v3 yaml metablocks from the MVP registry source", () => { + const parsed = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE); assert.equal(parsed.length, 9); const visualDesign = parsed.find((entry) => entry.id === "visual-design"); @@ -28,9 +25,8 @@ test("parseSkillsRegistry parses v3 yaml metablocks from v2 source", async () => assert.match(instructions, /Beurteile den ersten visuellen Eindruck/); }); -test("toAuditUsedSkill exposes stable ids for v3 registry entries", async () => { - const source = await readFile(join(process.cwd(), "v2_elemente", "skills.md"), "utf8"); - const parsed = parseSkillsRegistry(source); +test("toAuditUsedSkill exposes stable ids for v3 registry entries", () => { + const parsed = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE); const skill = parsed.find((entry) => entry.id === "contact-conversion"); assert.ok(skill); @@ -40,9 +36,8 @@ test("toAuditUsedSkill exposes stable ids for v3 registry entries", async () => }); }); -test("parseSkillsRegistry does not infer categories for v3 entries without explicit metadata", async () => { - const source = await readFile(join(process.cwd(), "v2_elemente", "skills.md"), "utf8"); - const parsed = parseSkillsRegistry(source); +test("parseSkillsRegistry does not infer categories for v3 entries without explicit metadata", () => { + const parsed = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE); const skill = parsed.find((entry) => entry.id === "performance-experience"); assert.ok(skill); diff --git a/tests/audit-skills-schema.test.ts b/tests/audit-skills-schema.test.ts index 53fe3c0..0f1db77 100644 --- a/tests/audit-skills-schema.test.ts +++ b/tests/audit-skills-schema.test.ts @@ -225,8 +225,8 @@ test("audits.getDetail returns audit + lead context with null-safe lead lookup", ); hasPattern( getDetailSource, - /return\s*{\s*audit,\s*lead\s*}/, - "getDetail should return { audit, lead }.", + /return\s*{[\s\S]*audit,[\s\S]*lead,[\s\S]*sourceSummaries:[\s\S]*}/, + "getDetail should return audit, lead, and sourceSummaries.", ); hasPattern( sourceFile.getFullText(), @@ -234,3 +234,48 @@ test("audits.getDetail returns audit + lead context with null-safe lead lookup", "audits.ts should export a getDetail query.", ); }); + +test("audits.getDetail joins compact checked-page evidence from latest successful enrichment", () => { + const getDetailSource = extractExportSource("getDetail"); + + hasPattern( + getDetailSource, + /query\("agentRuns"\)[\s\S]*withIndex\("by_type_and_status_and_leadId"[\s\S]*eq\("type",\s*"website_enrichment"\)[\s\S]*eq\("status",\s*"succeeded"\)[\s\S]*eq\("leadId",\s*audit\.leadId\)[\s\S]*order\("desc"\)[\s\S]*take\(1\)/, + "getDetail should locate the latest successful website_enrichment run for the audit lead.", + ); + + for (const table of [ + "websiteCrawlPages", + "websiteTechnicalChecks", + "websiteCrawlScreenshots", + ]) { + hasPattern( + getDetailSource, + new RegExp( + `query\\("${table}"\\)[\\s\\S]*withIndex\\("by_runId"[\\s\\S]*eq\\("runId",\\s*enrichmentRunId\\)[\\s\\S]*take\\(DETAIL_EVIDENCE_LIMIT\\)`, + ), + `${table} should be loaded from the bounded enrichment run evidence window.`, + ); + } + + hasPattern( + getDetailSource, + /audit\.checkedPages\.map\(/, + "getDetail should preserve audit.checkedPages as the canonical display order.", + ); + hasPattern( + getDetailSource, + /fallbackCheckedPageEvidence/, + "getDetail should return checked-page fallback rows when enrichment evidence is missing.", + ); + hasPattern( + getDetailSource, + /ctx\.storage\.getUrl\(screenshot\.storageId\)/, + "getDetail should resolve screenshot storage ids to display URLs.", + ); + hasPattern( + getDetailSource, + /sourceSummaries:\s*{\s*checkedPages/, + "getDetail should expose checked page summaries under sourceSummaries.checkedPages.", + ); +}); diff --git a/tests/audit-skills-ui.test.ts b/tests/audit-skills-ui.test.ts index 6e63168..112d03d 100644 --- a/tests/audit-skills-ui.test.ts +++ b/tests/audit-skills-ui.test.ts @@ -141,6 +141,53 @@ test("audit detail component uses getDetail query and renders skills overview se ); }); +test("audit detail component renders compact checked-page evidence", async () => { + const detailSource = await source("components/audits/audit-detail.tsx"); + + assert.match( + detailSource, + /sourceSummaries/, + "AuditDetail should read sourceSummaries from getDetail.", + ); + assert.match( + detailSource, + /checkedPageEvidence/, + "AuditDetail should derive checked page evidence from sourceSummaries.checkedPages.", + ); + assert.match( + detailSource, + /Geprüfte Seiten/, + "AuditDetail should render a checked-pages evidence card.", + ); + assert.match( + detailSource, + /checkedPageEvidence\.map/, + "AuditDetail should render one compact row per checked page.", + ); + for (const label of [ + "Meta", + "Kontaktformular", + "CTA", + "Interne Links", + ]) { + assert.match( + detailSource, + new RegExp(label), + `AuditDetail should expose ${label} evidence for each page.`, + ); + } + assert.match( + detailSource, + /page\.screenshots\.map/, + "AuditDetail should render optional screenshot thumbnails when present.", + ); + assert.match( + detailSource, + / { const pageSource = await source("app/dashboard/audits/[id]/page.tsx"); diff --git a/tests/ops-quality-source.test.ts b/tests/ops-quality-source.test.ts index c09aa5d..ca0f6e8 100644 --- a/tests/ops-quality-source.test.ts +++ b/tests/ops-quality-source.test.ts @@ -33,6 +33,9 @@ test("settings page surfaces integration status instead of a placeholder", () => assert.doesNotMatch(helperSource, /requiredEnv: \["TASK8_BROWSER_ASSET_URL"\]/); assert.match(helperSource, /requiredEnv: \["SCREENSHOTONE_API_KEY"\]/); assert.match(helperSource, /requiredEnv: \[\]/); + assert.match(componentSource, /Next\.js-Runtime/); + assert.match(componentSource, /Convex-Action-Env/); + assert.match(helperSource, /Convex-Run-Events/); }); test("verification notes cover critical MVP flows", () => {