feat: add OpenRouter audit generation pipeline

This commit is contained in:
2026-06-05 11:06:01 +02:00
parent 370aeec2a0
commit 03cb65fde4
29 changed files with 5462 additions and 74 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

@@ -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

@@ -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"),
@@ -17,6 +17,13 @@ const usedSkillsValidator = v.array(
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: {
@@ -71,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)),
);
@@ -323,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({
headless: true,
executablePath,
args: serverlessChromium.args,
});
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({
...devices["Desktop Chrome"],
});
mobileContext = await browser.newContext({
...devices["iPhone 11"],
});
desktopContext = await withActionTimeout(
browser.newContext({
...devices["Desktop Chrome"],
}),
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(
ctx,
desktopContext,
homepage.finalUrl,
"desktop",
timeoutMs,
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(
ctx,
mobileContext,
homepage.finalUrl,
"mobile",
timeoutMs,
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>;

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'

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,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

@@ -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(