Compare commits

..

2 Commits

45 changed files with 6796 additions and 90 deletions

View File

@@ -24,6 +24,12 @@ PAGESPEED_TIMEOUT_MS=60000
# OpenRouter
OPENROUTER_API_KEY=
OPENROUTER_MODEL_CLASSIFICATION=
OPENROUTER_MODEL_MULTIMODAL_AUDIT=
OPENROUTER_MODEL_GERMAN_COPY=
OPENROUTER_MODEL_QUALITY_REVIEW=
OPENROUTER_APP_NAME=
OPENROUTER_APP_URL=
# SMTP / Stalwart
SMTP_HOST=

View File

@@ -24,7 +24,7 @@ Copy `.env.example` to `.env.local` for local development. Keep real secrets out
- **App / Coolify:** `APP_ENV`, `NEXT_PUBLIC_APP_URL`
- **Convex:** `NEXT_PUBLIC_CONVEX_URL`, `NEXT_PUBLIC_CONVEX_SITE_URL`, `CONVEX_DEPLOYMENT`
- **Google / Task-9 PageSpeed:** `GOOGLE_GEOCODING_API_KEY`, `GOOGLE_PLACES_API_KEY`, `PAGESPEED_API_KEY`, `PAGESPEED_TIMEOUT_MS`
- **OpenRouter:** `OPENROUTER_API_KEY`
- **OpenRouter:** `OPENROUTER_API_KEY`, `OPENROUTER_MODEL_CLASSIFICATION`, `OPENROUTER_MODEL_MULTIMODAL_AUDIT`, `OPENROUTER_MODEL_GERMAN_COPY`, `OPENROUTER_MODEL_QUALITY_REVIEW`, optional: `OPENROUTER_APP_NAME`, `OPENROUTER_APP_URL`
- **SMTP / Stalwart:** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
- **Rybbit:** `RYBBIT_API_URL`, `RYBBIT_API_KEY`, `NEXT_PUBLIC_RYBBIT_SITE_ID`
- **Auth:** `BETTER_AUTH_SECRET`

View File

@@ -0,0 +1,17 @@
import { AuditDetail } from "@/components/audits/audit-detail";
export default async function AuditDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<main className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl">
<AuditDetail id={id as unknown as string} />
</div>
</main>
);
}

View File

@@ -1,10 +1,11 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
import { AuditsBoard } from "@/components/audits/audits-board";
export default function AuditsPage() {
return (
<DashboardPlaceholderPage
description="Audit-Review, Screenshots und oeffentliche Freigaben folgen in TASK-12 und TASK-13."
title="Audits"
/>
<main className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<AuditsBoard />
</div>
</main>
);
}

View File

