From 03cb65fde4f5e52a669e097623192e1025a00838 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 5 Jun 2026 11:06:01 +0200 Subject: [PATCH] feat: add OpenRouter audit generation pipeline --- .env.example | 6 + README.md | 2 +- ...Create-the-OpenRouter-AI-audit-pipeline.md | 48 +- ...nt-against-Convex-action-runtime-aborts.md | 43 + ...ardening-and-catch-all-failure-handling.md | 50 + convex/_generated/api.d.ts | 4 + convex/auditGeneration.ts | 578 ++++++++ convex/auditGenerationAction.ts | 1197 +++++++++++++++++ convex/audits.ts | 84 +- convex/domain.ts | 16 + convex/outreach.ts | 55 +- convex/schema.ts | 55 + convex/websiteEnrichmentAction.ts | 171 ++- lib/ai/audit-evidence.ts | 565 ++++++++ lib/ai/german-copy-guard.ts | 482 +++++++ lib/ai/model-profiles.ts | 81 ++ lib/ai/openrouter-provider.ts | 35 + lib/ai/schemas.ts | 58 + package.json | 2 + pnpm-lock.yaml | 142 +- tests/ai-model-profiles.test.ts | 130 ++ tests/ai-schemas.test.ts | 137 ++ tests/audit-evidence.test.ts | 337 +++++ tests/audit-generation-action-source.test.ts | 335 +++++ ...udit-generation-persistence-source.test.ts | 323 +++++ tests/audit-generation-schema.test.ts | 204 +++ tests/german-copy-guard.test.ts | 270 ++++ tests/openrouter-provider.test.ts | 50 + tests/website-enrichment-action.test.ts | 76 +- 29 files changed, 5462 insertions(+), 74 deletions(-) create mode 100644 backlog/tasks/task-25 - Harden-website-enrichment-against-Convex-action-runtime-aborts.md create mode 100644 backlog/tasks/task-26 - Finalize-audit-generation-hardening-and-catch-all-failure-handling.md create mode 100644 convex/auditGeneration.ts create mode 100644 convex/auditGenerationAction.ts create mode 100644 lib/ai/audit-evidence.ts create mode 100644 lib/ai/german-copy-guard.ts create mode 100644 lib/ai/model-profiles.ts create mode 100644 lib/ai/openrouter-provider.ts create mode 100644 lib/ai/schemas.ts create mode 100644 tests/ai-model-profiles.test.ts create mode 100644 tests/ai-schemas.test.ts create mode 100644 tests/audit-evidence.test.ts create mode 100644 tests/audit-generation-action-source.test.ts create mode 100644 tests/audit-generation-persistence-source.test.ts create mode 100644 tests/audit-generation-schema.test.ts create mode 100644 tests/german-copy-guard.test.ts create mode 100644 tests/openrouter-provider.test.ts diff --git a/.env.example b/.env.example index 1d12a6e..306fbdf 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 89dbf00..5cdb2d1 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/backlog/tasks/task-11 - Create-the-OpenRouter-AI-audit-pipeline.md b/backlog/tasks/task-11 - Create-the-OpenRouter-AI-audit-pipeline.md index e6892b9..be124aa 100644 --- a/backlog/tasks/task-11 - Create-the-OpenRouter-AI-audit-pipeline.md +++ b/backlog/tasks/task-11 - Create-the-OpenRouter-AI-audit-pipeline.md @@ -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 -- [ ] #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 ## Implementation Plan -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. + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/backlog/tasks/task-25 - Harden-website-enrichment-against-Convex-action-runtime-aborts.md b/backlog/tasks/task-25 - Harden-website-enrichment-against-Convex-action-runtime-aborts.md new file mode 100644 index 0000000..5f3d70c --- /dev/null +++ b/backlog/tasks/task-25 - Harden-website-enrichment-against-Convex-action-runtime-aborts.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Implementation Plan + + +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 + + +## Implementation Notes + + +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. + diff --git a/backlog/tasks/task-26 - Finalize-audit-generation-hardening-and-catch-all-failure-handling.md b/backlog/tasks/task-26 - Finalize-audit-generation-hardening-and-catch-all-failure-handling.md new file mode 100644 index 0000000..649be7b --- /dev/null +++ b/backlog/tasks/task-26 - Finalize-audit-generation-hardening-and-catch-all-failure-handling.md @@ -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 + + +Implement P1/P2/P3 audit-generation code-quality fixes with regression-safe behavior. + + +## Acceptance Criteria + +- [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 + + +## Implementation Plan + + +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 + + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index e3b8583..841469e 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/auditGeneration.ts b/convex/auditGeneration.ts new file mode 100644 index 0000000..25d8184 --- /dev/null +++ b/convex/auditGeneration.ts @@ -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 => { + 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 | 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, + }, + }); + }, +}); diff --git a/convex/auditGenerationAction.ts b/convex/auditGenerationAction.ts new file mode 100644 index 0000000..e2934c0 --- /dev/null +++ b/convex/auditGenerationAction.ts @@ -0,0 +1,1197 @@ +"use node"; + +import { type DataContent, generateObject } from "ai"; +import { createOpenRouterProvider } from "../lib/ai/openrouter-provider"; +import { resolveModelProfile } from "../lib/ai/model-profiles"; +import { + auditSummarySchema, + callScriptSchema, + emailDraftSchema, + emailSubjectSchema, + followUpDraftSchema, + internalFindingsSchema, + publicAuditTextSchema, + qualityReviewSchema, +} from "../lib/ai/schemas"; +import { + validateCustomerFacingCopy, + type GermanCopyGuardResult, +} from "../lib/ai/german-copy-guard"; +import { buildAuditEvidenceInput } from "../lib/ai/audit-evidence"; +import { api, internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; +import { + internalAction, + type ActionCtx, +} from "./_generated/server"; +import { v } from "convex/values"; + +const MAX_PROMPT_BYTES = 12_000; +const MAX_RAW_RESPONSE_BYTES = 12_000; +const MAX_PARSED_JSON_BYTES = 12_000; +const TRUNCATION_MARKER = "\n\n[... abgeschnitten ...]"; + +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 trimmed = truncateToByteLimit(value, Math.max(0, maxBytes - markerBytes)); + return `${trimmed}${TRUNCATION_MARKER}`; +} + +function sanitizeAndCapString(value: string | undefined, maxBytes: number) { + if (!value) { + return undefined; + } + const safe = sanitizeSecretCandidates(value); + return byteLength(safe) > maxBytes ? truncateWithMarker(safe, maxBytes) : safe; +} + +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) { + let safe = value; + + for (const key of secretHints) { + const secret = process.env[key]; + if (!secret) { + continue; + } + safe = safe.replace(new RegExp(escapeRegExp(secret), "g"), "[REDACTED]"); + } + + return safe + .replace(/\b(?:api[_-]?key|token|secret|password)\s*[:=]\s*[^\s\"']+/gi, "[REDACTED]") + .trim(); +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function messageFromError(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function sanitizeAndCapParsedJson(parsedJson: unknown): string | undefined { + if (parsedJson === undefined) { + return undefined; + } + if (typeof parsedJson === "string") { + return sanitizeAndCapString(parsedJson, MAX_PARSED_JSON_BYTES); + } + const serialized = safeStringify(parsedJson); + const safeSerialized = sanitizeAndCapString(serialized, MAX_PARSED_JSON_BYTES); + return safeSerialized; +} + +function safeStringify(value: unknown) { + try { + return JSON.stringify(value); + } catch { + return "[unserializable payload]"; + } +} + +function toPersistedUsage(usage: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + cacheReadTokens?: number; +}) { + return { + promptTokens: usage.inputTokens, + completionTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + cacheReadTokens: usage.cacheReadTokens, + }; +} + +type AuditEvidence = Awaited< + ReturnType +>; + +type GermanCopyOutput = { + internalSummary: string; + publicSummary: string; + publicBody: string; + emailSubject: string; + emailBody: string; + phoneScript: { + openingLine: string; + callScript: string[]; + closeLine: string; + }; + followUpDraft: { + message: string; + followInDays?: number; + goals?: string[]; + }; +}; + +type MultimodalContentPart = + | { + type: "text"; + text: string; + } + | MultimodalFilePart; + +type MultimodalFilePart = { + type: "file"; + data: DataContent | URL; + mediaType: string; + filename?: string; +}; + +type MultimodalUserMessage = { + role: "user"; + content: MultimodalContentPart[]; +}; + +const evidenceRunOptions = { + maxCheckedPages: 8, + screenshotLimit: 2, +} as const; + +const terminalLeadContactStatuses = [ + "do_not_contact", + "contacted", + "replied", +] as const; + +function toAuditGenerationProfileMessage(stage: string, runId: Id<"agentRuns">) { + return { + level: "info" as const, + runId, + message: `Audit-KI-Stufe ${stage} gestartet.`, + details: [{ label: "Run-ID", value: String(runId) }], + }; +} + +function isTerminalLeadContactStatus(status?: string) { + return status + ? terminalLeadContactStatuses.includes( + status as (typeof terminalLeadContactStatuses)[number], + ) + : false; +} + +function buildClassificationPrompt(evidence: AuditEvidence) { + return [ + "Du bist Senior-Analyst und erzeugst intern verwertbare Befunde in Deutsch.", + `Unternehmenskontext: ${evidence.companyContext.join(" | ")}`, + `Prüfseiten: ${evidence.checkedPages.join(" ; ")}`, + `UX-Signale: ${evidence.observedUxSignals.join(" ; ")}`, + `Content-Signale: ${evidence.observedContentSignals.join(" ; ")}`, + `Technische Signale: ${evidence.observedTechnicalSignals.join(" ; ")}`, + `Seitenperformance: ${evidence.pageSpeedCustomerImplications.join(" ; ")}`, + "Antworte ausschließlich als JSON-Objekt mit den Schlüsseln:", + "'findings' als Liste und 'summary' als kurzer Gesamttext.", + ].join("\n"); +} + +function buildMultimodalPrompt(evidence: AuditEvidence, withScreenshots = false) { + return [ + "Du bist Senior-Digitalberater für lokale Unternehmen und analysierst visuelle und textuelle Hinweise.", + `Unternehmenskontext: ${evidence.companyContext.join(" | ")}`, + `Untersuchte Seiten: ${evidence.checkedPages.join(" ; ")}`, + `UX-Signale: ${evidence.observedUxSignals.join(" ; ")}`, + `Content-Signale: ${evidence.observedContentSignals.join(" ; ")}`, + `Technische Signale: ${evidence.observedTechnicalSignals.join(" ; ")}`, + `PageSpeed-Folgen: ${evidence.pageSpeedCustomerImplications.join(" ; ")}`, + withScreenshots + ? "Bewerte, wo sinnvoll, sichtbare Seitelemente aus den Screenshots." + : "Nutze die textuellen Befunde, um eine kurze visuelle Bewertung abzuleiten.", + "Antworte als kurzes, intern nutzbares JSON-Objekt mit 'summary' und 3-6 'keyFindings'.", + ].join("\n"); +} + +function buildGermanCopyPrompt( + internalFindings: string, + multimodalSummary: string, +) { + return [ + "Du bist Senior-Redakteur für lokale Kundengewinnung.", + "Erstelle kundenrelevante Texte in deutscher Sprache, im Ich-Ich Kontext,", + "mit Beobachtung und konkretem Vorschlag in jedem Stück.", + `Interne Befunde: ${internalFindings}`, + `Multimodale Zusammenfassung: ${multimodalSummary}`, + "Liefer bitte alle Felder als validiertes JSON gemäß Schema.", + ].join("\n"); +} + +function buildQualityReviewPrompt( + internalFindings: string, + germanCopy: GermanCopyOutput, +) { + return [ + "Du bist Qualitätssicherungs-Engine für Kundenkommunikation.", + "Prüfe Inhalte auf deutsche Sprache, Tonalität, Beobachtung/Suggestion und klare, faktennahe Inhalte.", + `Interne Befunde: ${internalFindings}`, + `Öffentliche Zusammenfassung: ${germanCopy.publicSummary}`, + `Öffentlicher Text: ${germanCopy.publicBody}`, + `Email-Betreff: ${germanCopy.emailSubject}`, + `Email-Text: ${germanCopy.emailBody}`, + "Antworte als JSON mit isValid, issues, suggestions, notes.", + ].join("\n"); +} + +function toSkillSummaries( + skills: Array<{ + name: string; + category: string; + version?: string; + source?: string; + }>, +) { + return skills.slice(0, 6).map((skill) => ({ + name: skill.name, + purpose: "Erkenntnisbasiertes Hilfsmodul für die Audit-Bearbeitung.", + summary: `${skill.name}${skill.version ? ` (${skill.version})` : ""} aus ${skill.category}.`, + })); +} + +async function appendRunEvent( + ctx: ActionCtx, + args: { + runId: Id<"agentRuns">; + level: "info" | "warning" | "error"; + message: string; + details?: { label: string; value: string; source?: string }[]; + }, +) { + await ctx.runMutation(api.runs.appendEvent, { + runId: args.runId, + level: args.level, + message: args.message, + details: args.details, + }); +} + +async function persistAuditStage({ + ctx, + runId, + leadId, + auditId, + stage, + modelProfile, + modelId, + prompt, + systemPrompt, + rawResponse, + parsedJson, + usage, + status, + finishReason, + errorSummary, +}: { + ctx: ActionCtx; + runId: Id<"agentRuns">; + leadId: Id<"leads">; + auditId?: Id<"audits">; + stage: "classification" | "multimodalAudit" | "germanCopy" | "qualityReview"; + modelProfile: string; + modelId: string; + prompt: string; + systemPrompt?: string; + rawResponse?: string; + parsedJson?: string; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + cacheReadTokens?: number; + }; + status: "pending" | "running" | "succeeded" | "failed" | "canceled"; + finishReason?: string; + errorSummary?: string; +}) { + await ctx.runMutation(internal.auditGeneration.persistAuditGenerationResult, { + leadId, + runId, + ...(auditId ? { auditId } : {}), + stage, + modelProfile, + modelId, + prompt, + systemPrompt, + rawResponse, + parsedJson, + usage: usage ? toPersistedUsage(usage) : undefined, + status, + finishReason, + errorSummary, + }); +} + +function getValidMediaType(mimeType: string) { + if (!mimeType) { + return "image/png"; + } + if (mimeType.startsWith("image/")) { + return mimeType; + } + return "image/png"; +} + +export const processAuditGeneration = internalAction({ + args: { + runId: v.id("agentRuns"), + }, + handler: async (ctx, args) => { + let started: + | { + lead: { + _id: Id<"leads">; + websiteUrl?: string; + websiteDomain?: string; + contactStatus?: string; + }; + auditId?: Id<"audits">; + } + | null = null; + + let auditId: Id<"audits"> | undefined; + let classificationSummary = ""; + let multimodalSummary = ""; + let germanCopyOutput: GermanCopyOutput = { + internalSummary: "", + publicSummary: "", + publicBody: "", + emailSubject: "", + emailBody: "", + phoneScript: { + openingLine: "", + callScript: [], + closeLine: "", + }, + followUpDraft: { + message: "", + }, + }; + let qualityPassed = false; + let errors = 0; + let currentStep: "audit_generation" | "classification" | "multimodalAudit" | "germanCopy" | "qualityReview" = + "audit_generation"; + + try { + started = await ctx.runMutation(internal.auditGeneration.startAuditGenerationRun, { + runId: args.runId, + }); + } catch (error) { + await appendRunEvent(ctx, { + runId: args.runId, + level: "error", + message: "Audit-Generierung konnte nicht gestartet werden.", + details: [{ label: "Fehler", value: messageFromError(error) }], + }); + await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { + runId: args.runId, + status: "failed", + errors: 1, + errorSummary: "Start der Audit-Generierung fehlgeschlagen.", + currentStep: "audit_generation", + }); + return null; + } + + if (!started) { + return null; + } + + try { + const evidence = await ctx.runQuery(internal.auditGeneration.getAuditGenerationEvidence, { + runId: args.runId, + }); + + if (!evidence) { + await appendRunEvent(ctx, { + runId: args.runId, + level: "error", + message: "Audit-Generierung kann keine Datenbasis laden.", + }); + await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { + runId: args.runId, + status: "failed", + errors: 1, + currentStep, + errorSummary: "Evidence-Daten für den Lead konnten nicht geladen werden.", + }); + return null; + } + + if (started.auditId) { + auditId = started.auditId; + } + + const evidenceInput = buildAuditEvidenceInput({ + lead: evidence.lead, + crawlPages: evidence.crawlPages, + technicalChecks: evidence.technicalChecks, + screenshots: evidence.screenshots.slice(0, evidenceRunOptions.screenshotLimit), + pageSpeedInputs: evidence.pageSpeedInputs, + skillRegistry: [], + }); + + await appendRunEvent(ctx, toAuditGenerationProfileMessage("Start", args.runId)); + + const provider = createOpenRouterProvider(); + const classificationProfile = resolveModelProfile("classification"); + const multimodalProfile = resolveModelProfile("multimodalAudit"); + const germanCopyProfile = resolveModelProfile("germanCopy"); + const qualityReviewProfile = resolveModelProfile("qualityReview"); + + // Stage 1: classification + const classificationPrompt = buildClassificationPrompt(evidenceInput); + const classificationSystemPrompt = + "Du bist interner KI-Berater für Website-Audits. Gib nur strukturierte JSON-Ausgaben zurück."; + const safeClassificationPrompt = sanitizeAndCapString( + classificationPrompt, + MAX_PROMPT_BYTES, + ); + currentStep = "classification"; + await persistAuditStage({ + ctx, + runId: args.runId, + leadId: started.lead._id, + auditId, + stage: "classification", + modelProfile: "classification", + modelId: classificationProfile.modelId, + prompt: safeClassificationPrompt ?? "", + systemPrompt: classificationSystemPrompt, + status: "running", + parsedJson: undefined, + rawResponse: undefined, + }); + + try { + const classificationResult = await generateObject({ + model: provider(classificationProfile.modelId), + system: classificationSystemPrompt, + schema: internalFindingsSchema, + prompt: safeClassificationPrompt ?? "", + temperature: classificationProfile.temperature, + maxOutputTokens: classificationProfile.maxTokens, + }); + + classificationSummary = + typeof classificationResult.object.summary === "string" + ? classificationResult.object.summary + : ""; + const rawClassification = safeStringify(classificationResult.object); + await persistAuditStage({ + ctx, + runId: args.runId, + leadId: started.lead._id, + auditId, + stage: "classification", + modelProfile: "classification", + modelId: classificationProfile.modelId, + prompt: safeClassificationPrompt ?? "", + systemPrompt: classificationSystemPrompt, + rawResponse: sanitizeAndCapString( + rawClassification, + MAX_RAW_RESPONSE_BYTES, + ), + parsedJson: sanitizeAndCapParsedJson(classificationResult.object), + usage: { + inputTokens: classificationResult.usage.inputTokens, + outputTokens: classificationResult.usage.outputTokens, + totalTokens: classificationResult.usage.totalTokens, + cacheReadTokens: + classificationResult.usage.inputTokenDetails?.cacheReadTokens, + }, + status: "succeeded", + finishReason: classificationResult.finishReason, + }); + await appendRunEvent(ctx, { + runId: args.runId, + level: "info", + message: "Interne Klassifikation abgeschlossen.", + details: [ + { label: "Befunde", value: String(classificationResult.object.findings.length) }, + ], + }); + } catch (error) { + errors += 1; + const errorSummary = messageFromError(error); + await persistAuditStage({ + ctx, + runId: args.runId, + leadId: started.lead._id, + auditId, + stage: "classification", + modelProfile: "classification", + modelId: classificationProfile.modelId, + prompt: safeClassificationPrompt ?? "", + systemPrompt: classificationSystemPrompt, + status: "failed", + errorSummary, + }); + await appendRunEvent(ctx, { + runId: args.runId, + level: "error", + message: "Interne Klassifikation fehlgeschlagen.", + details: [{ label: "Fehler", value: errorSummary }], + }); + await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { + runId: args.runId, + status: "failed", + errors, + errorSummary: "Interne Klassifikation konnte nicht erstellt werden.", + currentStep: "classification", + }); + return null; + } + + // Stage 2: multimodal audit summary + const multimodalSystemPrompt = + "Du bist Prüfanalyst für Conversion-Optimierung mit Fokus auf lokale Unternehmen."; + const multimodalPrompt = buildMultimodalPrompt(evidenceInput, true); + const safeMultimodalPrompt = sanitizeAndCapString( + multimodalPrompt, + MAX_PROMPT_BYTES, + ); + const screenshotSources = multimodalProfile.supportsImages + ? evidence.screenshots.slice(0, evidenceRunOptions.screenshotLimit) + : []; + const screenshotParts = screenshotSources.length > 0 + ? await Promise.all( + screenshotSources.map( + async (screenshot): Promise => { + try { + const storageId = screenshot.storageId as Id<"_storage">; + const maybeBlob = await ctx.storage.get(storageId); + if (maybeBlob) { + const fileData = await maybeBlob.arrayBuffer(); + return { + type: "file" as const, + data: fileData, + mediaType: getValidMediaType(screenshot.mimeType), + }; + } + + const storageUrl = await ctx.storage.getUrl(storageId); + if (storageUrl) { + return { + type: "file" as const, + data: storageUrl, + mediaType: getValidMediaType(screenshot.mimeType), + }; + } + } catch { + return null; + } + return null; + }, + ), + ) + : []; + if (!multimodalProfile.supportsImages && evidence.screenshots.length > 0) { + await appendRunEvent(ctx, { + runId: args.runId, + level: "info", + message: + "Multimodales Modell unterstützt keine Bilder; Analyse läuft textbasiert.", + }); + } + + currentStep = "multimodalAudit"; + + const validScreenshotParts = screenshotParts.filter( + (part): part is MultimodalFilePart => part !== null, + ); + + if (validScreenshotParts.length === 0) { + await appendRunEvent(ctx, { + runId: args.runId, + level: "warning", + message: "Keine multimodalen Belege verfügbar; Analyse läuft textbasiert.", + details: [ + { + label: "Hinweis", + value: "Screenshots konnten nicht geladen werden.", + }, + ], + }); + } + + try { + await persistAuditStage({ + ctx, + runId: args.runId, + leadId: started.lead._id, + auditId, + stage: "multimodalAudit", + modelProfile: "multimodalAudit", + modelId: multimodalProfile.modelId, + prompt: safeMultimodalPrompt ?? "", + systemPrompt: multimodalSystemPrompt, + status: "running", + }); + + let multimodalResult: + | { + object: { summary?: string; keyFindings?: string[] }; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + inputTokenDetails?: { cacheReadTokens?: number }; + }; + finishReason?: string; + } = { + object: { summary: "" }, + usage: undefined, + }; + + if (validScreenshotParts.length > 0) { + const multimodalUserMessage: MultimodalUserMessage = { + role: "user", + content: [ + { type: "text", text: safeMultimodalPrompt ?? "" }, + ...validScreenshotParts, + ], + }; + multimodalResult = await generateObject({ + model: provider(multimodalProfile.modelId), + system: multimodalSystemPrompt, + schema: auditSummarySchema, + temperature: multimodalProfile.temperature, + maxOutputTokens: multimodalProfile.maxTokens, + messages: [multimodalUserMessage], + }); + } else { + const multimodalTextMessage: MultimodalUserMessage = { + role: "user", + content: [{ type: "text", text: safeMultimodalPrompt ?? "" }], + }; + multimodalResult = await generateObject({ + model: provider(multimodalProfile.modelId), + system: multimodalSystemPrompt, + schema: auditSummarySchema, + temperature: multimodalProfile.temperature, + maxOutputTokens: multimodalProfile.maxTokens, + messages: [multimodalTextMessage], + }); + } + + if (!multimodalResult?.object) { + throw new Error( + "Multimodale Audit-Analyse konnte nicht ausgeführt werden.", + ); + } + + multimodalSummary = + typeof multimodalResult.object.summary === "string" + ? multimodalResult.object.summary + : ""; + const multimodalRaw = safeStringify(multimodalResult.object); + await persistAuditStage({ + ctx, + runId: args.runId, + leadId: started.lead._id, + auditId, + stage: "multimodalAudit", + modelProfile: "multimodalAudit", + modelId: multimodalProfile.modelId, + prompt: safeMultimodalPrompt ?? "", + systemPrompt: multimodalSystemPrompt, + rawResponse: sanitizeAndCapString( + multimodalRaw, + MAX_RAW_RESPONSE_BYTES, + ), + parsedJson: sanitizeAndCapParsedJson(multimodalResult.object), + usage: { + inputTokens: multimodalResult.usage?.inputTokens, + outputTokens: multimodalResult.usage?.outputTokens, + totalTokens: multimodalResult.usage?.totalTokens, + cacheReadTokens: + multimodalResult.usage?.inputTokenDetails?.cacheReadTokens, + }, + status: "succeeded", + finishReason: multimodalResult.finishReason, + }); + await appendRunEvent(ctx, { + runId: args.runId, + level: "info", + message: "Multimodale Audit-Analyse abgeschlossen.", + }); + } catch (error) { + errors += 1; + const errorSummary = messageFromError(error); + await persistAuditStage({ + ctx, + runId: args.runId, + leadId: started.lead._id, + auditId, + stage: "multimodalAudit", + modelProfile: "multimodalAudit", + modelId: multimodalProfile.modelId, + prompt: safeMultimodalPrompt ?? "", + systemPrompt: multimodalSystemPrompt, + status: "failed", + errorSummary, + }); + await appendRunEvent(ctx, { + runId: args.runId, + level: "error", + message: "Multimodale Audit-Analyse fehlgeschlagen.", + details: [{ label: "Fehler", value: errorSummary }], + }); + await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { + runId: args.runId, + status: "failed", + errors, + errorSummary: + "Multimodale Audit-Analyse konnte nicht abgeschlossen werden.", + currentStep: "multimodalAudit", + }); + return null; + } + + currentStep = "germanCopy"; + // Stage 3: german copy generation + const germanSystemPrompt = + "Du bist fachlicher Texter für lokale Unternehmen im B2B-Kontext."; + const germanPrompt = buildGermanCopyPrompt( + classificationSummary, + multimodalSummary, + ); + const safeGermanPrompt = sanitizeAndCapString(germanPrompt, MAX_PROMPT_BYTES); + + try { + const publicSummaryResult = await generateObject({ + model: provider(germanCopyProfile.modelId), + system: germanSystemPrompt, + schema: publicAuditTextSchema, + prompt: safeGermanPrompt + ? `${safeGermanPrompt}\nAusgabe für publicSummary` + : "Ausgabe für publicSummary", + temperature: germanCopyProfile.temperature, + maxOutputTokens: germanCopyProfile.maxTokens, + }); + + const germanBodyResult = await generateObject({ + model: provider(germanCopyProfile.modelId), + system: germanSystemPrompt, + schema: publicAuditTextSchema, + prompt: `${safeGermanPrompt ?? ""}\nAusgabe für publicBody`, + temperature: germanCopyProfile.temperature, + maxOutputTokens: germanCopyProfile.maxTokens, + }); + + const germanSubjectResult = await generateObject({ + model: provider(germanCopyProfile.modelId), + system: germanSystemPrompt, + schema: emailSubjectSchema, + prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailSubject`, + temperature: germanCopyProfile.temperature, + maxOutputTokens: germanCopyProfile.maxTokens, + }); + + const germanEmailResult = await generateObject({ + model: provider(germanCopyProfile.modelId), + system: germanSystemPrompt, + schema: emailDraftSchema, + prompt: `${safeGermanPrompt ?? ""}\nAusgabe für emailBody`, + temperature: germanCopyProfile.temperature, + maxOutputTokens: germanCopyProfile.maxTokens, + }); + + const germanCallScriptResult = await generateObject({ + model: provider(germanCopyProfile.modelId), + system: germanSystemPrompt, + schema: callScriptSchema, + prompt: `${safeGermanPrompt ?? ""}\nAusgabe für callScript`, + temperature: germanCopyProfile.temperature, + maxOutputTokens: germanCopyProfile.maxTokens, + }); + + const germanFollowUpResult = await generateObject({ + model: provider(germanCopyProfile.modelId), + system: germanSystemPrompt, + schema: followUpDraftSchema, + prompt: `${safeGermanPrompt ?? ""}\nAusgabe für followUpDraft`, + temperature: germanCopyProfile.temperature, + maxOutputTokens: germanCopyProfile.maxTokens, + }); + + const publicSummary = publicSummaryResult.object.publicText ?? ""; + const publicBody = germanBodyResult.object.publicText ?? ""; + + germanCopyOutput = { + internalSummary: classificationSummary, + publicSummary, + publicBody, + emailSubject: germanSubjectResult.object.subject ?? "", + emailBody: germanEmailResult.object.body ?? "", + phoneScript: { + openingLine: germanCallScriptResult.object.openingLine ?? "", + callScript: germanCallScriptResult.object.callScript ?? [], + closeLine: germanCallScriptResult.object.closeLine ?? "", + }, + followUpDraft: { + message: germanFollowUpResult.object.message ?? "", + followInDays: germanFollowUpResult.object.followInDays, + goals: germanFollowUpResult.object.goals ?? [], + }, + }; + + const germanRaw = safeStringify(germanCopyOutput); + await persistAuditStage({ + ctx, + runId: args.runId, + leadId: started.lead._id, + auditId, + stage: "germanCopy", + modelProfile: "germanCopy", + modelId: germanCopyProfile.modelId, + prompt: safeGermanPrompt ?? "", + systemPrompt: germanSystemPrompt, + rawResponse: sanitizeAndCapString(germanRaw, MAX_RAW_RESPONSE_BYTES), + parsedJson: sanitizeAndCapParsedJson(germanCopyOutput), + usage: { + inputTokens: germanEmailResult.usage.inputTokens, + outputTokens: germanEmailResult.usage.outputTokens, + totalTokens: germanEmailResult.usage.totalTokens, + cacheReadTokens: + germanEmailResult.usage.inputTokenDetails?.cacheReadTokens, + }, + status: "succeeded", + finishReason: germanEmailResult.finishReason, + }); + + await appendRunEvent(ctx, { + runId: args.runId, + level: "info", + message: "Deutsche Kundenkommunikation generiert.", + }); + } catch (error) { + errors += 1; + const errorSummary = messageFromError(error); + await persistAuditStage({ + ctx, + runId: args.runId, + leadId: started.lead._id, + auditId, + stage: "germanCopy", + modelProfile: "germanCopy", + modelId: germanCopyProfile.modelId, + prompt: safeGermanPrompt ?? "", + systemPrompt: germanSystemPrompt, + status: "failed", + errorSummary, + }); + await appendRunEvent(ctx, { + runId: args.runId, + level: "error", + message: "Deutsche Texte konnten nicht generiert werden.", + details: [{ label: "Fehler", value: errorSummary }], + }); + await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { + runId: args.runId, + status: "failed", + errors, + currentStep: "germanCopy", + errorSummary: "Deutsche Texte konnten nicht generiert werden.", + }); + return null; + } + + const guardResult: GermanCopyGuardResult = validateCustomerFacingCopy({ + auditSummary: germanCopyOutput.publicSummary, + auditBody: germanCopyOutput.publicBody, + emailSubject: germanCopyOutput.emailSubject, + emailBody: germanCopyOutput.emailBody, + callScript: { + openingLine: germanCopyOutput.phoneScript.openingLine, + callScript: germanCopyOutput.phoneScript.callScript, + closeLine: germanCopyOutput.phoneScript.closeLine, + }, + followUp: germanCopyOutput.followUpDraft.message, + }); + + // Stage 4: final quality review + const qualityPrompt = buildQualityReviewPrompt( + classificationSummary, + germanCopyOutput, + ); + const safeQualityPrompt = sanitizeAndCapString(qualityPrompt, MAX_PROMPT_BYTES); + const qualitySystemPrompt = + "Du prüfst die erzeugten Inhalte als Qualitätssicherung."; + + currentStep = "qualityReview"; + try { + const qualityResult = await generateObject({ + model: provider(qualityReviewProfile.modelId), + system: qualitySystemPrompt, + schema: qualityReviewSchema, + prompt: safeQualityPrompt ?? "", + temperature: qualityReviewProfile.temperature, + maxOutputTokens: qualityReviewProfile.maxTokens, + }); + + qualityPassed = qualityResult.object.isValid && guardResult.passed; + + const qualityPayload = { + isValid: qualityPassed, + issues: [ + ...qualityResult.object.issues, + ...guardResult.issues.map((issue) => issue.message), + ], + suggestions: qualityResult.object.suggestions, + notes: qualityResult.object.notes, + }; + + await persistAuditStage({ + ctx, + runId: args.runId, + leadId: started.lead._id, + auditId, + stage: "qualityReview", + modelProfile: "qualityReview", + modelId: qualityReviewProfile.modelId, + prompt: safeQualityPrompt ?? "", + systemPrompt: qualitySystemPrompt, + rawResponse: sanitizeAndCapString( + safeStringify(qualityPayload), + MAX_RAW_RESPONSE_BYTES, + ), + parsedJson: sanitizeAndCapParsedJson(qualityPayload), + usage: { + inputTokens: qualityResult.usage.inputTokens, + outputTokens: qualityResult.usage.outputTokens, + totalTokens: qualityResult.usage.totalTokens, + cacheReadTokens: + qualityResult.usage.inputTokenDetails?.cacheReadTokens, + }, + status: qualityPassed ? "succeeded" : "failed", + finishReason: qualityResult.finishReason, + errorSummary: qualityPassed + ? undefined + : "Qualitätsprüfung hat Inhalte als ungenügend markiert.", + }); + + if (!qualityPassed) { + const message = + "Qualitätsprüfung und German-Copy-Guard haben nicht bestanden."; + await appendRunEvent(ctx, { + runId: args.runId, + level: "warning", + message, + }); + await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { + runId: args.runId, + status: "failed", + currentStep: "qualityReview", + errors: errors + 1, + errorSummary: message, + }); + return null; + } + + await appendRunEvent(ctx, { + runId: args.runId, + level: "info", + message: "Qualitätsprüfung bestanden.", + }); + } catch (error) { + const errorSummary = messageFromError(error); + errors += 1; + await persistAuditStage({ + ctx, + runId: args.runId, + leadId: started.lead._id, + auditId, + stage: "qualityReview", + modelProfile: "qualityReview", + modelId: qualityReviewProfile.modelId, + prompt: safeQualityPrompt ?? "", + systemPrompt: qualitySystemPrompt, + status: "failed", + errorSummary, + }); + await appendRunEvent(ctx, { + runId: args.runId, + level: "error", + message: "Qualitätsprüfung fehlgeschlagen.", + details: [{ label: "Fehler", value: errorSummary }], + }); + await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { + runId: args.runId, + status: "failed", + errors, + errorSummary, + currentStep: "qualityReview", + }); + return null; + } + + const checkedDomain = + started.lead.websiteDomain ?? + evidence.lead.websiteDomain ?? + "unbekannte-domain"; + const checkedPages = evidenceInput.checkedPages.slice( + 0, + evidenceRunOptions.maxCheckedPages, + ); + + const persistedAuditId = await ctx.runMutation( + internal.audits.upsertFromAuditGeneration, + { + leadId: started.lead._id, + runId: args.runId, + ...(auditId ? { auditId } : {}), + checkedDomain, + checkedPages, + internalSummary: classificationSummary, + multimodalSummary, + publicSummary: germanCopyOutput.publicSummary, + publicBody: germanCopyOutput.publicBody, + usedSkills: evidenceInput.selectedSkills.slice(0, 6).map((skill) => ({ + name: skill.name, + category: skill.category, + version: skill.version, + source: skill.source, + })), + skillSummaries: toSkillSummaries(evidenceInput.selectedSkills), + }, + ); + + if (persistedAuditId) { + auditId = persistedAuditId; + } + + await ctx.runMutation(internal.outreach.upsertFromAuditGeneration, { + leadId: started.lead._id, + ...(auditId ? { auditId } : {}), + strategy: "email_first", + phoneScript: [ + germanCopyOutput.phoneScript.openingLine, + ...germanCopyOutput.phoneScript.callScript, + germanCopyOutput.phoneScript.closeLine, + ] + .filter(Boolean) + .join(" "), + emailSubject: germanCopyOutput.emailSubject, + emailBody: germanCopyOutput.emailBody, + followUpDraft: `${germanCopyOutput.followUpDraft.message}\n${( + germanCopyOutput.followUpDraft.goals ?? [] + ) + .slice(0, 4) + .join(" | ")}`.trim(), + }); + + const lead = await ctx.runQuery(api.leads.get, { + id: started.lead._id, + }); + const leadContactStatus = + lead?.contactStatus ?? started.lead.contactStatus; + + if (isTerminalLeadContactStatus(leadContactStatus)) { + await appendRunEvent(ctx, { + runId: args.runId, + level: "warning", + message: "Lead-Status wurde nicht auf outreach_ready gesetzt.", + details: [ + { + label: "Grund", + value: + "Lead ist bereits als terminal kontaktiert oder blockiert markiert.", + }, + ], + }); + } else { + await ctx.runMutation(api.leads.reviewUpdate, { + id: started.lead._id, + contactStatus: "outreach_ready", + }); + } + + await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { + runId: args.runId, + status: "succeeded", + currentStep: "qualityReview", + errors, + errorSummary: qualityPassed + ? undefined + : "Qualitätsprüfung nicht bestanden.", + }); + + await appendRunEvent(ctx, { + runId: args.runId, + level: "info", + message: qualityPassed + ? "Audit-Generierung erfolgreich abgeschlossen." + : "Audit-Generierung abgeschlossen mit Qualitätsmängeln.", + details: [ + { label: "Ausgabe", value: "Audit und Outreach gespeichert." }, + ...(qualityPassed ? [{ label: "Status", value: "succeeded" }] : []), + ], + }); + + return args.runId; + } catch (error) { + errors += 1; + const errorSummary = messageFromError(error); + await appendRunEvent(ctx, { + runId: args.runId, + level: "error", + message: "Audit-Generierung wurde unerwartet beendet.", + details: [{ label: "Fehler", value: errorSummary }], + }); + await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, { + runId: args.runId, + status: "failed", + errors, + currentStep, + errorSummary, + }); + + return null; + } + }, +}); diff --git a/convex/audits.ts b/convex/audits.ts index 0558bb7..bbdc617 100644 --- a/convex/audits.ts +++ b/convex/audits.ts @@ -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) => { diff --git a/convex/domain.ts b/convex/domain.ts index 7c97add..f5b835b 100644 --- a/convex/domain.ts +++ b/convex/domain.ts @@ -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]; diff --git a/convex/outreach.ts b/convex/outreach.ts index 365fa4e..bbebc4f 100644 --- a/convex/outreach.ts +++ b/convex/outreach.ts @@ -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")), diff --git a/convex/schema.ts b/convex/schema.ts index 7e08b52..0e0b36d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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")), diff --git a/convex/websiteEnrichmentAction.ts b/convex/websiteEnrichmentAction.ts index 121a3eb..08b5acb 100644 --- a/convex/websiteEnrichmentAction.ts +++ b/convex/websiteEnrichmentAction.ts @@ -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( + promise: Promise, + timeoutMs: number, + label: string, +): Promise { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, 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({ diff --git a/lib/ai/audit-evidence.ts b/lib/ai/audit-evidence.ts new file mode 100644 index 0000000..02ae34c --- /dev/null +++ b/lib/ai/audit-evidence.ts @@ -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, + }; +} diff --git a/lib/ai/german-copy-guard.ts b/lib/ai/german-copy-guard.ts new file mode 100644 index 0000000..5c6adf3 --- /dev/null +++ b/lib/ai/german-copy-guard.ts @@ -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 }; +} diff --git a/lib/ai/model-profiles.ts b/lib/ai/model-profiles.ts new file mode 100644 index 0000000..7eec7dc --- /dev/null +++ b/lib/ai/model-profiles.ts @@ -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 = { + 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> = 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> = process.env, +): string { + const profile = MODEL_PROFILES[profileKey]; + const override = normalizeModelOverride(env[profile.envOverrideKey]); + + return override ?? profile.modelId; +} diff --git a/lib/ai/openrouter-provider.ts b/lib/ai/openrouter-provider.ts new file mode 100644 index 0000000..605a7e7 --- /dev/null +++ b/lib/ai/openrouter-provider.ts @@ -0,0 +1,35 @@ +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; + +type OpenRouterEnv = Readonly>; + +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 { + 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", + }, + }); +} diff --git a/lib/ai/schemas.ts b/lib/ai/schemas.ts new file mode 100644 index 0000000..943bcbb --- /dev/null +++ b/lib/ai/schemas.ts @@ -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; +export type InternalFindings = z.infer; +export type AuditSummary = z.infer; +export type PublicAuditText = z.infer; +export type EmailDraft = z.infer; +export type EmailSubject = z.infer; +export type CallScript = z.infer; +export type FollowUpDraft = z.infer; +export type QualityReview = z.infer; diff --git a/package.json b/package.json index afeeb13..b8dd059 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a0ab4e..f77b249 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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' diff --git a/tests/ai-model-profiles.test.ts b/tests/ai-model-profiles.test.ts new file mode 100644 index 0000000..8ed656b --- /dev/null +++ b/tests/ai-model-profiles.test.ts @@ -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"); +}); diff --git a/tests/ai-schemas.test.ts b/tests/ai-schemas.test.ts new file mode 100644 index 0000000..4c3a727 --- /dev/null +++ b/tests/ai-schemas.test.ts @@ -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); +}); diff --git a/tests/audit-evidence.test.ts b/tests/audit-evidence.test.ts new file mode 100644 index 0000000..c04e5b5 --- /dev/null +++ b/tests/audit-evidence.test.ts @@ -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 Muster", + niche: "Bäckerei & Kaffeehaus", + websiteUrl: "https://example.com/kontakt?ref=ad", + address: "Musterstraße 1, 10115 Berlin", + city: "Berlin", + contactPerson: "Anna 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 & { + 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); + } +}); diff --git a/tests/audit-generation-action-source.test.ts b/tests/audit-generation-action-source.test.ts new file mode 100644 index 0000000..90b1438 --- /dev/null +++ b/tests/audit-generation-action-source.test.ts @@ -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", + ); +}); diff --git a/tests/audit-generation-persistence-source.test.ts b/tests/audit-generation-persistence-source.test.ts new file mode 100644 index 0000000..b3f4204 --- /dev/null +++ b/tests/audit-generation-persistence-source.test.ts @@ -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(); + + 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.", + ); +}); diff --git a/tests/audit-generation-schema.test.ts b/tests/audit-generation-schema.test.ts new file mode 100644 index 0000000..74d39a1 --- /dev/null +++ b/tests/audit-generation-schema.test.ts @@ -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.", + ); +}); diff --git a/tests/german-copy-guard.test.ts b/tests/german-copy-guard.test.ts new file mode 100644 index 0000000..361bf3f --- /dev/null +++ b/tests/german-copy-guard.test.ts @@ -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, + ); +}); diff --git a/tests/openrouter-provider.test.ts b/tests/openrouter-provider.test.ts new file mode 100644 index 0000000..8dd3bb8 --- /dev/null +++ b/tests/openrouter-provider.test.ts @@ -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); +}); diff --git a/tests/website-enrichment-action.test.ts b/tests/website-enrichment-action.test.ts index db991b9..b251e75 100644 --- a/tests/website-enrichment-action.test.ts +++ b/tests/website-enrichment-action.test.ts @@ -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(