@@ -1,9 +1,10 @@
---
id: TASK-10
title: Build the local skills registry
status: To Do
status: Done
assignee: []
created_date: '2026-06-03 19:13'
updated_date: '2026-06-05 07:28'
labels:
- mvp
- agent
@@ -24,19 +25,34 @@ Create the local skills registry concept for the agent. Design and marketing ski
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 A project-local skills directory or convention exists for imported design and marketing skills
- [ ] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category
- [ ] #3 Agent code can load and parse the skills registry into structured skill metadata
- [ ] #4 Audit records store the list of used skills, including skill name/category and version or source where available
- [ ] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not
- [x] #1 A project-local skills directory or convention exists for imported design and marketing skills
- [x] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category
- [x] #3 Agent code can load and parse the skills registry into structured skill metadata
- [x] #4 Audit records store the list of used skills, including skill name/category and version or source where available
- [x] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Define project-local skill storage conventions.
2. Create the initial skills.md registry format and seed entries for design, UX, marketing, copy, SEO, and offer-writing skills.
3. Add parser/loader for registry metadata.
4. Store selected skill metadata with each audit.
5. Show used skills in the internal audit detail UI only.
1. Worker A uses TDD to add project-local skills conventions, seed skills.md, skills source files, and a strict skills registry parser/loader.
2. Worker B uses TDD to extend Convex audit persistence so audit records can store used skill metadata with name, category, version, and source.
3. Worker C uses TDD to add the internal dashboard audit detail/list UI and compact Verwendete Skills overview while keeping public audit pages free of skill metadata.
4. Orchestrator reviews subagent outputs, resolves integration issues through focused subagents, runs full verification, and checks TASK-10 acceptance criteria without marking Done until user confirmation.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation using subagent-driven and test-driven workflow with parallel agents where write scopes are independent. Orchestrator will not hand-code feature changes; workers own implementation patches and tests.
Worker C: implemented audits dashboard internals for TASK-10. Added new tests (tests/audit-skills-ui.test.ts), new components/audits/{audits-board,audit-detail}.tsx and routes app/dashboard/audits/page.tsx + app/dashboard/audits/[id]/page.tsx. Internal detail route still passes raw id from params Promise; public audit page unchanged and remains skill-free.
Implementation completed through parallel subagent-driven TDD slices. Worker scopes: registry/parser, Convex audit persistence, dashboard audit UI. Review findings addressed by follow-up workers for getDetail result shape/useQuery FunctionReference and indented skills.md field parsing. Fresh orchestrator verification: pnpm test passed with 179/179 tests; pnpm lint passed with 0 errors and 2 existing generated BetterAuth warnings; pnpm exec convex codegen --dry-run --typecheck enable passed after network escalation; pnpm build passed after network escalation. Sandbox-only failures before escalation were DNS/Sentry for Convex and Google Fonts for Next build.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Shipped the local skills registry with project-local skills.md and skills/ source files, parser/loader tests, Convex audit usedSkills persistence, and internal dashboard audit skill overview. Verified with pnpm test; task remains public-audit safe because used skills are only shown in the dashboard detail route.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,10 @@
---
id: TASK-11
title: Create the OpenRouter AI audit pipeline
status: To Do
status: Done
assignee: []
created_date: '2026-06-03 19:13'
updated_date: '2026-06-05 09:04'
labels:
- mvp
- agent
@@ -26,19 +27,44 @@ Implement the LLM-powered audit generation pipeline using Vercel AI SDK and Open
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Vercel AI SDK is configured with OpenRouter and environment/Convex secrets
- [ ] #2 Model profiles exist for classification, multimodal audit analysis, German text generation, and final quality review
- [ ] #3 Structured audit outputs use Zod schemas and are stored in Convex with raw prompts/responses and model metadata
- [ ] #4 Screenshots can be passed to multimodal-capable models where supported
- [ ] #5 Generated customer-facing text follows Ich-Form, German language, no scores, no prices, no generic KI-Slop, and factual observation plus suggestion style
- [x] #1 Vercel AI SDK is configured with OpenRouter and environment/Convex secrets
- [x] #2 Model profiles exist for classification, multimodal audit analysis, German text generation, and final quality review
- [x] #3 Structured audit outputs use Zod schemas and are stored in Convex with raw prompts/responses and model metadata
- [x] #4 Screenshots can be passed to multimodal-capable models where supported
- [x] #5 Generated customer-facing text follows Ich-Form, German language, no scores, no prices, no generic KI-Slop, and factual observation plus suggestion style
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add OpenRouter provider setup through Vercel AI SDK.
2. Define Zod schemas for internal findings, audit summary, email draft, subject, call script, follow-up, and quality review.
3. Build model-profile configuration for fast classification, multimodal analysis, and German copy generation.
4. Combine lead, crawl, screenshot, PageSpeed, and selected skills into prompt inputs.
5. Persist all prompts, model responses, normalized findings, final texts, and generation errors in Convex.
1. Worker A: add OpenRouter/Vercel AI SDK dependencies, provider config, model profiles, and schema helpers with RED/GREEN tests.
2. Worker B: add Convex schema and persistence contracts for structured LLM generations with RED/GREEN source/type tests.
3. Worker C: add evidence/prompt input builder combining lead, crawl, screenshots, PageSpeed, and local skills with RED/GREEN tests.
4. Worker D: add Node audit-generation action queue/process flow with screenshots, AI SDK structured outputs, audit/outreach persistence, and failure recording with RED/GREEN tests.
5. Worker E: add German copy quality guard tests/helpers for Ich-Form, no scores, no prices, no generic KI-Slop, and observation-plus-suggestion style.
6. Orchestrator: review worker patches, resolve integration gaps through Spark follow-up workers, run full verification, and check acceptance criteria without marking Done.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-06-05: Started TASK-11 implementation on branch codex-task-11-openrouter-audit-pipeline using subagent-driven and test-driven workflow. Existing TASK-25 worktree changes were present and will not be reverted or touched unless required.
Wave 1 dispatched with gpt-5.3-codex-spark: Worker A owns AI SDK/OpenRouter dependencies, model profiles, and Zod schemas; Worker B owns Convex auditGenerations schema/persistence; Worker C owns pure audit evidence builder; Worker E owns German customer-copy guard. Orchestrator remains integration/review only and is not hand-coding feature patches.
Implemented Worker-E German copy guard slice in pure deterministic helpers (lib/ai/german-copy-guard.ts) plus TDD tests (tests/german-copy-guard.test.ts). Added issue coverage for language quality, Ich-Form, score/page-speed artifacts, Preise, KI-Slop, anklagende Sprache, technische Artefakte, Beobachtung+Vorschlag. Keinen Fremdscope verändert.
Wave 1 review complete. Spec/code-quality reviewers found expected blocker: auditGenerationAction is not implemented yet and queue currently uses a temporary any reference. Follow-up scope: Worker D will add Node action, typed scheduler reference, screenshot multimodal handoff, AI SDK calls, audit/outreach persistence, and prompt/response size/sanitization guards. Worker F will harden German short-text detection, document model override env vars, and remove generated JS artifacts.
Wave 2 dispatched with gpt-5.3-codex-spark: Worker D owns auditGenerationAction, typed scheduler reference, multimodal screenshot handoff, AI SDK structured stages, audit/outreach persistence, and prompt/response persistence hardening. Worker F owns German short-text guard hardening, OpenRouter override env docs, and removal of generated JS artifacts. Orchestrator remains review/verification only.
Final review before closure: spec reviewer passed all five TASK-11 acceptance criteria, but code-quality reviewer found P1 risks in auditGenerationAction error handling and lead status patching, plus P2 hardening around UTF-8 byte capping/secret redaction. Worker H dispatched with gpt-5.3-codex-spark to address those findings before acceptance criteria are checked.
Implementation complete pending user confirmation. Built OpenRouter/Vercel AI SDK audit-generation pipeline with model profiles, Zod structured outputs, evidence builder, multimodal screenshot handoff, Convex auditGenerations persistence with prompt/response/model metadata, German copy guard, audit/outreach upserts, guarded lead status transition, action-level failure handling, UTF-8 byte-safe truncation, env-secret redaction, and model-profile driven generation parameters. Verification passed: pnpm test (235/235); pnpm exec tsc -p tsconfig.json --pretty false; pnpm lint (0 errors, existing BetterAuth generated warnings only); pnpm exec convex codegen --dry-run --typecheck enable; pnpm build. Final Spark review found no blocking/important issues; residual P3: PageSpeed evidence freshness on re-runs may need future runtime coverage.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented the OpenRouter/Vercel AI SDK audit-generation pipeline end to end: model profiles, Zod structured outputs, Convex audit generation persistence, evidence builder, multimodal screenshots, German copy guard, audit/outreach draft persistence, guarded lead transition, and hardening for failure handling/secret redaction. Verified with pnpm test, TypeScript, lint, Convex codegen/typecheck, build, and final Spark review.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-25
title: Harden website enrichment against Convex action runtime aborts
status: In Progress
assignee: []
created_date: '2026-06-05 06:59'
updated_date: '2026-06-05 07:04'
labels: []
dependencies: []
priority: high
ordinal: 27000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Website enrichment actions can be killed by Convex with a transient invalid environment error before the JS catch block runs, leaving runs without normal failure finalization or PageSpeed queueing. Add an internal action runtime budget so long browser/bootstrap/crawl work fails inside the action before the platform aborts it.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Website enrichment has an action-level runtime budget below the Convex runtime abort window
- [x] #2 Long Chromium bootstrap, browser launch, crawl, link checks, and screenshots are bounded by remaining action time
- [x] #3 When the runtime budget is exceeded, the existing catch path finalizes the enrichment run and queues PageSpeed for the lead
- [x] #4 Regression tests cover the runtime budget guard and full verification passes
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add RED source regression for action runtime budget and bounded browser/crawl steps
2. Implement minimal runtime budget helper in websiteEnrichmentAction
3. Run tests/type/lint and deploy Convex dev
4. Record findings and leave task open pending manual retest
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-06-05: Investigation found latest website_enrichment run was manually set to failed, but Convex logs show the underlying action ended with "Transient error while executing action" and environment "invalid" before app-level catch/finalization ran. This explains missing finishedAt/errorSummary/PageSpeed follow-up.
2026-06-05: Implemented action-level budget guard (default 120s, TASK8_ACTION_BUDGET_MS override) around Playwright import, Chromium executable resolution, AL2023 library preparation, browser launch/context creation, page crawls, internal link checks, and desktop/mobile screenshots so long work rejects inside the action catch path before Convex invalidates the runtime. Verified with targeted website-enrichment action tests, full pnpm test, TypeScript, lint, and Convex dev typecheck/deploy.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,50 @@
---
id: TASK-26
title: Finalize audit generation hardening and catch-all failure handling
status: Done
assignee: []
created_date: '2026-06-05 08:37'
updated_date: '2026-06-05 09:04'
labels: []
dependencies: []
priority: high
ordinal: 28000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement P1/P2/P3 audit-generation code-quality fixes with regression-safe behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 processAuditGeneration catches all late failures and marks run failed
- [x] #2 outreach_ready patch is guarded by terminal contact status
- [x] #3 truncateWithMarker is byte-safe and source tests cover byte behavior
- [x] #4 action/persistence sanitizer masks env-backed secret values
- [x] #5 model profile flags are used for model params and supportsImages
- [x] #6 reachability to deterministic outreach upsert behaviour for empty values
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add source-level regression tests for P1/P2/P3 points
2. Implement action-level robust failure handling and guarded lead status transition
3. Fix byte-aware truncation and shared sanitization paths in action/persistence
4. Rework model-profile driven generation config and multimodal gating
5. Add deterministic outreach upsert behavior and run full checks
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Verified as TASK-11 final hardening follow-up. Fixed action-level catch/failure finish, terminal-status guard for outreach_ready, UTF-8 byte-safe truncation, env-backed secret redaction, model-profile params/supportsImages usage, and deterministic outreach upsert for explicit empty values. Verification passed with TASK-11 final checks; task remains In Progress pending user confirmation.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Shipped final audit-generation hardening: catch-all post-start failure handling, terminal lead-status guard, byte-safe truncation, env-backed secret redaction, model-profile driven parameters/supportsImages, and deterministic outreach upsert behavior. Verified together with TASK-11 final checks.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,184 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "convex/react";
import type { Id } from "@/convex/_generated/dataModel";
import { api } from "@/convex/_generated/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Globe } from "lucide-react";
type UsedSkill = {
name: string;
purpose?: string;
category?: string;
source?: string;
version?: string;
};
type LeadContext = {
_id: Id<"leads">;
companyName?: string;
websiteDomain?: string;
websiteUrl?: string;
city?: string;
niche?: string;
};
type SkillAwareAudit = {
_id: Id<"audits">;
slug: string;
checkedDomain: string;
status: "draft" | "approved" | "published" | "deactivated";
checkedPages: string[];
createdAt?: number;
updatedAt?: number;
usedSkills?: UsedSkill[];
internalSummary?: string | null;
};
type AuditDetailResult = {
audit: SkillAwareAudit;
lead: LeadContext | null;
} | null;
const statusText: Record<string, string> = {
draft: "Entwurf",
approved: "Freigegeben",
published: "Veröffentlicht",
deactivated: "Deaktiviert",
};
function getStatusLabel(status: SkillAwareAudit["status"]) {
return statusText[status] ?? "Unbekannt";
}
function leadSummary(lead: LeadContext | null | undefined) {
if (!lead) {
return "Kein Lead-Kontext gespeichert";
}
const detail = [lead.city, lead.niche].filter(Boolean).join(" • ");
let leadDomain = lead.websiteDomain ?? "—";
if (!leadDomain && lead.websiteUrl) {
try {
leadDomain = new URL(lead.websiteUrl).hostname;
} catch {
leadDomain = lead.websiteUrl;
}
}
return (
<>
<p className="font-medium">{lead.companyName ?? "Lead ohne Name"}</p>
<p className="text-sm text-muted-foreground">{detail || "Kein Kontext textlich"}</p>
<p className="mt-1 inline-flex items-center gap-1 text-sm text-muted-foreground">
<Globe className="size-3.5" />
{leadDomain}
</p>
</>
);
}
export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
const result = useQuery(api.audits.getDetail, {
id: id as Id<"audits">,
}) as AuditDetailResult | undefined;
const audit = result?.audit;
const lead = result?.lead;
const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]);
if (result === null) {
return (
<Card>
<CardHeader>
<CardTitle>Audit nicht gefunden</CardTitle>
<CardDescription>
Der gewünschte Audit-Datensatz konnte nicht geladen werden.
</CardDescription>
</CardHeader>
</Card>
);
}
if (audit === undefined) {
return (
<Card>
<CardHeader>
<CardTitle>Audit wird geladen...</CardTitle>
</CardHeader>
</Card>
);
}
return (
<div className="grid gap-4">
<Card>
<CardHeader>
<CardDescription>Audit-Detail</CardDescription>
<CardTitle className="text-xl">#{audit.slug}</CardTitle>
<p className="inline-flex max-w-full items-center gap-1 truncate text-sm text-muted-foreground">
<Globe className="size-3.5" />
{audit.checkedDomain}
</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Status</p>
<p>
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Seitenanzahl</p>
<p>{audit.checkedPages.length}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Lead-Kontext</p>
<div className="text-sm">{leadSummary(lead)}</div>
</div>
{audit.internalSummary ? (
<div>
<p className="text-sm text-muted-foreground">Interne Notiz</p>
<p className="text-sm text-muted-foreground">{audit.internalSummary}</p>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Verwendete Skills</CardTitle>
<CardDescription>Skills, die an diesem Audit beteiligt wurden.</CardDescription>
</CardHeader>
<CardContent>
{usedSkills.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Skills gespeichert</p>
) : (
<ul className="grid gap-2">
{usedSkills.map((skill, index) => (
<li
className="rounded-md border p-2 text-sm"
key={`${skill.name}-${index}`}
>
<p className="font-medium">{skill.name}</p>
<p className="text-sm text-muted-foreground">
{skill.purpose ?? "Keine Zweckbeschreibung"}
</p>
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}
{skill.source ? <span className="text-xs text-muted-foreground">{skill.source}</span> : null}
</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { Files, SquarePen } from "lucide-react";
import Link from "next/link";
import { api } from "@/convex/_generated/api";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
type AuditsListResult = FunctionReturnType<typeof api.audits.list>;
type AuditRow = NonNullable<AuditsListResult>[number];
const statusText: Record<string, string> = {
draft: "Entwurf",
approved: "Freigegeben",
published: "Veröffentlicht",
deactivated: "Deaktiviert",
};
const fallbackStatus = "Unbekannt";
function formatPageCount(pages: AuditRow["checkedPages"]) {
return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`;
}
function getStatusLabel(status: AuditRow["status"]) {
return statusText[status] ?? fallbackStatus;
}
function AuditsBoardLoading() {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
</header>
<div className="rounded-lg border">
<div className="grid gap-2 p-3">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton className="h-20 rounded-md" key={index} />
))}
</div>
</div>
</section>
);
}
export function AuditsBoard() {
const audits = useQuery(api.audits.list, { limit: 100 });
const rows = useMemo(() => {
if (!audits) {
return [];
}
return [...audits].sort((a, b) => b.createdAt - a.createdAt);
}, [audits]);
if (audits === undefined) {
return <AuditsBoardLoading />;
}
if (rows.length === 0) {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<article className="rounded-lg border p-4">
<h2 className="text-sm font-medium">Noch keine Audits</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sobald neue Audits angelegt wurden, erscheinen sie hier als kompakte
Zeilen.
</p>
</article>
</section>
);
}
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<section className="space-y-2">
<div className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Slug</span>
<span>Domain</span>
<span>Status</span>
<span>Seitenanzahl</span>
<span className="text-right">Aktion</span>
</div>
<div className="space-y-2">
{rows.map((audit: AuditRow) => (
<article
className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
key={audit._id}
>
<div className="min-w-0">
<p className="truncate font-medium">{audit.slug}</p>
</div>
<p className="truncate text-muted-foreground">{audit.checkedDomain}</p>
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
<p className="text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Files className="size-3.5" />
{formatPageCount(audit.checkedPages)}
</span>
</p>
<div className="flex justify-end">
<Link
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
href={`/dashboard/audits/${audit._id}`}
>
<SquarePen className="size-4" />
Öffnen
</Link>
</div>
</article>
))}
</div>
</section>
</section>
);
}

View File

@@ -8,6 +8,8 @@
* @module
*/
import type * as auditGeneration from "../auditGeneration.js";
import type * as auditGenerationAction from "../auditGenerationAction.js";
import type * as auditInputs from "../auditInputs.js";
import type * as audits from "../audits.js";
import type * as blacklist from "../blacklist.js";
@@ -32,6 +34,8 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
auditGeneration: typeof auditGeneration;
auditGenerationAction: typeof auditGenerationAction;
auditInputs: typeof auditInputs;
audits: typeof audits;
blacklist: typeof blacklist;

578
convex/auditGeneration.ts Normal file
View File

@@ -0,0 +1,578 @@
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, internalQuery } from "./_generated/server";
import {
AUDIT_GENERATION_STAGES,
AUDIT_GENERATION_STATUSES,
RUN_STATUSES,
} from "./domain";
import { v } from "convex/values";
import {
type PageSpeedAuditErrorType,
type PageSpeedMinimalAuditResult,
} from "../lib/pagespeed-audit-input";
export const MAX_PROMPT_BYTES = 12_000;
export const MAX_RAW_RESPONSE_BYTES = 12_000;
export const MAX_PARSED_JSON_BYTES = 12_000;
const TRUNCATION_MARKER = "\n\n[... abgeschnitten ...]";
const auditGenerationStage = v.union(
...AUDIT_GENERATION_STAGES.map((stage) => v.literal(stage)),
);
const auditGenerationStatus = v.union(
...AUDIT_GENERATION_STATUSES.map((status) => v.literal(status)),
);
const runStatus = v.union(...RUN_STATUSES.map((status) => v.literal(status)));
const auditGenerationParsedValue = v.union(
v.string(),
v.number(),
v.boolean(),
v.null(),
v.array(v.any()),
v.record(v.string(), v.any()),
);
const auditGenerationParsedJson = v.union(
v.string(),
v.record(v.string(), auditGenerationParsedValue),
);
type AuditGenerationLead = Pick<
Doc<"leads">,
| "_id"
| "companyName"
| "niche"
| "city"
| "address"
| "websiteUrl"
| "websiteDomain"
| "phone"
| "contactPerson"
>;
type AuditGenerationEvidenceCrawlPage = Pick<
Doc<"websiteCrawlPages">,
| "sourceUrl"
| "finalUrl"
| "title"
| "metaDescription"
| "pageKind"
| "hasContactFormSignal"
| "hasContactCtaSignal"
| "visibleTextExcerpt"
>;
type AuditGenerationEvidenceTechnicalCheck = Pick<
Doc<"websiteTechnicalChecks">,
| "sourceUrl"
| "finalUrl"
| "usesHttps"
| "missingTitle"
| "missingMetaDescription"
| "hasVisibleContactPath"
| "brokenInternalLinkCount"
>;
type AuditGenerationEvidenceScreenshot = Pick<
Doc<"websiteCrawlScreenshots">,
| "storageId"
| "viewport"
| "sourceUrl"
| "capturedAt"
| "width"
| "height"
| "mimeType"
>;
type AuditGenerationEvidence = {
lead: AuditGenerationLead;
crawlPages: AuditGenerationEvidenceCrawlPage[];
technicalChecks: AuditGenerationEvidenceTechnicalCheck[];
screenshots: AuditGenerationEvidenceScreenshot[];
pageSpeedInputs: PageSpeedMinimalAuditResult[];
};
function byteLength(value: string) {
return new TextEncoder().encode(value).byteLength;
}
function truncateToByteLimit(value: string, maxBytes: number) {
if (maxBytes <= 0) {
return "";
}
let usedBytes = 0;
let endIndex = 0;
for (const char of value) {
const charBytes = byteLength(char);
if (usedBytes + charBytes > maxBytes) {
break;
}
usedBytes += charBytes;
endIndex += char.length;
}
return value.slice(0, endIndex);
}
function truncateWithMarker(value: string, maxBytes: number) {
if (byteLength(value) <= maxBytes) {
return value;
}
const markerBytes = byteLength(TRUNCATION_MARKER);
if (markerBytes >= maxBytes) {
const markerBytesBuffer = new TextEncoder().encode(TRUNCATION_MARKER);
return new TextDecoder().decode(markerBytesBuffer.slice(0, maxBytes));
}
const byteBudget = Math.max(0, maxBytes - markerBytes);
const trimmed = truncateToByteLimit(value, byteBudget);
return `${trimmed}${TRUNCATION_MARKER}`;
}
function sanitizeAndCapString(value: string | undefined, maxBytes: number) {
if (!value) {
return undefined;
}
const safe = (sanitizeSecretCandidates(value) ?? "").trim();
return byteLength(safe) > maxBytes ? truncateWithMarker(safe, maxBytes) : safe;
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
return "[unserializable payload]";
}
}
function sanitizeAndCapParsedJson(parsedJson: unknown) {
if (parsedJson === undefined) {
return undefined;
}
if (typeof parsedJson === "string") {
return sanitizeAndCapString(parsedJson, MAX_PARSED_JSON_BYTES);
}
const serialized = safeStringify(parsedJson);
const safeSerialized = sanitizeSecretCandidates(serialized) ?? "";
if (byteLength(safeSerialized) <= MAX_PARSED_JSON_BYTES) {
return safeSerialized;
}
return truncateWithMarker(safeSerialized, MAX_PARSED_JSON_BYTES);
}
function normalizePageSpeedResultRow(
row: Doc<"pageSpeedResults">,
): PageSpeedMinimalAuditResult {
return {
strategy: row.strategy,
status: row.status,
sourceUrl: row.sourceUrl,
...(row.finalUrl ? { finalUrl: row.finalUrl } : {}),
...(row.normalized ? { normalized: row.normalized } : {}),
...(row.errorType ? { errorType: row.errorType as PageSpeedAuditErrorType } : {}),
...(row.errorSummary ? { errorSummary: row.errorSummary } : {}),
};
}
const auditGenerationUsage = v.object({
promptTokens: v.optional(v.number()),
completionTokens: v.optional(v.number()),
totalTokens: v.optional(v.number()),
cacheReadTokens: v.optional(v.number()),
totalCostUsd: v.optional(v.number()),
});
const secretHints = [
"OPENROUTER_API_KEY",
"GOOGLE_PLACES_API_KEY",
"GOOGLE_GEOCODING_API_KEY",
"PAGESPEED_API_KEY",
"SMTP_PASSWORD",
"SMTP_HOST",
"SMTP_USER",
"BETTER_AUTH_SECRET",
"RYBBIT_API_KEY",
];
function sanitizeSecretCandidates(value: string | undefined): string | undefined {
if (!value) {
return value;
}
let sanitized = value;
for (const key of secretHints) {
const secret = process.env[key];
if (!secret) {
continue;
}
sanitized = sanitized.replace(
new RegExp(escapeRegExp(secret), "g"),
"[REDACTED]",
);
}
return sanitized
.replace(/\b(?:api[_-]?key|token|secret|password)\s*[:=]\s*[^\s\"']+/gi, "[REDACTED]")
.trim();
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
}
type StartLeadSnapshot = Pick<
Doc<"leads">,
"_id" | "websiteUrl" | "websiteDomain" | "contactStatus"
>;
export const getAuditGenerationEvidence = internalQuery({
args: {
runId: v.id("agentRuns"),
},
handler: async (ctx, args): Promise<AuditGenerationEvidence | null> => {
const run = await ctx.db.get(args.runId);
if (!run || !run.leadId) {
return null;
}
const lead = await ctx.db.get(run.leadId);
if (!lead) {
return null;
}
const runIdFilter = {
table: "by_runId" as const,
value: args.runId,
};
const leadIdFilter = {
table: "by_leadId" as const,
value: lead._id,
};
const crawlPagesByRun = await ctx.db
.query("websiteCrawlPages")
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
.order("desc")
.take(40);
const technicalChecksByRun = await ctx.db
.query("websiteTechnicalChecks")
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
.order("desc")
.take(80);
const screenshotsByRun = await ctx.db
.query("websiteCrawlScreenshots")
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
.order("desc")
.take(20);
const pageSpeedByRun = run.auditId
? await ctx.db
.query("pageSpeedResults")
.withIndex("by_auditId", (q) => q.eq("auditId", run.auditId as Id<"audits">))
.order("desc")
.take(20)
: await ctx.db
.query("pageSpeedResults")
.withIndex("by_leadId", (q) => q.eq("leadId", leadIdFilter.value))
.order("desc")
.take(20);
const crawlPages = crawlPagesByRun;
const technicalChecks = technicalChecksByRun;
const screenshots = screenshotsByRun;
return {
lead: {
_id: lead._id,
companyName: lead.companyName,
niche: lead.niche,
city: lead.city,
address: lead.address,
websiteUrl: lead.websiteUrl,
websiteDomain: lead.websiteDomain,
phone: lead.phone,
contactPerson: lead.contactPerson,
},
crawlPages,
technicalChecks,
screenshots,
pageSpeedInputs: pageSpeedByRun.map(normalizePageSpeedResultRow),
};
},
});
export const queueLeadAuditGeneration = internalMutation({
args: {
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
parentRunId: v.optional(v.id("agentRuns")),
},
returns: v.union(v.id("agentRuns"), v.null()),
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
const now = Date.now();
const lead = await ctx.db.get(args.leadId);
if (!lead) {
return null;
}
const existingPending = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q
.eq("type", "audit_generation")
.eq("status", "pending")
.eq("leadId", args.leadId),
)
.take(1);
const existingRunning = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q
.eq("type", "audit_generation")
.eq("status", "running")
.eq("leadId", args.leadId),
)
.take(1);
if (existingPending.length > 0) {
return existingPending[0]._id;
}
if (existingRunning.length > 0) {
return existingRunning[0]._id;
}
const runId = await ctx.db.insert("agentRuns", {
type: "audit_generation",
leadId: args.leadId,
...(args.auditId ? { auditId: args.auditId } : {}),
status: "pending",
currentStep: "audit_generation",
counters: {
leadsFound: 0,
leadsCreated: 0,
auditsCreated: 0,
outreachPrepared: 0,
errors: 0,
},
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId,
level: "info",
message: "Audit-Generierung wurde in die Warteschlange gesetzt.",
details: [
{ label: "Lead", value: args.leadId },
...(args.parentRunId
? [{ label: "Parent-Run", value: args.parentRunId }]
: []),
],
createdAt: now,
});
await ctx.scheduler.runAfter(
0,
internal.auditGenerationAction.processAuditGeneration,
{
runId,
},
);
return runId;
},
});
export const startAuditGenerationRun = internalMutation({
args: {
runId: v.id("agentRuns"),
},
returns: v.union(
v.object({
lead: v.object({
_id: v.id("leads"),
websiteUrl: v.optional(v.string()),
websiteDomain: v.optional(v.string()),
contactStatus: v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
),
}),
auditId: v.optional(v.id("audits")),
}),
v.null(),
),
handler: async (ctx, args): Promise<
{ lead: StartLeadSnapshot; auditId?: Id<"audits"> } | null
> => {
const now = Date.now();
const run = await ctx.db.get(args.runId);
if (!run || run.type !== "audit_generation" || run.status !== "pending") {
return null;
}
if (!run.leadId) {
await ctx.db.patch(args.runId, {
status: "failed",
currentStep: "audit_generation",
errorSummary: "Der Lauf hat keine Lead-ID.",
updatedAt: now,
finishedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "error",
message:
"Audit-Generierung konnte nicht gestartet werden: Keine Lead-ID.",
details: [{ label: "Lead-ID", value: "unbekannt" }],
createdAt: now,
});
return null;
}
const lead = await ctx.db.get(run.leadId);
if (!lead) {
await ctx.db.patch(args.runId, {
status: "failed",
currentStep: "audit_generation",
errorSummary: "Lead wurde nicht gefunden.",
updatedAt: now,
finishedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "error",
message:
"Audit-Generierung konnte nicht gestartet werden: Kein Lead mit dieser ID.",
details: [{ label: "Lead-ID", value: run.leadId }],
createdAt: now,
});
return null;
}
await ctx.db.patch(args.runId, {
status: "running",
currentStep: "audit_generation",
startedAt: now,
updatedAt: now,
errorSummary: undefined,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "Audit-Generierung gestartet.",
details: [{ label: "Lead-ID", value: lead._id }],
createdAt: now,
});
return {
lead: {
_id: lead._id,
websiteUrl: lead.websiteUrl,
websiteDomain: lead.websiteDomain,
contactStatus: lead.contactStatus,
},
...(run.auditId ? { auditId: run.auditId } : {}),
};
},
});
export const persistAuditGenerationResult = internalMutation({
args: {
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
runId: v.id("agentRuns"),
stage: auditGenerationStage,
modelProfile: v.string(),
modelId: v.string(),
prompt: v.string(),
systemPrompt: v.optional(v.string()),
rawResponse: v.optional(v.string()),
parsedJson: v.optional(auditGenerationParsedJson),
usage: v.optional(auditGenerationUsage),
finishReason: v.optional(v.string()),
status: auditGenerationStatus,
errorSummary: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
return await ctx.db.insert("auditGenerations", {
leadId: args.leadId,
auditId: args.auditId,
runId: args.runId,
stage: args.stage,
modelProfile: args.modelProfile,
modelId: args.modelId,
prompt: sanitizeAndCapString(args.prompt, MAX_PROMPT_BYTES) ?? "",
...(args.systemPrompt
? { systemPrompt: sanitizeAndCapString(args.systemPrompt, MAX_PROMPT_BYTES) }
: {}),
...(args.rawResponse
? { rawResponse: sanitizeAndCapString(args.rawResponse, MAX_RAW_RESPONSE_BYTES) }
: {}),
...(args.parsedJson ? { parsedJson: sanitizeAndCapParsedJson(args.parsedJson) } : {}),
...(args.usage ? { usage: args.usage } : {}),
...(args.finishReason ? { finishReason: args.finishReason } : {}),
status: args.status,
...(args.errorSummary
? { errorSummary: sanitizeAndCapString(args.errorSummary, MAX_RAW_RESPONSE_BYTES) }
: {}),
createdAt: now,
updatedAt: now,
});
},
});
export const finishAuditGenerationRun = internalMutation({
args: {
runId: v.id("agentRuns"),
status: runStatus,
currentStep: v.optional(v.string()),
errorSummary: v.optional(v.string()),
errors: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now();
await ctx.db.patch(args.runId, {
status: args.status,
updatedAt: now,
finishedAt: now,
currentStep: args.currentStep ?? "audit_generation",
errorSummary: args.errorSummary,
counters: {
leadsFound: 0,
leadsCreated: 0,
auditsCreated: 0,
outreachPrepared: 0,
errors: args.errors ?? 0,
},
});
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { v } from "convex/values";
import { normalizeListLimit } from "./domain";
import { mutation, query } from "./_generated/server";
import { internalMutation, mutation, query } from "./_generated/server";
const auditStatus = v.union(
v.literal("draft"),
@@ -9,6 +9,21 @@ const auditStatus = v.union(
v.literal("published"),
v.literal("deactivated"),
);
const usedSkillsValidator = v.array(
v.object({
name: v.string(),
category: v.string(),
version: v.optional(v.string()),
source: v.optional(v.string()),
}),
);
const skillSummaryValidator = v.array(
v.object({
name: v.string(),
purpose: v.string(),
summary: v.string(),
}),
);
export const create = mutation({
args: {
@@ -16,6 +31,7 @@ export const create = mutation({
slug: v.string(),
checkedDomain: v.string(),
checkedPages: v.array(v.string()),
usedSkills: v.optional(usedSkillsValidator),
status: v.optional(auditStatus),
internalSummary: v.optional(v.string()),
publicSummary: v.optional(v.string()),
@@ -42,6 +58,19 @@ export const create = mutation({
},
});
export const getDetail = query({
args: { id: v.id("audits") },
handler: async (ctx, args) => {
const audit = await ctx.db.get(args.id);
if (!audit) {
return null;
}
const lead = await ctx.db.get(audit.leadId);
return { audit, lead };
},
});
export const get = query({
args: { id: v.id("audits") },
handler: async (ctx, args) => {
@@ -49,6 +78,81 @@ export const get = query({
},
});
export const upsertFromAuditGeneration = internalMutation({
args: {
leadId: v.id("leads"),
runId: v.id("agentRuns"),
auditId: v.optional(v.id("audits")),
checkedDomain: v.string(),
checkedPages: v.array(v.string()),
internalSummary: v.optional(v.string()),
multimodalSummary: v.optional(v.string()),
publicSummary: v.optional(v.string()),
publicBody: v.optional(v.string()),
usedSkills: v.optional(usedSkillsValidator),
skillSummaries: v.optional(skillSummaryValidator),
},
handler: async (ctx, args) => {
const now = Date.now();
const lead = await ctx.db.get(args.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
if (args.auditId) {
const existing = await ctx.db.get(args.auditId);
if (!existing) {
throw new Error("Audit wurde nicht gefunden.");
}
await ctx.db.patch(args.auditId, {
checkedDomain: args.checkedDomain,
checkedPages: args.checkedPages,
internalSummary: args.internalSummary,
multimodalSummary: args.multimodalSummary,
publicSummary: args.publicSummary,
publicBody: args.publicBody,
usedSkills: args.usedSkills,
skillSummaries: args.skillSummaries,
updatedAt: now,
});
return args.auditId;
}
const safeCheckedDomain = args.checkedDomain.trim().toLowerCase();
const domainTag = safeCheckedDomain.length > 0
? safeCheckedDomain.replace(/[^a-z0-9]+/g, "-").slice(0, 50)
: "lead";
let slug = `audit-${domainTag}-${args.leadId}-${now}`;
const slugCandidates = await ctx.db
.query("audits")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.take(1);
if (slugCandidates.length > 0) {
slug = `${slug}-${Math.floor(now / 1_000)}`;
}
return await ctx.db.insert("audits", {
leadId: args.leadId,
status: "draft",
slug,
checkedDomain: args.checkedDomain,
checkedPages: args.checkedPages,
internalSummary: args.internalSummary,
multimodalSummary: args.multimodalSummary,
publicSummary: args.publicSummary,
publicBody: args.publicBody,
usedSkills: args.usedSkills,
skillSummaries: args.skillSummaries,
createdAt: now,
updatedAt: now,
});
},
});
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {

View File

@@ -82,6 +82,7 @@ export const RUN_TYPES = [
"campaign",
"lead_discovery",
"audit",
"audit_generation",
"outreach",
"lifecycle",
"website_enrichment",
@@ -93,6 +94,19 @@ export const RUN_STATUSES = [
"failed",
"canceled",
] as const;
export const AUDIT_GENERATION_STAGES = [
"classification",
"multimodalAudit",
"germanCopy",
"qualityReview",
] as const;
export const AUDIT_GENERATION_STATUSES = [
"pending",
"running",
"succeeded",
"failed",
"canceled",
] as const;
export const RUN_EVENT_LEVELS = ["info", "warning", "error"] as const;
export const SCREENSHOT_VIEWPORTS = ["desktop", "mobile"] as const;
export const PAGE_SPEED_STRATEGIES = ["mobile", "desktop"] as const;
@@ -122,6 +136,8 @@ export type OutreachSalesStatus = (typeof OUTREACH_SALES_STATUSES)[number];
export type BlacklistType = (typeof BLACKLIST_TYPES)[number];
export type RunType = (typeof RUN_TYPES)[number];
export type RunStatus = (typeof RUN_STATUSES)[number];
export type AuditGenerationStage = (typeof AUDIT_GENERATION_STAGES)[number];
export type AuditGenerationStatus = (typeof AUDIT_GENERATION_STATUSES)[number];
export type RunEventLevel = (typeof RUN_EVENT_LEVELS)[number];
export type ScreenshotViewport = (typeof SCREENSHOT_VIEWPORTS)[number];
export type PageSpeedStrategy = (typeof PAGE_SPEED_STRATEGIES)[number];

View File

@@ -1,7 +1,7 @@
import { v } from "convex/values";
import { normalizeListLimit } from "./domain";
import { mutation, query } from "./_generated/server";
import { internalMutation, mutation, query } from "./_generated/server";
const strategy = v.union(
v.literal("call_first"),
@@ -35,6 +35,59 @@ export const create = mutation({
},
});
export const upsertFromAuditGeneration = internalMutation({
args: {
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
strategy: strategy,
phoneScript: v.optional(v.string()),
emailSubject: v.optional(v.string()),
emailBody: v.optional(v.string()),
followUpDraft: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query("outreachRecords")
.withIndex("by_leadId", (q) => q.eq("leadId", args.leadId))
.order("desc")
.take(1);
if (existing.length > 0) {
const current = existing[0]!;
if (args.auditId) {
await ctx.db.patch(current._id, { auditId: args.auditId });
}
await ctx.db.patch(current._id, {
strategy: args.strategy,
...(args.phoneScript !== undefined ? { phoneScript: args.phoneScript } : {}),
...(args.emailSubject !== undefined
? { emailSubject: args.emailSubject }
: {}),
...(args.emailBody !== undefined ? { emailBody: args.emailBody } : {}),
...(args.followUpDraft !== undefined
? { followUpDraft: args.followUpDraft }
: {}),
updatedAt: now,
});
return current._id;
}
return await ctx.db.insert("outreachRecords", {
...args,
approvalStatus: "draft",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: "follow_up_planned",
createdAt: now,
updatedAt: now,
});
},
});
export const list = query({
args: {
leadId: v.optional(v.id("leads")),

View File

@@ -2,6 +2,8 @@ import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { tables as authTables } from "./betterAuth/schema";
import {
AUDIT_GENERATION_STAGES,
AUDIT_GENERATION_STATUSES,
RUN_EVENT_LEVELS,
RUN_STATUSES,
RUN_TYPES,
@@ -91,6 +93,35 @@ const websiteEnrichmentPageKind = v.union(
);
const runType = v.union(...RUN_TYPES.map((type) => v.literal(type)));
const runStatus = v.union(...RUN_STATUSES.map((status) => v.literal(status)));
const auditGenerationStatus = v.union(
...AUDIT_GENERATION_STATUSES.map((status) => v.literal(status)),
);
const auditGenerationStage = v.union(
...AUDIT_GENERATION_STAGES.map((stage) => v.literal(stage)),
);
const auditGenerationUsage = v.object({
promptTokens: v.optional(v.number()),
completionTokens: v.optional(v.number()),
totalTokens: v.optional(v.number()),
cacheReadTokens: v.optional(v.number()),
totalCostUsd: v.optional(v.number()),
});
const auditGenerationParsedJson = v.union(
v.string(),
v.record(
v.string(),
v.union(
v.string(),
v.number(),
v.boolean(),
v.null(),
v.array(v.string()),
v.array(v.number()),
v.array(v.boolean()),
v.object({}),
),
),
);
const runEventLevel = v.union(
...RUN_EVENT_LEVELS.map((level) => v.literal(level)),
);
@@ -231,6 +262,16 @@ export default defineSchema({
pageSpeedSummary: v.optional(auditMetricSummary),
playwrightSummary: v.optional(playwrightSummary),
textFindings: v.optional(v.array(v.string())),
usedSkills: v.optional(
v.array(
v.object({
name: v.string(),
category: v.string(),
version: v.optional(v.string()),
source: v.optional(v.string()),
}),
),
),
skillSummaries: v.optional(
v.array(
v.object({
@@ -313,6 +354,30 @@ export default defineSchema({
.index("by_auditId", ["auditId"])
.index("by_leadId_and_strategy", ["leadId", "strategy"]),
auditGenerations: defineTable({
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
runId: v.id("agentRuns"),
stage: auditGenerationStage,
modelProfile: v.string(),
modelId: v.string(),
prompt: v.string(),
systemPrompt: v.optional(v.string()),
rawResponse: v.optional(v.string()),
parsedJson: v.optional(auditGenerationParsedJson),
usage: v.optional(auditGenerationUsage),
finishReason: v.optional(v.string()),
status: auditGenerationStatus,
errorSummary: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_leadId", ["leadId"])
.index("by_auditId", ["auditId"])
.index("by_runId", ["runId"])
.index("by_stage", ["stage"])
.index("by_leadId_and_stage", ["leadId", "stage"]),
websiteCrawlPages: defineTable({
leadId: v.id("leads"),
runId: v.optional(v.id("agentRuns")),

View File

@@ -23,6 +23,10 @@ import { internalAction, type ActionCtx } from "./_generated/server";
const DEFAULT_CRAWL_TIMEOUT_MS = 60_000;
const DEFAULT_CRAWL_MAX_PAGES = 5;
const DEFAULT_ACTION_BUDGET_MS = 120_000;
const MIN_ACTION_BUDGET_MS = 30_000;
const MAX_ACTION_BUDGET_MS = 140_000;
const ACTION_TIMEOUT_BUFFER_MS = 5_000;
const MAX_PERSISTED_LINKS = 120;
const MAX_PERSISTED_EMAIL_CANDIDATES = 40;
const SCREENSHOT_MIME_TYPE = "image/png";
@@ -140,6 +144,47 @@ function crawlMaxPages() {
);
}
function actionBudgetMs() {
return Math.max(
MIN_ACTION_BUDGET_MS,
Math.min(
MAX_ACTION_BUDGET_MS,
readPositiveIntEnv("TASK8_ACTION_BUDGET_MS", DEFAULT_ACTION_BUDGET_MS),
),
);
}
function remainingActionBudgetMs(startedAt: number, budgetMs: number) {
const elapsed = Date.now() - startedAt;
return Math.max(1_000, budgetMs - elapsed - ACTION_TIMEOUT_BUFFER_MS);
}
async function withActionTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeout = setTimeout(() => {
reject(
new Error(
`Website-Enrichment Zeitbudget ueberschritten: ${label}.`,
),
);
}, Math.max(1, timeoutMs));
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
function makePageKind(url: string, rootUrl: string): EnrichmentPageKind {
const normalizedRoot = normalizeCrawlUrl(rootUrl);
if (!normalizedRoot) {
@@ -418,6 +463,8 @@ export const processLeadEnrichment = internalAction({
handler: async (ctx, args) => {
let started: StartedLead | null = null;
const runId = args.runId;
const actionStartedAt = Date.now();
const actionBudget = actionBudgetMs();
let browser: Browser | null = null;
let desktopContext: BrowserContext | null = null;
let mobileContext: BrowserContext | null = null;
@@ -480,9 +527,15 @@ export const processLeadEnrichment = internalAction({
const maxPages = crawlMaxPages();
const { playwrightCore, serverlessChromium } =
await loadPlaywrightModules();
const executablePath = await resolveChromiumExecutablePath(
serverlessChromium,
await withActionTimeout(
loadPlaywrightModules(),
remainingActionBudgetMs(actionStartedAt, actionBudget),
"Playwright-Module laden",
);
const executablePath = await withActionTimeout(
resolveChromiumExecutablePath(serverlessChromium),
remainingActionBudgetMs(actionStartedAt, actionBudget),
"Chromium executable vorbereiten",
);
const prepareChromiumSharedLibraries = async (
@@ -502,21 +555,50 @@ export const processLeadEnrichment = internalAction({
chromiumRuntime.setupLambdaEnvironment(path.join(tmpdir(), "al2023", "lib"));
};
await prepareChromiumSharedLibraries(serverlessChromium);
browser = await playwrightCore.chromium.launch({
await withActionTimeout(
prepareChromiumSharedLibraries(serverlessChromium),
remainingActionBudgetMs(actionStartedAt, actionBudget),
"Chromium-Bibliotheken vorbereiten",
);
browser = await withActionTimeout(
playwrightCore.chromium.launch({
headless: true,
executablePath,
args: serverlessChromium.args,
});
timeout: remainingActionBudgetMs(actionStartedAt, actionBudget),
}),
remainingActionBudgetMs(actionStartedAt, actionBudget),
"Chromium starten",
);
const { devices } = playwrightCore;
desktopContext = await browser.newContext({
desktopContext = await withActionTimeout(
browser.newContext({
...devices["Desktop Chrome"],
});
mobileContext = await browser.newContext({
}),
remainingActionBudgetMs(actionStartedAt, actionBudget),
"Desktop-Kontext erstellen",
);
mobileContext = await withActionTimeout(
browser.newContext({
...devices["iPhone 11"],
});
}),
remainingActionBudgetMs(actionStartedAt, actionBudget),
"Mobile-Kontext erstellen",
);
const homepage = await crawlPage(desktopContext, rootUrl, rootUrl, timeoutMs);
const homepage = await withActionTimeout(
crawlPage(
desktopContext,
rootUrl,
rootUrl,
Math.min(
timeoutMs,
remainingActionBudgetMs(actionStartedAt, actionBudget),
),
),
remainingActionBudgetMs(actionStartedAt, actionBudget),
"Homepage crawlen",
);
if (!homepage) {
throw new Error("Homepage konnte nicht geladen werden.");
}
@@ -529,7 +611,19 @@ export const processLeadEnrichment = internalAction({
const crawledPages: PageResult[] = [homepage];
for (const pageUrl of crawlTargets.slice(1)) {
const crawled = await crawlPage(desktopContext, pageUrl, rootUrl, timeoutMs);
const crawled = await withActionTimeout(
crawlPage(
desktopContext,
pageUrl,
rootUrl,
Math.min(
timeoutMs,
remainingActionBudgetMs(actionStartedAt, actionBudget),
),
),
remainingActionBudgetMs(actionStartedAt, actionBudget),
`Unterseite crawlen: ${pageUrl}`,
);
if (crawled) {
crawledPages.push(crawled);
}
@@ -552,7 +646,10 @@ export const processLeadEnrichment = internalAction({
for (const href of uniqueInternalLinks.slice(0, 30)) {
try {
const response = await desktopContext.request.get(href, {
timeout: Math.max(1_000, timeoutMs - 1_000),
timeout: Math.min(
Math.max(1_000, timeoutMs - 1_000),
remainingActionBudgetMs(actionStartedAt, actionBudget),
),
});
const status = response.status();
checkMap.set(href, {
@@ -567,19 +664,33 @@ export const processLeadEnrichment = internalAction({
}
}
const desktopScreenshot = await captureHomepageScreenshot(
const desktopScreenshot = await withActionTimeout(
captureHomepageScreenshot(
ctx,
desktopContext,
homepage.finalUrl,
"desktop",
Math.min(
timeoutMs,
remainingActionBudgetMs(actionStartedAt, actionBudget),
),
),
remainingActionBudgetMs(actionStartedAt, actionBudget),
"Desktop-Screenshot erfassen",
);
const mobileScreenshot = await captureHomepageScreenshot(
const mobileScreenshot = await withActionTimeout(
captureHomepageScreenshot(
ctx,
mobileContext,
homepage.finalUrl,
"mobile",
Math.min(
timeoutMs,
remainingActionBudgetMs(actionStartedAt, actionBudget),
),
),
remainingActionBudgetMs(actionStartedAt, actionBudget),
"Mobile-Screenshot erfassen",
);
const technicalInput = buildTechnicalChecks({

565
lib/ai/audit-evidence.ts Normal file
View File

@@ -0,0 +1,565 @@
import {
type SkillRegistryEntry,
toAuditUsedSkill,
type AuditUsedSkill,
} from "../skills-registry";
import {
buildPageSpeedAuditInputs,
type PageSpeedMinimalAuditResult,
} from "../pagespeed-audit-input";
export type SkillRegistryEntryEvidence = SkillRegistryEntry;
export type AuditLeadEvidence = {
companyName?: string | null;
niche?: string | null;
city?: string | null;
websiteDomain?: string | null;
websiteUrl?: string | null;
address?: string | null;
phone?: string | null;
contactPerson?: string | null;
};
export type AuditCrawlPageEvidence = {
sourceUrl?: string | null;
finalUrl?: string | null;
title?: string | null;
metaDescription?: string | null;
pageKind?: string | null;
hasContactFormSignal?: boolean;
hasContactCtaSignal?: boolean;
visibleText?: string | null;
visibleTextExcerpt?: string | null;
};
export type AuditTechnicalCheckEvidence = {
sourceUrl?: string | null;
finalUrl?: string | null;
usesHttps?: boolean;
missingTitle?: boolean;
missingMetaDescription?: boolean;
hasVisibleContactPath?: boolean;
brokenInternalLinkCount?: number;
};
export type AuditScreenshotEvidence = {
storageId: string;
viewport: string;
sourceUrl: string;
capturedAt: number;
width: number;
height: number;
mimeType: string;
[key: string]: unknown;
};
export type AuditEvidenceInput = {
companyContext: string[];
checkedPages: string[];
observedUxSignals: string[];
observedContentSignals: string[];
observedTechnicalSignals: string[];
screenshotReferences: Array<{
storageId: string;
sourceUrl: string;
viewport: string;
width: number;
height: number;
mimeType: string;
capturedAt: number;
}>;
pageSpeedCustomerImplications: string[];
selectedSkills: AuditUsedSkill[];
};
export type AuditEvidenceInputArgs = {
lead?: AuditLeadEvidence;
crawlPages?: readonly AuditCrawlPageEvidence[];
technicalChecks?: readonly AuditTechnicalCheckEvidence[];
screenshots?: readonly AuditScreenshotEvidence[];
pageSpeedInputs?: readonly PageSpeedMinimalAuditResult[];
skillRegistry?: readonly SkillRegistryEntryEvidence[];
};
const COMPANY_CONTEXT_LIMIT = 8;
const CHECKED_PAGES_LIMIT = 8;
const UX_SIGNAL_LIMIT = 6;
const CONTENT_SIGNAL_LIMIT = 6;
const TECHNICAL_SIGNAL_LIMIT = 6;
const PAGESPEED_SIGNAL_LIMIT = 8;
const SCREENSHOT_REFERENCE_LIMIT = 8;
const SELECTED_SKILLS_LIMIT = 6;
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"']+/i;
const JSON_BRACKET_PATTERN = /\{[^}]*\}|\[[^\]]*\]/;
const PAGESPEED_NOISE_PATTERN =
/\b(?:raw\s*storage\s*id|rawstorageid|lighthouse|pagespeed|score)\b/i;
const MACHINE_TOKEN_PATTERN = /\b[a-z\d_-]{24,}\b/i;
function trimAndNormalize(input: unknown): string {
if (typeof input !== "string") {
return "";
}
return input.replace(/\s+/g, " ").trim();
}
function sanitizeCustomerText(value: unknown, maxLength = 180): string {
let text = trimAndNormalize(value);
if (!text) {
return "";
}
text = text.replace(/<[^>]*>/g, " ");
text = text.replace(/\s{2,}/g, " ").trim();
if (URL_PATTERN.test(text)) {
return "";
}
if (JSON_BRACKET_PATTERN.test(text)) {
return "";
}
if (PAGESPEED_NOISE_PATTERN.test(text)) {
return "";
}
if (MACHINE_TOKEN_PATTERN.test(text)) {
return "";
}
if (text.length > maxLength) {
return "";
}
if (!/[a-zäöüß]/i.test(text)) {
return "";
}
return text;
}
function addUniqueCapped(
bucket: string[],
input: string,
max: number,
sanitizer = sanitizeCustomerText,
): void {
const candidate = sanitizer(input);
if (!candidate) {
return;
}
const normalized = candidate.toLowerCase();
const alreadyThere = bucket.some((line) => line.toLowerCase() === normalized);
if (!alreadyThere && bucket.length < max) {
bucket.push(candidate);
}
}
function compactPath(urlLike: string): string {
try {
const parsed = new URL(urlLike);
const normalizedPath = (parsed.pathname || "/").replace(/\/+/g, "/").trim();
if (!normalizedPath || normalizedPath === "/") {
return "Startseite";
}
return normalizedPath.replace(/^\//, "").slice(0, 70);
} catch {
return "";
}
}
function compactLabelForPage(pageKind: string, pageLabel: string): string {
if (pageLabel.length > 100) {
return pageLabel.slice(0, 100);
}
if (pageKind) {
return `${pageKind}: ${pageLabel}`;
}
return pageLabel;
}
function toSafePath(url: string | null | undefined): string {
if (!url) {
return "";
}
return compactPath(url);
}
function selectTopSkill(
skills: readonly SkillRegistryEntryEvidence[],
category: string,
evidenceText: string,
): AuditUsedSkill | null {
const evidenceTokens = evidenceText
.toLowerCase()
.split(/\s+/)
.filter((token) => token.length > 3);
if (evidenceTokens.length === 0) {
return null;
}
const candidates = skills.filter((skill) => skill.category === category);
if (candidates.length === 0) {
return null;
}
const scored = candidates.map((candidate) => {
const whenToUseText = candidate.whenToUse.toLowerCase();
const matchCount = evidenceTokens.filter((token) =>
whenToUseText.includes(token),
).length;
const score = 1 + Math.min(matchCount, 5) + (candidate.version ? 0.1 : 0);
return {
candidate,
score,
name: candidate.name.toLowerCase(),
};
});
scored.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return a.name.localeCompare(b.name);
});
return toAuditUsedSkill(scored[0]!.candidate);
}
function buildObservedSignals(
crawlPages: readonly AuditCrawlPageEvidence[],
technicalChecks: readonly AuditTechnicalCheckEvidence[],
): {
ux: string[];
content: string[];
technical: string[];
evidenceText: {
design: boolean;
ux: boolean;
copy: boolean;
seo: boolean;
};
} {
const uxSignals: string[] = [];
const contentSignals: string[] = [];
const technicalSignals: string[] = [];
let designEvidence = false;
let uxEvidence = false;
let copyEvidence = false;
let seoEvidence = false;
for (const page of crawlPages) {
const title = trimAndNormalize(page.title ?? "");
if (title) {
if (title.length > 4) {
copyEvidence = true;
addUniqueCapped(
contentSignals,
`Seitentitel wurde erfasst: ${title}`,
CONTENT_SIGNAL_LIMIT,
(value) => sanitizeCustomerText(value, 150),
);
}
}
if (page.hasContactFormSignal) {
uxEvidence = true;
addUniqueCapped(
uxSignals,
"Ein Kontaktformular wurde als potenzieller Einstiegspunkt erkannt.",
UX_SIGNAL_LIMIT,
);
}
if (page.hasContactCtaSignal) {
uxEvidence = true;
addUniqueCapped(
uxSignals,
"Ein klarer Call-to-Action scheint auf der Seite aktiv zu sein.",
UX_SIGNAL_LIMIT,
);
}
if (page.visibleText || page.visibleTextExcerpt) {
copyEvidence = true;
addUniqueCapped(
contentSignals,
"Sichtbarer Text wurde in der Crawl-Auswertung extrahiert.",
CONTENT_SIGNAL_LIMIT,
);
}
}
for (const check of technicalChecks) {
if (check.usesHttps === false) {
uxEvidence = true;
addUniqueCapped(
technicalSignals,
"Ein Teil der Seiten ist nicht per HTTPS erreichbar.",
TECHNICAL_SIGNAL_LIMIT,
);
addUniqueCapped(
uxSignals,
"Die sichere Übertragung der Seite ist nicht durchgängig verifiziert.",
UX_SIGNAL_LIMIT,
);
}
if (check.missingMetaDescription) {
seoEvidence = true;
addUniqueCapped(
technicalSignals,
"Fehlende Meta-Beschreibungen können die Auffindbarkeit schwächen.",
TECHNICAL_SIGNAL_LIMIT,
);
addUniqueCapped(
contentSignals,
"Meta-Informationen sind teilweise nicht vollständig vorhanden.",
CONTENT_SIGNAL_LIMIT,
);
}
if (check.missingTitle) {
seoEvidence = true;
addUniqueCapped(
technicalSignals,
"Einige Seiten besitzen keinen aussagekräftigen Titel.",
TECHNICAL_SIGNAL_LIMIT,
);
addUniqueCapped(
contentSignals,
"Seitentitel fehlen auf ausgewählten Seiten.",
CONTENT_SIGNAL_LIMIT,
);
}
if (check.hasVisibleContactPath) {
uxEvidence = true;
addUniqueCapped(
uxSignals,
"Ein klarer Kontaktpfad scheint bereits vorhanden zu sein.",
UX_SIGNAL_LIMIT,
);
}
const brokenLinks = check.brokenInternalLinkCount ?? 0;
if (brokenLinks > 0) {
addUniqueCapped(
technicalSignals,
`Es wurden ${Math.min(brokenLinks, 10)} interne Verlinkungen mit Fehlerstatus erkannt.`,
TECHNICAL_SIGNAL_LIMIT,
);
addUniqueCapped(
uxSignals,
"Nutzer könnten durch interne Linkfehler im Fluss abbrechen.",
UX_SIGNAL_LIMIT,
);
}
}
if (crawlPages.length > 0 || technicalChecks.length > 0) {
designEvidence = true;
}
if (
crawlPages.some(
(page) =>
page.pageKind === "contact" ||
page.pageKind === "impressum" ||
page.pageKind === "services",
)
) {
seoEvidence = true;
uxEvidence = true;
}
return {
ux: uxSignals,
content: contentSignals,
technical: technicalSignals,
evidenceText: {
design: designEvidence,
ux: uxEvidence,
copy: copyEvidence,
seo: seoEvidence,
},
};
}
function extractSkills(
skillRegistry: readonly SkillRegistryEntryEvidence[],
evidence: {
design: boolean;
ux: boolean;
copy: boolean;
seo: boolean;
marketing: boolean;
offer: boolean;
},
): AuditUsedSkill[] {
const selected: AuditUsedSkill[] = [];
const categoryOrder = ["design", "ux", "copy", "seo", "marketing", "offer"] as const;
const evidenceText = {
design:
"visuale layout seite struktur design hierarchie conversion",
ux:
"kontakt formular cta nutzer flow conversion pfad",
copy:
"text klarheit copy headline ton local",
seo: "local auffindbarkeit meta seo impressum kontakt",
marketing: "positionierung unterscheidung angebot",
offer: "angebot text preis rahmen",
};
for (const category of categoryOrder) {
if (!evidence[category]) {
continue;
}
const match = selectTopSkill(
skillRegistry,
category,
evidenceText[category]!,
);
if (match) {
selected.push(match);
}
}
if (selected.length > SELECTED_SKILLS_LIMIT) {
selected.length = SELECTED_SKILLS_LIMIT;
}
return selected;
}
export function buildAuditEvidenceInput(
args: AuditEvidenceInputArgs,
): AuditEvidenceInput {
const lead = args.lead ?? {};
const crawlPages = args.crawlPages ?? [];
const technicalChecks = args.technicalChecks ?? [];
const screenshots = args.screenshots ?? [];
const pageSpeedInputs = args.pageSpeedInputs ?? [];
const skillRegistry = args.skillRegistry ?? [];
const companyContext: string[] = [];
const checkedPages: string[] = [];
const screenshotReferences = screenshots
.slice(0, SCREENSHOT_REFERENCE_LIMIT)
.map((screenshot) => ({
storageId: screenshot.storageId,
sourceUrl: screenshot.sourceUrl,
viewport: screenshot.viewport,
width: screenshot.width,
height: screenshot.height,
mimeType: screenshot.mimeType,
capturedAt: screenshot.capturedAt,
}));
addUniqueCapped(
companyContext,
`Firma: ${lead.companyName ?? ""}`,
COMPANY_CONTEXT_LIMIT,
);
addUniqueCapped(companyContext, `Sparte: ${lead.niche ?? ""}`, COMPANY_CONTEXT_LIMIT);
addUniqueCapped(
companyContext,
`Ort: ${lead.city ?? ""}`,
COMPANY_CONTEXT_LIMIT,
);
addUniqueCapped(
companyContext,
`Adresse: ${lead.address ?? ""}`,
COMPANY_CONTEXT_LIMIT,
);
addUniqueCapped(
companyContext,
`Domain: ${lead.websiteDomain ?? ""}`,
COMPANY_CONTEXT_LIMIT,
);
addUniqueCapped(
companyContext,
`Kontaktperson: ${lead.contactPerson ?? ""}`,
COMPANY_CONTEXT_LIMIT,
);
addUniqueCapped(
companyContext,
`Telefon: ${lead.phone ?? ""}`,
COMPANY_CONTEXT_LIMIT,
);
addUniqueCapped(
companyContext,
`Website: ${lead.websiteUrl ?? ""}`,
COMPANY_CONTEXT_LIMIT,
);
for (const page of crawlPages) {
const safePath = toSafePath(page.finalUrl ?? page.sourceUrl ?? "");
const title = sanitizeCustomerText(page.title ?? "", 90);
const label = compactLabelForPage(
page.pageKind ?? "Seite",
title || safePath,
);
if (!label || label === page.pageKind) {
continue;
}
addUniqueCapped(checkedPages, label, CHECKED_PAGES_LIMIT);
}
if (checkedPages.length === 0 && lead.companyName) {
addUniqueCapped(
checkedPages,
`Website-Startseite analysiert: ${lead.companyName}`,
CHECKED_PAGES_LIMIT,
);
}
const signals = buildObservedSignals(crawlPages, technicalChecks);
const pageSpeedInputsOutput = buildPageSpeedAuditInputs(pageSpeedInputs);
const pageSpeedCustomerImplications: string[] = [];
for (const implication of pageSpeedInputsOutput.customerImplications) {
addUniqueCapped(
pageSpeedCustomerImplications,
implication,
PAGESPEED_SIGNAL_LIMIT,
sanitizeCustomerText,
);
}
const selectedSkills = extractSkills(skillRegistry, {
...signals.evidenceText,
marketing: false,
offer: false,
});
return {
companyContext,
checkedPages,
observedUxSignals: signals.ux,
observedContentSignals: signals.content,
observedTechnicalSignals: signals.technical,
screenshotReferences: screenshotReferences.map((reference) => ({
...reference,
width: Math.max(reference.width, 0),
height: Math.max(reference.height, 0),
capturedAt: Number(reference.capturedAt),
})),
pageSpeedCustomerImplications: pageSpeedCustomerImplications.slice(
0,
PAGESPEED_SIGNAL_LIMIT,
),
selectedSkills,
};
}

482
lib/ai/german-copy-guard.ts Normal file
View File

@@ -0,0 +1,482 @@
const GERMAN_MARKERS = new Set([
"ich",
"mich",
"mir",
"mein",
"meine",
"wir",
"du",
"sie",
"er",
"sie",
"der",
"die",
"das",
"und",
"ist",
"sind",
"sind",
"waren",
"hat",
"habe",
"haben",
"eine",
"einer",
"einem",
"dieser",
"diese",
"dieses",
"nicht",
"mit",
"wenn",
"für",
"bei",
]);
const ENGLISH_MARKERS = new Set([
"the",
"and",
"you",
"your",
"we",
"our",
"is",
"are",
"was",
"were",
"to",
"of",
"in",
"for",
"on",
"with",
"this",
"that",
"it",
"from",
"have",
"has",
"will",
"can",
"if",
"quick",
"audit",
"bad",
"website",
"report",
]);
const OBSERVATION_TOKENS = [
/\b(mir|ich)\b[^\n]{0,80}\b(aufgefallen|festgestellt|bemerkt|beobachtet|gesehen|sichtbar)\b/i,
/\b(erkennt|zeigt|sichtbar|feststell|finde|fällt)\b/i,
/\b(ich sehe|ich habe gesehen|bei der Prüfung)\b/i,
];
const SUGGESTION_TOKENS = [
/\b(empfehle|empfiehlt|vorschlage|vorschlagen|schlage vor|könnte helfen|kannst|können wir|sollte|sollten|ich könnte|ich würde|ich empfehle)\b/i,
/\b(schlage vor|schlage)\b/i,
/\b(ergänzt|ergänzen|anpassen|optimieren|verbessern|prüfen|einbauen|einzusetzen|setzten)\b/i,
];
const AI_SLOP_TOKENS = [
/\bmaßgeschneid(?:ert|ert|er)\b/i,
/\bnahtlos\b/i,
/\bstate[- ]of[- ]the[- ]art\b/i,
/\bgame[- ]?changer\b/i,
/\bsynerg(?:ie|istisch)\b/i,
/\brevolutionär\b/i,
/\bnext level\b/i,
/\bzukunftsweisend\b/i,
/\bdigital transformieren\b/i,
/\boutstanding\b/i,
/\bhebt.{0,20}Sichtbarkeit\b/i,
];
const HOSTILE_TOKENS = [
/\b(Ihr|Ihre|Sie|eure|euer)\b[^\n.!?]{0,80}\b(katastroph|schlecht|veraltet|unprofessionell|unbrauchbar|mangelhaft|chaotisch|desastr|desaster|skrupellos)\b/i,
/\b(ist|sind)\s+(?:total|absolut)\s+(?:schlecht|kaputt|katastroph)\b/i,
/\babsolut unprofessionell\b/i,
];
const SCORE_CONTEXT_TOKENS = [
/\b(?:pagespeed|lighthouse|score)\b[^\n]{0,120}\b\d{1,2}(?:[.,]\d+)?%?/i,
/\b\d{1,2}(?:[.,]\d+)?%?[^\n]{0,120}\b(?:pagespeed|lighthouse|score)\b/i,
];
const PRICE_PATTERNS = [
/\b\d{1,4}\s*(?:€|EUR|Euro|euro)/,
/(?:€|EUR|Euro|euro)\s*\d{1,4}(?:[.,]\d{1,2})?/,
/\b(?:preis|preise|kosten)\b[^a-z]{0,5}\d{1,4}\s*(?:€|EUR|Euro|euro)?/i,
];
const RAW_TECH_PATTERNS = [
/\braw\s*storage\s*id\b/i,
/\bstorage[_-]?id\b/i,
/\bmodel[_-]?id\b/i,
/\b(?:gpt|claude|gemini|llama|mistral|qwen|mixtral|deepseek|phi|sonar|gemma)\b[-\w]*/i,
/\{[^\n]{0,240}:[^\n]{0,240}\}/,
/\[[^\n]{0,240}\]/,
/\b[0-9a-f]{24}\b/i,
];
export type GermanCopyGuardIssue = {
field: string;
rule: string;
message: string;
};
export type GermanCopyGuardResult = {
passed: boolean;
issues: GermanCopyGuardIssue[];
};
export type AuditCopy = {
summary: string;
body: string;
};
export type EmailCopy = {
subject: string;
body: string;
};
export type CallScriptCopy = {
openingLine: string;
callScript: string[];
closeLine: string;
};
export type FollowUpCopy = {
message: string;
};
export type GermanCustomerCopy = {
auditSummary?: string;
auditBody?: string;
emailSubject?: string;
emailBody?: string;
callScript?: CallScriptCopy;
followUp?: string;
};
type ValidationOptions = {
requireIchForm?: boolean;
requireObservationAndSuggestion?: boolean;
skipIfTooShort?: boolean;
};
function addIssue(
issues: GermanCopyGuardIssue[],
field: string,
rule: string,
message: string,
) {
issues.push({ field, rule, message });
}
function tokenizeWords(value: string): string[] {
return value
.toLowerCase()
.match(/[a-zäöüß]{3,}/giu)
?.map((token) => token.toLowerCase()) ?? [];
}
function hasGermanAnchor(value: string): boolean {
const words = tokenizeWords(value);
if (!words.length) {
return true;
}
if (/[äöüß]/i.test(value)) {
return true;
}
const germanCount = words.reduce(
(count, word) => count + (GERMAN_MARKERS.has(word) ? 1 : 0),
0,
);
const englishCount = words.reduce(
(count, word) => count + (ENGLISH_MARKERS.has(word) ? 1 : 0),
0,
);
if (words.length <= 4) {
if (germanCount >= 1) {
return true;
}
return englishCount === 0;
}
if (germanCount >= 1) {
return true;
}
if (englishCount === 0) {
return true;
}
if (englishCount / words.length >= 0.2) {
return false;
}
return true;
}
function hasIchForm(value: string): boolean {
return /\b(ich|mich|mir|mein|meine|meinem|meiner)\b/i.test(value);
}
function hasObservation(value: string): boolean {
return OBSERVATION_TOKENS.some((pattern) => pattern.test(value));
}
function hasSuggestion(value: string): boolean {
return SUGGESTION_TOKENS.some((pattern) => pattern.test(value));
}
function hasAiSlop(value: string): boolean {
return AI_SLOP_TOKENS.some((pattern) => pattern.test(value));
}
function hasHostileTone(value: string): boolean {
return HOSTILE_TOKENS.some((pattern) => pattern.test(value));
}
function hasScoreArtifact(value: string): boolean {
return SCORE_CONTEXT_TOKENS.some((pattern) => pattern.test(value));
}
function hasPrice(value: string): boolean {
return PRICE_PATTERNS.some((pattern) => pattern.test(value));
}
function hasRawArtifact(value: string): boolean {
return RAW_TECH_PATTERNS.some((pattern) => pattern.test(value));
}
function validateTextField(
issues: GermanCopyGuardIssue[],
field: string,
value: string,
options: ValidationOptions = {},
) {
if (options.skipIfTooShort && value.trim().length < 6) {
return;
}
if (!hasGermanAnchor(value)) {
addIssue(
issues,
field,
"not_german",
"Text wirkt nicht ausreichend deutsch.",
);
}
if (options.requireIchForm && !hasIchForm(value)) {
addIssue(
issues,
field,
"missing_ich_form",
"Text sollte in Ich-Form geschrieben sein.",
);
}
if (hasScoreArtifact(value)) {
addIssue(
issues,
field,
"pagespeed_score_artifact",
"Technische Score-/PageSpeed-Werte sollten nicht im Kunden-Text erscheinen.",
);
}
if (hasPrice(value)) {
addIssue(
issues,
field,
"price_mention",
"Preis- oder Währungsangaben sollten im Kunden-Text vermieden werden.",
);
}
if (hasAiSlop(value)) {
addIssue(
issues,
field,
"generic_ai_slop",
"Generische KI-Slop-Formulierungen erkannt.",
);
}
if (hasHostileTone(value)) {
addIssue(
issues,
field,
"hostile_tone",
"Anklagende oder negativ wertende Sprache wurde erkannt.",
);
}
if (hasRawArtifact(value)) {
addIssue(
issues,
field,
"raw_technical_artifact",
"Technische Artefakte im Text erkannt.",
);
}
if (options.requireObservationAndSuggestion && (!hasObservation(value) || !hasSuggestion(value))) {
addIssue(
issues,
field,
"missing_observation_or_suggestion",
"Beobachtung und Vorschlag sollten im gleichen Text erkennbar sein.",
);
}
}
function validateCallScriptText(
issues: GermanCopyGuardIssue[],
linePrefix: string,
scriptLine: string,
options: ValidationOptions,
) {
const lineValue = scriptLine?.trim();
if (!lineValue) {
return;
}
validateTextField(issues, linePrefix, lineValue, options);
}
export function validateAuditCopy(audit: AuditCopy): GermanCopyGuardResult {
const issues: GermanCopyGuardIssue[] = [];
validateTextField(issues, "auditSummary", audit.summary, {
requireIchForm: true,
requireObservationAndSuggestion: true,
});
validateTextField(issues, "auditBody", audit.body, {
requireIchForm: true,
requireObservationAndSuggestion: true,
});
return { passed: issues.length === 0, issues };
}
export function validateEmailCopy(email: EmailCopy): GermanCopyGuardResult {
const issues: GermanCopyGuardIssue[] = [];
validateTextField(issues, "emailSubject", email.subject, { skipIfTooShort: true });
validateTextField(issues, "emailBody", email.body, {
requireIchForm: true,
requireObservationAndSuggestion: true,
});
return { passed: issues.length === 0, issues };
}
export function validateCallScriptCopy(script: CallScriptCopy): GermanCopyGuardResult {
const issues: GermanCopyGuardIssue[] = [];
validateCallScriptText(issues, "callScript.openingLine", script.openingLine, {
requireIchForm: true,
});
validateCallScriptText(issues, "callScript.closeLine", script.closeLine, {
requireIchForm: true,
});
script.callScript.forEach((line, index) => {
validateCallScriptText(
issues,
`callScript.callScript[${index}]`,
line,
{
requireIchForm: false,
},
);
});
const scriptConcatenated = [
script.openingLine,
...script.callScript,
script.closeLine,
]
.filter((line) => line.trim().length > 0)
.join(" ");
if (!hasObservation(scriptConcatenated) || !hasSuggestion(scriptConcatenated)) {
addIssue(
issues,
"callScript",
"missing_observation_or_suggestion",
"Beobachtung und Vorschlag sollten im Call-Script erkennbar sein.",
);
}
return { passed: issues.length === 0, issues };
}
export function validateFollowUpCopy(followUp: FollowUpCopy): GermanCopyGuardResult {
const issues: GermanCopyGuardIssue[] = [];
validateTextField(issues, "followUp", followUp.message, {
requireIchForm: true,
requireObservationAndSuggestion: true,
});
return { passed: issues.length === 0, issues };
}
export function validateCustomerFacingCopy(input: GermanCustomerCopy): GermanCopyGuardResult {
const issues: GermanCopyGuardIssue[] = [];
if (input.auditSummary !== undefined) {
validateTextField(issues, "auditSummary", input.auditSummary, {
requireIchForm: true,
requireObservationAndSuggestion: true,
});
}
if (input.auditBody !== undefined) {
validateTextField(issues, "auditBody", input.auditBody, {
requireIchForm: true,
requireObservationAndSuggestion: true,
});
}
if (input.emailSubject !== undefined) {
validateTextField(issues, "emailSubject", input.emailSubject, {
skipIfTooShort: true,
});
}
if (input.emailBody !== undefined) {
validateTextField(issues, "emailBody", input.emailBody, {
requireIchForm: true,
requireObservationAndSuggestion: true,
});
}
if (input.callScript) {
issues.push(
...validateCallScriptCopy({
openingLine: input.callScript.openingLine,
callScript: [...input.callScript.callScript],
closeLine: input.callScript.closeLine,
}).issues,
);
}
if (input.followUp !== undefined) {
validateTextField(issues, "followUp", input.followUp, {
requireIchForm: true,
requireObservationAndSuggestion: true,
});
}
return { passed: issues.length === 0, issues };
}

81
lib/ai/model-profiles.ts Normal file
View File

@@ -0,0 +1,81 @@
export const MODEL_PROFILE_KEYS = [
"classification",
"multimodalAudit",
"germanCopy",
"qualityReview",
] as const;
export type ModelProfileKey = (typeof MODEL_PROFILE_KEYS)[number];
export type AiModelProfile = {
modelId: string;
temperature: number;
maxTokens: number;
supportsImages: boolean;
stage: (typeof MODEL_PROFILE_KEYS)[number];
envOverrideKey: string;
};
export const MODEL_PROFILES: Record<ModelProfileKey, AiModelProfile> = {
classification: {
modelId: "openai/gpt-4.1-mini",
temperature: 0.2,
maxTokens: 1200,
supportsImages: false,
stage: "classification",
envOverrideKey: "OPENROUTER_MODEL_CLASSIFICATION",
},
multimodalAudit: {
modelId: "openai/gpt-4.1-mini",
temperature: 0.3,
maxTokens: 2800,
supportsImages: true,
stage: "multimodalAudit",
envOverrideKey: "OPENROUTER_MODEL_MULTIMODAL_AUDIT",
},
germanCopy: {
modelId: "openai/gpt-4.1-mini",
temperature: 0.4,
maxTokens: 1800,
supportsImages: false,
stage: "germanCopy",
envOverrideKey: "OPENROUTER_MODEL_GERMAN_COPY",
},
qualityReview: {
modelId: "openai/gpt-4.1-mini",
temperature: 0.1,
maxTokens: 900,
supportsImages: false,
stage: "qualityReview",
envOverrideKey: "OPENROUTER_MODEL_QUALITY_REVIEW",
},
} as const;
function normalizeModelOverride(value: string | undefined): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
export function resolveModelProfile(
profileKey: ModelProfileKey,
env: Readonly<Record<string, string | undefined>> = process.env,
): AiModelProfile {
const profile = MODEL_PROFILES[profileKey];
const override = normalizeModelOverride(env[profile.envOverrideKey]);
return {
...profile,
modelId: override ?? profile.modelId,
};
}
export function resolveModelId(
profileKey: ModelProfileKey,
env: Readonly<Record<string, string | undefined>> = process.env,
): string {
const profile = MODEL_PROFILES[profileKey];
const override = normalizeModelOverride(env[profile.envOverrideKey]);
return override ?? profile.modelId;
}

View File

@@ -0,0 +1,35 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
type OpenRouterEnv = Readonly<Record<string, string | undefined>>;
function normalizeOptionalEnvValue(value: string | undefined): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
export function createOpenRouterProvider(
env: OpenRouterEnv = process.env,
): ReturnType<typeof createOpenRouter> {
const apiKey = normalizeOptionalEnvValue(env.OPENROUTER_API_KEY);
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is required for OpenRouter provider.");
}
const appName = normalizeOptionalEnvValue(env.OPENROUTER_APP_NAME);
const appUrl = normalizeOptionalEnvValue(env.OPENROUTER_APP_URL);
return createOpenRouter({
apiKey,
appName,
appUrl,
compatibility: "strict",
headers: {
"Content-Type": "application/json",
},
});
}

58
lib/ai/schemas.ts Normal file
View File

@@ -0,0 +1,58 @@
import { z } from "zod";
export const findingItemSchema = z.object({
section: z.string(),
finding: z.string(),
suggestion: z.string(),
});
export const internalFindingsSchema = z.object({
findings: z.array(findingItemSchema),
summary: z.string(),
});
export const auditSummarySchema = z.object({
summary: z.string(),
keyFindings: z.array(z.string()),
});
export const publicAuditTextSchema = z.object({
publicText: z.string(),
});
export const emailDraftSchema = z.object({
body: z.string(),
});
export const emailSubjectSchema = z.object({
subject: z.string(),
});
export const callScriptSchema = z.object({
openingLine: z.string(),
callScript: z.array(z.string()),
closeLine: z.string(),
});
export const followUpDraftSchema = z.object({
message: z.string(),
followInDays: z.number().int().min(0).optional(),
goals: z.array(z.string()).optional(),
});
export const qualityReviewSchema = z.object({
isValid: z.boolean(),
issues: z.array(z.string()),
suggestions: z.array(z.string()),
notes: z.array(z.string()).optional(),
});
export type FindingItem = z.infer<typeof findingItemSchema>;
export type InternalFindings = z.infer<typeof internalFindingsSchema>;
export type AuditSummary = z.infer<typeof auditSummarySchema>;
export type PublicAuditText = z.infer<typeof publicAuditTextSchema>;
export type EmailDraft = z.infer<typeof emailDraftSchema>;
export type EmailSubject = z.infer<typeof emailSubjectSchema>;
export type CallScript = z.infer<typeof callScriptSchema>;
export type FollowUpDraft = z.infer<typeof followUpDraftSchema>;
export type QualityReview = z.infer<typeof qualityReviewSchema>;

178
lib/skills-registry.ts Normal file
View File

@@ -0,0 +1,178 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export const SKILL_CATEGORIES = [
"design",
"ux",
"marketing",
"copy",
"seo",
"offer",
] as const;
export type SkillCategory = (typeof SKILL_CATEGORIES)[number];
export type SkillRegistryEntry = {
name: string;
purpose: string;
whenToUse: string;
whenNotToUse: string;
requiredInput: string;
expectedOutput: string;
category: SkillCategory;
version?: string;
source?: string;
};
export type AuditUsedSkill = {
name: string;
category: SkillCategory;
version?: string;
source?: string;
};
type ParsedFieldName =
| "Purpose"
| "When to use"
| "When not to use"
| "Required input"
| "Expected output"
| "Category"
| "Version"
| "Source";
const REQUIRED_FIELDS: ParsedFieldName[] = [
"Purpose",
"When to use",
"When not to use",
"Required input",
"Expected output",
"Category",
];
const FIELD_LABELS_RE = /^(Purpose|When to use|When not to use|Required input|Expected output|Category|Version|Source):\s*(.*?)\s*$/;
function normalizeCategory(value: string): SkillCategory {
const normalized = value.toLowerCase();
if (!isValidSkillCategory(normalized)) {
throw new Error(
`Unknown category "${value}". Valid categories are: ${SKILL_CATEGORIES.join(", ")}.`,
);
}
return normalized;
}
function isValidSkillCategory(
value: string,
): value is SkillCategory {
return (SKILL_CATEGORIES as ReadonlyArray<string>).includes(value);
}
function parseSection(lines: string[], sectionIndex: number): SkillRegistryEntry {
let name: string | null = null;
const values: Partial<Record<ParsedFieldName, string>> = {};
let currentField: ParsedFieldName | null = null;
const sectionTitle = lines[0];
if (!sectionTitle.startsWith("##")) {
throw new Error(`Expected section ${sectionIndex} to start with a skill header.`);
}
name = sectionTitle.replace(/^##\s*/, "").trim();
if (name.length === 0) {
throw new Error(`Skill section ${sectionIndex} has an empty name.`);
}
for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex];
const trimmedLine = line.trim();
if (trimmedLine.length === 0) {
continue;
}
const match = trimmedLine.match(FIELD_LABELS_RE);
if (match) {
const field = match[1] as ParsedFieldName;
currentField = field;
values[field] = match[2].trim();
continue;
}
if (currentField === null) {
throw new Error(`Unexpected line in section "${name}": ${line}`);
}
values[currentField] = `${values[currentField] ?? ""}\n${line.trim()}`.trim();
}
for (const requiredField of REQUIRED_FIELDS) {
const value = values[requiredField]?.trim();
if (!value) {
throw new Error(
`Missing required field "${requiredField}" for skill "${name}".`,
);
}
}
const category = normalizeCategory(values.Category!.trim());
return {
name,
purpose: values["Purpose"]!,
whenToUse: values["When to use"]!,
whenNotToUse: values["When not to use"]!,
requiredInput: values["Required input"]!,
expectedOutput: values["Expected output"]!,
category,
version: values["Version"]?.trim() || undefined,
source: values["Source"]?.trim() || undefined,
};
}
export function parseSkillsRegistry(source: string): SkillRegistryEntry[] {
const normalized = source.replace(/\r\n/g, "\n");
const rawSections = normalized
.split(/^##\s+/m)
.map((entry) => entry.trim())
.filter(Boolean);
const entries: SkillRegistryEntry[] = [];
const names = new Set<string>();
for (let index = 0; index < rawSections.length; index += 1) {
const rawSection = rawSections[index];
const lines = rawSection
.split("\n")
.map((line) => line.trimEnd())
.filter((line, lineIndex) => line.length > 0 || lineIndex === 0);
const sectionLines = [`## ${lines.at(0) ?? ""}`, ...lines.slice(1)];
const parsed = parseSection(sectionLines, index + 1);
const normalizedName = parsed.name.trim().toLowerCase();
if (names.has(normalizedName)) {
throw new Error(`Duplicate skill name "${parsed.name}" in skills registry.`);
}
names.add(normalizedName);
entries.push(parsed);
}
return entries;
}
export async function loadSkillsRegistry(
registryPath = join(process.cwd(), "skills.md"),
): Promise<SkillRegistryEntry[]> {
const source = await readFile(registryPath, "utf8");
return parseSkillsRegistry(source);
}
export function toAuditUsedSkill(skill: SkillRegistryEntry): AuditUsedSkill {
return {
name: skill.name,
category: skill.category,
version: skill.version,
source: skill.source,
};
}

View File

@@ -13,7 +13,9 @@
"dependencies": {
"@convex-dev/better-auth": "^0.12.2",
"@hookform/resolvers": "^5.4.0",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@sparticuz/chromium-min": "^149.0.0",
"ai": "^6.0.196",
"better-auth": "^1.6.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

142
pnpm-lock.yaml generated
View File

@@ -10,16 +10,22 @@ importers:
dependencies:
'@convex-dev/better-auth':
specifier: ^0.12.2
version: 0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)
version: 0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)
'@hookform/resolvers':
specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.77.0(react@19.2.4))
'@openrouter/ai-sdk-provider':
specifier: ^2.9.0
version: 2.9.0(ai@6.0.196(zod@4.4.3))(zod@4.4.3)
'@sparticuz/chromium-min':
specifier: ^149.0.0
version: 149.0.0
ai:
specifier: ^6.0.196
version: 6.0.196(zod@4.4.3)
better-auth:
specifier: ^1.6.14
version: 1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -34,7 +40,7 @@ importers:
version: 1.17.0(react@19.2.4)
next:
specifier: 16.2.7
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
playwright-core:
specifier: ^1.60.0
version: 1.60.0
@@ -90,6 +96,22 @@ importers:
packages:
'@ai-sdk/gateway@3.0.124':
resolution: {integrity: sha512-h8CrmbSG+8X0C+M/E1M4oiDHYevqwbzAPN+uLRHS0eJaatF2MZ+juNtOHXNOjk7Bsk9mD2RjYMjJO9dFkb9I7Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.27':
resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@3.0.10':
resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
engines: {node: '>=18'}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -898,6 +920,17 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@openrouter/ai-sdk-provider@2.9.0':
resolution: {integrity: sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ==}
engines: {node: '>=18'}
peerDependencies:
ai: ^6.0.0
zod: ^3.25.0 || ^4.0.0
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@opentelemetry/semantic-conventions@1.41.1':
resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
engines: {node: '>=14'}
@@ -1921,6 +1954,10 @@ packages:
cpu: [x64]
os: [win32]
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1939,6 +1976,12 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ai@6.0.196:
resolution: {integrity: sha512-2T45UeqKL4a11KQ14I5i1YYHOvCFrMF478E1k6PVjlQSGUvXSv4xrxIaQbUL4qgv91DADSbddwv3oR49pPAK3g==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@@ -3204,6 +3247,9 @@ packages:
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -4302,6 +4348,24 @@ packages:
snapshots:
'@ai-sdk/gateway@3.0.124(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
'@vercel/oidc': 3.2.0
zod: 4.4.3
'@ai-sdk/provider-utils@4.0.27(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.1.0
zod: 4.4.3
'@ai-sdk/provider@3.0.10':
dependencies:
json-schema: 0.4.0
'@alloc/quick-lru@5.2.0': {}
'@babel/code-frame@7.29.7':
@@ -4490,7 +4554,7 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
'@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
dependencies:
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
@@ -4501,37 +4565,39 @@ snapshots:
kysely: 0.29.2
nanostores: 1.3.0
zod: 4.4.3
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@better-auth/drizzle-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
'@better-auth/drizzle-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-auth/kysely-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)':
'@better-auth/kysely-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
optionalDependencies:
kysely: 0.29.2
'@better-auth/memory-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
'@better-auth/memory-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-auth/mongo-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
'@better-auth/mongo-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
'@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-auth/telemetry@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)':
'@better-auth/telemetry@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
@@ -4541,10 +4607,10 @@ snapshots:
'@better-fetch/fetch@1.1.21': {}
'@convex-dev/better-auth@0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)':
'@convex-dev/better-auth@0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)':
dependencies:
'@better-fetch/fetch': 1.1.21
better-auth: 1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
better-auth: 1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
common-tags: 1.8.2
convex: 1.40.0(react@19.2.4)
convex-helpers: 0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3)
@@ -5007,6 +5073,13 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@openrouter/ai-sdk-provider@2.9.0(ai@6.0.196(zod@4.4.3))(zod@4.4.3)':
dependencies:
ai: 6.0.196(zod@4.4.3)
zod: 4.4.3
'@opentelemetry/api@1.9.1': {}
'@opentelemetry/semantic-conventions@1.41.1': {}
'@radix-ui/number@1.1.1': {}
@@ -6045,6 +6118,8 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vercel/oidc@3.2.0': {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -6058,6 +6133,14 @@ snapshots:
agent-base@7.1.4: {}
ai@6.0.196(zod@4.4.3):
dependencies:
'@ai-sdk/gateway': 3.0.124(zod@4.4.3)
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
'@opentelemetry/api': 1.9.1
zod: 4.4.3
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
@@ -6217,15 +6300,15 @@ snapshots:
baseline-browser-mapping@2.10.33: {}
better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
better-auth@1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/kysely-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)
'@better-auth/memory-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/mongo-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/prisma-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/telemetry': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/kysely-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)
'@better-auth/memory-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/mongo-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/prisma-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
'@better-auth/telemetry': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.2.0
@@ -6237,7 +6320,7 @@ snapshots:
nanostores: 1.3.0
zod: 4.4.3
optionalDependencies:
next: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
transitivePeerDependencies:
@@ -7412,6 +7495,8 @@ snapshots:
json-schema-typed@8.0.2: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@@ -7606,7 +7691,7 @@ snapshots:
negotiator@1.0.0: {}
next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.7
'@swc/helpers': 0.5.15
@@ -7625,6 +7710,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.7
'@next/swc-win32-arm64-msvc': 16.2.7
'@next/swc-win32-x64-msvc': 16.2.7
'@opentelemetry/api': 1.9.1
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'

59
skills.md Normal file
View File

@@ -0,0 +1,59 @@
## Design Audit
Purpose: Prüft die visuelle Qualität einer Seite auf Konsistenz, Orientierung und Conversion-Relevanz.
When to use: Nutze diesen Skill, wenn das Website-Layout, visuelle Hierarchie oder die Markenwirkung bewertet werden soll.
When not to use: Nicht für technische Crawling- oder Performance-Analyse.
Required input: URL, Seitenstruktur, Kernaussagen, Designsystem- oder Style-Constraints.
Expected output: Konkrete Verbesserungsvorschläge mit Priorität nach Wirkung auf Wahrnehmung und Conversion.
Category: design
Version: 1.0
Source: skills/design-audit.md
## UX Friction Review
Purpose: Findet Reibungspunkte in Nutzerfluss, Formularen und Aktionen, die Nutzer vom Ziel abhalten.
When to use: Nutze diesen Skill für Kontaktformulare, Terminbuchung und erste Kontaktversuche.
When not to use: Nicht verwenden, wenn keine klaren User-Flows sichtbar sind oder Rohdaten fehlen.
Required input: URL, beobachtete Nutzerwege, wichtigste Aktionen und Ziel-CTA.
Expected output: Priorisierte Liste der größten UX-Hürden mit klaren, umsetzbaren Gegenmaßnahmen.
Category: ux
Version: 1.0
Source: skills/ux-friction-review.md
## Marketing Positioning
Purpose: Schärft die Positionierung für lokales B2B/B2C-Webdesign im Wettbewerb.
When to use: Nutze ihn, wenn Differenzierung, Angebotsschwerpunkt und Nutzenstory fehlen.
When not to use: Nicht für reine SEO-Fix-Lists oder technische Fehlerbehebungen.
Required input: Leistungsversprechen, Zielkundensegment, lokale Wettbewerbslage, bisheriger Auftritt.
Expected output: Klare Positionierungshypothese mit passenden USPs und Zielgruppen-Fokus.
Category: marketing
Version: 1.0
Source: skills/marketing-positioning.md
## Copy Clarity
Purpose: Optimiert Klarheit, Lesbarkeit und Tonalität für Webseiten- und Outreach-Texte.
When to use: Nutze diesen Skill bei unklaren, langen oder unpassend abstrakten Texten.
When not to use: Nicht bei legalen Texten mit festen Formulierungen oder Produkt-CTAs ohne Kontext.
Required input: Zielgruppe, Zweck des Textes, bestehender Entwurf, bevorzugter Sprachstil.
Expected output: Überarbeitete, verständliche und handlungsstarke Textvorschläge.
Category: copy
Version: 1.0
Source: skills/copy-clarity.md
## Local SEO
Purpose: Bewertet lokale Auffindbarkeit, Konsistenz und Relevanz der Website für eine definierte Ortsmarke.
When to use: Nutze diesen Skill, wenn lokale Suchrelevanz, Google-Präsenz oder Impressum/Struktur geprüft werden.
When not to use: Nicht als Ersatz für technische SEO-Fehlerdiagnose ohne inhaltlichen Kontext.
Required input: Standortdaten, Nischenfokus, Seitenstruktur, Kontakt-/Nennungsdaten.
Expected output: Konkrete Maßnahmen für lokale Sichtbarkeit, NAP-Konsistenz und Local-Authority.
Category: seo
Version: 1.0
Source: skills/local-seo.md
## Offer Writing
Purpose: Erstellt klare, konkrete Angebote mit Problemfokus und glaubwürdiger Preislogik.
When to use: Nutze diesen Skill, wenn ein Vorschlag oder Angebotsentwurf in Audit- oder Outreach-Schritten gebraucht wird.
When not to use: Nicht für reine Analyse-Ausgaben ohne Angebotsreifheit.
Required input: Projektumfang, Zielprobleme, Budgetrahmen, Leistungsumfang, Risiko-/Zeitaspekte.
Expected output: Offer-Prompt, Paketstruktur und überzeugende, ehrliche Angebots-Formulierungen.
Category: offer
Version: 1.0
Source: skills/offer-writing.md

9
skills/copy-clarity.md Normal file
View File

@@ -0,0 +1,9 @@
# Copy Clarity
Use this file for headline/body copy improvements.
- Replace jargon with clear benefit language.
- Tighten sentences to one core message per paragraph.
- Keep benefit first, proof second, next step third.
- Preserve the requested tone and avoid overpromising.
- Output a concise before/after improvement set.

9
skills/design-audit.md Normal file
View File

@@ -0,0 +1,9 @@
# Design Audit
Use this file as the concrete operating guide for design-oriented analysis.
- Check visual hierarchy and above-the-fold clarity.
- Validate CTA prominence, spacing, and action visibility.
- Flag inconsistent components, mismatched states, and weak contrast.
- Verify tone alignment between typography, imagery, and brand intent.
- Prioritize 35 actionable fixes by expected conversion impact.

9
skills/local-seo.md Normal file
View File

@@ -0,0 +1,9 @@
# Local SEO
Use this file for local discovery and trust-relevance analysis.
- Verify NAP consistency across page and metadata.
- Check service-location clarity and service-area fit.
- Review local proof signals: reviews, case examples, local references.
- Validate schema/markup hints and local keyword fit.
- Recommend realistic, high-impact local SEO adjustments.

View File

@@ -0,0 +1,9 @@
# Marketing Positioning
Use this file to sharpen market message and differentiation.
- Define one-liner positioning for the target segment.
- List the top 3 differentiators vs local alternatives.
- Connect positioning to pain points and decision criteria.
- Keep claims evidence-backed from website and audit findings.
- Produce a short positioning paragraph for outreach and landing intro.

9
skills/offer-writing.md Normal file
View File

@@ -0,0 +1,9 @@
# Offer Writing
Use this guide when producing outreach-ready offer text.
- Translate findings into 12 concrete pain-result pairings.
- Keep scope, deliverables, and next step explicit.
- Tie price framing to measurable benefit and timeline.
- Use transparent language and avoid inflated promises.
- Return a compact offer draft and optional follow-up variant.

View File

@@ -0,0 +1,9 @@
# UX Friction Review
Use this file when analyzing onboarding, lead capture, and first conversion path friction.
- Map the main path from landing → action.
- Find blockers: too many clicks, hidden links, field overload, unclear status.
- Identify trust anchors: who, what, how long, what happens next.
- Assess mobile ergonomics and cognitive load.
- Recommend friction-reduction experiments with effort and impact.

View File

@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MODEL_PROFILE_KEYS,
MODEL_PROFILES,
resolveModelProfile,
resolveModelId,
} from "../lib/ai/model-profiles";
import type { ModelProfileKey } from "../lib/ai/model-profiles";
type AssertNoExtraProfiles = Array<
(typeof MODEL_PROFILE_KEYS)[number]
>;
const assertNoExtraProfiles: AssertNoExtraProfiles = [
"classification",
"multimodalAudit",
"germanCopy",
"qualityReview",
];
test("all required model profiles exist", () => {
const keys = Object.keys(MODEL_PROFILES).sort();
assert.deepEqual(keys, [...assertNoExtraProfiles].sort());
});
test("each profile includes required contract fields", () => {
const profileEntries = Object.entries(MODEL_PROFILES) as Array<
[ModelProfileKey, unknown]
>;
for (const [key, rawProfile] of profileEntries) {
const profile = rawProfile as {
modelId: string;
temperature: number;
maxTokens: number;
supportsImages: boolean;
stage: string;
envOverrideKey: string;
};
assert.equal(typeof profile.modelId, "string", `${key} modelId`);
assert.equal(
profile.modelId.length > 0,
true,
`${key} modelId should be non-empty`,
);
assert.equal(typeof profile.temperature, "number", `${key} temperature`);
assert.equal(typeof profile.maxTokens, "number", `${key} maxTokens`);
assert.equal(
Number.isFinite(profile.temperature),
true,
`${key} temperature numeric`,
);
assert.equal(
Number.isInteger(profile.maxTokens),
true,
`${key} maxTokens integer`,
);
assert.equal(
typeof profile.supportsImages,
"boolean",
`${key} supportsImages`,
);
assert.equal(typeof profile.stage, "string", `${key} stage`);
assert.equal(profile.stage.length > 0, true, `${key} stage label`);
assert.equal(typeof profile.envOverrideKey, "string", `${key} env override`);
assert.equal(profile.envOverrideKey.length > 0, true, `${key} env key`);
}
});
test("multimodal profile explicitly supports images", () => {
assert.equal(MODEL_PROFILES.multimodalAudit.supportsImages, true);
});
test("non-multimodal profiles disable image support", () => {
assert.equal(MODEL_PROFILES.classification.supportsImages, false);
assert.equal(MODEL_PROFILES.germanCopy.supportsImages, false);
assert.equal(MODEL_PROFILES.qualityReview.supportsImages, false);
});
test("model IDs can be overridden via dedicated env variables", () => {
assert.equal(
resolveModelId("classification", {
OPENROUTER_MODEL_CLASSIFICATION: "custom/classification",
}),
"custom/classification",
);
assert.equal(
resolveModelId("multimodalAudit", {
OPENROUTER_MODEL_MULTIMODAL_AUDIT: "custom/multimodal",
}),
"custom/multimodal",
);
assert.equal(
resolveModelId("germanCopy", {
OPENROUTER_MODEL_GERMAN_COPY: "custom/german",
}),
"custom/german",
);
assert.equal(
resolveModelId("qualityReview", {
OPENROUTER_MODEL_QUALITY_REVIEW: "custom/quality",
}),
"custom/quality",
);
});
test("ENV overrides are ignored when empty", () => {
assert.equal(
resolveModelId("classification", {
OPENROUTER_MODEL_CLASSIFICATION: "",
}),
MODEL_PROFILES.classification.modelId,
);
});
test("resolveModelProfile returns profile config including runtime values", () => {
const profile = resolveModelProfile("qualityReview", {
OPENROUTER_MODEL_QUALITY_REVIEW: "custom/quality-review-profile",
});
assert.equal(profile.stage, "qualityReview");
assert.equal(profile.maxTokens, MODEL_PROFILES.qualityReview.maxTokens);
assert.equal(profile.temperature, MODEL_PROFILES.qualityReview.temperature);
assert.equal(profile.supportsImages, MODEL_PROFILES.qualityReview.supportsImages);
assert.equal(profile.modelId, "custom/quality-review-profile");
});

137
tests/ai-schemas.test.ts Normal file
View File

@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
callScriptSchema,
emailDraftSchema,
emailSubjectSchema,
followUpDraftSchema,
auditSummarySchema,
qualityReviewSchema,
publicAuditTextSchema,
internalFindingsSchema,
type CallScript,
type EmailDraft,
type EmailSubject,
type FollowUpDraft,
type AuditSummary,
type PublicAuditText,
type QualityReview,
type InternalFindings,
} from "../lib/ai/schemas";
test("internal findings schema accepts task-focused evidence", () => {
const parsed = internalFindingsSchema.parse({
findings: [
{
section: "UX",
finding: "Landingpage is not responsive on mobile viewport.",
suggestion: "Add responsive breakpoints for cards and typography.",
},
],
summary: "One high-priority UX gap was found.",
});
assert.equal(parsed.findings.length, 1);
assert.equal(parsed.findings[0].section, "UX");
});
test("audit summary and public text schemas remain intentionally lightweight", () => {
const summaryParsed = auditSummarySchema.parse({
summary: "Kurze Zusammenfassung mit den wichtigsten Verbesserungen.",
keyFindings: ["Fehlende Kontaktmöglichkeit.", "Langsame Ladezeiten."],
});
const publicParsed = publicAuditTextSchema.parse({
publicText: "Dein Shop ist sauber, aber der erste Eindruck lässt Potenzial erkennen.",
});
assert.equal(summaryParsed.keyFindings.length, 2);
assert.equal(typeof publicParsed.publicText, "string");
});
test("outreach schemas parse German customer-facing payloads", () => {
const emailDraftParsed = emailDraftSchema.parse({
body: "Hallo, ich habe mir euer Angebot angesehen...",
});
const subjectParsed = emailSubjectSchema.parse({
subject: "Kurznotiz zu eurem Webauftritt",
});
const callParsed = callScriptSchema.parse({
openingLine: "Guten Tag, ich bin ...",
callScript: [
"Euer Fokus auf Terminbuchung ist stark.",
"Wie läuft eure aktuelle Lead-Generierung?",
],
closeLine: "Ich schicke im Anschluss kurz die wichtigsten Beobachtungen.",
});
const followParsed = followUpDraftSchema.parse({
message: "Kurzer Follow-up-Hinweis für nächste Woche.",
followInDays: 4,
goals: ["Antwort auf Rückmeldung erhalten"],
});
const qualityParsed = qualityReviewSchema.parse({
isValid: true,
issues: [],
suggestions: ["Mehr Kundennutzen konkret beschreiben."],
});
assert.equal(typeof emailDraftParsed.body, "string");
assert.equal(typeof subjectParsed.subject, "string");
assert.equal(Array.isArray(callParsed.callScript), true);
assert.equal(typeof followParsed.message, "string");
assert.equal(Array.isArray(qualityParsed.suggestions), true);
});
test("schema-inferred types are exported for Convex action wiring", () => {
const typedFindings: InternalFindings = {
findings: [
{
section: "Homepage",
finding: "No visible Datenschutzhinweis.",
suggestion: "Bitte Hinweis ergänzen.",
},
],
summary: "One finding identified.",
};
const typedSummary: AuditSummary = {
summary: "Kernbefund mit 2 Punkten.",
keyFindings: ["Kontaktseite fehlt."],
};
const typedPublicText: PublicAuditText = {
publicText: "Starker Start, aber optimierungsfähig.",
};
const typedEmail: EmailDraft = {
body: "Text mit Ich-Perspektive und konkretem Vorschlag.",
};
const typedSubject: EmailSubject = {
subject: "Kurzer Betreff",
};
const typedCall: CallScript = {
openingLine: "Hallo, ich habe euer Shop geprüft.",
callScript: ["Wie gehen die Leads aktuell rein?"],
closeLine: "Ich melde mich nach der Rückfrage erneut.",
};
const typedFollowUp: FollowUpDraft = {
message: "Kurzes Follow-up ohne harte Floskel.",
};
const typedQuality: QualityReview = {
isValid: true,
issues: [],
suggestions: [],
};
assert.equal(typedFindings.findings.length, 1);
assert.equal(typedSummary.keyFindings.length, 1);
assert.equal(typedPublicText.publicText.length > 0, true);
assert.equal(typeof typedEmail.body, "string");
assert.equal(typeof typedSubject.subject, "string");
assert.equal(typedCall.callScript.length, 1);
assert.equal(typedFollowUp.message.length > 0, true);
assert.equal(typedQuality.isValid, true);
});

View File

@@ -0,0 +1,337 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildAuditEvidenceInput,
type SkillRegistryEntryEvidence,
} from "../lib/ai/audit-evidence";
const SAMPLE_SKILL_REGISTRY: SkillRegistryEntryEvidence[] = [
{
name: "Design Audit",
purpose: "Designqualität prüfen.",
whenToUse:
"Nutze diesen Skill, wenn Seitenhierarchie, visuelle Klarheit und visuelle UX bewertet werden sollen.",
whenNotToUse: "Nicht für technische Fehlerlisten.",
requiredInput: "URL, Seitentypen und Screenshots.",
expectedOutput: "Praktische Design-Prioritäten.",
category: "design",
version: "1.0",
source: "skills/design-audit.md",
},
{
name: "UX Friction Review",
purpose: "Nutzerfluss prüfen.",
whenToUse:
"Nutze diesen Skill bei Formularen, Kontaktwegen und ersten Nutzeraktionen.",
whenNotToUse: "Nicht ohne Nutzerfluss.",
requiredInput: "URL, Kontaktflüsse, Aktionen.",
expectedOutput: "Priorisierte UX-Senkungen.",
category: "ux",
version: "1.0",
source: "skills/ux-friction-review.md",
},
{
name: "Copy Clarity",
purpose: "Textklarheit prüfen.",
whenToUse:
"Nutze diesen Skill bei unklaren, langen oder abstrakten Website-Texten.",
whenNotToUse: "Nicht für technische Datenlisten.",
requiredInput: "Textbausteine, Zielgruppe, Tonalität.",
expectedOutput: "Klarere Formulierungen.",
category: "copy",
version: "1.0",
source: "skills/copy-clarity.md",
},
{
name: "Local SEO",
purpose: "Lokale Auffindbarkeit prüfen.",
whenToUse:
"Nutze diesen Skill bei lokaler Relevanz, Impressum, Kontakt- und Google-Nähe.",
whenNotToUse: "Nicht ohne lokalen Kontext.",
requiredInput: "Ort, Nische, Seitenstruktur.",
expectedOutput: "Sichtbarkeits-Verbesserungen.",
category: "seo",
version: "1.0",
source: "skills/local-seo.md",
},
{
name: "Offer Writing",
purpose: "Angebotsstruktur liefern.",
whenToUse: "Nutze diesen Skill, wenn ein Angebotsentwurf gebraucht wird.",
whenNotToUse: "Nicht bei reinen Buglisten.",
requiredInput: "Projektumfang und Umfang.",
expectedOutput: "Konkrete Angebotsform.",
category: "offer",
version: "1.0",
source: "skills/offer-writing.md",
},
];
test("buildAuditEvidenceInput sanitizes and caps lead/company context", () => {
const actual = buildAuditEvidenceInput({
lead: {
companyName: "Bäckerei <strong>Muster</strong>",
niche: "Bäckerei & Kaffeehaus",
websiteUrl: "https://example.com/kontakt?ref=ad",
address: "Musterstraße 1, 10115 Berlin",
city: "Berlin",
contactPerson: "<b>Anna</b> Hoffmann",
},
crawlPages: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com",
pageKind: "homepage",
title: "Startseite",
},
{
sourceUrl: "https://example.com/kontakt",
finalUrl: "https://example.com/kontakt",
pageKind: "contact",
title: "Kontakt",
},
{
sourceUrl: "https://example.com/kontakt?x=1",
finalUrl: "https://example.com/kontakt?x=1",
pageKind: "contact",
title: "Kontakt",
},
],
skillRegistry: SAMPLE_SKILL_REGISTRY,
});
assert.equal(actual.companyContext.length > 0, true);
assert.equal(actual.companyContext.some((line) => /https?:\/\//.test(line)), false);
assert.equal(actual.companyContext.some((line) => /<[^>]+>/.test(line)), false);
assert.equal(
actual.companyContext.some((line) => line.includes("Bäckerei Muster")),
true,
);
assert.equal(actual.checkedPages.length >= 2, true);
});
test("buildAuditEvidenceInput deduplicates and caps checked pages", () => {
const pages = Array.from({ length: 28 }, (_, index) => ({
sourceUrl:
`https://example.com/seite-${Math.floor(index / 4)}?cache=${index}`,
finalUrl:
`https://example.com/seite-${Math.floor(index / 4)}?cache=${index}`,
pageKind: index % 2 === 0 ? "other" : "services",
title: `Seite ${index}`,
}));
const actual = buildAuditEvidenceInput({
crawlPages: pages,
skillRegistry: SAMPLE_SKILL_REGISTRY,
});
const uniqueCount = new Set(actual.checkedPages).size;
assert.equal(actual.checkedPages.length, uniqueCount);
assert.equal(actual.checkedPages.length <= 8, true);
});
test("buildAuditEvidenceInput builds observed UX/content/technical signals and sanitizes long text", () => {
const actual = buildAuditEvidenceInput({
crawlPages: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com",
pageKind: "homepage",
title: "Wir lieben guten Kaffee",
hasContactFormSignal: true,
hasContactCtaSignal: true,
},
{
sourceUrl: "https://example.com/ueber-uns",
finalUrl: "https://example.com/ueber-uns",
pageKind: "about",
title: "Über uns",
},
],
technicalChecks: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com",
usesHttps: false,
missingTitle: true,
missingMetaDescription: true,
hasVisibleContactPath: true,
brokenInternalLinkCount: 3,
},
],
skillRegistry: SAMPLE_SKILL_REGISTRY,
});
assert.equal(actual.observedUxSignals.length > 0, true);
assert.equal(actual.observedContentSignals.length > 0, true);
assert.equal(actual.observedTechnicalSignals.length > 0, true);
assert.equal(
actual.observedUxSignals.every((line) => !/https?:\/\//.test(line)),
true,
);
assert.equal(
actual.observedContentSignals.every((line) => !/https?:\/\//.test(line)),
true,
);
});
test("buildAuditEvidenceInput preserves screenshot references without base64 payloads", () => {
const actual = buildAuditEvidenceInput({
screenshots: [
{
storageId: "storage-1",
sourceUrl: "https://example.com",
viewport: "desktop",
width: 1200,
height: 3000,
mimeType: "image/png",
capturedAt: 1_700_000_000_000,
// builder must ignore any binary-like fields if they exist
imageBase64: "iVBORw0KGgoAAAANSUhEUgAAAAUA",
},
{
storageId: "storage-2",
sourceUrl: "https://example.com",
viewport: "mobile",
width: 390,
height: 844,
mimeType: "image/png",
capturedAt: 1_700_000_001_000,
},
] as const,
skillRegistry: SAMPLE_SKILL_REGISTRY,
}) as {
screenshotReferences: Array<
Record<string, unknown> & {
storageId: string;
sourceUrl: string;
viewport: string;
width: number;
height: number;
mimeType: string;
capturedAt: number;
}
>;
};
assert.equal(actual.screenshotReferences.length, 2);
for (const reference of actual.screenshotReferences) {
assert.equal(reference.storageId.startsWith("storage-"), true);
assert.equal("imageBase64" in reference, false);
assert.equal(typeof reference.sourceUrl, "string");
assert.equal(reference.width > 0, true);
assert.equal(reference.height > 0, true);
}
});
test("buildAuditEvidenceInput converts PageSpeed implications into sanitized customer-facing text", () => {
const actual = buildAuditEvidenceInput({
pageSpeedInputs: [
{
strategy: "mobile",
status: "succeeded",
sourceUrl: "https://example.com",
normalized: {
implications: [
"Score 0.42: Erster Inhalt liegt deutlich hinter Standards.",
"Die Seite zeigt das wichtigste Bild zu langsam.",
"Weitere Infos: https://example.com/psi",
"{ \"pagespeed\": 0.92, \"score\": 88 }",
],
},
},
{
strategy: "desktop",
status: "failed",
sourceUrl: "https://example.com",
errorType: "api_error",
errorSummary: "Score 0.22: timeout",
},
],
skillRegistry: SAMPLE_SKILL_REGISTRY,
});
assert.equal(actual.pageSpeedCustomerImplications.length >= 1, true);
assert.equal(
actual.pageSpeedCustomerImplications.includes(
"Score 0.42: Erster Inhalt liegt deutlich hinter Standards.",
),
false,
);
assert.equal(
actual.pageSpeedCustomerImplications.every(
(line) =>
!/https?:\/\/|pagespeed|score|lighthouse|raw storage|rawStorage/i.test(line),
),
true,
);
assert.equal(actual.pageSpeedCustomerImplications.length <= 8, true);
});
test("buildAuditEvidenceInput selects deterministic skills and supports design/ux/copy/seo", () => {
const input = {
lead: {
companyName: "Bäckerei Muster",
niche: "Bäckerei",
city: "Berlin",
websiteDomain: "example.com",
},
crawlPages: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com",
pageKind: "homepage",
title: "Willkommen bei Bäckerei Muster",
hasContactFormSignal: true,
hasContactCtaSignal: true,
},
],
technicalChecks: [
{
sourceUrl: "https://example.com",
finalUrl: "https://example.com",
usesHttps: false,
missingMetaDescription: true,
missingTitle: false,
hasVisibleContactPath: true,
brokenInternalLinkCount: 1,
},
],
screenshots: [
{
storageId: "storage-1",
sourceUrl: "https://example.com",
viewport: "desktop",
width: 1200,
height: 3000,
mimeType: "image/png",
capturedAt: 1700000000000,
},
],
skillRegistry: SAMPLE_SKILL_REGISTRY,
};
const first = buildAuditEvidenceInput(input);
const second = buildAuditEvidenceInput({
...input,
skillRegistry: [...SAMPLE_SKILL_REGISTRY].reverse(),
});
assert.equal(first.selectedSkills.length >= 4, true);
assert.equal(first.selectedSkills.length, second.selectedSkills.length);
assert.equal(
first.selectedSkills.every((skill, index) => {
const same = second.selectedSkills[index];
return same?.name === skill.name && same?.category === skill.category;
}),
true,
);
const expectedCategories: Array<
"design" | "ux" | "copy" | "seo"
> = ["design", "ux", "copy", "seo"];
const selectedCategories = new Set(first.selectedSkills.map((skill) => skill.category));
for (const category of expectedCategories) {
assert.equal(selectedCategories.has(category), true);
}
});

View File

@@ -0,0 +1,335 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
const actionPath = path.join(process.cwd(), "convex", "auditGenerationAction.ts");
const actionSource = existsSync(actionPath) ? readFileSync(actionPath, "utf8") : "";
const generationSourcePath = path.join(process.cwd(), "convex", "auditGeneration.ts");
const generationSource = existsSync(generationSourcePath)
? readFileSync(generationSourcePath, "utf8")
: "";
function hasPattern(source: string, pattern: RegExp) {
return pattern.test(source);
}
function hasExportedInternalAction(exportName: string) {
const pattern = new RegExp(
`export const ${exportName}\\s*=\\s*internalAction\\s*\\(`,
);
return hasPattern(actionSource, pattern);
}
function hasStageCall(schema: string) {
const escaped = schema.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return hasPattern(
actionSource,
new RegExp(
`generateObject\\([\\s\\S]*schema:\\s*${escaped}[\\s\\S]*\\)`,
"m",
),
);
}
test("auditGenerationAction module exists and is a Node action file", () => {
assert.equal(existsSync(actionPath), true, "auditGenerationAction.ts should exist");
assert.equal(
hasPattern(actionSource, /^"use node";/m),
true,
"auditGenerationAction.ts should start with \"use node\"",
);
});
test("auditGenerationAction exports processAuditGeneration with runId validator", () => {
assert.equal(
hasExportedInternalAction("processAuditGeneration"),
true,
"processAuditGeneration should be an internalAction",
);
assert.equal(
hasPattern(
actionSource,
/processAuditGeneration\s*=\s*internalAction\(\s*{\s*args:\s*{\s*runId:\s*v\.id\(\s*["']agentRuns["']\s*\)\s*,?\s*}/,
),
true,
"processAuditGeneration should validate runId: v.id(\"agentRuns\")",
);
});
test("action starts, queries evidence, and runs stage pipeline", () => {
assert.equal(
hasPattern(
actionSource,
/internal\.auditGeneration\.startAuditGenerationRun/,
),
true,
"Action should start the run via internal.auditGeneration.startAuditGenerationRun",
);
assert.equal(
hasPattern(
actionSource,
/internal\.auditGeneration\.getAuditGenerationEvidence/,
),
true,
"Action should load evidence via internal.auditGeneration.getAuditGenerationEvidence",
);
assert.equal(
hasPattern(
actionSource,
/internal\.auditGeneration\.persistAuditGenerationResult/,
),
true,
"Action should persist each stage result",
);
assert.equal(
hasPattern(
actionSource,
/internal\.auditGeneration\.finishAuditGenerationRun/,
),
true,
"Action should finish run via internal.auditGeneration.finishAuditGenerationRun",
);
});
test("action includes all required audit stages", () => {
for (const stage of [
"classification",
"multimodalAudit",
"germanCopy",
"qualityReview",
]) {
const token = new RegExp(`stage:\\s*["']${stage}["']`);
assert.equal(
hasPattern(actionSource, token),
true,
`Action should reference ${stage} stage`,
);
}
});
test("action handles post-start failure paths in action-level catch", () => {
assert.equal(
hasPattern(
actionSource,
/try\s*{[\s\S]*internal\.auditGeneration\.getAuditGenerationEvidence[\s\S]*const provider = createOpenRouterProvider\(\)/,
),
true,
"Action should include evidence query and provider init inside catch-covered flow.",
);
assert.equal(
hasPattern(
actionSource,
/catch\s*\(error\)\s*{[\s\S]*appendRunEvent[\s\S]*finishAuditGenerationRun[\s\S]*"failed"/,
),
true,
"Action-level error handler should emit run events.",
);
});
test("action calls generateObject with required schemas", () => {
const requiredSchemas = [
"internalFindingsSchema",
"auditSummarySchema",
"publicAuditTextSchema",
"emailDraftSchema",
"emailSubjectSchema",
"callScriptSchema",
"followUpDraftSchema",
"qualityReviewSchema",
];
for (const requiredSchema of requiredSchemas) {
assert.equal(
hasStageCall(requiredSchema),
true,
`Action should call generateObject with schema ${requiredSchema}`,
);
}
});
test("action uses multimodal file parts with mediaType image/* when screenshots are available", () => {
assert.equal(
hasPattern(
actionSource,
/type:\s*["']file["'][\s\S]*mediaType:\s*(?:getValidMediaType|["']image\/)/,
),
true,
"Multimodal call should include AI file parts with image mediaType",
);
assert.equal(
hasPattern(
actionSource,
/ctx\.storage\.(get|getUrl)\(/,
),
true,
"Multimodal call should try to fetch screenshots from Convex storage",
);
});
test("action handles missing screenshots with warning event fallback", () => {
assert.equal(
hasPattern(actionSource, /level:\s*["']warning["'][\s\S]*Screenshot|Vorschaubild/),
true,
"Action should append warning event when multimodal screenshot input is unavailable",
);
assert.equal(
hasPattern(actionSource, /messages:\s*\[[\s\S]*type:\s*["']text["'][\s\S]*\]/),
true,
"Action should fall back to text-only multimodal calls when required parts are missing",
);
});
test("action runs german copy guard and blocks outreach-ready on validation failure", () => {
assert.equal(
hasPattern(actionSource, /validateCustomerFacingCopy/),
true,
"Action should run German copy validation",
);
assert.equal(
hasPattern(
actionSource,
/guardResult\.passed|qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
),
true,
);
assert.equal(
hasPattern(actionSource, /api\.leads\.reviewUpdate/),
true,
"Action should patch lead via api.leads.reviewUpdate",
);
assert.equal(
hasPattern(
actionSource,
/isTerminalLeadContactStatus/,
),
true,
"Action should set contactStatus to outreach_ready only when terminal guard allows it.",
);
assert.equal(
hasPattern(
actionSource,
/do_not_contact|contacted|replied/i,
),
true,
"Action should explicitly guard against terminal lead statuses before outreach-ready.",
);
assert.equal(
hasPattern(
actionSource,
/Lead-Status wurde nicht auf outreach_ready gesetzt/,
),
true,
"Action should emit warning event when outreach-ready cannot be set.",
);
});
test("action persists audit and outreach outputs before finishing succeeded run", () => {
assert.equal(
hasPattern(
actionSource,
/internal\.audits\.upsertFromAuditGeneration/,
),
true,
"Action should persist audit output via internal.audits.upsertFromAuditGeneration",
);
assert.equal(
hasPattern(
actionSource,
/internal\.outreach\.upsertFromAuditGeneration/,
),
true,
"Action should persist outreach output via internal.outreach.upsertFromAuditGeneration",
);
assert.equal(
hasPattern(
actionSource,
/internal\.audits\.upsertFromAuditGeneration[\s\S]*internal\.outreach\.upsertFromAuditGeneration[\s\S]*internal\.auditGeneration\.finishAuditGenerationRun[\s\S]*status:\s*["']succeeded["']/,
),
true,
"Action should finish success after persisted outputs",
);
});
test("action uses model profiles for generation parameters", () => {
assert.equal(
hasPattern(actionSource, /resolveModelProfile\("classification"\)/),
true,
"classification generation should use resolveModelProfile.",
);
assert.equal(
hasPattern(actionSource, /resolveModelProfile\("multimodalAudit"\)/),
true,
"multimodal generation should use resolveModelProfile.",
);
assert.equal(
hasPattern(actionSource, /resolveModelProfile\("germanCopy"\)/),
true,
"german copy generation should use resolveModelProfile.",
);
assert.equal(
hasPattern(actionSource, /resolveModelProfile\("qualityReview"\)/),
true,
"quality review generation should use resolveModelProfile.",
);
assert.equal(
hasPattern(
actionSource,
/temperature:\s*classificationProfile\.temperature[\s\S]*maxOutputTokens:\s*classificationProfile\.maxTokens/,
),
true,
"classification stage should use profile temperature/maxTokens.",
);
assert.equal(
hasPattern(
actionSource,
/temperature:\s*germanCopyProfile\.temperature[\s\S]*maxOutputTokens:\s*germanCopyProfile\.maxTokens/,
),
true,
"german copy stages should use profile temperature/maxTokens.",
);
assert.equal(
hasPattern(
actionSource,
/temperature:\s*qualityReviewProfile\.temperature[\s\S]*maxOutputTokens:\s*qualityReviewProfile\.maxTokens/,
),
true,
"quality review stage should use profile temperature/maxTokens.",
);
});
test("action sanitization masks env-backed secrets", () => {
assert.equal(
hasPattern(
actionSource,
/sanitizeSecretCandidates\([\s\S]*process\.env/,
),
true,
"sanitize logic should include env-backed secret masking.",
);
assert.equal(
hasPattern(actionSource, /OPENROUTER_API_KEY/),
true,
"sanitizer should include OPENROUTER_API_KEY in secret hints.",
);
});
test("auditGeneration scheduler reference in queueLeadAuditGeneration is typed", () => {
assert.equal(
hasPattern(
generationSource,
/internal\.auditGenerationAction\.processAuditGeneration/,
),
true,
"queueLeadAuditGeneration should reference internal.auditGenerationAction.processAuditGeneration",
);
assert.equal(
hasPattern(
generationSource,
/internal as any/,
),
false,
"No temporary internal cast should remain for the processAuditGeneration schedule",
);
});

View File

@@ -0,0 +1,323 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import ts from "typescript";
const auditGenerationPath = join(process.cwd(), "convex", "auditGeneration.ts");
const auditGenerationSource = existsSync(auditGenerationPath)
? readFileSync(auditGenerationPath, "utf8")
: "";
const sourceFile = ts.createSourceFile(
"auditGeneration.ts",
auditGenerationSource,
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TS,
);
function getExportedConstNames(file: ts.SourceFile) {
const names = new Set<string>();
const visit = (node: ts.Node) => {
if (ts.isVariableStatement(node)) {
const isExported = node.modifiers?.some(
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
);
if (!isExported) {
ts.forEachChild(node, visit);
return;
}
const isConst = node.declarationList.flags & ts.NodeFlags.Const;
if (!isConst) {
ts.forEachChild(node, visit);
return;
}
for (const declaration of node.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(file, visit);
return names;
}
function hasPattern(source: string, pattern: RegExp) {
return pattern.test(source);
}
function extractExportSource(name: string) {
const marker = `export const ${name} = `;
const declarationIndex = auditGenerationSource.indexOf(marker);
assert.notEqual(
declarationIndex,
-1,
`Expected declaration for ${name}`,
);
const openBraceIndex = auditGenerationSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < auditGenerationSource.length; index += 1) {
const char = auditGenerationSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(end, -1, `Expected balanced braces for ${name}`);
return auditGenerationSource.slice(openBraceIndex, end + 1);
}
test("auditGeneration module exports required mutation contracts", () => {
assert.equal(
existsSync(auditGenerationPath),
true,
"auditGeneration.ts should be present",
);
const exports = getExportedConstNames(sourceFile);
const required = [
"queueLeadAuditGeneration",
"startAuditGenerationRun",
"persistAuditGenerationResult",
"finishAuditGenerationRun",
];
for (const exportName of required) {
assert.equal(
exports.has(exportName),
true,
`Expected export: ${exportName}`,
);
}
});
test("auditGeneration module registers internalMutation contracts", () => {
for (const name of [
"queueLeadAuditGeneration",
"startAuditGenerationRun",
"persistAuditGenerationResult",
"finishAuditGenerationRun",
]) {
assert.equal(
hasPattern(
auditGenerationSource,
new RegExp(`export const ${name} = internalMutation\\s*\\(`),
),
true,
`${name} should be registered as internalMutation.`,
);
}
});
test("queueLeadAuditGeneration dedupes pending/running runs and schedules action", () => {
const queueSource = extractExportSource("queueLeadAuditGeneration");
assert.equal(
hasPattern(
queueSource,
/withIndex\("by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit_generation"\)[\s\S]*?eq\("status",\s*"pending"\)[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
),
true,
"Queue should dedupe pending runs with by_type_and_status_and_leadId for type audit_generation.",
);
assert.equal(
hasPattern(
queueSource,
/withIndex\("by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit_generation"\)[\s\S]*?eq\("status",\s*"running"\)[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
),
true,
"Queue should dedupe running runs with by_type_and_status_and_leadId for type audit_generation.",
);
assert.equal(
hasPattern(
queueSource,
/ctx\.scheduler\.runAfter\(\s*0,\s*internal\.auditGenerationAction\.processAuditGeneration,[\s\S]*?runId/,
),
true,
"Queue should schedule internal.auditGenerationAction.processAuditGeneration.",
);
assert.equal(
hasPattern(queueSource, /Audit-Generierung wurde in die Warteschlange gesetzt\./),
true,
"Queue should emit a queue event message.",
);
});
test("startAuditGenerationRun validates and marks run as running", () => {
const startSource = extractExportSource("startAuditGenerationRun");
assert.equal(
hasPattern(startSource, /run\.type\s*!==\s*"audit_generation"/),
true,
"start should validate audit_generation run type.",
);
assert.equal(
hasPattern(startSource, /run\.status\s*!==\s*"pending"/),
true,
"start should require pending status.",
);
assert.equal(
hasPattern(startSource, /!run\.leadId[\s\S]*status:\s*"failed"/),
true,
"start should fail clearly when leadId missing.",
);
assert.equal(
hasPattern(startSource, /!lead[\s\S]*status:\s*"failed"/),
true,
"start should fail clearly when lead cannot be loaded.",
);
assert.equal(
hasPattern(
startSource,
/ctx\.db\.patch\(\s*args\.runId,[\s\S]*status:\s*"running"/,
),
true,
"start should set run status running.",
);
assert.equal(
hasPattern(startSource, /message:\s*"[^"]*konnte nicht gestartet werden[^"]*"/i),
true,
"start should emit clear failure events when starting fails.",
);
});
test("persistAuditGenerationResult inserts into auditGenerations", () => {
const persistSource = extractExportSource("persistAuditGenerationResult");
assert.equal(
hasPattern(persistSource, /ctx\.db\.insert\(\s*"auditGenerations"/),
true,
"persistAuditGenerationResult should insert into auditGenerations.",
);
assert.equal(
hasPattern(
persistSource,
/prompt:\s*sanitizeAndCapString\(args\.prompt,\s*MAX_PROMPT_BYTES\)/,
),
true,
"persist function should sanitize prompt before persisting to avoid secrets.",
);
assert.equal(
hasPattern(
persistSource,
/rawResponse:\s*sanitizeAndCapString\(args\.rawResponse,\s*MAX_RAW_RESPONSE_BYTES\)/,
),
true,
"persist function should sanitize rawResponse before persisting to avoid secrets.",
);
});
test("truncateWithMarker is byte-capped and marker-safe in persistence", () => {
assert.equal(
hasPattern(auditGenerationSource, /const markerBytes = byteLength\(TRUNCATION_MARKER\);/),
true,
"truncateWithMarker should calculate marker bytes explicitly.",
);
assert.equal(
hasPattern(
auditGenerationSource,
/if\s*\(byteLength\(value\)\s*<=\s*maxBytes\)\s*\{\s*return\s*value;\s*\}/,
),
true,
"truncateWithMarker should return early when already within byte limit.",
);
assert.equal(
hasPattern(
auditGenerationSource,
/if\s*\(markerBytes\s*>=\s*maxBytes\)/,
),
true,
"truncateWithMarker should handle marker length edge cases.",
);
assert.equal(
hasPattern(
auditGenerationSource,
/new TextDecoder\(\)\.decode\(markerBytesBuffer\.slice\(0,\s*maxBytes\)\)/,
),
true,
"truncateWithMarker should trim marker bytes with decoder slice fallback.",
);
assert.equal(
hasPattern(
auditGenerationSource,
/TRUNCATION_MARKER\\.slice\(0,\s*maxBytes\)/,
),
false,
"truncateWithMarker should not use unbounded marker slicing by bytes.",
);
});
test("sanitizer masks env-backed secret values in persistence", () => {
assert.equal(
hasPattern(auditGenerationSource, /function\s+sanitizeSecretCandidates/),
true,
"Persistence should expose secret candidate sanitizer.",
);
assert.equal(
hasPattern(auditGenerationSource, /OPENROUTER_API_KEY/),
true,
"Persistence sanitizer should know OPENROUTER_API_KEY.",
);
assert.equal(
hasPattern(
auditGenerationSource,
/return\s+sanitized\s*\r?\n\s*\.replace\(/,
),
true,
"Persistence sanitizer should apply regex secret-masking patterns.",
);
});
test("finishAuditGenerationRun updates run status/counters/currentStep", () => {
const finishSource = extractExportSource("finishAuditGenerationRun");
assert.equal(
hasPattern(
finishSource,
/ctx\.db\.patch\(\s*args\.runId,[\s\S]*?status:\s*args\.status/,
),
true,
"finish should set run status.",
);
assert.equal(
hasPattern(
finishSource,
/status:\s*args\.status[\s\S]*finishedAt:\s*now/,
),
true,
"finish should set finishedAt.",
);
assert.equal(
hasPattern(
finishSource,
/counters:\s*\{[\s\S]*errors:\s*args\.errors/,
),
true,
"finish should update counters with errors.",
);
assert.equal(
hasPattern(
finishSource,
/currentStep:\s*args\.currentStep\s*(\|\||\?\?)\s*"audit_generation"/,
),
true,
"finish should update currentStep.",
);
});

View File

@@ -0,0 +1,204 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
const schemaSource = readFileSync(
join(process.cwd(), "convex", "schema.ts"),
"utf8",
);
const domainSource = readFileSync(
join(process.cwd(), "convex", "domain.ts"),
"utf8",
);
function extractTableSection(tableName: string) {
const marker = `${tableName}: defineTable({`;
const markerIndex = schemaSource.indexOf(marker);
assert.notEqual(
markerIndex,
-1,
`Expected schema table definition for ${tableName}.`,
);
const objectStart = schemaSource.indexOf("{", markerIndex);
let depth = 0;
let objectEnd = -1;
for (let index = objectStart; index < schemaSource.length; index += 1) {
if (schemaSource[index] === "{") {
depth += 1;
} else if (schemaSource[index] === "}") {
depth -= 1;
if (depth === 0) {
objectEnd = index;
break;
}
}
}
assert.notEqual(objectEnd, -1, `Could not parse schema object for ${tableName}.`);
const remainder = schemaSource.slice(objectEnd + 1);
const nextTableMatch = remainder.match(
/^\s*[a-zA-Z_][\w]*:\s*defineTable\(/m,
);
const sectionEnd =
nextTableMatch === null
? schemaSource.length
: objectEnd + 1 + nextTableMatch.index!;
const section = schemaSource.slice(markerIndex, sectionEnd);
const objectBlock = schemaSource.slice(markerIndex, objectEnd + 1);
return { section, objectBlock };
}
function assertHas(pattern: RegExp, source: string, message: string) {
assert.equal(pattern.test(source), true, message);
}
test("auditGenerations table has contract fields", () => {
const { section, objectBlock } = extractTableSection("auditGenerations");
assertHas(
/leadId:\s*v\.id\(["']leads["']\)/,
objectBlock,
"auditGenerations.leadId must be required lead id.",
);
assertHas(
/auditId:\s*v\.optional\(\s*v\.id\(["']audits["']\)\s*\)/,
objectBlock,
"auditGenerations.auditId should be optional audit id.",
);
assertHas(
/runId:\s*v\.id\(["']agentRuns["']\)/,
objectBlock,
"auditGenerations.runId should be required agent run id.",
);
assertHas(
/stage:\s*auditGenerationStage/,
objectBlock,
"auditGenerations.stage should use auditGenerationStage validator.",
);
assertHas(
/modelProfile:\s*v\.string\(\)/,
objectBlock,
"auditGenerations.modelProfile should be required string.",
);
assertHas(
/modelId:\s*v\.string\(\)/,
objectBlock,
"auditGenerations.modelId should be required string.",
);
assertHas(
/prompt:\s*v\.string\(\)/,
objectBlock,
"auditGenerations.prompt should be required string.",
);
assertHas(
/systemPrompt:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
objectBlock,
"auditGenerations.systemPrompt should be optional string.",
);
assertHas(
/rawResponse:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
objectBlock,
"auditGenerations.rawResponse should be optional string.",
);
assertHas(
/parsedJson:\s*v\.optional\(\s*auditGenerationParsedJson\s*\)/,
objectBlock,
"auditGenerations.parsedJson should allow string or structured object.",
);
assertHas(
/usage:\s*v\.optional\(\s*auditGenerationUsage\s*\)/,
objectBlock,
"auditGenerations.usage should be optional token usage object.",
);
assertHas(
/finishReason:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
objectBlock,
"auditGenerations.finishReason should be optional string.",
);
assertHas(
/status:\s*auditGenerationStatus/,
objectBlock,
"auditGenerations.status should use auditGenerationStatus validator.",
);
assertHas(
/errorSummary:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
objectBlock,
"auditGenerations.errorSummary should be optional string.",
);
assertHas(
/createdAt:\s*v\.number\(\)/,
objectBlock,
"auditGenerations.createdAt should be required number.",
);
assertHas(
/updatedAt:\s*v\.number\(\)/,
objectBlock,
"auditGenerations.updatedAt should be required number.",
);
assertHas(
/index\("by_leadId",\s*\["leadId"\]\)/,
section,
"auditGenerations should have by_leadId index.",
);
assertHas(
/index\("by_auditId",\s*\["auditId"\]\)/,
section,
"auditGenerations should have by_auditId index.",
);
assertHas(
/index\("by_runId",\s*\["runId"\]\)/,
section,
"auditGenerations should have by_runId index.",
);
assertHas(
/index\("by_stage",\s*\["stage"\]\)/,
section,
"auditGenerations should have by_stage index.",
);
assertHas(
/index\("by_leadId_and_stage",\s*\["leadId",\s*"stage"\]\)/,
section,
"auditGenerations should have by_leadId_and_stage index.",
);
});
test("audit-generation validators are declared", () => {
assertHas(
/const\s+auditGenerationStage\s*=\s*v\.union\([\s\S]*\)/,
schemaSource,
"schema should define auditGenerationStage union.",
);
assertHas(
/const\s+auditGenerationStatus\s*=\s*v\.union\([\s\S]*\)/,
schemaSource,
"schema should define auditGenerationStatus union.",
);
assertHas(
/AUDIT_GENERATION_STAGES\s*=\s*\[[\s\S]*["']classification["'][\s\S]*\]/,
domainSource,
"auditGenerationStage should include classification.",
);
assertHas(
/AUDIT_GENERATION_STAGES\s*=\s*\[[\s\S]*["']multimodalAudit["'][\s\S]*\]/,
domainSource,
"auditGenerationStage should include multimodalAudit.",
);
assertHas(
/AUDIT_GENERATION_STAGES\s*=\s*\[[\s\S]*["']germanCopy["'][\s\S]*\]/,
domainSource,
"auditGenerationStage should include germanCopy.",
);
assertHas(
/AUDIT_GENERATION_STAGES\s*=\s*\[[\s\S]*["']qualityReview["'][\s\S]*\]/,
domainSource,
"auditGenerationStage should include qualityReview.",
);
});

View File

@@ -0,0 +1,231 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import ts from "typescript";
const schemaPath = join(process.cwd(), "convex", "schema.ts");
const auditsPath = join(process.cwd(), "convex", "audits.ts");
const schemaSource = readFileSync(schemaPath, "utf8");
const auditsSource = readFileSync(auditsPath, "utf8");
const sourceFile = ts.createSourceFile(
"audits.ts",
auditsSource,
ts.ScriptTarget.ES2022,
true,
);
function extractTableSection(tableName: string) {
const marker = `${tableName}: defineTable({`;
const markerIndex = schemaSource.indexOf(marker);
assert.notEqual(
markerIndex,
-1,
`Expected schema table definition for ${tableName}.`,
);
const objectStart = schemaSource.indexOf("{", markerIndex);
let depth = 0;
let objectEnd = -1;
for (let index = objectStart; index < schemaSource.length; index += 1) {
if (schemaSource[index] === "{") {
depth += 1;
} else if (schemaSource[index] === "}") {
depth -= 1;
if (depth === 0) {
objectEnd = index;
break;
}
}
}
assert.notEqual(
objectEnd,
-1,
`Could not parse schema object for ${tableName}.`,
);
const objectBlock = schemaSource.slice(objectStart, objectEnd + 1);
return { objectBlock };
}
function extractExportSource(name: string) {
const marker = `export const ${name} = `;
const declarationIndex = auditsSource.indexOf(marker);
assert.notEqual(
declarationIndex,
-1,
`Expected declaration for ${name}.`,
);
const openBraceIndex = auditsSource.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < auditsSource.length; index += 1) {
const char = auditsSource[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(
end,
-1,
`Expected balanced braces for export ${name}.`,
);
return auditsSource.slice(openBraceIndex, end + 1);
}
function extractFieldSection(source: string, fieldName: string, nextFieldName: string) {
const match = source.match(
new RegExp(
`${fieldName}:\\s*v\\.optional\\([\\s\\S]*?(?=\\s*${nextFieldName}:)`,
),
);
assert.notEqual(
match,
null,
`Expected ${fieldName} field with expected object structure in schema.`,
);
return match![0];
}
function hasPattern(source: string, pattern: RegExp, message: string) {
assert.equal(pattern.test(source), true, message);
}
test("audits schema stores compact usedSkills metadata", () => {
const { objectBlock } = extractTableSection("audits");
const usedSkillsSection = extractFieldSection(
objectBlock,
"usedSkills",
"skillSummaries",
);
const skillSummariesSection = extractFieldSection(
objectBlock,
"skillSummaries",
"multimodalSummary",
);
hasPattern(usedSkillsSection, /usedSkills:\s*v\.optional\(/, "usedSkills should be optional.");
hasPattern(
usedSkillsSection,
/name:\s*v\.string\(\)/,
"usedSkills.name should be string.",
);
hasPattern(
usedSkillsSection,
/category:\s*v\.string\(\)/,
"usedSkills.category should be string.",
);
hasPattern(
usedSkillsSection,
/version:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"usedSkills.version should be optional string.",
);
hasPattern(
usedSkillsSection,
/source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"usedSkills.source should be optional string.",
);
hasPattern(
usedSkillsSection,
/v\.array\(/,
"usedSkills should be an optional array of objects.",
);
hasPattern(
usedSkillsSection,
/v\.object\(/,
"usedSkills should be defined with v.object fields.",
);
hasPattern(skillSummariesSection, /skillSummaries:/, "skillSummaries should still exist.");
hasPattern(
skillSummariesSection,
/name:\s*v\.string\(\)/,
"skillSummaries.name should stay string.",
);
hasPattern(
skillSummariesSection,
/purpose:\s*v\.string\(\)/,
"skillSummaries.purpose should stay string.",
);
hasPattern(
skillSummariesSection,
/summary:\s*v\.string\(\)/,
"skillSummaries.summary should stay string.",
);
});
test("audits.create accepts usedSkills validator and persists metadata payloads", () => {
const createSource = extractExportSource("create");
hasPattern(
auditsSource,
/const usedSkillsValidator\s*=\s*v\.array\(/,
"audits.ts should define a reusable usedSkillsValidator.",
);
hasPattern(
auditsSource,
/v\.object\([\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.string\(\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
"audits.ts should define a reusable usedSkillsValidator.",
);
hasPattern(
createSource,
/usedSkills:\s*v\.optional\(usedSkillsValidator\)/,
"create args should include optional usedSkills field.",
);
hasPattern(
createSource,
/ctx\.db\.insert\(\s*["']audits["'][\s\S]*?args[\s\S]*\}/,
"create should persist audit payload from args (so usedSkills is stored when provided).",
);
});
test("audits.getDetail returns audit + lead context with null-safe lead lookup", () => {
const getDetailSource = extractExportSource("getDetail");
hasPattern(
getDetailSource,
/args:\s*{[\s\S]*id:\s*v\.id\(["']audits["']\)[\s\S]*}/,
"getDetail should require id argument for audits.",
);
hasPattern(
getDetailSource,
/const\s+audit\s*=\s*await\s+ctx\.db\.get\s*\(\s*args\.id\s*\)/,
"getDetail should load audit by id.",
);
hasPattern(
getDetailSource,
/if\s*\(\s*!audit\s*\)\s*{\s*return null;\s*}/,
"getDetail should return null when audit is missing.",
);
hasPattern(
getDetailSource,
/const\s+lead\s*=\s*await\s+ctx\.db\.get\s*\(\s*audit\.leadId\s*\)/,
"getDetail should load lead by leadId from the audit.",
);
hasPattern(
getDetailSource,
/return\s*{\s*audit,\s*lead\s*}/,
"getDetail should return { audit, lead }.",
);
hasPattern(
sourceFile.getFullText(),
/export const getDetail = query\(/,
"audits.ts should export a getDetail query.",
);
});

View File

@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const source = async (relativePath: string) => {
return await readFile(
join(process.cwd(), ...relativePath.split("/")),
"utf8",
);
};
test("audits dashboard page uses a dedicated board component", async () => {
const dashboardPageSource = await source("app/dashboard/audits/page.tsx");
assert.doesNotMatch(
dashboardPageSource,
/DashboardPlaceholderPage/i,
"Dashboard audits route should not render the placeholder page.",
);
assert.match(
dashboardPageSource,
/<AuditsBoard \/>/,
"Audits board should be mounted from route page.",
);
assert.match(
dashboardPageSource,
/"@\/components\/audits\/audits-board"/,
"Audits board should be imported from components.",
);
});
test("audits board renders compact list with convex list query and core columns", async () => {
const boardSource = await source("components/audits/audits-board.tsx");
assert.match(
boardSource,
/\"use client\"/,
"AuditsBoard must be a Client Component for useQuery.",
);
assert.match(
boardSource,
/useQuery\s*\(\s*api\.audits\.list,\s*\{\s*limit:\s*100\s*\}\s*\)/,
"AuditsBoard should call api.audits.list with { limit: 100 }.",
);
assert.match(
boardSource,
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.createdAt\s*-\s*a\.createdAt\)/,
"Audits should be sorted newest first.",
);
assert.match(boardSource, /Loading|lädt|Lade/i);
assert.match(boardSource, /Keine Audits|keine Audits/i);
assert.match(boardSource, /Slug/);
assert.match(boardSource, /Domain/);
assert.match(boardSource, /Status/);
assert.match(boardSource, /Seiten/);
assert.match(
boardSource,
/href=\{`\/dashboard\/audits\/\$\{audit\._id\}`\}/,
"Each audit row should link to /dashboard/audits/{id}.",
);
});
test("audit detail component uses getDetail query and renders skills overview section", async () => {
const detailSource = await source("components/audits/audit-detail.tsx");
assert.match(
detailSource,
/\"use client\"/,
"AuditDetail must be client-side for Convex query calls.",
);
assert.match(
detailSource,
/api\.audits[\s\S]{0,80}getDetail/,
"AuditDetail should use api.audits.getDetail query.",
);
assert.match(
detailSource,
/useQuery\(\s*api\.audits\.getDetail,\s*\{/,
"AuditDetail should call useQuery with api.audits.getDetail directly.",
);
assert.doesNotMatch(
detailSource,
/const\s+auditDetailQueryRef/,
"AuditDetail should not use a cast-based query fallback variable.",
);
assert.match(
detailSource,
/const\s+audit\s*=\s*result\?\.audit;/,
"AuditDetail should destructure audit from result.audit.",
);
assert.match(
detailSource,
/const\s+lead\s*=\s*result\?\.lead;/,
"AuditDetail should destructure lead from result.lead.",
);
assert.match(
detailSource,
/leadSummary\(\s*lead\s*\)/,
"AuditDetail should pass lead into leadSummary from result.lead.",
);
assert.match(
detailSource,
/usedSkills/,
"AuditDetail should inspect usedSkills for overview rendering.",
);
assert.match(
detailSource,
/Keine Skills gespeichert/,
"AuditDetail should show fallback text when no skills are saved.",
);
assert.match(
detailSource,
/Verwendete Skills/,
"AuditDetail should render Verwendete Skills heading.",
);
assert.match(
detailSource,
/Lead|lead/,
"AuditDetail should surface lead context when available.",
);
assert.doesNotMatch(
detailSource,
/<p[^>]*>\s*\{leadSummary\(\s*lead\|[\s\S]*?\)\s*\}\s*<\/p>/,
"Lead summary should not wrap leadSummary output in a nested <p>.",
);
assert.doesNotMatch(
detailSource,
/<p[^>]*>\s*\{leadSummary\(\s*audit\.lead\)\s*\}\s*<\/p>/,
"Lead summary should not wrap leadSummary output in a nested <p>.",
);
});
test("audits detail route passes id to AuditDetail via Promise params", async () => {
const pageSource = await source("app/dashboard/audits/[id]/page.tsx");
assert.match(
pageSource,
/params:\s*Promise<\{\s*id:\s*string\s*\}>/,
"Audit detail route should accept params as Promise in Next.js 16 style.",
);
assert.match(
pageSource,
/const \{\s*id\s*\}\s*=\s*await params/,
"Audit detail route should unwrap Promise params.",
);
assert.match(
pageSource,
/<AuditDetail\s+id=/,
"Audit detail route should pass id prop into AuditDetail.",
);
});
test("public audit page does not expose used skills", async () => {
const publicAuditSource = await source("app/audit/[slug]/page.tsx");
assert.doesNotMatch(
publicAuditSource,
/Verwendete Skills|usedSkills/i,
"Public audit page must not show used skills.",
);
});

View File

@@ -0,0 +1,270 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
validateCallScriptCopy,
validateCustomerFacingCopy,
validateFollowUpCopy,
} from "../lib/ai/german-copy-guard";
const validPayload = {
auditSummary:
"Ich habe euren Webauftritt geprüft. Mir ist aufgefallen, dass die Kontaktseite nicht klar erreichbar ist. Ich empfehle, den Kontaktbereich im Header sichtbar zu platzieren.",
auditBody:
"Mir ist aufgefallen, dass die Kontaktseite nur am Ende der Startseite eingebettet ist. Ich empfehle, sie im Kopfbereich direkt zu platzieren.",
emailSubject: "Kurzes Feedback zu eurem Webauftritt",
emailBody:
"Hallo, ich habe eure Seite betrachtet und festgestellt, dass die Kontaktoptionen auf mobilen Geräten schwer zu finden sind. Ich empfehle, einen klar sichtbaren Button einzubauen.",
callScript: {
openingLine: "Hallo, ich bin Matthias von der Webberatung.",
callScript: [
"Ich habe eure Website geprüft und gesehen, dass der Kontaktbereich nicht sofort sichtbar ist.",
"Ich schlage vor, den Kontakt-Button in den Header zu setzen und die Mobil-Ansicht anzupassen.",
],
closeLine: "Wenn das hilfreich klingt, kann ich euch in zwei Minuten die nächsten Schritte skizzieren.",
},
followUp:
"Mir ist noch etwas aufgefallen: Auf der Mobilversion fehlt ein klarer Termin- oder Kontakthinweis. Ich schlage vor, diesen Bereich oberhalb der Leistungstexte deutlich zu markieren.",
};
test("validateCustomerFacingCopy passes clean German outreach and audit copy", () => {
const result = validateCustomerFacingCopy(validPayload);
assert.equal(result.passed, true);
assert.equal(result.issues.length, 0);
});
test("validateCustomerFacingCopy rejects likely non-German copy and reports language", () => {
const result = validateCustomerFacingCopy({
...validPayload,
emailBody:
"Your site looks very strong, and your performance score is 0.82 with good Lighthouse numbers.",
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some((issue) =>
issue.field === "emailBody" && issue.rule === "not_german",
),
true,
);
});
test("validateCustomerFacingCopy flags short English artifact-like snippets in content fields", () => {
const shortInputs: Array<{
field: "auditSummary" | "auditBody" | "emailBody" | "followUp";
value: string;
}> = [
{ field: "emailBody", value: "quick audit" },
{ field: "auditBody", value: "bad website" },
{ field: "followUp", value: "AI report" },
];
for (const { field, value } of shortInputs) {
const payload = { ...validPayload, [field]: value };
const result = validateCustomerFacingCopy(payload as typeof validPayload);
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) => issue.field === field && issue.rule === "not_german",
),
true,
`Expected ${field} short snippet "${value}" to fail german language check.`,
);
}
});
test("validateCustomerFacingCopy requires Ich-form in applicable customer-facing fields", () => {
const result = validateCustomerFacingCopy({
...validPayload,
auditBody:
"Ihre Seite hat eine gute Struktur. Der Kontaktbereich sollte klarer werden.",
followUp: "Die Website sollte verbessert werden. Setzt bitte einen Kontaktbutton.",
});
const hasAuditIssue = result.issues.some(
(issue) => issue.field === "auditBody" && issue.rule === "missing_ich_form",
);
const hasFollowUpIssue = result.issues.some(
(issue) => issue.field === "followUp" && issue.rule === "missing_ich_form",
);
assert.equal(result.passed, false);
assert.equal(hasAuditIssue, true);
assert.equal(hasFollowUpIssue, true);
});
test("validateCustomerFacingCopy blocks PageSpeed-like score artifacts in public text", () => {
const result = validateCustomerFacingCopy({
...validPayload,
auditSummary:
"Aus dem PageSpeed-Check ergibt sich ein score: 0.82 im Bereich Performance.",
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) =>
issue.field === "auditSummary" &&
issue.rule === "pagespeed_score_artifact",
),
true,
);
});
test("validateCustomerFacingCopy blocks price/currency mention", () => {
const result = validateCustomerFacingCopy({
...validPayload,
callScript: {
...validPayload.callScript,
callScript: [
"Der Kontaktpunkt ist gut sichtbar.",
"Ihr Paket kostet nur 99 € pro Monat.",
"Ich habe den Kontaktpunkt geprüft und schlage vor, ihn in der Headerzeile zu fixieren.",
],
},
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) => issue.field === "callScript.callScript[1]" && issue.rule === "price_mention",
),
true,
);
});
test("validateCustomerFacingCopy rejects generic AI slop language", () => {
const result = validateCustomerFacingCopy({
...validPayload,
emailBody:
"Unsere maßgeschneiderte, nahtlose, innovative Lösung hebt Ihre Sichtbarkeit auf ein neues Level und ist wirklich disruptive.",
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) =>
issue.field === "emailBody" && issue.rule === "generic_ai_slop",
),
true,
);
});
test("validateCustomerFacingCopy flags accusatory tone", () => {
const result = validateCustomerFacingCopy({
...validPayload,
auditBody:
"Ihre Website ist katastrophal und wirkt absolut unprofessionell. Das sollte dringend geändert werden.",
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) => issue.field === "auditBody" && issue.rule === "hostile_tone",
),
true,
);
});
test("validateCustomerFacingCopy strips technical artifacts like model ids and raw JSON", () => {
const result = validateCustomerFacingCopy({
...validPayload,
followUp:
'Ich habe folgende Diagnose: {"score": 0.8, "lighthouseResult": "ok", "storageId": "rawstorageid_abc123"}',
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) =>
issue.field === "followUp" &&
issue.rule === "raw_technical_artifact",
),
true,
);
});
test("validateCustomerFacingCopy enforces observation + suggestion style", () => {
const result = validateCustomerFacingCopy({
...validPayload,
emailBody:
"Deine Website ist großartig, tolle Arbeit.",
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) =>
issue.field === "emailBody" &&
issue.rule === "missing_observation_or_suggestion",
),
true,
);
});
test("validateCustomerFacingCopy is permissive for phone numbers and date values", () => {
const result = validateCustomerFacingCopy({
auditSummary:
"Ich habe gesehen, dass eure Kontaktseite am 12.02.2026 aktualisiert wurde. Ich empfehle, den Kontaktbereich als Nächstes im Header zu verbessern.",
auditBody:
"Mir ist aufgefallen, dass die Telefonnummer 0201 123456 in der Fußzeile steht. Ich empfehle, sie zusätzlich im Header zu platzieren.",
emailSubject: "Kurzes Feedback zu eurem Terminplan",
emailBody:
"Hallo, ich habe euren Webauftritt geprüft und habe gesehen, dass Termine auf der Seite mit dem Datum 12. Oktober erwähnt sind. Ich empfehle, diese Terminangabe im Header stärker hervorzuheben.",
callScript: {
openingLine:
"Hallo, ich bin Matthias und ich habe eure Seite geprüft.",
callScript: [
"Ich habe auf eurer Seite gesehen, dass der Kontaktbutton erst sehr weit unten erscheint.",
"Mir ist aufgefallen, dass hier noch eine kleine Verbesserung fehlt; ich schlage vor, den Kontaktbereich nach oben zu ziehen.",
],
closeLine: "Dann nehme ich das Thema in den nächsten Schritt mit auf.",
},
followUp:
"Mir ist am 12. Oktober aufgefallen, dass die Telefonnummer 030 1234567 schon gut auffindbar ist; ich schlage vor, eine kleine Sichtbarkeitsanpassung vorzunehmen.",
});
assert.equal(result.passed, true);
});
test("validateCallScriptCopy validates each script line individually and returns field paths", () => {
const result = validateCallScriptCopy({
openingLine: "Hallo, ich bin Matthias.",
callScript: [
"{" +
'"score": 0.82, "rawstorageid":"abc123"' +
"}",
"Ich habe auf der Seite gesehen, dass der Kontaktbutton fehlt.",
"Mir fehlt noch ein konkreter Verbesserungsschritt.",
],
closeLine: "Schöne Grüße",
});
assert.equal(result.passed, false);
assert.equal(
result.issues.some(
(issue) =>
issue.field === "callScript.callScript[0]" &&
issue.rule === "raw_technical_artifact",
),
true,
);
});
test("validateFollowUpCopy enforces ich-form and guard output shape", () => {
const result = validateFollowUpCopy({
message: "Hier ist der Inhalt für das Follow-up.",
});
assert.equal(result.passed, false);
assert.equal(result.issues.length > 0, true);
assert.equal(
result.issues.some(
(issue) =>
issue.field === "followUp" && issue.rule === "missing_ich_form",
),
true,
);
});

View File

@@ -0,0 +1,50 @@
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
import { join } from "node:path";
import test from "node:test";
import { createOpenRouterProvider } from "../lib/ai/openrouter-provider";
const providerSource = readFileSync(
join(process.cwd(), "lib", "ai", "openrouter-provider.ts"),
"utf8",
);
test("provider reads OPENROUTER_API_KEY from environment and requires it", () => {
assert.equal(
/OPENROUTER_API_KEY/.test(providerSource),
true,
"Provider should read OPENROUTER_API_KEY.",
);
assert.equal(
/OPENROUTER_APP_NAME/.test(providerSource),
true,
"Provider should include optional OPENROUTER_APP_NAME.",
);
assert.equal(
/OPENROUTER_APP_URL/.test(providerSource),
true,
"Provider should include optional OPENROUTER_APP_URL.",
);
assert.throws(
() =>
createOpenRouterProvider({
OPENROUTER_API_KEY: undefined,
OPENROUTER_APP_NAME: "local-audit-tool",
OPENROUTER_APP_URL: "https://example.local",
}),
/OPENROUTER_API_KEY is required/i,
);
});
test("provider forwards optional app metadata to createOpenRouter call", () => {
const provider = createOpenRouterProvider({
OPENROUTER_API_KEY: "dummy-key",
OPENROUTER_APP_NAME: "local-audit-tool",
OPENROUTER_APP_URL: "https://example.local",
});
assert.equal(typeof provider, "function");
assert.equal(provider !== null, true);
});

View File

@@ -0,0 +1,250 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, sep } from "node:path";
import test from "node:test";
import {
type AuditUsedSkill,
loadSkillsRegistry,
parseSkillsRegistry,
toAuditUsedSkill,
SKILL_CATEGORIES,
} from "../lib/skills-registry";
function assertIncludes(values: readonly string[], value: string) {
assert.ok(values.includes(value), `Expected ${value} in [${values.join(", ")}]`);
}
function withTempProjectRegistry(
source: string,
run: () => Promise<void> | void,
) {
return mkdtemp(`${tmpdir()}${sep}`).then(async (projectRoot) => {
const registryPath = join(projectRoot, "skills.md");
const originalCwd = process.cwd();
await writeFile(registryPath, source, "utf8");
process.chdir(projectRoot);
try {
await run();
} finally {
process.chdir(originalCwd);
await rm(projectRoot, { recursive: true, force: true });
}
});
}
test("parseSkillsRegistry parses valid entries, trims whitespace, and normalizes category", async () => {
const registrySource = `
## Design Audit
Purpose: Evaluate layout, visual hierarchy, and CTA clarity.
When to use: Use when a homepage is available and should be assessed for conversion quality.
When not to use: Don't run during technical outages or non-web touchpoints.
Required input: Homepage URL, top-level page sections, style language, brand context.
Expected output: Prioritized improvement list with concrete design changes.
Category: design
Version: 2026.06
Source: skills/design-audit.md
`;
const parsed = parseSkillsRegistry(registrySource);
assert.equal(parsed.length, 1);
const entry = parsed.at(0);
assert.ok(entry);
assert.equal(entry!.name, "Design Audit");
assert.equal(entry!.purpose, "Evaluate layout, visual hierarchy, and CTA clarity.");
assert.equal(entry!.category, "design");
assert.equal(entry!.version, "2026.06");
assert.equal(entry!.source, "skills/design-audit.md");
});
test("parseSkillsRegistry accepts indented field labels", () => {
const registrySource = `
## Local SEO Boost
Purpose: Evaluate visibility for local search with nearby intent.
When to use: Use for local business pages and service locations.
When not to use: Avoid for non-local marketing pages.
Required input: City, address, NAP consistency.
Expected output: Prioritized local SEO recommendations.
Category: seo
`;
const parsed = parseSkillsRegistry(registrySource);
assert.equal(parsed.length, 1);
const entry = parsed.at(0);
assert.ok(entry);
assert.equal(entry!.name, "Local SEO Boost");
assert.equal(entry!.purpose, "Evaluate visibility for local search with nearby intent.");
assert.equal(entry!.category, "seo");
});
test("parseSkillsRegistry throws for missing required fields", () => {
const registrySource = `
## UX Friction Review
Purpose: Review interaction patterns for friction points.
When to use: Use for lead capture and booking flows.
When not to use: Use only when there is a user journey.
Required input: Session flow and target action.
Category: ux
`;
assert.throws(
() => parseSkillsRegistry(registrySource),
/missing required field "Expected output"/i,
);
});
test("parseSkillsRegistry throws for unknown category", () => {
const registrySource = `
## Bad Category Example
Purpose: Example.
When to use: Example scenario.
When not to use: Never for this case.
Required input: Example data.
Expected output: Example output.
Category: analytics
`;
assert.throws(
() => parseSkillsRegistry(registrySource),
/unknown category "analytics"/i,
);
});
test("parseSkillsRegistry throws for duplicate skill names", () => {
const registrySource = `
## Local SEO Boost
Purpose: Strengthen local SERPs.
When to use: Use for local service businesses.
When not to use: Not for international-only landing pages.
Required input: Name, address, service area.
Expected output: Local SEO gaps and quick wins.
Category: seo
## Local SEO Boost
Purpose: Another local SEO pass.
When to use: Use for new regions.
When not to use: Skip for pure lead-gen pages.
Required input: Name, address, service area.
Expected output: Competitor baseline.
Category: seo
`;
assert.throws(
() => parseSkillsRegistry(registrySource),
/duplicate skill name "Local SEO Boost"/i,
);
});
test("parseSkillsRegistry accepts all configured categories", () => {
assertIncludes(SKILL_CATEGORIES, "design");
assertIncludes(SKILL_CATEGORIES, "ux");
assertIncludes(SKILL_CATEGORIES, "marketing");
assertIncludes(SKILL_CATEGORIES, "copy");
assertIncludes(SKILL_CATEGORIES, "seo");
assertIncludes(SKILL_CATEGORIES, "offer");
const registrySource = SKILL_CATEGORIES.map(
(category) => `
## ${category}-skill
Purpose: Valid for ${category}.
When to use: Use for ${category} tasks.
When not to use: Skip when ${category} is not in scope.
Required input: Category inputs.
Expected output: Category-specific recommendations.
Category: ${category}
`,
).join("\n\n");
const parsed = parseSkillsRegistry(registrySource);
assert.equal(parsed.length, SKILL_CATEGORIES.length);
for (const category of SKILL_CATEGORIES) {
const match = parsed.find((entry) => entry.name === `${category}-skill`);
assert.ok(match, `Expected parsed entry for ${category}`);
assert.equal(match.category, category);
}
});
test("loadSkillsRegistry reads skills.md from process.cwd() by default", async () => {
await withTempProjectRegistry(
`
## Offer Writing
Purpose: Build offer-focused copy for outreach.
When to use: Use before drafting proposals.
When not to use: Avoid when no offer exists.
Required input: Offer structure and pricing envelope.
Expected output: Offer draft and pricing emphasis.
Category: offer
`,
async () => {
const parsed = await loadSkillsRegistry();
const parsedEntry = parsed.find((entry) => entry.name === "Offer Writing");
assert.ok(parsedEntry);
assert.equal(parsedEntry.category, "offer");
},
);
});
test("loadSkillsRegistry accepts an explicit registry path", async () => {
const projectRoot = await mkdtemp(`${tmpdir()}${sep}`);
const registryPath = join(projectRoot, "seed-skills.md");
await writeFile(
registryPath,
`
## Design Audit
Purpose: Validate design quality for local business pages.
When to use: Use for a quick visual prioritization pass.
When not to use: Skip when no public page exists.
Required input: Homepage URL and target conversion goal.
Expected output: Ranked design actions with confidence.
Category: design
`,
"utf8",
);
try {
const parsed = await loadSkillsRegistry(registryPath);
assert.equal(parsed.at(0)?.name, "Design Audit");
} finally {
await rm(projectRoot, { recursive: true, force: true });
}
});
test("toAuditUsedSkill returns only required audit-facing fields", () => {
const skill = {
name: "Copy Clarity",
purpose: "Reduce complexity and improve readability.",
whenToUse: "When existing copy is verbose.",
whenNotToUse: "Skip if website is plain text-only.",
requiredInput: "Page sections and CTAs.",
expectedOutput: "A concise writing pass.",
category: "copy",
version: "1.0",
source: "skills/copy-clarity.md",
} satisfies {
name: string;
purpose: string;
whenToUse: string;
whenNotToUse: string;
requiredInput: string;
expectedOutput: string;
category: "copy";
version: string;
source: string;
};
const auditUsed = toAuditUsedSkill(skill);
const expected: AuditUsedSkill = {
name: "Copy Clarity",
category: "copy",
version: "1.0",
source: "skills/copy-clarity.md",
};
assert.deepEqual(auditUsed, expected);
});

View File

@@ -360,7 +360,8 @@ test("website enrichment action prepares Chromium AL2023 shared libraries for Co
);
const executableIndex = actionSource.indexOf(
"const executablePath = await resolveChromiumExecutablePath(",
"resolveChromiumExecutablePath(",
actionSource.indexOf("export const processLeadEnrichment"),
);
const launchIndex = actionSource.indexOf("chromium.launch({");
const hasSetupIndex = Math.max(
@@ -381,7 +382,7 @@ test("processLeadEnrichment wraps Playwright bootstrap in protected try/catch",
assert.equal(
hasPattern(
actionSource,
/try\s*\{[\s\S]*?const \{ playwrightCore, serverlessChromium \}\s*=\s*await loadPlaywrightModules\(\);[\s\S]*?const executablePath = await resolveChromiumExecutablePath\(\s*serverlessChromium,\s*\);[\s\S]*?browser = await playwrightCore\.chromium\.launch\([\s\S]*?executablePath,[\s\S]*?desktopContext = await browser\.newContext\([\s\S]*?mobileContext = await browser\.newContext\(/,
/try\s*\{[\s\S]*?const \{ playwrightCore, serverlessChromium \}\s*=[\s\S]*?loadPlaywrightModules\(\)[\s\S]*?const executablePath = await withActionTimeout\([\s\S]*?resolveChromiumExecutablePath\(\s*serverlessChromium\s*\)[\s\S]*?browser = await withActionTimeout\([\s\S]*?playwrightCore\.chromium\.launch\([\s\S]*?executablePath,[\s\S]*?desktopContext = await withActionTimeout\([\s\S]*?browser\.newContext\([\s\S]*?mobileContext = await withActionTimeout\([\s\S]*?browser\.newContext\(/,
),
true,
"Playwright runtime bootstrap should use resolveChromiumExecutablePath() inside the action's try/catch-protected block",
@@ -558,6 +559,77 @@ test("website enrichment enforces TASK-8 crawler limits and runtime timeboxes",
);
});
test("website enrichment guards long browser work before Convex action runtime aborts", () => {
assert.equal(
hasPattern(actionSource, /DEFAULT_ACTION_BUDGET_MS\s*=\s*120_000/),
true,
"Action should keep an overall runtime budget below the observed Convex abort window.",
);
assert.equal(
hasPattern(actionSource, /TASK8_ACTION_BUDGET_MS/),
true,
"Action runtime budget should be configurable for manual tuning.",
);
assert.equal(
hasPattern(actionSource, /function actionBudgetMs\(\)/),
true,
"Action should resolve a bounded runtime budget.",
);
assert.equal(
hasPattern(actionSource, /function remainingActionBudgetMs\(/),
true,
"Action should calculate remaining runtime before long awaits.",
);
assert.equal(
hasPattern(actionSource, /async function withActionTimeout/),
true,
"Action should wrap long promises so JS catch runs before Convex kills the runtime.",
);
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
assert.equal(
hasPattern(processBody, /const actionStartedAt = Date\.now\(\)/),
true,
"processLeadEnrichment should track action start time.",
);
assert.equal(
hasPattern(processBody, /const actionBudget = actionBudgetMs\(\)/),
true,
"processLeadEnrichment should resolve the action budget once.",
);
const guardedPatterns = [
/withActionTimeout\([\s\S]*loadPlaywrightModules\(\)/,
/withActionTimeout\([\s\S]*resolveChromiumExecutablePath\(/,
/withActionTimeout\([\s\S]*prepareChromiumSharedLibraries\(/,
/withActionTimeout\([\s\S]*playwrightCore\.chromium\.launch\(/,
/withActionTimeout\([\s\S]*crawlPage\(\s*desktopContext,\s*rootUrl/,
/withActionTimeout\([\s\S]*captureHomepageScreenshot\(/,
];
for (const pattern of guardedPatterns) {
assert.equal(
hasPattern(processBody, pattern),
true,
`Expected long await to be guarded by withActionTimeout: ${pattern}`,
);
}
assert.equal(
hasPattern(processBody, /Math\.min\(\s*timeoutMs,\s*remainingActionBudgetMs\(/),
true,
"Per-page crawl timeout should be capped by remaining action budget.",
);
assert.equal(
hasPattern(
processBody,
/desktopContext\.request\.get\([\s\S]*timeout:\s*Math\.min\([\s\S]*remainingActionBudgetMs\(/,
),
true,
"Internal link checks should cap request timeouts by remaining action budget.",
);
});
test("processLeadEnrichment schedules PageSpeed audit jobs after successful enrichment", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
const persistIndex = processBody.indexOf(