Compare commits
2 Commits
f0a948aec9
...
03cb65fde4
| Author | SHA1 | Date | |
|---|---|---|---|
| 03cb65fde4 | |||
| 370aeec2a0 |
@@ -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=
|
||||
|
||||
@@ -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`
|
||||
|
||||
17
app/dashboard/audits/[id]/page.tsx
Normal file
17
app/dashboard/audits/[id]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AuditDetail } from "@/components/audits/audit-detail";
|
||||
|
||||
export default async function AuditDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<main className="px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<AuditDetail id={id as unknown as string} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
|
||||
import { AuditsBoard } from "@/components/audits/audits-board";
|
||||
|
||||
export default function AuditsPage() {
|
||||
return (
|
||||
<DashboardPlaceholderPage
|
||||
description="Audit-Review, Screenshots und oeffentliche Freigaben folgen in TASK-12 und TASK-13."
|
||||
title="Audits"
|
||||
/>
|
||||
<main className="px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
||||
<AuditsBoard />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
id: TASK-10
|
||||
title: Build the local skills registry
|
||||
status: To Do
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-03 19:13'
|
||||
updated_date: '2026-06-05 07:28'
|
||||
labels:
|
||||
- mvp
|
||||
- agent
|
||||
@@ -24,19 +25,34 @@ Create the local skills registry concept for the agent. Design and marketing ski
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 A project-local skills directory or convention exists for imported design and marketing skills
|
||||
- [ ] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category
|
||||
- [ ] #3 Agent code can load and parse the skills registry into structured skill metadata
|
||||
- [ ] #4 Audit records store the list of used skills, including skill name/category and version or source where available
|
||||
- [ ] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not
|
||||
- [x] #1 A project-local skills directory or convention exists for imported design and marketing skills
|
||||
- [x] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category
|
||||
- [x] #3 Agent code can load and parse the skills registry into structured skill metadata
|
||||
- [x] #4 Audit records store the list of used skills, including skill name/category and version or source where available
|
||||
- [x] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Define project-local skill storage conventions.
|
||||
2. Create the initial skills.md registry format and seed entries for design, UX, marketing, copy, SEO, and offer-writing skills.
|
||||
3. Add parser/loader for registry metadata.
|
||||
4. Store selected skill metadata with each audit.
|
||||
5. Show used skills in the internal audit detail UI only.
|
||||
1. Worker A uses TDD to add project-local skills conventions, seed skills.md, skills source files, and a strict skills registry parser/loader.
|
||||
2. Worker B uses TDD to extend Convex audit persistence so audit records can store used skill metadata with name, category, version, and source.
|
||||
3. Worker C uses TDD to add the internal dashboard audit detail/list UI and compact Verwendete Skills overview while keeping public audit pages free of skill metadata.
|
||||
4. Orchestrator reviews subagent outputs, resolves integration issues through focused subagents, runs full verification, and checks TASK-10 acceptance criteria without marking Done until user confirmation.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Started implementation using subagent-driven and test-driven workflow with parallel agents where write scopes are independent. Orchestrator will not hand-code feature changes; workers own implementation patches and tests.
|
||||
|
||||
Worker C: implemented audits dashboard internals for TASK-10. Added new tests (tests/audit-skills-ui.test.ts), new components/audits/{audits-board,audit-detail}.tsx and routes app/dashboard/audits/page.tsx + app/dashboard/audits/[id]/page.tsx. Internal detail route still passes raw id from params Promise; public audit page unchanged and remains skill-free.
|
||||
|
||||
Implementation completed through parallel subagent-driven TDD slices. Worker scopes: registry/parser, Convex audit persistence, dashboard audit UI. Review findings addressed by follow-up workers for getDetail result shape/useQuery FunctionReference and indented skills.md field parsing. Fresh orchestrator verification: pnpm test passed with 179/179 tests; pnpm lint passed with 0 errors and 2 existing generated BetterAuth warnings; pnpm exec convex codegen --dry-run --typecheck enable passed after network escalation; pnpm build passed after network escalation. Sandbox-only failures before escalation were DNS/Sentry for Convex and Google Fonts for Next build.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Shipped the local skills registry with project-local skills.md and skills/ source files, parser/loader tests, Convex audit usedSkills persistence, and internal dashboard audit skill overview. Verified with pnpm test; task remains public-audit safe because used skills are only shown in the dashboard detail route.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
id: TASK-11
|
||||
title: Create the OpenRouter AI audit pipeline
|
||||
status: To Do
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-03 19:13'
|
||||
updated_date: '2026-06-05 09:04'
|
||||
labels:
|
||||
- mvp
|
||||
- agent
|
||||
@@ -26,19 +27,44 @@ Implement the LLM-powered audit generation pipeline using Vercel AI SDK and Open
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Vercel AI SDK is configured with OpenRouter and environment/Convex secrets
|
||||
- [ ] #2 Model profiles exist for classification, multimodal audit analysis, German text generation, and final quality review
|
||||
- [ ] #3 Structured audit outputs use Zod schemas and are stored in Convex with raw prompts/responses and model metadata
|
||||
- [ ] #4 Screenshots can be passed to multimodal-capable models where supported
|
||||
- [ ] #5 Generated customer-facing text follows Ich-Form, German language, no scores, no prices, no generic KI-Slop, and factual observation plus suggestion style
|
||||
- [x] #1 Vercel AI SDK is configured with OpenRouter and environment/Convex secrets
|
||||
- [x] #2 Model profiles exist for classification, multimodal audit analysis, German text generation, and final quality review
|
||||
- [x] #3 Structured audit outputs use Zod schemas and are stored in Convex with raw prompts/responses and model metadata
|
||||
- [x] #4 Screenshots can be passed to multimodal-capable models where supported
|
||||
- [x] #5 Generated customer-facing text follows Ich-Form, German language, no scores, no prices, no generic KI-Slop, and factual observation plus suggestion style
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add OpenRouter provider setup through Vercel AI SDK.
|
||||
2. Define Zod schemas for internal findings, audit summary, email draft, subject, call script, follow-up, and quality review.
|
||||
3. Build model-profile configuration for fast classification, multimodal analysis, and German copy generation.
|
||||
4. Combine lead, crawl, screenshot, PageSpeed, and selected skills into prompt inputs.
|
||||
5. Persist all prompts, model responses, normalized findings, final texts, and generation errors in Convex.
|
||||
1. Worker A: add OpenRouter/Vercel AI SDK dependencies, provider config, model profiles, and schema helpers with RED/GREEN tests.
|
||||
2. Worker B: add Convex schema and persistence contracts for structured LLM generations with RED/GREEN source/type tests.
|
||||
3. Worker C: add evidence/prompt input builder combining lead, crawl, screenshots, PageSpeed, and local skills with RED/GREEN tests.
|
||||
4. Worker D: add Node audit-generation action queue/process flow with screenshots, AI SDK structured outputs, audit/outreach persistence, and failure recording with RED/GREEN tests.
|
||||
5. Worker E: add German copy quality guard tests/helpers for Ich-Form, no scores, no prices, no generic KI-Slop, and observation-plus-suggestion style.
|
||||
6. Orchestrator: review worker patches, resolve integration gaps through Spark follow-up workers, run full verification, and check acceptance criteria without marking Done.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-06-05: Started TASK-11 implementation on branch codex-task-11-openrouter-audit-pipeline using subagent-driven and test-driven workflow. Existing TASK-25 worktree changes were present and will not be reverted or touched unless required.
|
||||
|
||||
Wave 1 dispatched with gpt-5.3-codex-spark: Worker A owns AI SDK/OpenRouter dependencies, model profiles, and Zod schemas; Worker B owns Convex auditGenerations schema/persistence; Worker C owns pure audit evidence builder; Worker E owns German customer-copy guard. Orchestrator remains integration/review only and is not hand-coding feature patches.
|
||||
|
||||
Implemented Worker-E German copy guard slice in pure deterministic helpers (lib/ai/german-copy-guard.ts) plus TDD tests (tests/german-copy-guard.test.ts). Added issue coverage for language quality, Ich-Form, score/page-speed artifacts, Preise, KI-Slop, anklagende Sprache, technische Artefakte, Beobachtung+Vorschlag. Keinen Fremdscope verändert.
|
||||
|
||||
Wave 1 review complete. Spec/code-quality reviewers found expected blocker: auditGenerationAction is not implemented yet and queue currently uses a temporary any reference. Follow-up scope: Worker D will add Node action, typed scheduler reference, screenshot multimodal handoff, AI SDK calls, audit/outreach persistence, and prompt/response size/sanitization guards. Worker F will harden German short-text detection, document model override env vars, and remove generated JS artifacts.
|
||||
|
||||
Wave 2 dispatched with gpt-5.3-codex-spark: Worker D owns auditGenerationAction, typed scheduler reference, multimodal screenshot handoff, AI SDK structured stages, audit/outreach persistence, and prompt/response persistence hardening. Worker F owns German short-text guard hardening, OpenRouter override env docs, and removal of generated JS artifacts. Orchestrator remains review/verification only.
|
||||
|
||||
Final review before closure: spec reviewer passed all five TASK-11 acceptance criteria, but code-quality reviewer found P1 risks in auditGenerationAction error handling and lead status patching, plus P2 hardening around UTF-8 byte capping/secret redaction. Worker H dispatched with gpt-5.3-codex-spark to address those findings before acceptance criteria are checked.
|
||||
|
||||
Implementation complete pending user confirmation. Built OpenRouter/Vercel AI SDK audit-generation pipeline with model profiles, Zod structured outputs, evidence builder, multimodal screenshot handoff, Convex auditGenerations persistence with prompt/response/model metadata, German copy guard, audit/outreach upserts, guarded lead status transition, action-level failure handling, UTF-8 byte-safe truncation, env-secret redaction, and model-profile driven generation parameters. Verification passed: pnpm test (235/235); pnpm exec tsc -p tsconfig.json --pretty false; pnpm lint (0 errors, existing BetterAuth generated warnings only); pnpm exec convex codegen --dry-run --typecheck enable; pnpm build. Final Spark review found no blocking/important issues; residual P3: PageSpeed evidence freshness on re-runs may need future runtime coverage.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented the OpenRouter/Vercel AI SDK audit-generation pipeline end to end: model profiles, Zod structured outputs, Convex audit generation persistence, evidence builder, multimodal screenshots, German copy guard, audit/outreach draft persistence, guarded lead transition, and hardening for failure handling/secret redaction. Verified with pnpm test, TypeScript, lint, Convex codegen/typecheck, build, and final Spark review.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-25
|
||||
title: Harden website enrichment against Convex action runtime aborts
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-05 06:59'
|
||||
updated_date: '2026-06-05 07:04'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 27000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Website enrichment actions can be killed by Convex with a transient invalid environment error before the JS catch block runs, leaving runs without normal failure finalization or PageSpeed queueing. Add an internal action runtime budget so long browser/bootstrap/crawl work fails inside the action before the platform aborts it.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Website enrichment has an action-level runtime budget below the Convex runtime abort window
|
||||
- [x] #2 Long Chromium bootstrap, browser launch, crawl, link checks, and screenshots are bounded by remaining action time
|
||||
- [x] #3 When the runtime budget is exceeded, the existing catch path finalizes the enrichment run and queues PageSpeed for the lead
|
||||
- [x] #4 Regression tests cover the runtime budget guard and full verification passes
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add RED source regression for action runtime budget and bounded browser/crawl steps
|
||||
2. Implement minimal runtime budget helper in websiteEnrichmentAction
|
||||
3. Run tests/type/lint and deploy Convex dev
|
||||
4. Record findings and leave task open pending manual retest
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-06-05: Investigation found latest website_enrichment run was manually set to failed, but Convex logs show the underlying action ended with "Transient error while executing action" and environment "invalid" before app-level catch/finalization ran. This explains missing finishedAt/errorSummary/PageSpeed follow-up.
|
||||
|
||||
2026-06-05: Implemented action-level budget guard (default 120s, TASK8_ACTION_BUDGET_MS override) around Playwright import, Chromium executable resolution, AL2023 library preparation, browser launch/context creation, page crawls, internal link checks, and desktop/mobile screenshots so long work rejects inside the action catch path before Convex invalidates the runtime. Verified with targeted website-enrichment action tests, full pnpm test, TypeScript, lint, and Convex dev typecheck/deploy.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: TASK-26
|
||||
title: Finalize audit generation hardening and catch-all failure handling
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-05 08:37'
|
||||
updated_date: '2026-06-05 09:04'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 28000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Implement P1/P2/P3 audit-generation code-quality fixes with regression-safe behavior.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 processAuditGeneration catches all late failures and marks run failed
|
||||
- [x] #2 outreach_ready patch is guarded by terminal contact status
|
||||
- [x] #3 truncateWithMarker is byte-safe and source tests cover byte behavior
|
||||
- [x] #4 action/persistence sanitizer masks env-backed secret values
|
||||
- [x] #5 model profile flags are used for model params and supportsImages
|
||||
- [x] #6 reachability to deterministic outreach upsert behaviour for empty values
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add source-level regression tests for P1/P2/P3 points
|
||||
2. Implement action-level robust failure handling and guarded lead status transition
|
||||
3. Fix byte-aware truncation and shared sanitization paths in action/persistence
|
||||
4. Rework model-profile driven generation config and multimodal gating
|
||||
5. Add deterministic outreach upsert behavior and run full checks
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Verified as TASK-11 final hardening follow-up. Fixed action-level catch/failure finish, terminal-status guard for outreach_ready, UTF-8 byte-safe truncation, env-backed secret redaction, model-profile params/supportsImages usage, and deterministic outreach upsert for explicit empty values. Verification passed with TASK-11 final checks; task remains In Progress pending user confirmation.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Shipped final audit-generation hardening: catch-all post-start failure handling, terminal lead-status guard, byte-safe truncation, env-backed secret redaction, model-profile driven parameters/supportsImages, and deterministic outreach upsert behavior. Verified together with TASK-11 final checks.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
184
components/audits/audit-detail.tsx
Normal file
184
components/audits/audit-detail.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
type UsedSkill = {
|
||||
name: string;
|
||||
purpose?: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type LeadContext = {
|
||||
_id: Id<"leads">;
|
||||
companyName?: string;
|
||||
websiteDomain?: string;
|
||||
websiteUrl?: string;
|
||||
city?: string;
|
||||
niche?: string;
|
||||
};
|
||||
|
||||
type SkillAwareAudit = {
|
||||
_id: Id<"audits">;
|
||||
slug: string;
|
||||
checkedDomain: string;
|
||||
status: "draft" | "approved" | "published" | "deactivated";
|
||||
checkedPages: string[];
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
usedSkills?: UsedSkill[];
|
||||
internalSummary?: string | null;
|
||||
};
|
||||
|
||||
type AuditDetailResult = {
|
||||
audit: SkillAwareAudit;
|
||||
lead: LeadContext | null;
|
||||
} | null;
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
draft: "Entwurf",
|
||||
approved: "Freigegeben",
|
||||
published: "Veröffentlicht",
|
||||
deactivated: "Deaktiviert",
|
||||
};
|
||||
|
||||
function getStatusLabel(status: SkillAwareAudit["status"]) {
|
||||
return statusText[status] ?? "Unbekannt";
|
||||
}
|
||||
|
||||
function leadSummary(lead: LeadContext | null | undefined) {
|
||||
if (!lead) {
|
||||
return "Kein Lead-Kontext gespeichert";
|
||||
}
|
||||
|
||||
const detail = [lead.city, lead.niche].filter(Boolean).join(" • ");
|
||||
let leadDomain = lead.websiteDomain ?? "—";
|
||||
|
||||
if (!leadDomain && lead.websiteUrl) {
|
||||
try {
|
||||
leadDomain = new URL(lead.websiteUrl).hostname;
|
||||
} catch {
|
||||
leadDomain = lead.websiteUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="font-medium">{lead.companyName ?? "Lead ohne Name"}</p>
|
||||
<p className="text-sm text-muted-foreground">{detail || "Kein Kontext textlich"}</p>
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Globe className="size-3.5" />
|
||||
{leadDomain}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
||||
const result = useQuery(api.audits.getDetail, {
|
||||
id: id as Id<"audits">,
|
||||
}) as AuditDetailResult | undefined;
|
||||
const audit = result?.audit;
|
||||
const lead = result?.lead;
|
||||
|
||||
const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]);
|
||||
|
||||
if (result === null) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit nicht gefunden</CardTitle>
|
||||
<CardDescription>
|
||||
Der gewünschte Audit-Datensatz konnte nicht geladen werden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (audit === undefined) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit wird geladen...</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Audit-Detail</CardDescription>
|
||||
<CardTitle className="text-xl">#{audit.slug}</CardTitle>
|
||||
<p className="inline-flex max-w-full items-center gap-1 truncate text-sm text-muted-foreground">
|
||||
<Globe className="size-3.5" />
|
||||
{audit.checkedDomain}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Status</p>
|
||||
<p>
|
||||
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Seitenanzahl</p>
|
||||
<p>{audit.checkedPages.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Lead-Kontext</p>
|
||||
<div className="text-sm">{leadSummary(lead)}</div>
|
||||
</div>
|
||||
{audit.internalSummary ? (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Interne Notiz</p>
|
||||
<p className="text-sm text-muted-foreground">{audit.internalSummary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verwendete Skills</CardTitle>
|
||||
<CardDescription>Skills, die an diesem Audit beteiligt wurden.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usedSkills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Skills gespeichert</p>
|
||||
) : (
|
||||
<ul className="grid gap-2">
|
||||
{usedSkills.map((skill, index) => (
|
||||
<li
|
||||
className="rounded-md border p-2 text-sm"
|
||||
key={`${skill.name}-${index}`}
|
||||
>
|
||||
<p className="font-medium">{skill.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skill.purpose ?? "Keine Zweckbeschreibung"}
|
||||
</p>
|
||||
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
|
||||
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
|
||||
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}
|
||||
{skill.source ? <span className="text-xs text-muted-foreground">{skill.source}</span> : null}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
components/audits/audits-board.tsx
Normal file
134
components/audits/audits-board.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { Files, SquarePen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type AuditsListResult = FunctionReturnType<typeof api.audits.list>;
|
||||
type AuditRow = NonNullable<AuditsListResult>[number];
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
draft: "Entwurf",
|
||||
approved: "Freigegeben",
|
||||
published: "Veröffentlicht",
|
||||
deactivated: "Deaktiviert",
|
||||
};
|
||||
|
||||
const fallbackStatus = "Unbekannt";
|
||||
|
||||
function formatPageCount(pages: AuditRow["checkedPages"]) {
|
||||
return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`;
|
||||
}
|
||||
|
||||
function getStatusLabel(status: AuditRow["status"]) {
|
||||
return statusText[status] ?? fallbackStatus;
|
||||
}
|
||||
|
||||
function AuditsBoardLoading() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
|
||||
</header>
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid gap-2 p-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton className="h-20 rounded-md" key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditsBoard() {
|
||||
const audits = useQuery(api.audits.list, { limit: 100 });
|
||||
const rows = useMemo(() => {
|
||||
if (!audits) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...audits].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}, [audits]);
|
||||
|
||||
if (audits === undefined) {
|
||||
return <AuditsBoardLoading />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
</header>
|
||||
|
||||
<article className="rounded-lg border p-4">
|
||||
<h2 className="text-sm font-medium">Noch keine Audits</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Sobald neue Audits angelegt wurden, erscheinen sie hier als kompakte
|
||||
Zeilen.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
</header>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>Slug</span>
|
||||
<span>Domain</span>
|
||||
<span>Status</span>
|
||||
<span>Seitenanzahl</span>
|
||||
<span className="text-right">Aktion</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((audit: AuditRow) => (
|
||||
<article
|
||||
className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
key={audit._id}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{audit.slug}</p>
|
||||
</div>
|
||||
<p className="truncate text-muted-foreground">{audit.checkedDomain}</p>
|
||||
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Files className="size-3.5" />
|
||||
{formatPageCount(audit.checkedPages)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
|
||||
href={`/dashboard/audits/${audit._id}`}
|
||||
>
|
||||
<SquarePen className="size-4" />
|
||||
Öffnen
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -8,6 +8,8 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as auditGeneration from "../auditGeneration.js";
|
||||
import type * as auditGenerationAction from "../auditGenerationAction.js";
|
||||
import type * as auditInputs from "../auditInputs.js";
|
||||
import type * as audits from "../audits.js";
|
||||
import type * as blacklist from "../blacklist.js";
|
||||
@@ -32,6 +34,8 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
auditGeneration: typeof auditGeneration;
|
||||
auditGenerationAction: typeof auditGenerationAction;
|
||||
auditInputs: typeof auditInputs;
|
||||
audits: typeof audits;
|
||||
blacklist: typeof blacklist;
|
||||
|
||||
578
convex/auditGeneration.ts
Normal file
578
convex/auditGeneration.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { internalMutation, internalQuery } from "./_generated/server";
|
||||
import {
|
||||
AUDIT_GENERATION_STAGES,
|
||||
AUDIT_GENERATION_STATUSES,
|
||||
RUN_STATUSES,
|
||||
} from "./domain";
|
||||
import { v } from "convex/values";
|
||||
import {
|
||||
type PageSpeedAuditErrorType,
|
||||
type PageSpeedMinimalAuditResult,
|
||||
} from "../lib/pagespeed-audit-input";
|
||||
|
||||
export const MAX_PROMPT_BYTES = 12_000;
|
||||
export const MAX_RAW_RESPONSE_BYTES = 12_000;
|
||||
export const MAX_PARSED_JSON_BYTES = 12_000;
|
||||
const TRUNCATION_MARKER = "\n\n[... abgeschnitten ...]";
|
||||
|
||||
const auditGenerationStage = v.union(
|
||||
...AUDIT_GENERATION_STAGES.map((stage) => v.literal(stage)),
|
||||
);
|
||||
const auditGenerationStatus = v.union(
|
||||
...AUDIT_GENERATION_STATUSES.map((status) => v.literal(status)),
|
||||
);
|
||||
const runStatus = v.union(...RUN_STATUSES.map((status) => v.literal(status)));
|
||||
|
||||
const auditGenerationParsedValue = v.union(
|
||||
v.string(),
|
||||
v.number(),
|
||||
v.boolean(),
|
||||
v.null(),
|
||||
v.array(v.any()),
|
||||
v.record(v.string(), v.any()),
|
||||
);
|
||||
|
||||
const auditGenerationParsedJson = v.union(
|
||||
v.string(),
|
||||
v.record(v.string(), auditGenerationParsedValue),
|
||||
);
|
||||
|
||||
type AuditGenerationLead = Pick<
|
||||
Doc<"leads">,
|
||||
| "_id"
|
||||
| "companyName"
|
||||
| "niche"
|
||||
| "city"
|
||||
| "address"
|
||||
| "websiteUrl"
|
||||
| "websiteDomain"
|
||||
| "phone"
|
||||
| "contactPerson"
|
||||
>;
|
||||
type AuditGenerationEvidenceCrawlPage = Pick<
|
||||
Doc<"websiteCrawlPages">,
|
||||
| "sourceUrl"
|
||||
| "finalUrl"
|
||||
| "title"
|
||||
| "metaDescription"
|
||||
| "pageKind"
|
||||
| "hasContactFormSignal"
|
||||
| "hasContactCtaSignal"
|
||||
| "visibleTextExcerpt"
|
||||
>;
|
||||
type AuditGenerationEvidenceTechnicalCheck = Pick<
|
||||
Doc<"websiteTechnicalChecks">,
|
||||
| "sourceUrl"
|
||||
| "finalUrl"
|
||||
| "usesHttps"
|
||||
| "missingTitle"
|
||||
| "missingMetaDescription"
|
||||
| "hasVisibleContactPath"
|
||||
| "brokenInternalLinkCount"
|
||||
>;
|
||||
type AuditGenerationEvidenceScreenshot = Pick<
|
||||
Doc<"websiteCrawlScreenshots">,
|
||||
| "storageId"
|
||||
| "viewport"
|
||||
| "sourceUrl"
|
||||
| "capturedAt"
|
||||
| "width"
|
||||
| "height"
|
||||
| "mimeType"
|
||||
>;
|
||||
|
||||
type AuditGenerationEvidence = {
|
||||
lead: AuditGenerationLead;
|
||||
crawlPages: AuditGenerationEvidenceCrawlPage[];
|
||||
technicalChecks: AuditGenerationEvidenceTechnicalCheck[];
|
||||
screenshots: AuditGenerationEvidenceScreenshot[];
|
||||
pageSpeedInputs: PageSpeedMinimalAuditResult[];
|
||||
};
|
||||
|
||||
function byteLength(value: string) {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
function truncateToByteLimit(value: string, maxBytes: number) {
|
||||
if (maxBytes <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let usedBytes = 0;
|
||||
let endIndex = 0;
|
||||
|
||||
for (const char of value) {
|
||||
const charBytes = byteLength(char);
|
||||
if (usedBytes + charBytes > maxBytes) {
|
||||
break;
|
||||
}
|
||||
usedBytes += charBytes;
|
||||
endIndex += char.length;
|
||||
}
|
||||
|
||||
return value.slice(0, endIndex);
|
||||
}
|
||||
|
||||
function truncateWithMarker(value: string, maxBytes: number) {
|
||||
if (byteLength(value) <= maxBytes) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const markerBytes = byteLength(TRUNCATION_MARKER);
|
||||
if (markerBytes >= maxBytes) {
|
||||
const markerBytesBuffer = new TextEncoder().encode(TRUNCATION_MARKER);
|
||||
return new TextDecoder().decode(markerBytesBuffer.slice(0, maxBytes));
|
||||
}
|
||||
|
||||
const byteBudget = Math.max(0, maxBytes - markerBytes);
|
||||
const trimmed = truncateToByteLimit(value, byteBudget);
|
||||
|
||||
return `${trimmed}${TRUNCATION_MARKER}`;
|
||||
}
|
||||
|
||||
function sanitizeAndCapString(value: string | undefined, maxBytes: number) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const safe = (sanitizeSecretCandidates(value) ?? "").trim();
|
||||
return byteLength(safe) > maxBytes ? truncateWithMarker(safe, maxBytes) : safe;
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "[unserializable payload]";
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAndCapParsedJson(parsedJson: unknown) {
|
||||
if (parsedJson === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof parsedJson === "string") {
|
||||
return sanitizeAndCapString(parsedJson, MAX_PARSED_JSON_BYTES);
|
||||
}
|
||||
|
||||
const serialized = safeStringify(parsedJson);
|
||||
const safeSerialized = sanitizeSecretCandidates(serialized) ?? "";
|
||||
if (byteLength(safeSerialized) <= MAX_PARSED_JSON_BYTES) {
|
||||
return safeSerialized;
|
||||
}
|
||||
|
||||
return truncateWithMarker(safeSerialized, MAX_PARSED_JSON_BYTES);
|
||||
}
|
||||
|
||||
function normalizePageSpeedResultRow(
|
||||
row: Doc<"pageSpeedResults">,
|
||||
): PageSpeedMinimalAuditResult {
|
||||
return {
|
||||
strategy: row.strategy,
|
||||
status: row.status,
|
||||
sourceUrl: row.sourceUrl,
|
||||
...(row.finalUrl ? { finalUrl: row.finalUrl } : {}),
|
||||
...(row.normalized ? { normalized: row.normalized } : {}),
|
||||
...(row.errorType ? { errorType: row.errorType as PageSpeedAuditErrorType } : {}),
|
||||
...(row.errorSummary ? { errorSummary: row.errorSummary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const auditGenerationUsage = v.object({
|
||||
promptTokens: v.optional(v.number()),
|
||||
completionTokens: v.optional(v.number()),
|
||||
totalTokens: v.optional(v.number()),
|
||||
cacheReadTokens: v.optional(v.number()),
|
||||
totalCostUsd: v.optional(v.number()),
|
||||
});
|
||||
|
||||
const secretHints = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"GOOGLE_PLACES_API_KEY",
|
||||
"GOOGLE_GEOCODING_API_KEY",
|
||||
"PAGESPEED_API_KEY",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_HOST",
|
||||
"SMTP_USER",
|
||||
"BETTER_AUTH_SECRET",
|
||||
"RYBBIT_API_KEY",
|
||||
];
|
||||
|
||||
function sanitizeSecretCandidates(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let sanitized = value;
|
||||
|
||||
for (const key of secretHints) {
|
||||
const secret = process.env[key];
|
||||
if (!secret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized = sanitized.replace(
|
||||
new RegExp(escapeRegExp(secret), "g"),
|
||||
"[REDACTED]",
|
||||
);
|
||||
}
|
||||
|
||||
return sanitized
|
||||
.replace(/\b(?:api[_-]?key|token|secret|password)\s*[:=]\s*[^\s\"']+/gi, "[REDACTED]")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
type StartLeadSnapshot = Pick<
|
||||
Doc<"leads">,
|
||||
"_id" | "websiteUrl" | "websiteDomain" | "contactStatus"
|
||||
>;
|
||||
|
||||
export const getAuditGenerationEvidence = internalQuery({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<AuditGenerationEvidence | null> => {
|
||||
const run = await ctx.db.get(args.runId);
|
||||
if (!run || !run.leadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lead = await ctx.db.get(run.leadId);
|
||||
if (!lead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runIdFilter = {
|
||||
table: "by_runId" as const,
|
||||
value: args.runId,
|
||||
};
|
||||
const leadIdFilter = {
|
||||
table: "by_leadId" as const,
|
||||
value: lead._id,
|
||||
};
|
||||
|
||||
const crawlPagesByRun = await ctx.db
|
||||
.query("websiteCrawlPages")
|
||||
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
|
||||
.order("desc")
|
||||
.take(40);
|
||||
|
||||
const technicalChecksByRun = await ctx.db
|
||||
.query("websiteTechnicalChecks")
|
||||
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
|
||||
.order("desc")
|
||||
.take(80);
|
||||
|
||||
const screenshotsByRun = await ctx.db
|
||||
.query("websiteCrawlScreenshots")
|
||||
.withIndex("by_runId", (q) => q.eq("runId", runIdFilter.value))
|
||||
.order("desc")
|
||||
.take(20);
|
||||
|
||||
const pageSpeedByRun = run.auditId
|
||||
? await ctx.db
|
||||
.query("pageSpeedResults")
|
||||
.withIndex("by_auditId", (q) => q.eq("auditId", run.auditId as Id<"audits">))
|
||||
.order("desc")
|
||||
.take(20)
|
||||
: await ctx.db
|
||||
.query("pageSpeedResults")
|
||||
.withIndex("by_leadId", (q) => q.eq("leadId", leadIdFilter.value))
|
||||
.order("desc")
|
||||
.take(20);
|
||||
|
||||
const crawlPages = crawlPagesByRun;
|
||||
const technicalChecks = technicalChecksByRun;
|
||||
const screenshots = screenshotsByRun;
|
||||
|
||||
return {
|
||||
lead: {
|
||||
_id: lead._id,
|
||||
companyName: lead.companyName,
|
||||
niche: lead.niche,
|
||||
city: lead.city,
|
||||
address: lead.address,
|
||||
websiteUrl: lead.websiteUrl,
|
||||
websiteDomain: lead.websiteDomain,
|
||||
phone: lead.phone,
|
||||
contactPerson: lead.contactPerson,
|
||||
},
|
||||
crawlPages,
|
||||
technicalChecks,
|
||||
screenshots,
|
||||
pageSpeedInputs: pageSpeedByRun.map(normalizePageSpeedResultRow),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const queueLeadAuditGeneration = internalMutation({
|
||||
args: {
|
||||
leadId: v.id("leads"),
|
||||
auditId: v.optional(v.id("audits")),
|
||||
parentRunId: v.optional(v.id("agentRuns")),
|
||||
},
|
||||
returns: v.union(v.id("agentRuns"), v.null()),
|
||||
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
||||
const now = Date.now();
|
||||
const lead = await ctx.db.get(args.leadId);
|
||||
|
||||
if (!lead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingPending = await ctx.db
|
||||
.query("agentRuns")
|
||||
.withIndex("by_type_and_status_and_leadId", (q) =>
|
||||
q
|
||||
.eq("type", "audit_generation")
|
||||
.eq("status", "pending")
|
||||
.eq("leadId", args.leadId),
|
||||
)
|
||||
.take(1);
|
||||
|
||||
const existingRunning = await ctx.db
|
||||
.query("agentRuns")
|
||||
.withIndex("by_type_and_status_and_leadId", (q) =>
|
||||
q
|
||||
.eq("type", "audit_generation")
|
||||
.eq("status", "running")
|
||||
.eq("leadId", args.leadId),
|
||||
)
|
||||
.take(1);
|
||||
|
||||
if (existingPending.length > 0) {
|
||||
return existingPending[0]._id;
|
||||
}
|
||||
|
||||
if (existingRunning.length > 0) {
|
||||
return existingRunning[0]._id;
|
||||
}
|
||||
|
||||
const runId = await ctx.db.insert("agentRuns", {
|
||||
type: "audit_generation",
|
||||
leadId: args.leadId,
|
||||
...(args.auditId ? { auditId: args.auditId } : {}),
|
||||
status: "pending",
|
||||
currentStep: "audit_generation",
|
||||
counters: {
|
||||
leadsFound: 0,
|
||||
leadsCreated: 0,
|
||||
auditsCreated: 0,
|
||||
outreachPrepared: 0,
|
||||
errors: 0,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await ctx.db.insert("agentRunEvents", {
|
||||
runId,
|
||||
level: "info",
|
||||
message: "Audit-Generierung wurde in die Warteschlange gesetzt.",
|
||||
details: [
|
||||
{ label: "Lead", value: args.leadId },
|
||||
...(args.parentRunId
|
||||
? [{ label: "Parent-Run", value: args.parentRunId }]
|
||||
: []),
|
||||
],
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.auditGenerationAction.processAuditGeneration,
|
||||
{
|
||||
runId,
|
||||
},
|
||||
);
|
||||
|
||||
return runId;
|
||||
},
|
||||
});
|
||||
|
||||
export const startAuditGenerationRun = internalMutation({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
lead: v.object({
|
||||
_id: v.id("leads"),
|
||||
websiteUrl: v.optional(v.string()),
|
||||
websiteDomain: v.optional(v.string()),
|
||||
contactStatus: v.union(
|
||||
v.literal("new"),
|
||||
v.literal("missing_contact"),
|
||||
v.literal("audit_ready"),
|
||||
v.literal("outreach_ready"),
|
||||
v.literal("contacted"),
|
||||
v.literal("replied"),
|
||||
v.literal("do_not_contact"),
|
||||
),
|
||||
}),
|
||||
auditId: v.optional(v.id("audits")),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
handler: async (ctx, args): Promise<
|
||||
{ lead: StartLeadSnapshot; auditId?: Id<"audits"> } | null
|
||||
> => {
|
||||
const now = Date.now();
|
||||
const run = await ctx.db.get(args.runId);
|
||||
|
||||
if (!run || run.type !== "audit_generation" || run.status !== "pending") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!run.leadId) {
|
||||
await ctx.db.patch(args.runId, {
|
||||
status: "failed",
|
||||
currentStep: "audit_generation",
|
||||
errorSummary: "Der Lauf hat keine Lead-ID.",
|
||||
updatedAt: now,
|
||||
finishedAt: now,
|
||||
});
|
||||
|
||||
await ctx.db.insert("agentRunEvents", {
|
||||
runId: args.runId,
|
||||
level: "error",
|
||||
message:
|
||||
"Audit-Generierung konnte nicht gestartet werden: Keine Lead-ID.",
|
||||
details: [{ label: "Lead-ID", value: "unbekannt" }],
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const lead = await ctx.db.get(run.leadId);
|
||||
if (!lead) {
|
||||
await ctx.db.patch(args.runId, {
|
||||
status: "failed",
|
||||
currentStep: "audit_generation",
|
||||
errorSummary: "Lead wurde nicht gefunden.",
|
||||
updatedAt: now,
|
||||
finishedAt: now,
|
||||
});
|
||||
|
||||
await ctx.db.insert("agentRunEvents", {
|
||||
runId: args.runId,
|
||||
level: "error",
|
||||
message:
|
||||
"Audit-Generierung konnte nicht gestartet werden: Kein Lead mit dieser ID.",
|
||||
details: [{ label: "Lead-ID", value: run.leadId }],
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.runId, {
|
||||
status: "running",
|
||||
currentStep: "audit_generation",
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
errorSummary: undefined,
|
||||
});
|
||||
|
||||
await ctx.db.insert("agentRunEvents", {
|
||||
runId: args.runId,
|
||||
level: "info",
|
||||
message: "Audit-Generierung gestartet.",
|
||||
details: [{ label: "Lead-ID", value: lead._id }],
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
lead: {
|
||||
_id: lead._id,
|
||||
websiteUrl: lead.websiteUrl,
|
||||
websiteDomain: lead.websiteDomain,
|
||||
contactStatus: lead.contactStatus,
|
||||
},
|
||||
...(run.auditId ? { auditId: run.auditId } : {}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const persistAuditGenerationResult = internalMutation({
|
||||
args: {
|
||||
leadId: v.id("leads"),
|
||||
auditId: v.optional(v.id("audits")),
|
||||
runId: v.id("agentRuns"),
|
||||
stage: auditGenerationStage,
|
||||
modelProfile: v.string(),
|
||||
modelId: v.string(),
|
||||
prompt: v.string(),
|
||||
systemPrompt: v.optional(v.string()),
|
||||
rawResponse: v.optional(v.string()),
|
||||
parsedJson: v.optional(auditGenerationParsedJson),
|
||||
usage: v.optional(auditGenerationUsage),
|
||||
finishReason: v.optional(v.string()),
|
||||
status: auditGenerationStatus,
|
||||
errorSummary: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
|
||||
return await ctx.db.insert("auditGenerations", {
|
||||
leadId: args.leadId,
|
||||
auditId: args.auditId,
|
||||
runId: args.runId,
|
||||
stage: args.stage,
|
||||
modelProfile: args.modelProfile,
|
||||
modelId: args.modelId,
|
||||
prompt: sanitizeAndCapString(args.prompt, MAX_PROMPT_BYTES) ?? "",
|
||||
...(args.systemPrompt
|
||||
? { systemPrompt: sanitizeAndCapString(args.systemPrompt, MAX_PROMPT_BYTES) }
|
||||
: {}),
|
||||
...(args.rawResponse
|
||||
? { rawResponse: sanitizeAndCapString(args.rawResponse, MAX_RAW_RESPONSE_BYTES) }
|
||||
: {}),
|
||||
...(args.parsedJson ? { parsedJson: sanitizeAndCapParsedJson(args.parsedJson) } : {}),
|
||||
...(args.usage ? { usage: args.usage } : {}),
|
||||
...(args.finishReason ? { finishReason: args.finishReason } : {}),
|
||||
status: args.status,
|
||||
...(args.errorSummary
|
||||
? { errorSummary: sanitizeAndCapString(args.errorSummary, MAX_RAW_RESPONSE_BYTES) }
|
||||
: {}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const finishAuditGenerationRun = internalMutation({
|
||||
args: {
|
||||
runId: v.id("agentRuns"),
|
||||
status: runStatus,
|
||||
currentStep: v.optional(v.string()),
|
||||
errorSummary: v.optional(v.string()),
|
||||
errors: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
|
||||
await ctx.db.patch(args.runId, {
|
||||
status: args.status,
|
||||
updatedAt: now,
|
||||
finishedAt: now,
|
||||
currentStep: args.currentStep ?? "audit_generation",
|
||||
errorSummary: args.errorSummary,
|
||||
counters: {
|
||||
leadsFound: 0,
|
||||
leadsCreated: 0,
|
||||
auditsCreated: 0,
|
||||
outreachPrepared: 0,
|
||||
errors: args.errors ?? 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
1197
convex/auditGenerationAction.ts
Normal file
1197
convex/auditGenerationAction.ts
Normal file
File diff suppressed because it is too large
Load Diff
106
convex/audits.ts
106
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"),
|
||||
@@ -9,6 +9,21 @@ const auditStatus = v.union(
|
||||
v.literal("published"),
|
||||
v.literal("deactivated"),
|
||||
);
|
||||
const usedSkillsValidator = v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
category: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
}),
|
||||
);
|
||||
const skillSummaryValidator = v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
purpose: v.string(),
|
||||
summary: v.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
@@ -16,6 +31,7 @@ export const create = mutation({
|
||||
slug: v.string(),
|
||||
checkedDomain: v.string(),
|
||||
checkedPages: v.array(v.string()),
|
||||
usedSkills: v.optional(usedSkillsValidator),
|
||||
status: v.optional(auditStatus),
|
||||
internalSummary: v.optional(v.string()),
|
||||
publicSummary: v.optional(v.string()),
|
||||
@@ -42,6 +58,19 @@ export const create = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const getDetail = query({
|
||||
args: { id: v.id("audits") },
|
||||
handler: async (ctx, args) => {
|
||||
const audit = await ctx.db.get(args.id);
|
||||
if (!audit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lead = await ctx.db.get(audit.leadId);
|
||||
return { audit, lead };
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { id: v.id("audits") },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -49,6 +78,81 @@ export const get = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const upsertFromAuditGeneration = internalMutation({
|
||||
args: {
|
||||
leadId: v.id("leads"),
|
||||
runId: v.id("agentRuns"),
|
||||
auditId: v.optional(v.id("audits")),
|
||||
checkedDomain: v.string(),
|
||||
checkedPages: v.array(v.string()),
|
||||
internalSummary: v.optional(v.string()),
|
||||
multimodalSummary: v.optional(v.string()),
|
||||
publicSummary: v.optional(v.string()),
|
||||
publicBody: v.optional(v.string()),
|
||||
usedSkills: v.optional(usedSkillsValidator),
|
||||
skillSummaries: v.optional(skillSummaryValidator),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const lead = await ctx.db.get(args.leadId);
|
||||
if (!lead) {
|
||||
throw new Error("Lead wurde nicht gefunden.");
|
||||
}
|
||||
|
||||
if (args.auditId) {
|
||||
const existing = await ctx.db.get(args.auditId);
|
||||
if (!existing) {
|
||||
throw new Error("Audit wurde nicht gefunden.");
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.auditId, {
|
||||
checkedDomain: args.checkedDomain,
|
||||
checkedPages: args.checkedPages,
|
||||
internalSummary: args.internalSummary,
|
||||
multimodalSummary: args.multimodalSummary,
|
||||
publicSummary: args.publicSummary,
|
||||
publicBody: args.publicBody,
|
||||
usedSkills: args.usedSkills,
|
||||
skillSummaries: args.skillSummaries,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return args.auditId;
|
||||
}
|
||||
|
||||
const safeCheckedDomain = args.checkedDomain.trim().toLowerCase();
|
||||
const domainTag = safeCheckedDomain.length > 0
|
||||
? safeCheckedDomain.replace(/[^a-z0-9]+/g, "-").slice(0, 50)
|
||||
: "lead";
|
||||
let slug = `audit-${domainTag}-${args.leadId}-${now}`;
|
||||
|
||||
const slugCandidates = await ctx.db
|
||||
.query("audits")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.take(1);
|
||||
|
||||
if (slugCandidates.length > 0) {
|
||||
slug = `${slug}-${Math.floor(now / 1_000)}`;
|
||||
}
|
||||
|
||||
return await ctx.db.insert("audits", {
|
||||
leadId: args.leadId,
|
||||
status: "draft",
|
||||
slug,
|
||||
checkedDomain: args.checkedDomain,
|
||||
checkedPages: args.checkedPages,
|
||||
internalSummary: args.internalSummary,
|
||||
multimodalSummary: args.multimodalSummary,
|
||||
publicSummary: args.publicSummary,
|
||||
publicBody: args.publicBody,
|
||||
usedSkills: args.usedSkills,
|
||||
skillSummaries: args.skillSummaries,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const getBySlug = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -2,6 +2,8 @@ import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { tables as authTables } from "./betterAuth/schema";
|
||||
import {
|
||||
AUDIT_GENERATION_STAGES,
|
||||
AUDIT_GENERATION_STATUSES,
|
||||
RUN_EVENT_LEVELS,
|
||||
RUN_STATUSES,
|
||||
RUN_TYPES,
|
||||
@@ -91,6 +93,35 @@ const websiteEnrichmentPageKind = v.union(
|
||||
);
|
||||
const runType = v.union(...RUN_TYPES.map((type) => v.literal(type)));
|
||||
const runStatus = v.union(...RUN_STATUSES.map((status) => v.literal(status)));
|
||||
const auditGenerationStatus = v.union(
|
||||
...AUDIT_GENERATION_STATUSES.map((status) => v.literal(status)),
|
||||
);
|
||||
const auditGenerationStage = v.union(
|
||||
...AUDIT_GENERATION_STAGES.map((stage) => v.literal(stage)),
|
||||
);
|
||||
const auditGenerationUsage = v.object({
|
||||
promptTokens: v.optional(v.number()),
|
||||
completionTokens: v.optional(v.number()),
|
||||
totalTokens: v.optional(v.number()),
|
||||
cacheReadTokens: v.optional(v.number()),
|
||||
totalCostUsd: v.optional(v.number()),
|
||||
});
|
||||
const auditGenerationParsedJson = v.union(
|
||||
v.string(),
|
||||
v.record(
|
||||
v.string(),
|
||||
v.union(
|
||||
v.string(),
|
||||
v.number(),
|
||||
v.boolean(),
|
||||
v.null(),
|
||||
v.array(v.string()),
|
||||
v.array(v.number()),
|
||||
v.array(v.boolean()),
|
||||
v.object({}),
|
||||
),
|
||||
),
|
||||
);
|
||||
const runEventLevel = v.union(
|
||||
...RUN_EVENT_LEVELS.map((level) => v.literal(level)),
|
||||
);
|
||||
@@ -231,6 +262,16 @@ export default defineSchema({
|
||||
pageSpeedSummary: v.optional(auditMetricSummary),
|
||||
playwrightSummary: v.optional(playwrightSummary),
|
||||
textFindings: v.optional(v.array(v.string())),
|
||||
usedSkills: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
category: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
skillSummaries: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
@@ -313,6 +354,30 @@ export default defineSchema({
|
||||
.index("by_auditId", ["auditId"])
|
||||
.index("by_leadId_and_strategy", ["leadId", "strategy"]),
|
||||
|
||||
auditGenerations: defineTable({
|
||||
leadId: v.id("leads"),
|
||||
auditId: v.optional(v.id("audits")),
|
||||
runId: v.id("agentRuns"),
|
||||
stage: auditGenerationStage,
|
||||
modelProfile: v.string(),
|
||||
modelId: v.string(),
|
||||
prompt: v.string(),
|
||||
systemPrompt: v.optional(v.string()),
|
||||
rawResponse: v.optional(v.string()),
|
||||
parsedJson: v.optional(auditGenerationParsedJson),
|
||||
usage: v.optional(auditGenerationUsage),
|
||||
finishReason: v.optional(v.string()),
|
||||
status: auditGenerationStatus,
|
||||
errorSummary: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_leadId", ["leadId"])
|
||||
.index("by_auditId", ["auditId"])
|
||||
.index("by_runId", ["runId"])
|
||||
.index("by_stage", ["stage"])
|
||||
.index("by_leadId_and_stage", ["leadId", "stage"]),
|
||||
|
||||
websiteCrawlPages: defineTable({
|
||||
leadId: v.id("leads"),
|
||||
runId: v.optional(v.id("agentRuns")),
|
||||
|
||||
@@ -23,6 +23,10 @@ import { internalAction, type ActionCtx } from "./_generated/server";
|
||||
|
||||
const DEFAULT_CRAWL_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_CRAWL_MAX_PAGES = 5;
|
||||
const DEFAULT_ACTION_BUDGET_MS = 120_000;
|
||||
const MIN_ACTION_BUDGET_MS = 30_000;
|
||||
const MAX_ACTION_BUDGET_MS = 140_000;
|
||||
const ACTION_TIMEOUT_BUFFER_MS = 5_000;
|
||||
const MAX_PERSISTED_LINKS = 120;
|
||||
const MAX_PERSISTED_EMAIL_CANDIDATES = 40;
|
||||
const SCREENSHOT_MIME_TYPE = "image/png";
|
||||
@@ -140,6 +144,47 @@ function crawlMaxPages() {
|
||||
);
|
||||
}
|
||||
|
||||
function actionBudgetMs() {
|
||||
return Math.max(
|
||||
MIN_ACTION_BUDGET_MS,
|
||||
Math.min(
|
||||
MAX_ACTION_BUDGET_MS,
|
||||
readPositiveIntEnv("TASK8_ACTION_BUDGET_MS", DEFAULT_ACTION_BUDGET_MS),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function remainingActionBudgetMs(startedAt: number, budgetMs: number) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
return Math.max(1_000, budgetMs - elapsed - ACTION_TIMEOUT_BUFFER_MS);
|
||||
}
|
||||
|
||||
async function withActionTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Website-Enrichment Zeitbudget ueberschritten: ${label}.`,
|
||||
),
|
||||
);
|
||||
}, Math.max(1, timeoutMs));
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makePageKind(url: string, rootUrl: string): EnrichmentPageKind {
|
||||
const normalizedRoot = normalizeCrawlUrl(rootUrl);
|
||||
if (!normalizedRoot) {
|
||||
@@ -418,6 +463,8 @@ export const processLeadEnrichment = internalAction({
|
||||
handler: async (ctx, args) => {
|
||||
let started: StartedLead | null = null;
|
||||
const runId = args.runId;
|
||||
const actionStartedAt = Date.now();
|
||||
const actionBudget = actionBudgetMs();
|
||||
let browser: Browser | null = null;
|
||||
let desktopContext: BrowserContext | null = null;
|
||||
let mobileContext: BrowserContext | null = null;
|
||||
@@ -480,9 +527,15 @@ export const processLeadEnrichment = internalAction({
|
||||
const maxPages = crawlMaxPages();
|
||||
|
||||
const { playwrightCore, serverlessChromium } =
|
||||
await loadPlaywrightModules();
|
||||
const executablePath = await resolveChromiumExecutablePath(
|
||||
serverlessChromium,
|
||||
await withActionTimeout(
|
||||
loadPlaywrightModules(),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
"Playwright-Module laden",
|
||||
);
|
||||
const executablePath = await withActionTimeout(
|
||||
resolveChromiumExecutablePath(serverlessChromium),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
"Chromium executable vorbereiten",
|
||||
);
|
||||
|
||||
const prepareChromiumSharedLibraries = async (
|
||||
@@ -502,21 +555,50 @@ export const processLeadEnrichment = internalAction({
|
||||
chromiumRuntime.setupLambdaEnvironment(path.join(tmpdir(), "al2023", "lib"));
|
||||
};
|
||||
|
||||
await prepareChromiumSharedLibraries(serverlessChromium);
|
||||
browser = await playwrightCore.chromium.launch({
|
||||
await withActionTimeout(
|
||||
prepareChromiumSharedLibraries(serverlessChromium),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
"Chromium-Bibliotheken vorbereiten",
|
||||
);
|
||||
browser = await withActionTimeout(
|
||||
playwrightCore.chromium.launch({
|
||||
headless: true,
|
||||
executablePath,
|
||||
args: serverlessChromium.args,
|
||||
});
|
||||
timeout: remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
}),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
"Chromium starten",
|
||||
);
|
||||
const { devices } = playwrightCore;
|
||||
desktopContext = await browser.newContext({
|
||||
desktopContext = await withActionTimeout(
|
||||
browser.newContext({
|
||||
...devices["Desktop Chrome"],
|
||||
});
|
||||
mobileContext = await browser.newContext({
|
||||
}),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
"Desktop-Kontext erstellen",
|
||||
);
|
||||
mobileContext = await withActionTimeout(
|
||||
browser.newContext({
|
||||
...devices["iPhone 11"],
|
||||
});
|
||||
}),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
"Mobile-Kontext erstellen",
|
||||
);
|
||||
|
||||
const homepage = await crawlPage(desktopContext, rootUrl, rootUrl, timeoutMs);
|
||||
const homepage = await withActionTimeout(
|
||||
crawlPage(
|
||||
desktopContext,
|
||||
rootUrl,
|
||||
rootUrl,
|
||||
Math.min(
|
||||
timeoutMs,
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
),
|
||||
),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
"Homepage crawlen",
|
||||
);
|
||||
if (!homepage) {
|
||||
throw new Error("Homepage konnte nicht geladen werden.");
|
||||
}
|
||||
@@ -529,7 +611,19 @@ export const processLeadEnrichment = internalAction({
|
||||
const crawledPages: PageResult[] = [homepage];
|
||||
|
||||
for (const pageUrl of crawlTargets.slice(1)) {
|
||||
const crawled = await crawlPage(desktopContext, pageUrl, rootUrl, timeoutMs);
|
||||
const crawled = await withActionTimeout(
|
||||
crawlPage(
|
||||
desktopContext,
|
||||
pageUrl,
|
||||
rootUrl,
|
||||
Math.min(
|
||||
timeoutMs,
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
),
|
||||
),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
`Unterseite crawlen: ${pageUrl}`,
|
||||
);
|
||||
if (crawled) {
|
||||
crawledPages.push(crawled);
|
||||
}
|
||||
@@ -552,7 +646,10 @@ export const processLeadEnrichment = internalAction({
|
||||
for (const href of uniqueInternalLinks.slice(0, 30)) {
|
||||
try {
|
||||
const response = await desktopContext.request.get(href, {
|
||||
timeout: Math.max(1_000, timeoutMs - 1_000),
|
||||
timeout: Math.min(
|
||||
Math.max(1_000, timeoutMs - 1_000),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
),
|
||||
});
|
||||
const status = response.status();
|
||||
checkMap.set(href, {
|
||||
@@ -567,19 +664,33 @@ export const processLeadEnrichment = internalAction({
|
||||
}
|
||||
}
|
||||
|
||||
const desktopScreenshot = await captureHomepageScreenshot(
|
||||
const desktopScreenshot = await withActionTimeout(
|
||||
captureHomepageScreenshot(
|
||||
ctx,
|
||||
desktopContext,
|
||||
homepage.finalUrl,
|
||||
"desktop",
|
||||
Math.min(
|
||||
timeoutMs,
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
),
|
||||
),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
"Desktop-Screenshot erfassen",
|
||||
);
|
||||
const mobileScreenshot = await captureHomepageScreenshot(
|
||||
const mobileScreenshot = await withActionTimeout(
|
||||
captureHomepageScreenshot(
|
||||
ctx,
|
||||
mobileContext,
|
||||
homepage.finalUrl,
|
||||
"mobile",
|
||||
Math.min(
|
||||
timeoutMs,
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
),
|
||||
),
|
||||
remainingActionBudgetMs(actionStartedAt, actionBudget),
|
||||
"Mobile-Screenshot erfassen",
|
||||
);
|
||||
|
||||
const technicalInput = buildTechnicalChecks({
|
||||
|
||||
565
lib/ai/audit-evidence.ts
Normal file
565
lib/ai/audit-evidence.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
import {
|
||||
type SkillRegistryEntry,
|
||||
toAuditUsedSkill,
|
||||
type AuditUsedSkill,
|
||||
} from "../skills-registry";
|
||||
import {
|
||||
buildPageSpeedAuditInputs,
|
||||
type PageSpeedMinimalAuditResult,
|
||||
} from "../pagespeed-audit-input";
|
||||
|
||||
export type SkillRegistryEntryEvidence = SkillRegistryEntry;
|
||||
|
||||
export type AuditLeadEvidence = {
|
||||
companyName?: string | null;
|
||||
niche?: string | null;
|
||||
city?: string | null;
|
||||
websiteDomain?: string | null;
|
||||
websiteUrl?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
contactPerson?: string | null;
|
||||
};
|
||||
|
||||
export type AuditCrawlPageEvidence = {
|
||||
sourceUrl?: string | null;
|
||||
finalUrl?: string | null;
|
||||
title?: string | null;
|
||||
metaDescription?: string | null;
|
||||
pageKind?: string | null;
|
||||
hasContactFormSignal?: boolean;
|
||||
hasContactCtaSignal?: boolean;
|
||||
visibleText?: string | null;
|
||||
visibleTextExcerpt?: string | null;
|
||||
};
|
||||
|
||||
export type AuditTechnicalCheckEvidence = {
|
||||
sourceUrl?: string | null;
|
||||
finalUrl?: string | null;
|
||||
usesHttps?: boolean;
|
||||
missingTitle?: boolean;
|
||||
missingMetaDescription?: boolean;
|
||||
hasVisibleContactPath?: boolean;
|
||||
brokenInternalLinkCount?: number;
|
||||
};
|
||||
|
||||
export type AuditScreenshotEvidence = {
|
||||
storageId: string;
|
||||
viewport: string;
|
||||
sourceUrl: string;
|
||||
capturedAt: number;
|
||||
width: number;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AuditEvidenceInput = {
|
||||
companyContext: string[];
|
||||
checkedPages: string[];
|
||||
observedUxSignals: string[];
|
||||
observedContentSignals: string[];
|
||||
observedTechnicalSignals: string[];
|
||||
screenshotReferences: Array<{
|
||||
storageId: string;
|
||||
sourceUrl: string;
|
||||
viewport: string;
|
||||
width: number;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
capturedAt: number;
|
||||
}>;
|
||||
pageSpeedCustomerImplications: string[];
|
||||
selectedSkills: AuditUsedSkill[];
|
||||
};
|
||||
|
||||
export type AuditEvidenceInputArgs = {
|
||||
lead?: AuditLeadEvidence;
|
||||
crawlPages?: readonly AuditCrawlPageEvidence[];
|
||||
technicalChecks?: readonly AuditTechnicalCheckEvidence[];
|
||||
screenshots?: readonly AuditScreenshotEvidence[];
|
||||
pageSpeedInputs?: readonly PageSpeedMinimalAuditResult[];
|
||||
skillRegistry?: readonly SkillRegistryEntryEvidence[];
|
||||
};
|
||||
|
||||
const COMPANY_CONTEXT_LIMIT = 8;
|
||||
const CHECKED_PAGES_LIMIT = 8;
|
||||
const UX_SIGNAL_LIMIT = 6;
|
||||
const CONTENT_SIGNAL_LIMIT = 6;
|
||||
const TECHNICAL_SIGNAL_LIMIT = 6;
|
||||
const PAGESPEED_SIGNAL_LIMIT = 8;
|
||||
const SCREENSHOT_REFERENCE_LIMIT = 8;
|
||||
const SELECTED_SKILLS_LIMIT = 6;
|
||||
|
||||
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"']+/i;
|
||||
const JSON_BRACKET_PATTERN = /\{[^}]*\}|\[[^\]]*\]/;
|
||||
const PAGESPEED_NOISE_PATTERN =
|
||||
/\b(?:raw\s*storage\s*id|rawstorageid|lighthouse|pagespeed|score)\b/i;
|
||||
const MACHINE_TOKEN_PATTERN = /\b[a-z\d_-]{24,}\b/i;
|
||||
|
||||
function trimAndNormalize(input: unknown): string {
|
||||
if (typeof input !== "string") {
|
||||
return "";
|
||||
}
|
||||
return input.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function sanitizeCustomerText(value: unknown, maxLength = 180): string {
|
||||
let text = trimAndNormalize(value);
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
text = text.replace(/<[^>]*>/g, " ");
|
||||
text = text.replace(/\s{2,}/g, " ").trim();
|
||||
|
||||
if (URL_PATTERN.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (JSON_BRACKET_PATTERN.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (PAGESPEED_NOISE_PATTERN.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (MACHINE_TOKEN_PATTERN.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (text.length > maxLength) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!/[a-zäöüß]/i.test(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function addUniqueCapped(
|
||||
bucket: string[],
|
||||
input: string,
|
||||
max: number,
|
||||
sanitizer = sanitizeCustomerText,
|
||||
): void {
|
||||
const candidate = sanitizer(input);
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = candidate.toLowerCase();
|
||||
const alreadyThere = bucket.some((line) => line.toLowerCase() === normalized);
|
||||
if (!alreadyThere && bucket.length < max) {
|
||||
bucket.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function compactPath(urlLike: string): string {
|
||||
try {
|
||||
const parsed = new URL(urlLike);
|
||||
const normalizedPath = (parsed.pathname || "/").replace(/\/+/g, "/").trim();
|
||||
if (!normalizedPath || normalizedPath === "/") {
|
||||
return "Startseite";
|
||||
}
|
||||
return normalizedPath.replace(/^\//, "").slice(0, 70);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function compactLabelForPage(pageKind: string, pageLabel: string): string {
|
||||
if (pageLabel.length > 100) {
|
||||
return pageLabel.slice(0, 100);
|
||||
}
|
||||
|
||||
if (pageKind) {
|
||||
return `${pageKind}: ${pageLabel}`;
|
||||
}
|
||||
|
||||
return pageLabel;
|
||||
}
|
||||
|
||||
function toSafePath(url: string | null | undefined): string {
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return compactPath(url);
|
||||
}
|
||||
|
||||
function selectTopSkill(
|
||||
skills: readonly SkillRegistryEntryEvidence[],
|
||||
category: string,
|
||||
evidenceText: string,
|
||||
): AuditUsedSkill | null {
|
||||
const evidenceTokens = evidenceText
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 3);
|
||||
if (evidenceTokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = skills.filter((skill) => skill.category === category);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scored = candidates.map((candidate) => {
|
||||
const whenToUseText = candidate.whenToUse.toLowerCase();
|
||||
const matchCount = evidenceTokens.filter((token) =>
|
||||
whenToUseText.includes(token),
|
||||
).length;
|
||||
const score = 1 + Math.min(matchCount, 5) + (candidate.version ? 0.1 : 0);
|
||||
|
||||
return {
|
||||
candidate,
|
||||
score,
|
||||
name: candidate.name.toLowerCase(),
|
||||
};
|
||||
});
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (b.score !== a.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return toAuditUsedSkill(scored[0]!.candidate);
|
||||
}
|
||||
|
||||
function buildObservedSignals(
|
||||
crawlPages: readonly AuditCrawlPageEvidence[],
|
||||
technicalChecks: readonly AuditTechnicalCheckEvidence[],
|
||||
): {
|
||||
ux: string[];
|
||||
content: string[];
|
||||
technical: string[];
|
||||
evidenceText: {
|
||||
design: boolean;
|
||||
ux: boolean;
|
||||
copy: boolean;
|
||||
seo: boolean;
|
||||
};
|
||||
} {
|
||||
const uxSignals: string[] = [];
|
||||
const contentSignals: string[] = [];
|
||||
const technicalSignals: string[] = [];
|
||||
|
||||
let designEvidence = false;
|
||||
let uxEvidence = false;
|
||||
let copyEvidence = false;
|
||||
let seoEvidence = false;
|
||||
|
||||
for (const page of crawlPages) {
|
||||
const title = trimAndNormalize(page.title ?? "");
|
||||
if (title) {
|
||||
if (title.length > 4) {
|
||||
copyEvidence = true;
|
||||
addUniqueCapped(
|
||||
contentSignals,
|
||||
`Seitentitel wurde erfasst: ${title}`,
|
||||
CONTENT_SIGNAL_LIMIT,
|
||||
(value) => sanitizeCustomerText(value, 150),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (page.hasContactFormSignal) {
|
||||
uxEvidence = true;
|
||||
addUniqueCapped(
|
||||
uxSignals,
|
||||
"Ein Kontaktformular wurde als potenzieller Einstiegspunkt erkannt.",
|
||||
UX_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
if (page.hasContactCtaSignal) {
|
||||
uxEvidence = true;
|
||||
addUniqueCapped(
|
||||
uxSignals,
|
||||
"Ein klarer Call-to-Action scheint auf der Seite aktiv zu sein.",
|
||||
UX_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
if (page.visibleText || page.visibleTextExcerpt) {
|
||||
copyEvidence = true;
|
||||
addUniqueCapped(
|
||||
contentSignals,
|
||||
"Sichtbarer Text wurde in der Crawl-Auswertung extrahiert.",
|
||||
CONTENT_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const check of technicalChecks) {
|
||||
if (check.usesHttps === false) {
|
||||
uxEvidence = true;
|
||||
addUniqueCapped(
|
||||
technicalSignals,
|
||||
"Ein Teil der Seiten ist nicht per HTTPS erreichbar.",
|
||||
TECHNICAL_SIGNAL_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
uxSignals,
|
||||
"Die sichere Übertragung der Seite ist nicht durchgängig verifiziert.",
|
||||
UX_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
if (check.missingMetaDescription) {
|
||||
seoEvidence = true;
|
||||
addUniqueCapped(
|
||||
technicalSignals,
|
||||
"Fehlende Meta-Beschreibungen können die Auffindbarkeit schwächen.",
|
||||
TECHNICAL_SIGNAL_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
contentSignals,
|
||||
"Meta-Informationen sind teilweise nicht vollständig vorhanden.",
|
||||
CONTENT_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
if (check.missingTitle) {
|
||||
seoEvidence = true;
|
||||
addUniqueCapped(
|
||||
technicalSignals,
|
||||
"Einige Seiten besitzen keinen aussagekräftigen Titel.",
|
||||
TECHNICAL_SIGNAL_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
contentSignals,
|
||||
"Seitentitel fehlen auf ausgewählten Seiten.",
|
||||
CONTENT_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
if (check.hasVisibleContactPath) {
|
||||
uxEvidence = true;
|
||||
addUniqueCapped(
|
||||
uxSignals,
|
||||
"Ein klarer Kontaktpfad scheint bereits vorhanden zu sein.",
|
||||
UX_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
const brokenLinks = check.brokenInternalLinkCount ?? 0;
|
||||
if (brokenLinks > 0) {
|
||||
addUniqueCapped(
|
||||
technicalSignals,
|
||||
`Es wurden ${Math.min(brokenLinks, 10)} interne Verlinkungen mit Fehlerstatus erkannt.`,
|
||||
TECHNICAL_SIGNAL_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
uxSignals,
|
||||
"Nutzer könnten durch interne Linkfehler im Fluss abbrechen.",
|
||||
UX_SIGNAL_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (crawlPages.length > 0 || technicalChecks.length > 0) {
|
||||
designEvidence = true;
|
||||
}
|
||||
if (
|
||||
crawlPages.some(
|
||||
(page) =>
|
||||
page.pageKind === "contact" ||
|
||||
page.pageKind === "impressum" ||
|
||||
page.pageKind === "services",
|
||||
)
|
||||
) {
|
||||
seoEvidence = true;
|
||||
uxEvidence = true;
|
||||
}
|
||||
|
||||
return {
|
||||
ux: uxSignals,
|
||||
content: contentSignals,
|
||||
technical: technicalSignals,
|
||||
evidenceText: {
|
||||
design: designEvidence,
|
||||
ux: uxEvidence,
|
||||
copy: copyEvidence,
|
||||
seo: seoEvidence,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractSkills(
|
||||
skillRegistry: readonly SkillRegistryEntryEvidence[],
|
||||
evidence: {
|
||||
design: boolean;
|
||||
ux: boolean;
|
||||
copy: boolean;
|
||||
seo: boolean;
|
||||
marketing: boolean;
|
||||
offer: boolean;
|
||||
},
|
||||
): AuditUsedSkill[] {
|
||||
const selected: AuditUsedSkill[] = [];
|
||||
const categoryOrder = ["design", "ux", "copy", "seo", "marketing", "offer"] as const;
|
||||
const evidenceText = {
|
||||
design:
|
||||
"visuale layout seite struktur design hierarchie conversion",
|
||||
ux:
|
||||
"kontakt formular cta nutzer flow conversion pfad",
|
||||
copy:
|
||||
"text klarheit copy headline ton local",
|
||||
seo: "local auffindbarkeit meta seo impressum kontakt",
|
||||
marketing: "positionierung unterscheidung angebot",
|
||||
offer: "angebot text preis rahmen",
|
||||
};
|
||||
|
||||
for (const category of categoryOrder) {
|
||||
if (!evidence[category]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = selectTopSkill(
|
||||
skillRegistry,
|
||||
category,
|
||||
evidenceText[category]!,
|
||||
);
|
||||
|
||||
if (match) {
|
||||
selected.push(match);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length > SELECTED_SKILLS_LIMIT) {
|
||||
selected.length = SELECTED_SKILLS_LIMIT;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function buildAuditEvidenceInput(
|
||||
args: AuditEvidenceInputArgs,
|
||||
): AuditEvidenceInput {
|
||||
const lead = args.lead ?? {};
|
||||
const crawlPages = args.crawlPages ?? [];
|
||||
const technicalChecks = args.technicalChecks ?? [];
|
||||
const screenshots = args.screenshots ?? [];
|
||||
const pageSpeedInputs = args.pageSpeedInputs ?? [];
|
||||
const skillRegistry = args.skillRegistry ?? [];
|
||||
|
||||
const companyContext: string[] = [];
|
||||
const checkedPages: string[] = [];
|
||||
const screenshotReferences = screenshots
|
||||
.slice(0, SCREENSHOT_REFERENCE_LIMIT)
|
||||
.map((screenshot) => ({
|
||||
storageId: screenshot.storageId,
|
||||
sourceUrl: screenshot.sourceUrl,
|
||||
viewport: screenshot.viewport,
|
||||
width: screenshot.width,
|
||||
height: screenshot.height,
|
||||
mimeType: screenshot.mimeType,
|
||||
capturedAt: screenshot.capturedAt,
|
||||
}));
|
||||
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Firma: ${lead.companyName ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(companyContext, `Sparte: ${lead.niche ?? ""}`, COMPANY_CONTEXT_LIMIT);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Ort: ${lead.city ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Adresse: ${lead.address ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Domain: ${lead.websiteDomain ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Kontaktperson: ${lead.contactPerson ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Telefon: ${lead.phone ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
addUniqueCapped(
|
||||
companyContext,
|
||||
`Website: ${lead.websiteUrl ?? ""}`,
|
||||
COMPANY_CONTEXT_LIMIT,
|
||||
);
|
||||
|
||||
for (const page of crawlPages) {
|
||||
const safePath = toSafePath(page.finalUrl ?? page.sourceUrl ?? "");
|
||||
const title = sanitizeCustomerText(page.title ?? "", 90);
|
||||
|
||||
const label = compactLabelForPage(
|
||||
page.pageKind ?? "Seite",
|
||||
title || safePath,
|
||||
);
|
||||
if (!label || label === page.pageKind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
addUniqueCapped(checkedPages, label, CHECKED_PAGES_LIMIT);
|
||||
}
|
||||
|
||||
if (checkedPages.length === 0 && lead.companyName) {
|
||||
addUniqueCapped(
|
||||
checkedPages,
|
||||
`Website-Startseite analysiert: ${lead.companyName}`,
|
||||
CHECKED_PAGES_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
const signals = buildObservedSignals(crawlPages, technicalChecks);
|
||||
const pageSpeedInputsOutput = buildPageSpeedAuditInputs(pageSpeedInputs);
|
||||
const pageSpeedCustomerImplications: string[] = [];
|
||||
|
||||
for (const implication of pageSpeedInputsOutput.customerImplications) {
|
||||
addUniqueCapped(
|
||||
pageSpeedCustomerImplications,
|
||||
implication,
|
||||
PAGESPEED_SIGNAL_LIMIT,
|
||||
sanitizeCustomerText,
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSkills = extractSkills(skillRegistry, {
|
||||
...signals.evidenceText,
|
||||
marketing: false,
|
||||
offer: false,
|
||||
});
|
||||
|
||||
return {
|
||||
companyContext,
|
||||
checkedPages,
|
||||
observedUxSignals: signals.ux,
|
||||
observedContentSignals: signals.content,
|
||||
observedTechnicalSignals: signals.technical,
|
||||
screenshotReferences: screenshotReferences.map((reference) => ({
|
||||
...reference,
|
||||
width: Math.max(reference.width, 0),
|
||||
height: Math.max(reference.height, 0),
|
||||
capturedAt: Number(reference.capturedAt),
|
||||
})),
|
||||
pageSpeedCustomerImplications: pageSpeedCustomerImplications.slice(
|
||||
0,
|
||||
PAGESPEED_SIGNAL_LIMIT,
|
||||
),
|
||||
selectedSkills,
|
||||
};
|
||||
}
|
||||
482
lib/ai/german-copy-guard.ts
Normal file
482
lib/ai/german-copy-guard.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
const GERMAN_MARKERS = new Set([
|
||||
"ich",
|
||||
"mich",
|
||||
"mir",
|
||||
"mein",
|
||||
"meine",
|
||||
"wir",
|
||||
"du",
|
||||
"sie",
|
||||
"er",
|
||||
"sie",
|
||||
"der",
|
||||
"die",
|
||||
"das",
|
||||
"und",
|
||||
"ist",
|
||||
"sind",
|
||||
"sind",
|
||||
"waren",
|
||||
"hat",
|
||||
"habe",
|
||||
"haben",
|
||||
"eine",
|
||||
"einer",
|
||||
"einem",
|
||||
"dieser",
|
||||
"diese",
|
||||
"dieses",
|
||||
"nicht",
|
||||
"mit",
|
||||
"wenn",
|
||||
"für",
|
||||
"bei",
|
||||
]);
|
||||
|
||||
const ENGLISH_MARKERS = new Set([
|
||||
"the",
|
||||
"and",
|
||||
"you",
|
||||
"your",
|
||||
"we",
|
||||
"our",
|
||||
"is",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"to",
|
||||
"of",
|
||||
"in",
|
||||
"for",
|
||||
"on",
|
||||
"with",
|
||||
"this",
|
||||
"that",
|
||||
"it",
|
||||
"from",
|
||||
"have",
|
||||
"has",
|
||||
"will",
|
||||
"can",
|
||||
"if",
|
||||
"quick",
|
||||
"audit",
|
||||
"bad",
|
||||
"website",
|
||||
"report",
|
||||
]);
|
||||
|
||||
const OBSERVATION_TOKENS = [
|
||||
/\b(mir|ich)\b[^\n]{0,80}\b(aufgefallen|festgestellt|bemerkt|beobachtet|gesehen|sichtbar)\b/i,
|
||||
/\b(erkennt|zeigt|sichtbar|feststell|finde|fällt)\b/i,
|
||||
/\b(ich sehe|ich habe gesehen|bei der Prüfung)\b/i,
|
||||
];
|
||||
|
||||
const SUGGESTION_TOKENS = [
|
||||
/\b(empfehle|empfiehlt|vorschlage|vorschlagen|schlage vor|könnte helfen|kannst|können wir|sollte|sollten|ich könnte|ich würde|ich empfehle)\b/i,
|
||||
/\b(schlage vor|schlage)\b/i,
|
||||
/\b(ergänzt|ergänzen|anpassen|optimieren|verbessern|prüfen|einbauen|einzusetzen|setzten)\b/i,
|
||||
];
|
||||
|
||||
const AI_SLOP_TOKENS = [
|
||||
/\bmaßgeschneid(?:ert|ert|er)\b/i,
|
||||
/\bnahtlos\b/i,
|
||||
/\bstate[- ]of[- ]the[- ]art\b/i,
|
||||
/\bgame[- ]?changer\b/i,
|
||||
/\bsynerg(?:ie|istisch)\b/i,
|
||||
/\brevolutionär\b/i,
|
||||
/\bnext level\b/i,
|
||||
/\bzukunftsweisend\b/i,
|
||||
/\bdigital transformieren\b/i,
|
||||
/\boutstanding\b/i,
|
||||
/\bhebt.{0,20}Sichtbarkeit\b/i,
|
||||
];
|
||||
|
||||
const HOSTILE_TOKENS = [
|
||||
/\b(Ihr|Ihre|Sie|eure|euer)\b[^\n.!?]{0,80}\b(katastroph|schlecht|veraltet|unprofessionell|unbrauchbar|mangelhaft|chaotisch|desastr|desaster|skrupellos)\b/i,
|
||||
/\b(ist|sind)\s+(?:total|absolut)\s+(?:schlecht|kaputt|katastroph)\b/i,
|
||||
/\babsolut unprofessionell\b/i,
|
||||
];
|
||||
|
||||
const SCORE_CONTEXT_TOKENS = [
|
||||
/\b(?:pagespeed|lighthouse|score)\b[^\n]{0,120}\b\d{1,2}(?:[.,]\d+)?%?/i,
|
||||
/\b\d{1,2}(?:[.,]\d+)?%?[^\n]{0,120}\b(?:pagespeed|lighthouse|score)\b/i,
|
||||
];
|
||||
|
||||
const PRICE_PATTERNS = [
|
||||
/\b\d{1,4}\s*(?:€|EUR|Euro|euro)/,
|
||||
/(?:€|EUR|Euro|euro)\s*\d{1,4}(?:[.,]\d{1,2})?/,
|
||||
/\b(?:preis|preise|kosten)\b[^a-z]{0,5}\d{1,4}\s*(?:€|EUR|Euro|euro)?/i,
|
||||
];
|
||||
|
||||
const RAW_TECH_PATTERNS = [
|
||||
/\braw\s*storage\s*id\b/i,
|
||||
/\bstorage[_-]?id\b/i,
|
||||
/\bmodel[_-]?id\b/i,
|
||||
/\b(?:gpt|claude|gemini|llama|mistral|qwen|mixtral|deepseek|phi|sonar|gemma)\b[-\w]*/i,
|
||||
/\{[^\n]{0,240}:[^\n]{0,240}\}/,
|
||||
/\[[^\n]{0,240}\]/,
|
||||
/\b[0-9a-f]{24}\b/i,
|
||||
];
|
||||
|
||||
export type GermanCopyGuardIssue = {
|
||||
field: string;
|
||||
rule: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type GermanCopyGuardResult = {
|
||||
passed: boolean;
|
||||
issues: GermanCopyGuardIssue[];
|
||||
};
|
||||
|
||||
export type AuditCopy = {
|
||||
summary: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type EmailCopy = {
|
||||
subject: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type CallScriptCopy = {
|
||||
openingLine: string;
|
||||
callScript: string[];
|
||||
closeLine: string;
|
||||
};
|
||||
|
||||
export type FollowUpCopy = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type GermanCustomerCopy = {
|
||||
auditSummary?: string;
|
||||
auditBody?: string;
|
||||
emailSubject?: string;
|
||||
emailBody?: string;
|
||||
callScript?: CallScriptCopy;
|
||||
followUp?: string;
|
||||
};
|
||||
|
||||
type ValidationOptions = {
|
||||
requireIchForm?: boolean;
|
||||
requireObservationAndSuggestion?: boolean;
|
||||
skipIfTooShort?: boolean;
|
||||
};
|
||||
|
||||
function addIssue(
|
||||
issues: GermanCopyGuardIssue[],
|
||||
field: string,
|
||||
rule: string,
|
||||
message: string,
|
||||
) {
|
||||
issues.push({ field, rule, message });
|
||||
}
|
||||
|
||||
function tokenizeWords(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.match(/[a-zäöüß]{3,}/giu)
|
||||
?.map((token) => token.toLowerCase()) ?? [];
|
||||
}
|
||||
|
||||
function hasGermanAnchor(value: string): boolean {
|
||||
const words = tokenizeWords(value);
|
||||
|
||||
if (!words.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/[äöüß]/i.test(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const germanCount = words.reduce(
|
||||
(count, word) => count + (GERMAN_MARKERS.has(word) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
const englishCount = words.reduce(
|
||||
(count, word) => count + (ENGLISH_MARKERS.has(word) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (words.length <= 4) {
|
||||
if (germanCount >= 1) {
|
||||
return true;
|
||||
}
|
||||
return englishCount === 0;
|
||||
}
|
||||
|
||||
if (germanCount >= 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (englishCount === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (englishCount / words.length >= 0.2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasIchForm(value: string): boolean {
|
||||
return /\b(ich|mich|mir|mein|meine|meinem|meiner)\b/i.test(value);
|
||||
}
|
||||
|
||||
function hasObservation(value: string): boolean {
|
||||
return OBSERVATION_TOKENS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function hasSuggestion(value: string): boolean {
|
||||
return SUGGESTION_TOKENS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function hasAiSlop(value: string): boolean {
|
||||
return AI_SLOP_TOKENS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function hasHostileTone(value: string): boolean {
|
||||
return HOSTILE_TOKENS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function hasScoreArtifact(value: string): boolean {
|
||||
return SCORE_CONTEXT_TOKENS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function hasPrice(value: string): boolean {
|
||||
return PRICE_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function hasRawArtifact(value: string): boolean {
|
||||
return RAW_TECH_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function validateTextField(
|
||||
issues: GermanCopyGuardIssue[],
|
||||
field: string,
|
||||
value: string,
|
||||
options: ValidationOptions = {},
|
||||
) {
|
||||
if (options.skipIfTooShort && value.trim().length < 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasGermanAnchor(value)) {
|
||||
addIssue(
|
||||
issues,
|
||||
field,
|
||||
"not_german",
|
||||
"Text wirkt nicht ausreichend deutsch.",
|
||||
);
|
||||
}
|
||||
|
||||
if (options.requireIchForm && !hasIchForm(value)) {
|
||||
addIssue(
|
||||
issues,
|
||||
field,
|
||||
"missing_ich_form",
|
||||
"Text sollte in Ich-Form geschrieben sein.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasScoreArtifact(value)) {
|
||||
addIssue(
|
||||
issues,
|
||||
field,
|
||||
"pagespeed_score_artifact",
|
||||
"Technische Score-/PageSpeed-Werte sollten nicht im Kunden-Text erscheinen.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPrice(value)) {
|
||||
addIssue(
|
||||
issues,
|
||||
field,
|
||||
"price_mention",
|
||||
"Preis- oder Währungsangaben sollten im Kunden-Text vermieden werden.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasAiSlop(value)) {
|
||||
addIssue(
|
||||
issues,
|
||||
field,
|
||||
"generic_ai_slop",
|
||||
"Generische KI-Slop-Formulierungen erkannt.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasHostileTone(value)) {
|
||||
addIssue(
|
||||
issues,
|
||||
field,
|
||||
"hostile_tone",
|
||||
"Anklagende oder negativ wertende Sprache wurde erkannt.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasRawArtifact(value)) {
|
||||
addIssue(
|
||||
issues,
|
||||
field,
|
||||
"raw_technical_artifact",
|
||||
"Technische Artefakte im Text erkannt.",
|
||||
);
|
||||
}
|
||||
|
||||
if (options.requireObservationAndSuggestion && (!hasObservation(value) || !hasSuggestion(value))) {
|
||||
addIssue(
|
||||
issues,
|
||||
field,
|
||||
"missing_observation_or_suggestion",
|
||||
"Beobachtung und Vorschlag sollten im gleichen Text erkennbar sein.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCallScriptText(
|
||||
issues: GermanCopyGuardIssue[],
|
||||
linePrefix: string,
|
||||
scriptLine: string,
|
||||
options: ValidationOptions,
|
||||
) {
|
||||
const lineValue = scriptLine?.trim();
|
||||
if (!lineValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateTextField(issues, linePrefix, lineValue, options);
|
||||
}
|
||||
|
||||
export function validateAuditCopy(audit: AuditCopy): GermanCopyGuardResult {
|
||||
const issues: GermanCopyGuardIssue[] = [];
|
||||
|
||||
validateTextField(issues, "auditSummary", audit.summary, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
});
|
||||
validateTextField(issues, "auditBody", audit.body, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
});
|
||||
|
||||
return { passed: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
export function validateEmailCopy(email: EmailCopy): GermanCopyGuardResult {
|
||||
const issues: GermanCopyGuardIssue[] = [];
|
||||
|
||||
validateTextField(issues, "emailSubject", email.subject, { skipIfTooShort: true });
|
||||
validateTextField(issues, "emailBody", email.body, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
});
|
||||
|
||||
return { passed: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
export function validateCallScriptCopy(script: CallScriptCopy): GermanCopyGuardResult {
|
||||
const issues: GermanCopyGuardIssue[] = [];
|
||||
|
||||
validateCallScriptText(issues, "callScript.openingLine", script.openingLine, {
|
||||
requireIchForm: true,
|
||||
});
|
||||
validateCallScriptText(issues, "callScript.closeLine", script.closeLine, {
|
||||
requireIchForm: true,
|
||||
});
|
||||
|
||||
script.callScript.forEach((line, index) => {
|
||||
validateCallScriptText(
|
||||
issues,
|
||||
`callScript.callScript[${index}]`,
|
||||
line,
|
||||
{
|
||||
requireIchForm: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const scriptConcatenated = [
|
||||
script.openingLine,
|
||||
...script.callScript,
|
||||
script.closeLine,
|
||||
]
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.join(" ");
|
||||
|
||||
if (!hasObservation(scriptConcatenated) || !hasSuggestion(scriptConcatenated)) {
|
||||
addIssue(
|
||||
issues,
|
||||
"callScript",
|
||||
"missing_observation_or_suggestion",
|
||||
"Beobachtung und Vorschlag sollten im Call-Script erkennbar sein.",
|
||||
);
|
||||
}
|
||||
|
||||
return { passed: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
export function validateFollowUpCopy(followUp: FollowUpCopy): GermanCopyGuardResult {
|
||||
const issues: GermanCopyGuardIssue[] = [];
|
||||
|
||||
validateTextField(issues, "followUp", followUp.message, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
});
|
||||
|
||||
return { passed: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
export function validateCustomerFacingCopy(input: GermanCustomerCopy): GermanCopyGuardResult {
|
||||
const issues: GermanCopyGuardIssue[] = [];
|
||||
|
||||
if (input.auditSummary !== undefined) {
|
||||
validateTextField(issues, "auditSummary", input.auditSummary, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.auditBody !== undefined) {
|
||||
validateTextField(issues, "auditBody", input.auditBody, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.emailSubject !== undefined) {
|
||||
validateTextField(issues, "emailSubject", input.emailSubject, {
|
||||
skipIfTooShort: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.emailBody !== undefined) {
|
||||
validateTextField(issues, "emailBody", input.emailBody, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.callScript) {
|
||||
issues.push(
|
||||
...validateCallScriptCopy({
|
||||
openingLine: input.callScript.openingLine,
|
||||
callScript: [...input.callScript.callScript],
|
||||
closeLine: input.callScript.closeLine,
|
||||
}).issues,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.followUp !== undefined) {
|
||||
validateTextField(issues, "followUp", input.followUp, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { passed: issues.length === 0, issues };
|
||||
}
|
||||
81
lib/ai/model-profiles.ts
Normal file
81
lib/ai/model-profiles.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export const MODEL_PROFILE_KEYS = [
|
||||
"classification",
|
||||
"multimodalAudit",
|
||||
"germanCopy",
|
||||
"qualityReview",
|
||||
] as const;
|
||||
|
||||
export type ModelProfileKey = (typeof MODEL_PROFILE_KEYS)[number];
|
||||
|
||||
export type AiModelProfile = {
|
||||
modelId: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
supportsImages: boolean;
|
||||
stage: (typeof MODEL_PROFILE_KEYS)[number];
|
||||
envOverrideKey: string;
|
||||
};
|
||||
|
||||
export const MODEL_PROFILES: Record<ModelProfileKey, AiModelProfile> = {
|
||||
classification: {
|
||||
modelId: "openai/gpt-4.1-mini",
|
||||
temperature: 0.2,
|
||||
maxTokens: 1200,
|
||||
supportsImages: false,
|
||||
stage: "classification",
|
||||
envOverrideKey: "OPENROUTER_MODEL_CLASSIFICATION",
|
||||
},
|
||||
multimodalAudit: {
|
||||
modelId: "openai/gpt-4.1-mini",
|
||||
temperature: 0.3,
|
||||
maxTokens: 2800,
|
||||
supportsImages: true,
|
||||
stage: "multimodalAudit",
|
||||
envOverrideKey: "OPENROUTER_MODEL_MULTIMODAL_AUDIT",
|
||||
},
|
||||
germanCopy: {
|
||||
modelId: "openai/gpt-4.1-mini",
|
||||
temperature: 0.4,
|
||||
maxTokens: 1800,
|
||||
supportsImages: false,
|
||||
stage: "germanCopy",
|
||||
envOverrideKey: "OPENROUTER_MODEL_GERMAN_COPY",
|
||||
},
|
||||
qualityReview: {
|
||||
modelId: "openai/gpt-4.1-mini",
|
||||
temperature: 0.1,
|
||||
maxTokens: 900,
|
||||
supportsImages: false,
|
||||
stage: "qualityReview",
|
||||
envOverrideKey: "OPENROUTER_MODEL_QUALITY_REVIEW",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function normalizeModelOverride(value: string | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
export function resolveModelProfile(
|
||||
profileKey: ModelProfileKey,
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): AiModelProfile {
|
||||
const profile = MODEL_PROFILES[profileKey];
|
||||
const override = normalizeModelOverride(env[profile.envOverrideKey]);
|
||||
|
||||
return {
|
||||
...profile,
|
||||
modelId: override ?? profile.modelId,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveModelId(
|
||||
profileKey: ModelProfileKey,
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): string {
|
||||
const profile = MODEL_PROFILES[profileKey];
|
||||
const override = normalizeModelOverride(env[profile.envOverrideKey]);
|
||||
|
||||
return override ?? profile.modelId;
|
||||
}
|
||||
35
lib/ai/openrouter-provider.ts
Normal file
35
lib/ai/openrouter-provider.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
|
||||
type OpenRouterEnv = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
function normalizeOptionalEnvValue(value: string | undefined): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
export function createOpenRouterProvider(
|
||||
env: OpenRouterEnv = process.env,
|
||||
): ReturnType<typeof createOpenRouter> {
|
||||
const apiKey = normalizeOptionalEnvValue(env.OPENROUTER_API_KEY);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENROUTER_API_KEY is required for OpenRouter provider.");
|
||||
}
|
||||
|
||||
const appName = normalizeOptionalEnvValue(env.OPENROUTER_APP_NAME);
|
||||
const appUrl = normalizeOptionalEnvValue(env.OPENROUTER_APP_URL);
|
||||
|
||||
return createOpenRouter({
|
||||
apiKey,
|
||||
appName,
|
||||
appUrl,
|
||||
compatibility: "strict",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
58
lib/ai/schemas.ts
Normal file
58
lib/ai/schemas.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const findingItemSchema = z.object({
|
||||
section: z.string(),
|
||||
finding: z.string(),
|
||||
suggestion: z.string(),
|
||||
});
|
||||
|
||||
export const internalFindingsSchema = z.object({
|
||||
findings: z.array(findingItemSchema),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
export const auditSummarySchema = z.object({
|
||||
summary: z.string(),
|
||||
keyFindings: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const publicAuditTextSchema = z.object({
|
||||
publicText: z.string(),
|
||||
});
|
||||
|
||||
export const emailDraftSchema = z.object({
|
||||
body: z.string(),
|
||||
});
|
||||
|
||||
export const emailSubjectSchema = z.object({
|
||||
subject: z.string(),
|
||||
});
|
||||
|
||||
export const callScriptSchema = z.object({
|
||||
openingLine: z.string(),
|
||||
callScript: z.array(z.string()),
|
||||
closeLine: z.string(),
|
||||
});
|
||||
|
||||
export const followUpDraftSchema = z.object({
|
||||
message: z.string(),
|
||||
followInDays: z.number().int().min(0).optional(),
|
||||
goals: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const qualityReviewSchema = z.object({
|
||||
isValid: z.boolean(),
|
||||
issues: z.array(z.string()),
|
||||
suggestions: z.array(z.string()),
|
||||
notes: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type FindingItem = z.infer<typeof findingItemSchema>;
|
||||
export type InternalFindings = z.infer<typeof internalFindingsSchema>;
|
||||
export type AuditSummary = z.infer<typeof auditSummarySchema>;
|
||||
export type PublicAuditText = z.infer<typeof publicAuditTextSchema>;
|
||||
export type EmailDraft = z.infer<typeof emailDraftSchema>;
|
||||
export type EmailSubject = z.infer<typeof emailSubjectSchema>;
|
||||
export type CallScript = z.infer<typeof callScriptSchema>;
|
||||
export type FollowUpDraft = z.infer<typeof followUpDraftSchema>;
|
||||
export type QualityReview = z.infer<typeof qualityReviewSchema>;
|
||||
178
lib/skills-registry.ts
Normal file
178
lib/skills-registry.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const SKILL_CATEGORIES = [
|
||||
"design",
|
||||
"ux",
|
||||
"marketing",
|
||||
"copy",
|
||||
"seo",
|
||||
"offer",
|
||||
] as const;
|
||||
|
||||
export type SkillCategory = (typeof SKILL_CATEGORIES)[number];
|
||||
|
||||
export type SkillRegistryEntry = {
|
||||
name: string;
|
||||
purpose: string;
|
||||
whenToUse: string;
|
||||
whenNotToUse: string;
|
||||
requiredInput: string;
|
||||
expectedOutput: string;
|
||||
category: SkillCategory;
|
||||
version?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type AuditUsedSkill = {
|
||||
name: string;
|
||||
category: SkillCategory;
|
||||
version?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
type ParsedFieldName =
|
||||
| "Purpose"
|
||||
| "When to use"
|
||||
| "When not to use"
|
||||
| "Required input"
|
||||
| "Expected output"
|
||||
| "Category"
|
||||
| "Version"
|
||||
| "Source";
|
||||
|
||||
const REQUIRED_FIELDS: ParsedFieldName[] = [
|
||||
"Purpose",
|
||||
"When to use",
|
||||
"When not to use",
|
||||
"Required input",
|
||||
"Expected output",
|
||||
"Category",
|
||||
];
|
||||
|
||||
const FIELD_LABELS_RE = /^(Purpose|When to use|When not to use|Required input|Expected output|Category|Version|Source):\s*(.*?)\s*$/;
|
||||
|
||||
function normalizeCategory(value: string): SkillCategory {
|
||||
const normalized = value.toLowerCase();
|
||||
if (!isValidSkillCategory(normalized)) {
|
||||
throw new Error(
|
||||
`Unknown category "${value}". Valid categories are: ${SKILL_CATEGORIES.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isValidSkillCategory(
|
||||
value: string,
|
||||
): value is SkillCategory {
|
||||
return (SKILL_CATEGORIES as ReadonlyArray<string>).includes(value);
|
||||
}
|
||||
|
||||
function parseSection(lines: string[], sectionIndex: number): SkillRegistryEntry {
|
||||
let name: string | null = null;
|
||||
const values: Partial<Record<ParsedFieldName, string>> = {};
|
||||
let currentField: ParsedFieldName | null = null;
|
||||
|
||||
const sectionTitle = lines[0];
|
||||
if (!sectionTitle.startsWith("##")) {
|
||||
throw new Error(`Expected section ${sectionIndex} to start with a skill header.`);
|
||||
}
|
||||
name = sectionTitle.replace(/^##\s*/, "").trim();
|
||||
if (name.length === 0) {
|
||||
throw new Error(`Skill section ${sectionIndex} has an empty name.`);
|
||||
}
|
||||
|
||||
for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) {
|
||||
const line = lines[lineIndex];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (trimmedLine.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = trimmedLine.match(FIELD_LABELS_RE);
|
||||
if (match) {
|
||||
const field = match[1] as ParsedFieldName;
|
||||
currentField = field;
|
||||
values[field] = match[2].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentField === null) {
|
||||
throw new Error(`Unexpected line in section "${name}": ${line}`);
|
||||
}
|
||||
|
||||
values[currentField] = `${values[currentField] ?? ""}\n${line.trim()}`.trim();
|
||||
}
|
||||
|
||||
for (const requiredField of REQUIRED_FIELDS) {
|
||||
const value = values[requiredField]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
`Missing required field "${requiredField}" for skill "${name}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const category = normalizeCategory(values.Category!.trim());
|
||||
|
||||
return {
|
||||
name,
|
||||
purpose: values["Purpose"]!,
|
||||
whenToUse: values["When to use"]!,
|
||||
whenNotToUse: values["When not to use"]!,
|
||||
requiredInput: values["Required input"]!,
|
||||
expectedOutput: values["Expected output"]!,
|
||||
category,
|
||||
version: values["Version"]?.trim() || undefined,
|
||||
source: values["Source"]?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSkillsRegistry(source: string): SkillRegistryEntry[] {
|
||||
const normalized = source.replace(/\r\n/g, "\n");
|
||||
const rawSections = normalized
|
||||
.split(/^##\s+/m)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const entries: SkillRegistryEntry[] = [];
|
||||
const names = new Set<string>();
|
||||
|
||||
for (let index = 0; index < rawSections.length; index += 1) {
|
||||
const rawSection = rawSections[index];
|
||||
const lines = rawSection
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line, lineIndex) => line.length > 0 || lineIndex === 0);
|
||||
|
||||
const sectionLines = [`## ${lines.at(0) ?? ""}`, ...lines.slice(1)];
|
||||
const parsed = parseSection(sectionLines, index + 1);
|
||||
|
||||
const normalizedName = parsed.name.trim().toLowerCase();
|
||||
if (names.has(normalizedName)) {
|
||||
throw new Error(`Duplicate skill name "${parsed.name}" in skills registry.`);
|
||||
}
|
||||
|
||||
names.add(normalizedName);
|
||||
entries.push(parsed);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function loadSkillsRegistry(
|
||||
registryPath = join(process.cwd(), "skills.md"),
|
||||
): Promise<SkillRegistryEntry[]> {
|
||||
const source = await readFile(registryPath, "utf8");
|
||||
return parseSkillsRegistry(source);
|
||||
}
|
||||
|
||||
export function toAuditUsedSkill(skill: SkillRegistryEntry): AuditUsedSkill {
|
||||
return {
|
||||
name: skill.name,
|
||||
category: skill.category,
|
||||
version: skill.version,
|
||||
source: skill.source,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,9 @@
|
||||
"dependencies": {
|
||||
"@convex-dev/better-auth": "^0.12.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@openrouter/ai-sdk-provider": "^2.9.0",
|
||||
"@sparticuz/chromium-min": "^149.0.0",
|
||||
"ai": "^6.0.196",
|
||||
"better-auth": "^1.6.14",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
142
pnpm-lock.yaml
generated
142
pnpm-lock.yaml
generated
@@ -10,16 +10,22 @@ importers:
|
||||
dependencies:
|
||||
'@convex-dev/better-auth':
|
||||
specifier: ^0.12.2
|
||||
version: 0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)
|
||||
version: 0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.4.0
|
||||
version: 5.4.0(react-hook-form@7.77.0(react@19.2.4))
|
||||
'@openrouter/ai-sdk-provider':
|
||||
specifier: ^2.9.0
|
||||
version: 2.9.0(ai@6.0.196(zod@4.4.3))(zod@4.4.3)
|
||||
'@sparticuz/chromium-min':
|
||||
specifier: ^149.0.0
|
||||
version: 149.0.0
|
||||
ai:
|
||||
specifier: ^6.0.196
|
||||
version: 6.0.196(zod@4.4.3)
|
||||
better-auth:
|
||||
specifier: ^1.6.14
|
||||
version: 1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -34,7 +40,7 @@ importers:
|
||||
version: 1.17.0(react@19.2.4)
|
||||
next:
|
||||
specifier: 16.2.7
|
||||
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
playwright-core:
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
@@ -90,6 +96,22 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@ai-sdk/gateway@3.0.124':
|
||||
resolution: {integrity: sha512-h8CrmbSG+8X0C+M/E1M4oiDHYevqwbzAPN+uLRHS0eJaatF2MZ+juNtOHXNOjk7Bsk9mD2RjYMjJO9dFkb9I7Q==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.27':
|
||||
resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider@3.0.10':
|
||||
resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -898,6 +920,17 @@ packages:
|
||||
'@open-draft/until@2.1.0':
|
||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||
|
||||
'@openrouter/ai-sdk-provider@2.9.0':
|
||||
resolution: {integrity: sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
ai: ^6.0.0
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@opentelemetry/api@1.9.1':
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.41.1':
|
||||
resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1921,6 +1954,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@vercel/oidc@3.2.0':
|
||||
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -1939,6 +1976,12 @@ packages:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ai@6.0.196:
|
||||
resolution: {integrity: sha512-2T45UeqKL4a11KQ14I5i1YYHOvCFrMF478E1k6PVjlQSGUvXSv4xrxIaQbUL4qgv91DADSbddwv3oR49pPAK3g==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
@@ -3204,6 +3247,9 @@ packages:
|
||||
json-schema-typed@8.0.2:
|
||||
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||
|
||||
json-schema@0.4.0:
|
||||
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
@@ -4302,6 +4348,24 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@ai-sdk/gateway@3.0.124(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.10
|
||||
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
|
||||
'@vercel/oidc': 3.2.0
|
||||
zod: 4.4.3
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.27(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.10
|
||||
'@standard-schema/spec': 1.1.0
|
||||
eventsource-parser: 3.1.0
|
||||
zod: 4.4.3
|
||||
|
||||
'@ai-sdk/provider@3.0.10':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
@@ -4490,7 +4554,7 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
|
||||
'@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.4.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
@@ -4501,37 +4565,39 @@ snapshots:
|
||||
kysely: 0.29.2
|
||||
nanostores: 1.3.0
|
||||
zod: 4.4.3
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@better-auth/drizzle-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
|
||||
'@better-auth/drizzle-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.1
|
||||
|
||||
'@better-auth/kysely-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)':
|
||||
'@better-auth/kysely-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.1
|
||||
optionalDependencies:
|
||||
kysely: 0.29.2
|
||||
|
||||
'@better-auth/memory-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
|
||||
'@better-auth/memory-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.1
|
||||
|
||||
'@better-auth/mongo-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
|
||||
'@better-auth/mongo-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.1
|
||||
|
||||
'@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
|
||||
'@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.1
|
||||
|
||||
'@better-auth/telemetry@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)':
|
||||
'@better-auth/telemetry@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
|
||||
@@ -4541,10 +4607,10 @@ snapshots:
|
||||
|
||||
'@better-fetch/fetch@1.1.21': {}
|
||||
|
||||
'@convex-dev/better-auth@0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)':
|
||||
'@convex-dev/better-auth@0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
better-auth: 1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
better-auth: 1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
common-tags: 1.8.2
|
||||
convex: 1.40.0(react@19.2.4)
|
||||
convex-helpers: 0.1.118(@standard-schema/spec@1.1.0)(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)(zod@4.4.3)
|
||||
@@ -5007,6 +5073,13 @@ snapshots:
|
||||
|
||||
'@open-draft/until@2.1.0': {}
|
||||
|
||||
'@openrouter/ai-sdk-provider@2.9.0(ai@6.0.196(zod@4.4.3))(zod@4.4.3)':
|
||||
dependencies:
|
||||
ai: 6.0.196(zod@4.4.3)
|
||||
zod: 4.4.3
|
||||
|
||||
'@opentelemetry/api@1.9.1': {}
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.41.1': {}
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
@@ -6045,6 +6118,8 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
|
||||
optional: true
|
||||
|
||||
'@vercel/oidc@3.2.0': {}
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -6058,6 +6133,14 @@ snapshots:
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ai@6.0.196(zod@4.4.3):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.124(zod@4.4.3)
|
||||
'@ai-sdk/provider': 3.0.10
|
||||
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
|
||||
'@opentelemetry/api': 1.9.1
|
||||
zod: 4.4.3
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.20.0
|
||||
@@ -6217,15 +6300,15 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.33: {}
|
||||
|
||||
better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
better-auth@1.6.14(@opentelemetry/api@1.9.1)(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
|
||||
'@better-auth/kysely-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)
|
||||
'@better-auth/memory-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
|
||||
'@better-auth/mongo-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
|
||||
'@better-auth/prisma-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
|
||||
'@better-auth/telemetry': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)
|
||||
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
|
||||
'@better-auth/kysely-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)
|
||||
'@better-auth/memory-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
|
||||
'@better-auth/mongo-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
|
||||
'@better-auth/prisma-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
|
||||
'@better-auth/telemetry': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)
|
||||
'@better-auth/utils': 0.4.1
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@noble/ciphers': 2.2.0
|
||||
@@ -6237,7 +6320,7 @@ snapshots:
|
||||
nanostores: 1.3.0
|
||||
zod: 4.4.3
|
||||
optionalDependencies:
|
||||
next: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
transitivePeerDependencies:
|
||||
@@ -7412,6 +7495,8 @@ snapshots:
|
||||
|
||||
json-schema-typed@8.0.2: {}
|
||||
|
||||
json-schema@0.4.0: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json5@1.0.2:
|
||||
@@ -7606,7 +7691,7 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.2.7
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -7625,6 +7710,7 @@ snapshots:
|
||||
'@next/swc-linux-x64-musl': 16.2.7
|
||||
'@next/swc-win32-arm64-msvc': 16.2.7
|
||||
'@next/swc-win32-x64-msvc': 16.2.7
|
||||
'@opentelemetry/api': 1.9.1
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
59
skills.md
Normal file
59
skills.md
Normal file
@@ -0,0 +1,59 @@
|
||||
## Design Audit
|
||||
Purpose: Prüft die visuelle Qualität einer Seite auf Konsistenz, Orientierung und Conversion-Relevanz.
|
||||
When to use: Nutze diesen Skill, wenn das Website-Layout, visuelle Hierarchie oder die Markenwirkung bewertet werden soll.
|
||||
When not to use: Nicht für technische Crawling- oder Performance-Analyse.
|
||||
Required input: URL, Seitenstruktur, Kernaussagen, Designsystem- oder Style-Constraints.
|
||||
Expected output: Konkrete Verbesserungsvorschläge mit Priorität nach Wirkung auf Wahrnehmung und Conversion.
|
||||
Category: design
|
||||
Version: 1.0
|
||||
Source: skills/design-audit.md
|
||||
|
||||
## UX Friction Review
|
||||
Purpose: Findet Reibungspunkte in Nutzerfluss, Formularen und Aktionen, die Nutzer vom Ziel abhalten.
|
||||
When to use: Nutze diesen Skill für Kontaktformulare, Terminbuchung und erste Kontaktversuche.
|
||||
When not to use: Nicht verwenden, wenn keine klaren User-Flows sichtbar sind oder Rohdaten fehlen.
|
||||
Required input: URL, beobachtete Nutzerwege, wichtigste Aktionen und Ziel-CTA.
|
||||
Expected output: Priorisierte Liste der größten UX-Hürden mit klaren, umsetzbaren Gegenmaßnahmen.
|
||||
Category: ux
|
||||
Version: 1.0
|
||||
Source: skills/ux-friction-review.md
|
||||
|
||||
## Marketing Positioning
|
||||
Purpose: Schärft die Positionierung für lokales B2B/B2C-Webdesign im Wettbewerb.
|
||||
When to use: Nutze ihn, wenn Differenzierung, Angebotsschwerpunkt und Nutzenstory fehlen.
|
||||
When not to use: Nicht für reine SEO-Fix-Lists oder technische Fehlerbehebungen.
|
||||
Required input: Leistungsversprechen, Zielkundensegment, lokale Wettbewerbslage, bisheriger Auftritt.
|
||||
Expected output: Klare Positionierungshypothese mit passenden USPs und Zielgruppen-Fokus.
|
||||
Category: marketing
|
||||
Version: 1.0
|
||||
Source: skills/marketing-positioning.md
|
||||
|
||||
## Copy Clarity
|
||||
Purpose: Optimiert Klarheit, Lesbarkeit und Tonalität für Webseiten- und Outreach-Texte.
|
||||
When to use: Nutze diesen Skill bei unklaren, langen oder unpassend abstrakten Texten.
|
||||
When not to use: Nicht bei legalen Texten mit festen Formulierungen oder Produkt-CTAs ohne Kontext.
|
||||
Required input: Zielgruppe, Zweck des Textes, bestehender Entwurf, bevorzugter Sprachstil.
|
||||
Expected output: Überarbeitete, verständliche und handlungsstarke Textvorschläge.
|
||||
Category: copy
|
||||
Version: 1.0
|
||||
Source: skills/copy-clarity.md
|
||||
|
||||
## Local SEO
|
||||
Purpose: Bewertet lokale Auffindbarkeit, Konsistenz und Relevanz der Website für eine definierte Ortsmarke.
|
||||
When to use: Nutze diesen Skill, wenn lokale Suchrelevanz, Google-Präsenz oder Impressum/Struktur geprüft werden.
|
||||
When not to use: Nicht als Ersatz für technische SEO-Fehlerdiagnose ohne inhaltlichen Kontext.
|
||||
Required input: Standortdaten, Nischenfokus, Seitenstruktur, Kontakt-/Nennungsdaten.
|
||||
Expected output: Konkrete Maßnahmen für lokale Sichtbarkeit, NAP-Konsistenz und Local-Authority.
|
||||
Category: seo
|
||||
Version: 1.0
|
||||
Source: skills/local-seo.md
|
||||
|
||||
## Offer Writing
|
||||
Purpose: Erstellt klare, konkrete Angebote mit Problemfokus und glaubwürdiger Preislogik.
|
||||
When to use: Nutze diesen Skill, wenn ein Vorschlag oder Angebotsentwurf in Audit- oder Outreach-Schritten gebraucht wird.
|
||||
When not to use: Nicht für reine Analyse-Ausgaben ohne Angebotsreifheit.
|
||||
Required input: Projektumfang, Zielprobleme, Budgetrahmen, Leistungsumfang, Risiko-/Zeitaspekte.
|
||||
Expected output: Offer-Prompt, Paketstruktur und überzeugende, ehrliche Angebots-Formulierungen.
|
||||
Category: offer
|
||||
Version: 1.0
|
||||
Source: skills/offer-writing.md
|
||||
9
skills/copy-clarity.md
Normal file
9
skills/copy-clarity.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copy Clarity
|
||||
|
||||
Use this file for headline/body copy improvements.
|
||||
|
||||
- Replace jargon with clear benefit language.
|
||||
- Tighten sentences to one core message per paragraph.
|
||||
- Keep benefit first, proof second, next step third.
|
||||
- Preserve the requested tone and avoid overpromising.
|
||||
- Output a concise before/after improvement set.
|
||||
9
skills/design-audit.md
Normal file
9
skills/design-audit.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Design Audit
|
||||
|
||||
Use this file as the concrete operating guide for design-oriented analysis.
|
||||
|
||||
- Check visual hierarchy and above-the-fold clarity.
|
||||
- Validate CTA prominence, spacing, and action visibility.
|
||||
- Flag inconsistent components, mismatched states, and weak contrast.
|
||||
- Verify tone alignment between typography, imagery, and brand intent.
|
||||
- Prioritize 3–5 actionable fixes by expected conversion impact.
|
||||
9
skills/local-seo.md
Normal file
9
skills/local-seo.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Local SEO
|
||||
|
||||
Use this file for local discovery and trust-relevance analysis.
|
||||
|
||||
- Verify NAP consistency across page and metadata.
|
||||
- Check service-location clarity and service-area fit.
|
||||
- Review local proof signals: reviews, case examples, local references.
|
||||
- Validate schema/markup hints and local keyword fit.
|
||||
- Recommend realistic, high-impact local SEO adjustments.
|
||||
9
skills/marketing-positioning.md
Normal file
9
skills/marketing-positioning.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Marketing Positioning
|
||||
|
||||
Use this file to sharpen market message and differentiation.
|
||||
|
||||
- Define one-liner positioning for the target segment.
|
||||
- List the top 3 differentiators vs local alternatives.
|
||||
- Connect positioning to pain points and decision criteria.
|
||||
- Keep claims evidence-backed from website and audit findings.
|
||||
- Produce a short positioning paragraph for outreach and landing intro.
|
||||
9
skills/offer-writing.md
Normal file
9
skills/offer-writing.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Offer Writing
|
||||
|
||||
Use this guide when producing outreach-ready offer text.
|
||||
|
||||
- Translate findings into 1–2 concrete pain-result pairings.
|
||||
- Keep scope, deliverables, and next step explicit.
|
||||
- Tie price framing to measurable benefit and timeline.
|
||||
- Use transparent language and avoid inflated promises.
|
||||
- Return a compact offer draft and optional follow-up variant.
|
||||
9
skills/ux-friction-review.md
Normal file
9
skills/ux-friction-review.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# UX Friction Review
|
||||
|
||||
Use this file when analyzing onboarding, lead capture, and first conversion path friction.
|
||||
|
||||
- Map the main path from landing → action.
|
||||
- Find blockers: too many clicks, hidden links, field overload, unclear status.
|
||||
- Identify trust anchors: who, what, how long, what happens next.
|
||||
- Assess mobile ergonomics and cognitive load.
|
||||
- Recommend friction-reduction experiments with effort and impact.
|
||||
130
tests/ai-model-profiles.test.ts
Normal file
130
tests/ai-model-profiles.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
MODEL_PROFILE_KEYS,
|
||||
MODEL_PROFILES,
|
||||
resolveModelProfile,
|
||||
resolveModelId,
|
||||
} from "../lib/ai/model-profiles";
|
||||
import type { ModelProfileKey } from "../lib/ai/model-profiles";
|
||||
|
||||
type AssertNoExtraProfiles = Array<
|
||||
(typeof MODEL_PROFILE_KEYS)[number]
|
||||
>;
|
||||
|
||||
const assertNoExtraProfiles: AssertNoExtraProfiles = [
|
||||
"classification",
|
||||
"multimodalAudit",
|
||||
"germanCopy",
|
||||
"qualityReview",
|
||||
];
|
||||
|
||||
test("all required model profiles exist", () => {
|
||||
const keys = Object.keys(MODEL_PROFILES).sort();
|
||||
|
||||
assert.deepEqual(keys, [...assertNoExtraProfiles].sort());
|
||||
});
|
||||
|
||||
test("each profile includes required contract fields", () => {
|
||||
const profileEntries = Object.entries(MODEL_PROFILES) as Array<
|
||||
[ModelProfileKey, unknown]
|
||||
>;
|
||||
|
||||
for (const [key, rawProfile] of profileEntries) {
|
||||
const profile = rawProfile as {
|
||||
modelId: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
supportsImages: boolean;
|
||||
stage: string;
|
||||
envOverrideKey: string;
|
||||
};
|
||||
|
||||
assert.equal(typeof profile.modelId, "string", `${key} modelId`);
|
||||
assert.equal(
|
||||
profile.modelId.length > 0,
|
||||
true,
|
||||
`${key} modelId should be non-empty`,
|
||||
);
|
||||
assert.equal(typeof profile.temperature, "number", `${key} temperature`);
|
||||
assert.equal(typeof profile.maxTokens, "number", `${key} maxTokens`);
|
||||
assert.equal(
|
||||
Number.isFinite(profile.temperature),
|
||||
true,
|
||||
`${key} temperature numeric`,
|
||||
);
|
||||
assert.equal(
|
||||
Number.isInteger(profile.maxTokens),
|
||||
true,
|
||||
`${key} maxTokens integer`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof profile.supportsImages,
|
||||
"boolean",
|
||||
`${key} supportsImages`,
|
||||
);
|
||||
assert.equal(typeof profile.stage, "string", `${key} stage`);
|
||||
assert.equal(profile.stage.length > 0, true, `${key} stage label`);
|
||||
assert.equal(typeof profile.envOverrideKey, "string", `${key} env override`);
|
||||
assert.equal(profile.envOverrideKey.length > 0, true, `${key} env key`);
|
||||
}
|
||||
});
|
||||
|
||||
test("multimodal profile explicitly supports images", () => {
|
||||
assert.equal(MODEL_PROFILES.multimodalAudit.supportsImages, true);
|
||||
});
|
||||
|
||||
test("non-multimodal profiles disable image support", () => {
|
||||
assert.equal(MODEL_PROFILES.classification.supportsImages, false);
|
||||
assert.equal(MODEL_PROFILES.germanCopy.supportsImages, false);
|
||||
assert.equal(MODEL_PROFILES.qualityReview.supportsImages, false);
|
||||
});
|
||||
|
||||
test("model IDs can be overridden via dedicated env variables", () => {
|
||||
assert.equal(
|
||||
resolveModelId("classification", {
|
||||
OPENROUTER_MODEL_CLASSIFICATION: "custom/classification",
|
||||
}),
|
||||
"custom/classification",
|
||||
);
|
||||
assert.equal(
|
||||
resolveModelId("multimodalAudit", {
|
||||
OPENROUTER_MODEL_MULTIMODAL_AUDIT: "custom/multimodal",
|
||||
}),
|
||||
"custom/multimodal",
|
||||
);
|
||||
assert.equal(
|
||||
resolveModelId("germanCopy", {
|
||||
OPENROUTER_MODEL_GERMAN_COPY: "custom/german",
|
||||
}),
|
||||
"custom/german",
|
||||
);
|
||||
assert.equal(
|
||||
resolveModelId("qualityReview", {
|
||||
OPENROUTER_MODEL_QUALITY_REVIEW: "custom/quality",
|
||||
}),
|
||||
"custom/quality",
|
||||
);
|
||||
});
|
||||
|
||||
test("ENV overrides are ignored when empty", () => {
|
||||
assert.equal(
|
||||
resolveModelId("classification", {
|
||||
OPENROUTER_MODEL_CLASSIFICATION: "",
|
||||
}),
|
||||
MODEL_PROFILES.classification.modelId,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveModelProfile returns profile config including runtime values", () => {
|
||||
const profile = resolveModelProfile("qualityReview", {
|
||||
OPENROUTER_MODEL_QUALITY_REVIEW: "custom/quality-review-profile",
|
||||
});
|
||||
|
||||
assert.equal(profile.stage, "qualityReview");
|
||||
assert.equal(profile.maxTokens, MODEL_PROFILES.qualityReview.maxTokens);
|
||||
assert.equal(profile.temperature, MODEL_PROFILES.qualityReview.temperature);
|
||||
assert.equal(profile.supportsImages, MODEL_PROFILES.qualityReview.supportsImages);
|
||||
assert.equal(profile.modelId, "custom/quality-review-profile");
|
||||
});
|
||||
137
tests/ai-schemas.test.ts
Normal file
137
tests/ai-schemas.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
callScriptSchema,
|
||||
emailDraftSchema,
|
||||
emailSubjectSchema,
|
||||
followUpDraftSchema,
|
||||
auditSummarySchema,
|
||||
qualityReviewSchema,
|
||||
publicAuditTextSchema,
|
||||
internalFindingsSchema,
|
||||
type CallScript,
|
||||
type EmailDraft,
|
||||
type EmailSubject,
|
||||
type FollowUpDraft,
|
||||
type AuditSummary,
|
||||
type PublicAuditText,
|
||||
type QualityReview,
|
||||
type InternalFindings,
|
||||
} from "../lib/ai/schemas";
|
||||
|
||||
test("internal findings schema accepts task-focused evidence", () => {
|
||||
const parsed = internalFindingsSchema.parse({
|
||||
findings: [
|
||||
{
|
||||
section: "UX",
|
||||
finding: "Landingpage is not responsive on mobile viewport.",
|
||||
suggestion: "Add responsive breakpoints for cards and typography.",
|
||||
},
|
||||
],
|
||||
summary: "One high-priority UX gap was found.",
|
||||
});
|
||||
|
||||
assert.equal(parsed.findings.length, 1);
|
||||
assert.equal(parsed.findings[0].section, "UX");
|
||||
});
|
||||
|
||||
test("audit summary and public text schemas remain intentionally lightweight", () => {
|
||||
const summaryParsed = auditSummarySchema.parse({
|
||||
summary: "Kurze Zusammenfassung mit den wichtigsten Verbesserungen.",
|
||||
keyFindings: ["Fehlende Kontaktmöglichkeit.", "Langsame Ladezeiten."],
|
||||
});
|
||||
const publicParsed = publicAuditTextSchema.parse({
|
||||
publicText: "Dein Shop ist sauber, aber der erste Eindruck lässt Potenzial erkennen.",
|
||||
});
|
||||
|
||||
assert.equal(summaryParsed.keyFindings.length, 2);
|
||||
assert.equal(typeof publicParsed.publicText, "string");
|
||||
});
|
||||
|
||||
test("outreach schemas parse German customer-facing payloads", () => {
|
||||
const emailDraftParsed = emailDraftSchema.parse({
|
||||
body: "Hallo, ich habe mir euer Angebot angesehen...",
|
||||
});
|
||||
const subjectParsed = emailSubjectSchema.parse({
|
||||
subject: "Kurznotiz zu eurem Webauftritt",
|
||||
});
|
||||
const callParsed = callScriptSchema.parse({
|
||||
openingLine: "Guten Tag, ich bin ...",
|
||||
callScript: [
|
||||
"Euer Fokus auf Terminbuchung ist stark.",
|
||||
"Wie läuft eure aktuelle Lead-Generierung?",
|
||||
],
|
||||
closeLine: "Ich schicke im Anschluss kurz die wichtigsten Beobachtungen.",
|
||||
});
|
||||
const followParsed = followUpDraftSchema.parse({
|
||||
message: "Kurzer Follow-up-Hinweis für nächste Woche.",
|
||||
followInDays: 4,
|
||||
goals: ["Antwort auf Rückmeldung erhalten"],
|
||||
});
|
||||
const qualityParsed = qualityReviewSchema.parse({
|
||||
isValid: true,
|
||||
issues: [],
|
||||
suggestions: ["Mehr Kundennutzen konkret beschreiben."],
|
||||
});
|
||||
|
||||
assert.equal(typeof emailDraftParsed.body, "string");
|
||||
assert.equal(typeof subjectParsed.subject, "string");
|
||||
assert.equal(Array.isArray(callParsed.callScript), true);
|
||||
assert.equal(typeof followParsed.message, "string");
|
||||
assert.equal(Array.isArray(qualityParsed.suggestions), true);
|
||||
});
|
||||
|
||||
test("schema-inferred types are exported for Convex action wiring", () => {
|
||||
const typedFindings: InternalFindings = {
|
||||
findings: [
|
||||
{
|
||||
section: "Homepage",
|
||||
finding: "No visible Datenschutzhinweis.",
|
||||
suggestion: "Bitte Hinweis ergänzen.",
|
||||
},
|
||||
],
|
||||
summary: "One finding identified.",
|
||||
};
|
||||
|
||||
const typedSummary: AuditSummary = {
|
||||
summary: "Kernbefund mit 2 Punkten.",
|
||||
keyFindings: ["Kontaktseite fehlt."],
|
||||
};
|
||||
|
||||
const typedPublicText: PublicAuditText = {
|
||||
publicText: "Starker Start, aber optimierungsfähig.",
|
||||
};
|
||||
|
||||
const typedEmail: EmailDraft = {
|
||||
body: "Text mit Ich-Perspektive und konkretem Vorschlag.",
|
||||
};
|
||||
|
||||
const typedSubject: EmailSubject = {
|
||||
subject: "Kurzer Betreff",
|
||||
};
|
||||
|
||||
const typedCall: CallScript = {
|
||||
openingLine: "Hallo, ich habe euer Shop geprüft.",
|
||||
callScript: ["Wie gehen die Leads aktuell rein?"],
|
||||
closeLine: "Ich melde mich nach der Rückfrage erneut.",
|
||||
};
|
||||
|
||||
const typedFollowUp: FollowUpDraft = {
|
||||
message: "Kurzes Follow-up ohne harte Floskel.",
|
||||
};
|
||||
|
||||
const typedQuality: QualityReview = {
|
||||
isValid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
assert.equal(typedFindings.findings.length, 1);
|
||||
assert.equal(typedSummary.keyFindings.length, 1);
|
||||
assert.equal(typedPublicText.publicText.length > 0, true);
|
||||
assert.equal(typeof typedEmail.body, "string");
|
||||
assert.equal(typeof typedSubject.subject, "string");
|
||||
assert.equal(typedCall.callScript.length, 1);
|
||||
assert.equal(typedFollowUp.message.length > 0, true);
|
||||
assert.equal(typedQuality.isValid, true);
|
||||
});
|
||||
337
tests/audit-evidence.test.ts
Normal file
337
tests/audit-evidence.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildAuditEvidenceInput,
|
||||
type SkillRegistryEntryEvidence,
|
||||
} from "../lib/ai/audit-evidence";
|
||||
|
||||
const SAMPLE_SKILL_REGISTRY: SkillRegistryEntryEvidence[] = [
|
||||
{
|
||||
name: "Design Audit",
|
||||
purpose: "Designqualität prüfen.",
|
||||
whenToUse:
|
||||
"Nutze diesen Skill, wenn Seitenhierarchie, visuelle Klarheit und visuelle UX bewertet werden sollen.",
|
||||
whenNotToUse: "Nicht für technische Fehlerlisten.",
|
||||
requiredInput: "URL, Seitentypen und Screenshots.",
|
||||
expectedOutput: "Praktische Design-Prioritäten.",
|
||||
category: "design",
|
||||
version: "1.0",
|
||||
source: "skills/design-audit.md",
|
||||
},
|
||||
{
|
||||
name: "UX Friction Review",
|
||||
purpose: "Nutzerfluss prüfen.",
|
||||
whenToUse:
|
||||
"Nutze diesen Skill bei Formularen, Kontaktwegen und ersten Nutzeraktionen.",
|
||||
whenNotToUse: "Nicht ohne Nutzerfluss.",
|
||||
requiredInput: "URL, Kontaktflüsse, Aktionen.",
|
||||
expectedOutput: "Priorisierte UX-Senkungen.",
|
||||
category: "ux",
|
||||
version: "1.0",
|
||||
source: "skills/ux-friction-review.md",
|
||||
},
|
||||
{
|
||||
name: "Copy Clarity",
|
||||
purpose: "Textklarheit prüfen.",
|
||||
whenToUse:
|
||||
"Nutze diesen Skill bei unklaren, langen oder abstrakten Website-Texten.",
|
||||
whenNotToUse: "Nicht für technische Datenlisten.",
|
||||
requiredInput: "Textbausteine, Zielgruppe, Tonalität.",
|
||||
expectedOutput: "Klarere Formulierungen.",
|
||||
category: "copy",
|
||||
version: "1.0",
|
||||
source: "skills/copy-clarity.md",
|
||||
},
|
||||
{
|
||||
name: "Local SEO",
|
||||
purpose: "Lokale Auffindbarkeit prüfen.",
|
||||
whenToUse:
|
||||
"Nutze diesen Skill bei lokaler Relevanz, Impressum, Kontakt- und Google-Nähe.",
|
||||
whenNotToUse: "Nicht ohne lokalen Kontext.",
|
||||
requiredInput: "Ort, Nische, Seitenstruktur.",
|
||||
expectedOutput: "Sichtbarkeits-Verbesserungen.",
|
||||
category: "seo",
|
||||
version: "1.0",
|
||||
source: "skills/local-seo.md",
|
||||
},
|
||||
{
|
||||
name: "Offer Writing",
|
||||
purpose: "Angebotsstruktur liefern.",
|
||||
whenToUse: "Nutze diesen Skill, wenn ein Angebotsentwurf gebraucht wird.",
|
||||
whenNotToUse: "Nicht bei reinen Buglisten.",
|
||||
requiredInput: "Projektumfang und Umfang.",
|
||||
expectedOutput: "Konkrete Angebotsform.",
|
||||
category: "offer",
|
||||
version: "1.0",
|
||||
source: "skills/offer-writing.md",
|
||||
},
|
||||
];
|
||||
|
||||
test("buildAuditEvidenceInput sanitizes and caps lead/company context", () => {
|
||||
const actual = buildAuditEvidenceInput({
|
||||
lead: {
|
||||
companyName: "Bäckerei <strong>Muster</strong>",
|
||||
niche: "Bäckerei & Kaffeehaus",
|
||||
websiteUrl: "https://example.com/kontakt?ref=ad",
|
||||
address: "Musterstraße 1, 10115 Berlin",
|
||||
city: "Berlin",
|
||||
contactPerson: "<b>Anna</b> Hoffmann",
|
||||
},
|
||||
crawlPages: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com",
|
||||
pageKind: "homepage",
|
||||
title: "Startseite",
|
||||
},
|
||||
{
|
||||
sourceUrl: "https://example.com/kontakt",
|
||||
finalUrl: "https://example.com/kontakt",
|
||||
pageKind: "contact",
|
||||
title: "Kontakt",
|
||||
},
|
||||
{
|
||||
sourceUrl: "https://example.com/kontakt?x=1",
|
||||
finalUrl: "https://example.com/kontakt?x=1",
|
||||
pageKind: "contact",
|
||||
title: "Kontakt",
|
||||
},
|
||||
],
|
||||
skillRegistry: SAMPLE_SKILL_REGISTRY,
|
||||
});
|
||||
|
||||
assert.equal(actual.companyContext.length > 0, true);
|
||||
assert.equal(actual.companyContext.some((line) => /https?:\/\//.test(line)), false);
|
||||
assert.equal(actual.companyContext.some((line) => /<[^>]+>/.test(line)), false);
|
||||
assert.equal(
|
||||
actual.companyContext.some((line) => line.includes("Bäckerei Muster")),
|
||||
true,
|
||||
);
|
||||
assert.equal(actual.checkedPages.length >= 2, true);
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput deduplicates and caps checked pages", () => {
|
||||
const pages = Array.from({ length: 28 }, (_, index) => ({
|
||||
sourceUrl:
|
||||
`https://example.com/seite-${Math.floor(index / 4)}?cache=${index}`,
|
||||
finalUrl:
|
||||
`https://example.com/seite-${Math.floor(index / 4)}?cache=${index}`,
|
||||
pageKind: index % 2 === 0 ? "other" : "services",
|
||||
title: `Seite ${index}`,
|
||||
}));
|
||||
|
||||
const actual = buildAuditEvidenceInput({
|
||||
crawlPages: pages,
|
||||
skillRegistry: SAMPLE_SKILL_REGISTRY,
|
||||
});
|
||||
|
||||
const uniqueCount = new Set(actual.checkedPages).size;
|
||||
assert.equal(actual.checkedPages.length, uniqueCount);
|
||||
assert.equal(actual.checkedPages.length <= 8, true);
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput builds observed UX/content/technical signals and sanitizes long text", () => {
|
||||
const actual = buildAuditEvidenceInput({
|
||||
crawlPages: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com",
|
||||
pageKind: "homepage",
|
||||
title: "Wir lieben guten Kaffee",
|
||||
hasContactFormSignal: true,
|
||||
hasContactCtaSignal: true,
|
||||
},
|
||||
{
|
||||
sourceUrl: "https://example.com/ueber-uns",
|
||||
finalUrl: "https://example.com/ueber-uns",
|
||||
pageKind: "about",
|
||||
title: "Über uns",
|
||||
},
|
||||
],
|
||||
technicalChecks: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com",
|
||||
usesHttps: false,
|
||||
missingTitle: true,
|
||||
missingMetaDescription: true,
|
||||
hasVisibleContactPath: true,
|
||||
brokenInternalLinkCount: 3,
|
||||
},
|
||||
],
|
||||
skillRegistry: SAMPLE_SKILL_REGISTRY,
|
||||
});
|
||||
|
||||
assert.equal(actual.observedUxSignals.length > 0, true);
|
||||
assert.equal(actual.observedContentSignals.length > 0, true);
|
||||
assert.equal(actual.observedTechnicalSignals.length > 0, true);
|
||||
assert.equal(
|
||||
actual.observedUxSignals.every((line) => !/https?:\/\//.test(line)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
actual.observedContentSignals.every((line) => !/https?:\/\//.test(line)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput preserves screenshot references without base64 payloads", () => {
|
||||
const actual = buildAuditEvidenceInput({
|
||||
screenshots: [
|
||||
{
|
||||
storageId: "storage-1",
|
||||
sourceUrl: "https://example.com",
|
||||
viewport: "desktop",
|
||||
width: 1200,
|
||||
height: 3000,
|
||||
mimeType: "image/png",
|
||||
capturedAt: 1_700_000_000_000,
|
||||
// builder must ignore any binary-like fields if they exist
|
||||
imageBase64: "iVBORw0KGgoAAAANSUhEUgAAAAUA",
|
||||
},
|
||||
{
|
||||
storageId: "storage-2",
|
||||
sourceUrl: "https://example.com",
|
||||
viewport: "mobile",
|
||||
width: 390,
|
||||
height: 844,
|
||||
mimeType: "image/png",
|
||||
capturedAt: 1_700_000_001_000,
|
||||
},
|
||||
] as const,
|
||||
skillRegistry: SAMPLE_SKILL_REGISTRY,
|
||||
}) as {
|
||||
screenshotReferences: Array<
|
||||
Record<string, unknown> & {
|
||||
storageId: string;
|
||||
sourceUrl: string;
|
||||
viewport: string;
|
||||
width: number;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
capturedAt: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
assert.equal(actual.screenshotReferences.length, 2);
|
||||
for (const reference of actual.screenshotReferences) {
|
||||
assert.equal(reference.storageId.startsWith("storage-"), true);
|
||||
assert.equal("imageBase64" in reference, false);
|
||||
assert.equal(typeof reference.sourceUrl, "string");
|
||||
assert.equal(reference.width > 0, true);
|
||||
assert.equal(reference.height > 0, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput converts PageSpeed implications into sanitized customer-facing text", () => {
|
||||
const actual = buildAuditEvidenceInput({
|
||||
pageSpeedInputs: [
|
||||
{
|
||||
strategy: "mobile",
|
||||
status: "succeeded",
|
||||
sourceUrl: "https://example.com",
|
||||
normalized: {
|
||||
implications: [
|
||||
"Score 0.42: Erster Inhalt liegt deutlich hinter Standards.",
|
||||
"Die Seite zeigt das wichtigste Bild zu langsam.",
|
||||
"Weitere Infos: https://example.com/psi",
|
||||
"{ \"pagespeed\": 0.92, \"score\": 88 }",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
strategy: "desktop",
|
||||
status: "failed",
|
||||
sourceUrl: "https://example.com",
|
||||
errorType: "api_error",
|
||||
errorSummary: "Score 0.22: timeout",
|
||||
},
|
||||
],
|
||||
skillRegistry: SAMPLE_SKILL_REGISTRY,
|
||||
});
|
||||
|
||||
assert.equal(actual.pageSpeedCustomerImplications.length >= 1, true);
|
||||
assert.equal(
|
||||
actual.pageSpeedCustomerImplications.includes(
|
||||
"Score 0.42: Erster Inhalt liegt deutlich hinter Standards.",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
actual.pageSpeedCustomerImplications.every(
|
||||
(line) =>
|
||||
!/https?:\/\/|pagespeed|score|lighthouse|raw storage|rawStorage/i.test(line),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(actual.pageSpeedCustomerImplications.length <= 8, true);
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput selects deterministic skills and supports design/ux/copy/seo", () => {
|
||||
const input = {
|
||||
lead: {
|
||||
companyName: "Bäckerei Muster",
|
||||
niche: "Bäckerei",
|
||||
city: "Berlin",
|
||||
websiteDomain: "example.com",
|
||||
},
|
||||
crawlPages: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com",
|
||||
pageKind: "homepage",
|
||||
title: "Willkommen bei Bäckerei Muster",
|
||||
hasContactFormSignal: true,
|
||||
hasContactCtaSignal: true,
|
||||
},
|
||||
],
|
||||
technicalChecks: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com",
|
||||
usesHttps: false,
|
||||
missingMetaDescription: true,
|
||||
missingTitle: false,
|
||||
hasVisibleContactPath: true,
|
||||
brokenInternalLinkCount: 1,
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
storageId: "storage-1",
|
||||
sourceUrl: "https://example.com",
|
||||
viewport: "desktop",
|
||||
width: 1200,
|
||||
height: 3000,
|
||||
mimeType: "image/png",
|
||||
capturedAt: 1700000000000,
|
||||
},
|
||||
],
|
||||
skillRegistry: SAMPLE_SKILL_REGISTRY,
|
||||
};
|
||||
|
||||
const first = buildAuditEvidenceInput(input);
|
||||
const second = buildAuditEvidenceInput({
|
||||
...input,
|
||||
skillRegistry: [...SAMPLE_SKILL_REGISTRY].reverse(),
|
||||
});
|
||||
|
||||
assert.equal(first.selectedSkills.length >= 4, true);
|
||||
assert.equal(first.selectedSkills.length, second.selectedSkills.length);
|
||||
assert.equal(
|
||||
first.selectedSkills.every((skill, index) => {
|
||||
const same = second.selectedSkills[index];
|
||||
return same?.name === skill.name && same?.category === skill.category;
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const expectedCategories: Array<
|
||||
"design" | "ux" | "copy" | "seo"
|
||||
> = ["design", "ux", "copy", "seo"];
|
||||
const selectedCategories = new Set(first.selectedSkills.map((skill) => skill.category));
|
||||
for (const category of expectedCategories) {
|
||||
assert.equal(selectedCategories.has(category), true);
|
||||
}
|
||||
});
|
||||
335
tests/audit-generation-action-source.test.ts
Normal file
335
tests/audit-generation-action-source.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const actionPath = path.join(process.cwd(), "convex", "auditGenerationAction.ts");
|
||||
const actionSource = existsSync(actionPath) ? readFileSync(actionPath, "utf8") : "";
|
||||
const generationSourcePath = path.join(process.cwd(), "convex", "auditGeneration.ts");
|
||||
const generationSource = existsSync(generationSourcePath)
|
||||
? readFileSync(generationSourcePath, "utf8")
|
||||
: "";
|
||||
|
||||
function hasPattern(source: string, pattern: RegExp) {
|
||||
return pattern.test(source);
|
||||
}
|
||||
|
||||
function hasExportedInternalAction(exportName: string) {
|
||||
const pattern = new RegExp(
|
||||
`export const ${exportName}\\s*=\\s*internalAction\\s*\\(`,
|
||||
);
|
||||
return hasPattern(actionSource, pattern);
|
||||
}
|
||||
|
||||
function hasStageCall(schema: string) {
|
||||
const escaped = schema.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return hasPattern(
|
||||
actionSource,
|
||||
new RegExp(
|
||||
`generateObject\\([\\s\\S]*schema:\\s*${escaped}[\\s\\S]*\\)`,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test("auditGenerationAction module exists and is a Node action file", () => {
|
||||
assert.equal(existsSync(actionPath), true, "auditGenerationAction.ts should exist");
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /^"use node";/m),
|
||||
true,
|
||||
"auditGenerationAction.ts should start with \"use node\"",
|
||||
);
|
||||
});
|
||||
|
||||
test("auditGenerationAction exports processAuditGeneration with runId validator", () => {
|
||||
assert.equal(
|
||||
hasExportedInternalAction("processAuditGeneration"),
|
||||
true,
|
||||
"processAuditGeneration should be an internalAction",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/processAuditGeneration\s*=\s*internalAction\(\s*{\s*args:\s*{\s*runId:\s*v\.id\(\s*["']agentRuns["']\s*\)\s*,?\s*}/,
|
||||
),
|
||||
true,
|
||||
"processAuditGeneration should validate runId: v.id(\"agentRuns\")",
|
||||
);
|
||||
});
|
||||
|
||||
test("action starts, queries evidence, and runs stage pipeline", () => {
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/internal\.auditGeneration\.startAuditGenerationRun/,
|
||||
),
|
||||
true,
|
||||
"Action should start the run via internal.auditGeneration.startAuditGenerationRun",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/internal\.auditGeneration\.getAuditGenerationEvidence/,
|
||||
),
|
||||
true,
|
||||
"Action should load evidence via internal.auditGeneration.getAuditGenerationEvidence",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/internal\.auditGeneration\.persistAuditGenerationResult/,
|
||||
),
|
||||
true,
|
||||
"Action should persist each stage result",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/internal\.auditGeneration\.finishAuditGenerationRun/,
|
||||
),
|
||||
true,
|
||||
"Action should finish run via internal.auditGeneration.finishAuditGenerationRun",
|
||||
);
|
||||
});
|
||||
|
||||
test("action includes all required audit stages", () => {
|
||||
for (const stage of [
|
||||
"classification",
|
||||
"multimodalAudit",
|
||||
"germanCopy",
|
||||
"qualityReview",
|
||||
]) {
|
||||
const token = new RegExp(`stage:\\s*["']${stage}["']`);
|
||||
assert.equal(
|
||||
hasPattern(actionSource, token),
|
||||
true,
|
||||
`Action should reference ${stage} stage`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("action handles post-start failure paths in action-level catch", () => {
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/try\s*{[\s\S]*internal\.auditGeneration\.getAuditGenerationEvidence[\s\S]*const provider = createOpenRouterProvider\(\)/,
|
||||
),
|
||||
true,
|
||||
"Action should include evidence query and provider init inside catch-covered flow.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/catch\s*\(error\)\s*{[\s\S]*appendRunEvent[\s\S]*finishAuditGenerationRun[\s\S]*"failed"/,
|
||||
),
|
||||
true,
|
||||
"Action-level error handler should emit run events.",
|
||||
);
|
||||
});
|
||||
|
||||
test("action calls generateObject with required schemas", () => {
|
||||
const requiredSchemas = [
|
||||
"internalFindingsSchema",
|
||||
"auditSummarySchema",
|
||||
"publicAuditTextSchema",
|
||||
"emailDraftSchema",
|
||||
"emailSubjectSchema",
|
||||
"callScriptSchema",
|
||||
"followUpDraftSchema",
|
||||
"qualityReviewSchema",
|
||||
];
|
||||
|
||||
for (const requiredSchema of requiredSchemas) {
|
||||
assert.equal(
|
||||
hasStageCall(requiredSchema),
|
||||
true,
|
||||
`Action should call generateObject with schema ${requiredSchema}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("action uses multimodal file parts with mediaType image/* when screenshots are available", () => {
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/type:\s*["']file["'][\s\S]*mediaType:\s*(?:getValidMediaType|["']image\/)/,
|
||||
),
|
||||
true,
|
||||
"Multimodal call should include AI file parts with image mediaType",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/ctx\.storage\.(get|getUrl)\(/,
|
||||
),
|
||||
true,
|
||||
"Multimodal call should try to fetch screenshots from Convex storage",
|
||||
);
|
||||
});
|
||||
|
||||
test("action handles missing screenshots with warning event fallback", () => {
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /level:\s*["']warning["'][\s\S]*Screenshot|Vorschaubild/),
|
||||
true,
|
||||
"Action should append warning event when multimodal screenshot input is unavailable",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /messages:\s*\[[\s\S]*type:\s*["']text["'][\s\S]*\]/),
|
||||
true,
|
||||
"Action should fall back to text-only multimodal calls when required parts are missing",
|
||||
);
|
||||
});
|
||||
|
||||
test("action runs german copy guard and blocks outreach-ready on validation failure", () => {
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /validateCustomerFacingCopy/),
|
||||
true,
|
||||
"Action should run German copy validation",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/guardResult\.passed|qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /api\.leads\.reviewUpdate/),
|
||||
true,
|
||||
"Action should patch lead via api.leads.reviewUpdate",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/isTerminalLeadContactStatus/,
|
||||
),
|
||||
true,
|
||||
"Action should set contactStatus to outreach_ready only when terminal guard allows it.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/do_not_contact|contacted|replied/i,
|
||||
),
|
||||
true,
|
||||
"Action should explicitly guard against terminal lead statuses before outreach-ready.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/Lead-Status wurde nicht auf outreach_ready gesetzt/,
|
||||
),
|
||||
true,
|
||||
"Action should emit warning event when outreach-ready cannot be set.",
|
||||
);
|
||||
});
|
||||
|
||||
test("action persists audit and outreach outputs before finishing succeeded run", () => {
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/internal\.audits\.upsertFromAuditGeneration/,
|
||||
),
|
||||
true,
|
||||
"Action should persist audit output via internal.audits.upsertFromAuditGeneration",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/internal\.outreach\.upsertFromAuditGeneration/,
|
||||
),
|
||||
true,
|
||||
"Action should persist outreach output via internal.outreach.upsertFromAuditGeneration",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/internal\.audits\.upsertFromAuditGeneration[\s\S]*internal\.outreach\.upsertFromAuditGeneration[\s\S]*internal\.auditGeneration\.finishAuditGenerationRun[\s\S]*status:\s*["']succeeded["']/,
|
||||
),
|
||||
true,
|
||||
"Action should finish success after persisted outputs",
|
||||
);
|
||||
});
|
||||
|
||||
test("action uses model profiles for generation parameters", () => {
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /resolveModelProfile\("classification"\)/),
|
||||
true,
|
||||
"classification generation should use resolveModelProfile.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /resolveModelProfile\("multimodalAudit"\)/),
|
||||
true,
|
||||
"multimodal generation should use resolveModelProfile.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /resolveModelProfile\("germanCopy"\)/),
|
||||
true,
|
||||
"german copy generation should use resolveModelProfile.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /resolveModelProfile\("qualityReview"\)/),
|
||||
true,
|
||||
"quality review generation should use resolveModelProfile.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/temperature:\s*classificationProfile\.temperature[\s\S]*maxOutputTokens:\s*classificationProfile\.maxTokens/,
|
||||
),
|
||||
true,
|
||||
"classification stage should use profile temperature/maxTokens.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/temperature:\s*germanCopyProfile\.temperature[\s\S]*maxOutputTokens:\s*germanCopyProfile\.maxTokens/,
|
||||
),
|
||||
true,
|
||||
"german copy stages should use profile temperature/maxTokens.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/temperature:\s*qualityReviewProfile\.temperature[\s\S]*maxOutputTokens:\s*qualityReviewProfile\.maxTokens/,
|
||||
),
|
||||
true,
|
||||
"quality review stage should use profile temperature/maxTokens.",
|
||||
);
|
||||
});
|
||||
|
||||
test("action sanitization masks env-backed secrets", () => {
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/sanitizeSecretCandidates\([\s\S]*process\.env/,
|
||||
),
|
||||
true,
|
||||
"sanitize logic should include env-backed secret masking.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /OPENROUTER_API_KEY/),
|
||||
true,
|
||||
"sanitizer should include OPENROUTER_API_KEY in secret hints.",
|
||||
);
|
||||
});
|
||||
|
||||
test("auditGeneration scheduler reference in queueLeadAuditGeneration is typed", () => {
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
generationSource,
|
||||
/internal\.auditGenerationAction\.processAuditGeneration/,
|
||||
),
|
||||
true,
|
||||
"queueLeadAuditGeneration should reference internal.auditGenerationAction.processAuditGeneration",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
generationSource,
|
||||
/internal as any/,
|
||||
),
|
||||
false,
|
||||
"No temporary internal cast should remain for the processAuditGeneration schedule",
|
||||
);
|
||||
});
|
||||
323
tests/audit-generation-persistence-source.test.ts
Normal file
323
tests/audit-generation-persistence-source.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const auditGenerationPath = join(process.cwd(), "convex", "auditGeneration.ts");
|
||||
const auditGenerationSource = existsSync(auditGenerationPath)
|
||||
? readFileSync(auditGenerationPath, "utf8")
|
||||
: "";
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
"auditGeneration.ts",
|
||||
auditGenerationSource,
|
||||
ts.ScriptTarget.ES2022,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
function getExportedConstNames(file: ts.SourceFile) {
|
||||
const names = new Set<string>();
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (ts.isVariableStatement(node)) {
|
||||
const isExported = node.modifiers?.some(
|
||||
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
|
||||
);
|
||||
if (!isExported) {
|
||||
ts.forEachChild(node, visit);
|
||||
return;
|
||||
}
|
||||
|
||||
const isConst = node.declarationList.flags & ts.NodeFlags.Const;
|
||||
if (!isConst) {
|
||||
ts.forEachChild(node, visit);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const declaration of node.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
names.add(declaration.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
ts.forEachChild(file, visit);
|
||||
return names;
|
||||
}
|
||||
|
||||
function hasPattern(source: string, pattern: RegExp) {
|
||||
return pattern.test(source);
|
||||
}
|
||||
|
||||
function extractExportSource(name: string) {
|
||||
const marker = `export const ${name} = `;
|
||||
const declarationIndex = auditGenerationSource.indexOf(marker);
|
||||
assert.notEqual(
|
||||
declarationIndex,
|
||||
-1,
|
||||
`Expected declaration for ${name}`,
|
||||
);
|
||||
|
||||
const openBraceIndex = auditGenerationSource.indexOf("{", declarationIndex);
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
|
||||
for (let index = openBraceIndex; index < auditGenerationSource.length; index += 1) {
|
||||
const char = auditGenerationSource[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(end, -1, `Expected balanced braces for ${name}`);
|
||||
return auditGenerationSource.slice(openBraceIndex, end + 1);
|
||||
}
|
||||
|
||||
test("auditGeneration module exports required mutation contracts", () => {
|
||||
assert.equal(
|
||||
existsSync(auditGenerationPath),
|
||||
true,
|
||||
"auditGeneration.ts should be present",
|
||||
);
|
||||
|
||||
const exports = getExportedConstNames(sourceFile);
|
||||
const required = [
|
||||
"queueLeadAuditGeneration",
|
||||
"startAuditGenerationRun",
|
||||
"persistAuditGenerationResult",
|
||||
"finishAuditGenerationRun",
|
||||
];
|
||||
|
||||
for (const exportName of required) {
|
||||
assert.equal(
|
||||
exports.has(exportName),
|
||||
true,
|
||||
`Expected export: ${exportName}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("auditGeneration module registers internalMutation contracts", () => {
|
||||
for (const name of [
|
||||
"queueLeadAuditGeneration",
|
||||
"startAuditGenerationRun",
|
||||
"persistAuditGenerationResult",
|
||||
"finishAuditGenerationRun",
|
||||
]) {
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
auditGenerationSource,
|
||||
new RegExp(`export const ${name} = internalMutation\\s*\\(`),
|
||||
),
|
||||
true,
|
||||
`${name} should be registered as internalMutation.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("queueLeadAuditGeneration dedupes pending/running runs and schedules action", () => {
|
||||
const queueSource = extractExportSource("queueLeadAuditGeneration");
|
||||
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
queueSource,
|
||||
/withIndex\("by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit_generation"\)[\s\S]*?eq\("status",\s*"pending"\)[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
|
||||
),
|
||||
true,
|
||||
"Queue should dedupe pending runs with by_type_and_status_and_leadId for type audit_generation.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
queueSource,
|
||||
/withIndex\("by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit_generation"\)[\s\S]*?eq\("status",\s*"running"\)[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
|
||||
),
|
||||
true,
|
||||
"Queue should dedupe running runs with by_type_and_status_and_leadId for type audit_generation.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
queueSource,
|
||||
/ctx\.scheduler\.runAfter\(\s*0,\s*internal\.auditGenerationAction\.processAuditGeneration,[\s\S]*?runId/,
|
||||
),
|
||||
true,
|
||||
"Queue should schedule internal.auditGenerationAction.processAuditGeneration.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(queueSource, /Audit-Generierung wurde in die Warteschlange gesetzt\./),
|
||||
true,
|
||||
"Queue should emit a queue event message.",
|
||||
);
|
||||
});
|
||||
|
||||
test("startAuditGenerationRun validates and marks run as running", () => {
|
||||
const startSource = extractExportSource("startAuditGenerationRun");
|
||||
|
||||
assert.equal(
|
||||
hasPattern(startSource, /run\.type\s*!==\s*"audit_generation"/),
|
||||
true,
|
||||
"start should validate audit_generation run type.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(startSource, /run\.status\s*!==\s*"pending"/),
|
||||
true,
|
||||
"start should require pending status.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(startSource, /!run\.leadId[\s\S]*status:\s*"failed"/),
|
||||
true,
|
||||
"start should fail clearly when leadId missing.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(startSource, /!lead[\s\S]*status:\s*"failed"/),
|
||||
true,
|
||||
"start should fail clearly when lead cannot be loaded.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
startSource,
|
||||
/ctx\.db\.patch\(\s*args\.runId,[\s\S]*status:\s*"running"/,
|
||||
),
|
||||
true,
|
||||
"start should set run status running.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(startSource, /message:\s*"[^"]*konnte nicht gestartet werden[^"]*"/i),
|
||||
true,
|
||||
"start should emit clear failure events when starting fails.",
|
||||
);
|
||||
});
|
||||
|
||||
test("persistAuditGenerationResult inserts into auditGenerations", () => {
|
||||
const persistSource = extractExportSource("persistAuditGenerationResult");
|
||||
|
||||
assert.equal(
|
||||
hasPattern(persistSource, /ctx\.db\.insert\(\s*"auditGenerations"/),
|
||||
true,
|
||||
"persistAuditGenerationResult should insert into auditGenerations.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
persistSource,
|
||||
/prompt:\s*sanitizeAndCapString\(args\.prompt,\s*MAX_PROMPT_BYTES\)/,
|
||||
),
|
||||
true,
|
||||
"persist function should sanitize prompt before persisting to avoid secrets.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
persistSource,
|
||||
/rawResponse:\s*sanitizeAndCapString\(args\.rawResponse,\s*MAX_RAW_RESPONSE_BYTES\)/,
|
||||
),
|
||||
true,
|
||||
"persist function should sanitize rawResponse before persisting to avoid secrets.",
|
||||
);
|
||||
});
|
||||
|
||||
test("truncateWithMarker is byte-capped and marker-safe in persistence", () => {
|
||||
assert.equal(
|
||||
hasPattern(auditGenerationSource, /const markerBytes = byteLength\(TRUNCATION_MARKER\);/),
|
||||
true,
|
||||
"truncateWithMarker should calculate marker bytes explicitly.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
auditGenerationSource,
|
||||
/if\s*\(byteLength\(value\)\s*<=\s*maxBytes\)\s*\{\s*return\s*value;\s*\}/,
|
||||
),
|
||||
true,
|
||||
"truncateWithMarker should return early when already within byte limit.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
auditGenerationSource,
|
||||
/if\s*\(markerBytes\s*>=\s*maxBytes\)/,
|
||||
),
|
||||
true,
|
||||
"truncateWithMarker should handle marker length edge cases.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
auditGenerationSource,
|
||||
/new TextDecoder\(\)\.decode\(markerBytesBuffer\.slice\(0,\s*maxBytes\)\)/,
|
||||
),
|
||||
true,
|
||||
"truncateWithMarker should trim marker bytes with decoder slice fallback.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
auditGenerationSource,
|
||||
/TRUNCATION_MARKER\\.slice\(0,\s*maxBytes\)/,
|
||||
),
|
||||
false,
|
||||
"truncateWithMarker should not use unbounded marker slicing by bytes.",
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizer masks env-backed secret values in persistence", () => {
|
||||
assert.equal(
|
||||
hasPattern(auditGenerationSource, /function\s+sanitizeSecretCandidates/),
|
||||
true,
|
||||
"Persistence should expose secret candidate sanitizer.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(auditGenerationSource, /OPENROUTER_API_KEY/),
|
||||
true,
|
||||
"Persistence sanitizer should know OPENROUTER_API_KEY.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
auditGenerationSource,
|
||||
/return\s+sanitized\s*\r?\n\s*\.replace\(/,
|
||||
),
|
||||
true,
|
||||
"Persistence sanitizer should apply regex secret-masking patterns.",
|
||||
);
|
||||
});
|
||||
|
||||
test("finishAuditGenerationRun updates run status/counters/currentStep", () => {
|
||||
const finishSource = extractExportSource("finishAuditGenerationRun");
|
||||
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
finishSource,
|
||||
/ctx\.db\.patch\(\s*args\.runId,[\s\S]*?status:\s*args\.status/,
|
||||
),
|
||||
true,
|
||||
"finish should set run status.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
finishSource,
|
||||
/status:\s*args\.status[\s\S]*finishedAt:\s*now/,
|
||||
),
|
||||
true,
|
||||
"finish should set finishedAt.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
finishSource,
|
||||
/counters:\s*\{[\s\S]*errors:\s*args\.errors/,
|
||||
),
|
||||
true,
|
||||
"finish should update counters with errors.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
finishSource,
|
||||
/currentStep:\s*args\.currentStep\s*(\|\||\?\?)\s*"audit_generation"/,
|
||||
),
|
||||
true,
|
||||
"finish should update currentStep.",
|
||||
);
|
||||
});
|
||||
204
tests/audit-generation-schema.test.ts
Normal file
204
tests/audit-generation-schema.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const schemaSource = readFileSync(
|
||||
join(process.cwd(), "convex", "schema.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const domainSource = readFileSync(
|
||||
join(process.cwd(), "convex", "domain.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function extractTableSection(tableName: string) {
|
||||
const marker = `${tableName}: defineTable({`;
|
||||
const markerIndex = schemaSource.indexOf(marker);
|
||||
assert.notEqual(
|
||||
markerIndex,
|
||||
-1,
|
||||
`Expected schema table definition for ${tableName}.`,
|
||||
);
|
||||
|
||||
const objectStart = schemaSource.indexOf("{", markerIndex);
|
||||
let depth = 0;
|
||||
let objectEnd = -1;
|
||||
|
||||
for (let index = objectStart; index < schemaSource.length; index += 1) {
|
||||
if (schemaSource[index] === "{") {
|
||||
depth += 1;
|
||||
} else if (schemaSource[index] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
objectEnd = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(objectEnd, -1, `Could not parse schema object for ${tableName}.`);
|
||||
|
||||
const remainder = schemaSource.slice(objectEnd + 1);
|
||||
const nextTableMatch = remainder.match(
|
||||
/^\s*[a-zA-Z_][\w]*:\s*defineTable\(/m,
|
||||
);
|
||||
|
||||
const sectionEnd =
|
||||
nextTableMatch === null
|
||||
? schemaSource.length
|
||||
: objectEnd + 1 + nextTableMatch.index!;
|
||||
|
||||
const section = schemaSource.slice(markerIndex, sectionEnd);
|
||||
const objectBlock = schemaSource.slice(markerIndex, objectEnd + 1);
|
||||
|
||||
return { section, objectBlock };
|
||||
}
|
||||
|
||||
function assertHas(pattern: RegExp, source: string, message: string) {
|
||||
assert.equal(pattern.test(source), true, message);
|
||||
}
|
||||
|
||||
test("auditGenerations table has contract fields", () => {
|
||||
const { section, objectBlock } = extractTableSection("auditGenerations");
|
||||
|
||||
assertHas(
|
||||
/leadId:\s*v\.id\(["']leads["']\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.leadId must be required lead id.",
|
||||
);
|
||||
assertHas(
|
||||
/auditId:\s*v\.optional\(\s*v\.id\(["']audits["']\)\s*\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.auditId should be optional audit id.",
|
||||
);
|
||||
assertHas(
|
||||
/runId:\s*v\.id\(["']agentRuns["']\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.runId should be required agent run id.",
|
||||
);
|
||||
assertHas(
|
||||
/stage:\s*auditGenerationStage/,
|
||||
objectBlock,
|
||||
"auditGenerations.stage should use auditGenerationStage validator.",
|
||||
);
|
||||
assertHas(
|
||||
/modelProfile:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.modelProfile should be required string.",
|
||||
);
|
||||
assertHas(
|
||||
/modelId:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.modelId should be required string.",
|
||||
);
|
||||
assertHas(
|
||||
/prompt:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.prompt should be required string.",
|
||||
);
|
||||
assertHas(
|
||||
/systemPrompt:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.systemPrompt should be optional string.",
|
||||
);
|
||||
assertHas(
|
||||
/rawResponse:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.rawResponse should be optional string.",
|
||||
);
|
||||
assertHas(
|
||||
/parsedJson:\s*v\.optional\(\s*auditGenerationParsedJson\s*\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.parsedJson should allow string or structured object.",
|
||||
);
|
||||
assertHas(
|
||||
/usage:\s*v\.optional\(\s*auditGenerationUsage\s*\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.usage should be optional token usage object.",
|
||||
);
|
||||
assertHas(
|
||||
/finishReason:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.finishReason should be optional string.",
|
||||
);
|
||||
assertHas(
|
||||
/status:\s*auditGenerationStatus/,
|
||||
objectBlock,
|
||||
"auditGenerations.status should use auditGenerationStatus validator.",
|
||||
);
|
||||
assertHas(
|
||||
/errorSummary:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.errorSummary should be optional string.",
|
||||
);
|
||||
assertHas(
|
||||
/createdAt:\s*v\.number\(\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.createdAt should be required number.",
|
||||
);
|
||||
assertHas(
|
||||
/updatedAt:\s*v\.number\(\)/,
|
||||
objectBlock,
|
||||
"auditGenerations.updatedAt should be required number.",
|
||||
);
|
||||
|
||||
assertHas(
|
||||
/index\("by_leadId",\s*\["leadId"\]\)/,
|
||||
section,
|
||||
"auditGenerations should have by_leadId index.",
|
||||
);
|
||||
assertHas(
|
||||
/index\("by_auditId",\s*\["auditId"\]\)/,
|
||||
section,
|
||||
"auditGenerations should have by_auditId index.",
|
||||
);
|
||||
assertHas(
|
||||
/index\("by_runId",\s*\["runId"\]\)/,
|
||||
section,
|
||||
"auditGenerations should have by_runId index.",
|
||||
);
|
||||
assertHas(
|
||||
/index\("by_stage",\s*\["stage"\]\)/,
|
||||
section,
|
||||
"auditGenerations should have by_stage index.",
|
||||
);
|
||||
assertHas(
|
||||
/index\("by_leadId_and_stage",\s*\["leadId",\s*"stage"\]\)/,
|
||||
section,
|
||||
"auditGenerations should have by_leadId_and_stage index.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audit-generation validators are declared", () => {
|
||||
assertHas(
|
||||
/const\s+auditGenerationStage\s*=\s*v\.union\([\s\S]*\)/,
|
||||
schemaSource,
|
||||
"schema should define auditGenerationStage union.",
|
||||
);
|
||||
assertHas(
|
||||
/const\s+auditGenerationStatus\s*=\s*v\.union\([\s\S]*\)/,
|
||||
schemaSource,
|
||||
"schema should define auditGenerationStatus union.",
|
||||
);
|
||||
assertHas(
|
||||
/AUDIT_GENERATION_STAGES\s*=\s*\[[\s\S]*["']classification["'][\s\S]*\]/,
|
||||
domainSource,
|
||||
"auditGenerationStage should include classification.",
|
||||
);
|
||||
assertHas(
|
||||
/AUDIT_GENERATION_STAGES\s*=\s*\[[\s\S]*["']multimodalAudit["'][\s\S]*\]/,
|
||||
domainSource,
|
||||
"auditGenerationStage should include multimodalAudit.",
|
||||
);
|
||||
assertHas(
|
||||
/AUDIT_GENERATION_STAGES\s*=\s*\[[\s\S]*["']germanCopy["'][\s\S]*\]/,
|
||||
domainSource,
|
||||
"auditGenerationStage should include germanCopy.",
|
||||
);
|
||||
assertHas(
|
||||
/AUDIT_GENERATION_STAGES\s*=\s*\[[\s\S]*["']qualityReview["'][\s\S]*\]/,
|
||||
domainSource,
|
||||
"auditGenerationStage should include qualityReview.",
|
||||
);
|
||||
});
|
||||
231
tests/audit-skills-schema.test.ts
Normal file
231
tests/audit-skills-schema.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
const schemaPath = join(process.cwd(), "convex", "schema.ts");
|
||||
const auditsPath = join(process.cwd(), "convex", "audits.ts");
|
||||
const schemaSource = readFileSync(schemaPath, "utf8");
|
||||
const auditsSource = readFileSync(auditsPath, "utf8");
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
"audits.ts",
|
||||
auditsSource,
|
||||
ts.ScriptTarget.ES2022,
|
||||
true,
|
||||
);
|
||||
|
||||
function extractTableSection(tableName: string) {
|
||||
const marker = `${tableName}: defineTable({`;
|
||||
const markerIndex = schemaSource.indexOf(marker);
|
||||
assert.notEqual(
|
||||
markerIndex,
|
||||
-1,
|
||||
`Expected schema table definition for ${tableName}.`,
|
||||
);
|
||||
|
||||
const objectStart = schemaSource.indexOf("{", markerIndex);
|
||||
let depth = 0;
|
||||
let objectEnd = -1;
|
||||
|
||||
for (let index = objectStart; index < schemaSource.length; index += 1) {
|
||||
if (schemaSource[index] === "{") {
|
||||
depth += 1;
|
||||
} else if (schemaSource[index] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
objectEnd = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(
|
||||
objectEnd,
|
||||
-1,
|
||||
`Could not parse schema object for ${tableName}.`,
|
||||
);
|
||||
const objectBlock = schemaSource.slice(objectStart, objectEnd + 1);
|
||||
|
||||
return { objectBlock };
|
||||
}
|
||||
|
||||
function extractExportSource(name: string) {
|
||||
const marker = `export const ${name} = `;
|
||||
const declarationIndex = auditsSource.indexOf(marker);
|
||||
assert.notEqual(
|
||||
declarationIndex,
|
||||
-1,
|
||||
`Expected declaration for ${name}.`,
|
||||
);
|
||||
|
||||
const openBraceIndex = auditsSource.indexOf("{", declarationIndex);
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
|
||||
for (let index = openBraceIndex; index < auditsSource.length; index += 1) {
|
||||
const char = auditsSource[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(
|
||||
end,
|
||||
-1,
|
||||
`Expected balanced braces for export ${name}.`,
|
||||
);
|
||||
return auditsSource.slice(openBraceIndex, end + 1);
|
||||
}
|
||||
|
||||
function extractFieldSection(source: string, fieldName: string, nextFieldName: string) {
|
||||
const match = source.match(
|
||||
new RegExp(
|
||||
`${fieldName}:\\s*v\\.optional\\([\\s\\S]*?(?=\\s*${nextFieldName}:)`,
|
||||
),
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
match,
|
||||
null,
|
||||
`Expected ${fieldName} field with expected object structure in schema.`,
|
||||
);
|
||||
|
||||
return match![0];
|
||||
}
|
||||
|
||||
function hasPattern(source: string, pattern: RegExp, message: string) {
|
||||
assert.equal(pattern.test(source), true, message);
|
||||
}
|
||||
|
||||
test("audits schema stores compact usedSkills metadata", () => {
|
||||
const { objectBlock } = extractTableSection("audits");
|
||||
const usedSkillsSection = extractFieldSection(
|
||||
objectBlock,
|
||||
"usedSkills",
|
||||
"skillSummaries",
|
||||
);
|
||||
const skillSummariesSection = extractFieldSection(
|
||||
objectBlock,
|
||||
"skillSummaries",
|
||||
"multimodalSummary",
|
||||
);
|
||||
|
||||
hasPattern(usedSkillsSection, /usedSkills:\s*v\.optional\(/, "usedSkills should be optional.");
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/name:\s*v\.string\(\)/,
|
||||
"usedSkills.name should be string.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/category:\s*v\.string\(\)/,
|
||||
"usedSkills.category should be string.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/version:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
"usedSkills.version should be optional string.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
"usedSkills.source should be optional string.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/v\.array\(/,
|
||||
"usedSkills should be an optional array of objects.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/v\.object\(/,
|
||||
"usedSkills should be defined with v.object fields.",
|
||||
);
|
||||
|
||||
hasPattern(skillSummariesSection, /skillSummaries:/, "skillSummaries should still exist.");
|
||||
hasPattern(
|
||||
skillSummariesSection,
|
||||
/name:\s*v\.string\(\)/,
|
||||
"skillSummaries.name should stay string.",
|
||||
);
|
||||
hasPattern(
|
||||
skillSummariesSection,
|
||||
/purpose:\s*v\.string\(\)/,
|
||||
"skillSummaries.purpose should stay string.",
|
||||
);
|
||||
hasPattern(
|
||||
skillSummariesSection,
|
||||
/summary:\s*v\.string\(\)/,
|
||||
"skillSummaries.summary should stay string.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audits.create accepts usedSkills validator and persists metadata payloads", () => {
|
||||
const createSource = extractExportSource("create");
|
||||
|
||||
hasPattern(
|
||||
auditsSource,
|
||||
/const usedSkillsValidator\s*=\s*v\.array\(/,
|
||||
"audits.ts should define a reusable usedSkillsValidator.",
|
||||
);
|
||||
hasPattern(
|
||||
auditsSource,
|
||||
/v\.object\([\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.string\(\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
"audits.ts should define a reusable usedSkillsValidator.",
|
||||
);
|
||||
|
||||
hasPattern(
|
||||
createSource,
|
||||
/usedSkills:\s*v\.optional\(usedSkillsValidator\)/,
|
||||
"create args should include optional usedSkills field.",
|
||||
);
|
||||
hasPattern(
|
||||
createSource,
|
||||
/ctx\.db\.insert\(\s*["']audits["'][\s\S]*?args[\s\S]*\}/,
|
||||
"create should persist audit payload from args (so usedSkills is stored when provided).",
|
||||
);
|
||||
});
|
||||
|
||||
test("audits.getDetail returns audit + lead context with null-safe lead lookup", () => {
|
||||
const getDetailSource = extractExportSource("getDetail");
|
||||
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/args:\s*{[\s\S]*id:\s*v\.id\(["']audits["']\)[\s\S]*}/,
|
||||
"getDetail should require id argument for audits.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/const\s+audit\s*=\s*await\s+ctx\.db\.get\s*\(\s*args\.id\s*\)/,
|
||||
"getDetail should load audit by id.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/if\s*\(\s*!audit\s*\)\s*{\s*return null;\s*}/,
|
||||
"getDetail should return null when audit is missing.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/const\s+lead\s*=\s*await\s+ctx\.db\.get\s*\(\s*audit\.leadId\s*\)/,
|
||||
"getDetail should load lead by leadId from the audit.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/return\s*{\s*audit,\s*lead\s*}/,
|
||||
"getDetail should return { audit, lead }.",
|
||||
);
|
||||
hasPattern(
|
||||
sourceFile.getFullText(),
|
||||
/export const getDetail = query\(/,
|
||||
"audits.ts should export a getDetail query.",
|
||||
);
|
||||
});
|
||||
162
tests/audit-skills-ui.test.ts
Normal file
162
tests/audit-skills-ui.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const source = async (relativePath: string) => {
|
||||
return await readFile(
|
||||
join(process.cwd(), ...relativePath.split("/")),
|
||||
"utf8",
|
||||
);
|
||||
};
|
||||
|
||||
test("audits dashboard page uses a dedicated board component", async () => {
|
||||
const dashboardPageSource = await source("app/dashboard/audits/page.tsx");
|
||||
|
||||
assert.doesNotMatch(
|
||||
dashboardPageSource,
|
||||
/DashboardPlaceholderPage/i,
|
||||
"Dashboard audits route should not render the placeholder page.",
|
||||
);
|
||||
assert.match(
|
||||
dashboardPageSource,
|
||||
/<AuditsBoard \/>/,
|
||||
"Audits board should be mounted from route page.",
|
||||
);
|
||||
assert.match(
|
||||
dashboardPageSource,
|
||||
/"@\/components\/audits\/audits-board"/,
|
||||
"Audits board should be imported from components.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audits board renders compact list with convex list query and core columns", async () => {
|
||||
const boardSource = await source("components/audits/audits-board.tsx");
|
||||
|
||||
assert.match(
|
||||
boardSource,
|
||||
/\"use client\"/,
|
||||
"AuditsBoard must be a Client Component for useQuery.",
|
||||
);
|
||||
assert.match(
|
||||
boardSource,
|
||||
/useQuery\s*\(\s*api\.audits\.list,\s*\{\s*limit:\s*100\s*\}\s*\)/,
|
||||
"AuditsBoard should call api.audits.list with { limit: 100 }.",
|
||||
);
|
||||
assert.match(
|
||||
boardSource,
|
||||
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.createdAt\s*-\s*a\.createdAt\)/,
|
||||
"Audits should be sorted newest first.",
|
||||
);
|
||||
assert.match(boardSource, /Loading|lädt|Lade/i);
|
||||
assert.match(boardSource, /Keine Audits|keine Audits/i);
|
||||
assert.match(boardSource, /Slug/);
|
||||
assert.match(boardSource, /Domain/);
|
||||
assert.match(boardSource, /Status/);
|
||||
assert.match(boardSource, /Seiten/);
|
||||
assert.match(
|
||||
boardSource,
|
||||
/href=\{`\/dashboard\/audits\/\$\{audit\._id\}`\}/,
|
||||
"Each audit row should link to /dashboard/audits/{id}.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audit detail component uses getDetail query and renders skills overview section", async () => {
|
||||
const detailSource = await source("components/audits/audit-detail.tsx");
|
||||
|
||||
assert.match(
|
||||
detailSource,
|
||||
/\"use client\"/,
|
||||
"AuditDetail must be client-side for Convex query calls.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/api\.audits[\s\S]{0,80}getDetail/,
|
||||
"AuditDetail should use api.audits.getDetail query.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/useQuery\(\s*api\.audits\.getDetail,\s*\{/,
|
||||
"AuditDetail should call useQuery with api.audits.getDetail directly.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
detailSource,
|
||||
/const\s+auditDetailQueryRef/,
|
||||
"AuditDetail should not use a cast-based query fallback variable.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/const\s+audit\s*=\s*result\?\.audit;/,
|
||||
"AuditDetail should destructure audit from result.audit.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/const\s+lead\s*=\s*result\?\.lead;/,
|
||||
"AuditDetail should destructure lead from result.lead.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/leadSummary\(\s*lead\s*\)/,
|
||||
"AuditDetail should pass lead into leadSummary from result.lead.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/usedSkills/,
|
||||
"AuditDetail should inspect usedSkills for overview rendering.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/Keine Skills gespeichert/,
|
||||
"AuditDetail should show fallback text when no skills are saved.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/Verwendete Skills/,
|
||||
"AuditDetail should render Verwendete Skills heading.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/Lead|lead/,
|
||||
"AuditDetail should surface lead context when available.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
detailSource,
|
||||
/<p[^>]*>\s*\{leadSummary\(\s*lead\|[\s\S]*?\)\s*\}\s*<\/p>/,
|
||||
"Lead summary should not wrap leadSummary output in a nested <p>.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
detailSource,
|
||||
/<p[^>]*>\s*\{leadSummary\(\s*audit\.lead\)\s*\}\s*<\/p>/,
|
||||
"Lead summary should not wrap leadSummary output in a nested <p>.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audits detail route passes id to AuditDetail via Promise params", async () => {
|
||||
const pageSource = await source("app/dashboard/audits/[id]/page.tsx");
|
||||
|
||||
assert.match(
|
||||
pageSource,
|
||||
/params:\s*Promise<\{\s*id:\s*string\s*\}>/,
|
||||
"Audit detail route should accept params as Promise in Next.js 16 style.",
|
||||
);
|
||||
assert.match(
|
||||
pageSource,
|
||||
/const \{\s*id\s*\}\s*=\s*await params/,
|
||||
"Audit detail route should unwrap Promise params.",
|
||||
);
|
||||
assert.match(
|
||||
pageSource,
|
||||
/<AuditDetail\s+id=/,
|
||||
"Audit detail route should pass id prop into AuditDetail.",
|
||||
);
|
||||
});
|
||||
|
||||
test("public audit page does not expose used skills", async () => {
|
||||
const publicAuditSource = await source("app/audit/[slug]/page.tsx");
|
||||
|
||||
assert.doesNotMatch(
|
||||
publicAuditSource,
|
||||
/Verwendete Skills|usedSkills/i,
|
||||
"Public audit page must not show used skills.",
|
||||
);
|
||||
});
|
||||
270
tests/german-copy-guard.test.ts
Normal file
270
tests/german-copy-guard.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
validateCallScriptCopy,
|
||||
validateCustomerFacingCopy,
|
||||
validateFollowUpCopy,
|
||||
} from "../lib/ai/german-copy-guard";
|
||||
|
||||
const validPayload = {
|
||||
auditSummary:
|
||||
"Ich habe euren Webauftritt geprüft. Mir ist aufgefallen, dass die Kontaktseite nicht klar erreichbar ist. Ich empfehle, den Kontaktbereich im Header sichtbar zu platzieren.",
|
||||
auditBody:
|
||||
"Mir ist aufgefallen, dass die Kontaktseite nur am Ende der Startseite eingebettet ist. Ich empfehle, sie im Kopfbereich direkt zu platzieren.",
|
||||
emailSubject: "Kurzes Feedback zu eurem Webauftritt",
|
||||
emailBody:
|
||||
"Hallo, ich habe eure Seite betrachtet und festgestellt, dass die Kontaktoptionen auf mobilen Geräten schwer zu finden sind. Ich empfehle, einen klar sichtbaren Button einzubauen.",
|
||||
callScript: {
|
||||
openingLine: "Hallo, ich bin Matthias von der Webberatung.",
|
||||
callScript: [
|
||||
"Ich habe eure Website geprüft und gesehen, dass der Kontaktbereich nicht sofort sichtbar ist.",
|
||||
"Ich schlage vor, den Kontakt-Button in den Header zu setzen und die Mobil-Ansicht anzupassen.",
|
||||
],
|
||||
closeLine: "Wenn das hilfreich klingt, kann ich euch in zwei Minuten die nächsten Schritte skizzieren.",
|
||||
},
|
||||
followUp:
|
||||
"Mir ist noch etwas aufgefallen: Auf der Mobilversion fehlt ein klarer Termin- oder Kontakthinweis. Ich schlage vor, diesen Bereich oberhalb der Leistungstexte deutlich zu markieren.",
|
||||
};
|
||||
|
||||
test("validateCustomerFacingCopy passes clean German outreach and audit copy", () => {
|
||||
const result = validateCustomerFacingCopy(validPayload);
|
||||
|
||||
assert.equal(result.passed, true);
|
||||
assert.equal(result.issues.length, 0);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy rejects likely non-German copy and reports language", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailBody:
|
||||
"Your site looks very strong, and your performance score is 0.82 with good Lighthouse numbers.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some((issue) =>
|
||||
issue.field === "emailBody" && issue.rule === "not_german",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy flags short English artifact-like snippets in content fields", () => {
|
||||
const shortInputs: Array<{
|
||||
field: "auditSummary" | "auditBody" | "emailBody" | "followUp";
|
||||
value: string;
|
||||
}> = [
|
||||
{ field: "emailBody", value: "quick audit" },
|
||||
{ field: "auditBody", value: "bad website" },
|
||||
{ field: "followUp", value: "AI report" },
|
||||
];
|
||||
|
||||
for (const { field, value } of shortInputs) {
|
||||
const payload = { ...validPayload, [field]: value };
|
||||
const result = validateCustomerFacingCopy(payload as typeof validPayload);
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) => issue.field === field && issue.rule === "not_german",
|
||||
),
|
||||
true,
|
||||
`Expected ${field} short snippet "${value}" to fail german language check.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy requires Ich-form in applicable customer-facing fields", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
auditBody:
|
||||
"Ihre Seite hat eine gute Struktur. Der Kontaktbereich sollte klarer werden.",
|
||||
followUp: "Die Website sollte verbessert werden. Setzt bitte einen Kontaktbutton.",
|
||||
});
|
||||
|
||||
const hasAuditIssue = result.issues.some(
|
||||
(issue) => issue.field === "auditBody" && issue.rule === "missing_ich_form",
|
||||
);
|
||||
const hasFollowUpIssue = result.issues.some(
|
||||
(issue) => issue.field === "followUp" && issue.rule === "missing_ich_form",
|
||||
);
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(hasAuditIssue, true);
|
||||
assert.equal(hasFollowUpIssue, true);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy blocks PageSpeed-like score artifacts in public text", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
auditSummary:
|
||||
"Aus dem PageSpeed-Check ergibt sich ein score: 0.82 im Bereich Performance.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "auditSummary" &&
|
||||
issue.rule === "pagespeed_score_artifact",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy blocks price/currency mention", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
callScript: {
|
||||
...validPayload.callScript,
|
||||
callScript: [
|
||||
"Der Kontaktpunkt ist gut sichtbar.",
|
||||
"Ihr Paket kostet nur 99 € pro Monat.",
|
||||
"Ich habe den Kontaktpunkt geprüft und schlage vor, ihn in der Headerzeile zu fixieren.",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) => issue.field === "callScript.callScript[1]" && issue.rule === "price_mention",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy rejects generic AI slop language", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailBody:
|
||||
"Unsere maßgeschneiderte, nahtlose, innovative Lösung hebt Ihre Sichtbarkeit auf ein neues Level und ist wirklich disruptive.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "emailBody" && issue.rule === "generic_ai_slop",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy flags accusatory tone", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
auditBody:
|
||||
"Ihre Website ist katastrophal und wirkt absolut unprofessionell. Das sollte dringend geändert werden.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) => issue.field === "auditBody" && issue.rule === "hostile_tone",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy strips technical artifacts like model ids and raw JSON", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
followUp:
|
||||
'Ich habe folgende Diagnose: {"score": 0.8, "lighthouseResult": "ok", "storageId": "rawstorageid_abc123"}',
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "followUp" &&
|
||||
issue.rule === "raw_technical_artifact",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy enforces observation + suggestion style", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailBody:
|
||||
"Deine Website ist großartig, tolle Arbeit.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "emailBody" &&
|
||||
issue.rule === "missing_observation_or_suggestion",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy is permissive for phone numbers and date values", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
auditSummary:
|
||||
"Ich habe gesehen, dass eure Kontaktseite am 12.02.2026 aktualisiert wurde. Ich empfehle, den Kontaktbereich als Nächstes im Header zu verbessern.",
|
||||
auditBody:
|
||||
"Mir ist aufgefallen, dass die Telefonnummer 0201 123456 in der Fußzeile steht. Ich empfehle, sie zusätzlich im Header zu platzieren.",
|
||||
emailSubject: "Kurzes Feedback zu eurem Terminplan",
|
||||
emailBody:
|
||||
"Hallo, ich habe euren Webauftritt geprüft und habe gesehen, dass Termine auf der Seite mit dem Datum 12. Oktober erwähnt sind. Ich empfehle, diese Terminangabe im Header stärker hervorzuheben.",
|
||||
callScript: {
|
||||
openingLine:
|
||||
"Hallo, ich bin Matthias und ich habe eure Seite geprüft.",
|
||||
callScript: [
|
||||
"Ich habe auf eurer Seite gesehen, dass der Kontaktbutton erst sehr weit unten erscheint.",
|
||||
"Mir ist aufgefallen, dass hier noch eine kleine Verbesserung fehlt; ich schlage vor, den Kontaktbereich nach oben zu ziehen.",
|
||||
],
|
||||
closeLine: "Dann nehme ich das Thema in den nächsten Schritt mit auf.",
|
||||
},
|
||||
followUp:
|
||||
"Mir ist am 12. Oktober aufgefallen, dass die Telefonnummer 030 1234567 schon gut auffindbar ist; ich schlage vor, eine kleine Sichtbarkeitsanpassung vorzunehmen.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, true);
|
||||
});
|
||||
|
||||
test("validateCallScriptCopy validates each script line individually and returns field paths", () => {
|
||||
const result = validateCallScriptCopy({
|
||||
openingLine: "Hallo, ich bin Matthias.",
|
||||
callScript: [
|
||||
"{" +
|
||||
'"score": 0.82, "rawstorageid":"abc123"' +
|
||||
"}",
|
||||
"Ich habe auf der Seite gesehen, dass der Kontaktbutton fehlt.",
|
||||
"Mir fehlt noch ein konkreter Verbesserungsschritt.",
|
||||
],
|
||||
closeLine: "Schöne Grüße",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "callScript.callScript[0]" &&
|
||||
issue.rule === "raw_technical_artifact",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateFollowUpCopy enforces ich-form and guard output shape", () => {
|
||||
const result = validateFollowUpCopy({
|
||||
message: "Hier ist der Inhalt für das Follow-up.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(result.issues.length > 0, true);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "followUp" && issue.rule === "missing_ich_form",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
50
tests/openrouter-provider.test.ts
Normal file
50
tests/openrouter-provider.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { createOpenRouterProvider } from "../lib/ai/openrouter-provider";
|
||||
|
||||
const providerSource = readFileSync(
|
||||
join(process.cwd(), "lib", "ai", "openrouter-provider.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("provider reads OPENROUTER_API_KEY from environment and requires it", () => {
|
||||
assert.equal(
|
||||
/OPENROUTER_API_KEY/.test(providerSource),
|
||||
true,
|
||||
"Provider should read OPENROUTER_API_KEY.",
|
||||
);
|
||||
assert.equal(
|
||||
/OPENROUTER_APP_NAME/.test(providerSource),
|
||||
true,
|
||||
"Provider should include optional OPENROUTER_APP_NAME.",
|
||||
);
|
||||
assert.equal(
|
||||
/OPENROUTER_APP_URL/.test(providerSource),
|
||||
true,
|
||||
"Provider should include optional OPENROUTER_APP_URL.",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
createOpenRouterProvider({
|
||||
OPENROUTER_API_KEY: undefined,
|
||||
OPENROUTER_APP_NAME: "local-audit-tool",
|
||||
OPENROUTER_APP_URL: "https://example.local",
|
||||
}),
|
||||
/OPENROUTER_API_KEY is required/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("provider forwards optional app metadata to createOpenRouter call", () => {
|
||||
const provider = createOpenRouterProvider({
|
||||
OPENROUTER_API_KEY: "dummy-key",
|
||||
OPENROUTER_APP_NAME: "local-audit-tool",
|
||||
OPENROUTER_APP_URL: "https://example.local",
|
||||
});
|
||||
|
||||
assert.equal(typeof provider, "function");
|
||||
assert.equal(provider !== null, true);
|
||||
});
|
||||
250
tests/skills-registry.test.ts
Normal file
250
tests/skills-registry.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, sep } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
type AuditUsedSkill,
|
||||
loadSkillsRegistry,
|
||||
parseSkillsRegistry,
|
||||
toAuditUsedSkill,
|
||||
SKILL_CATEGORIES,
|
||||
} from "../lib/skills-registry";
|
||||
|
||||
function assertIncludes(values: readonly string[], value: string) {
|
||||
assert.ok(values.includes(value), `Expected ${value} in [${values.join(", ")}]`);
|
||||
}
|
||||
|
||||
function withTempProjectRegistry(
|
||||
source: string,
|
||||
run: () => Promise<void> | void,
|
||||
) {
|
||||
return mkdtemp(`${tmpdir()}${sep}`).then(async (projectRoot) => {
|
||||
const registryPath = join(projectRoot, "skills.md");
|
||||
const originalCwd = process.cwd();
|
||||
await writeFile(registryPath, source, "utf8");
|
||||
process.chdir(projectRoot);
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("parseSkillsRegistry parses valid entries, trims whitespace, and normalizes category", async () => {
|
||||
const registrySource = `
|
||||
## Design Audit
|
||||
Purpose: Evaluate layout, visual hierarchy, and CTA clarity.
|
||||
When to use: Use when a homepage is available and should be assessed for conversion quality.
|
||||
When not to use: Don't run during technical outages or non-web touchpoints.
|
||||
Required input: Homepage URL, top-level page sections, style language, brand context.
|
||||
Expected output: Prioritized improvement list with concrete design changes.
|
||||
Category: design
|
||||
Version: 2026.06
|
||||
Source: skills/design-audit.md
|
||||
`;
|
||||
|
||||
const parsed = parseSkillsRegistry(registrySource);
|
||||
|
||||
assert.equal(parsed.length, 1);
|
||||
const entry = parsed.at(0);
|
||||
assert.ok(entry);
|
||||
assert.equal(entry!.name, "Design Audit");
|
||||
assert.equal(entry!.purpose, "Evaluate layout, visual hierarchy, and CTA clarity.");
|
||||
assert.equal(entry!.category, "design");
|
||||
assert.equal(entry!.version, "2026.06");
|
||||
assert.equal(entry!.source, "skills/design-audit.md");
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry accepts indented field labels", () => {
|
||||
const registrySource = `
|
||||
## Local SEO Boost
|
||||
Purpose: Evaluate visibility for local search with nearby intent.
|
||||
When to use: Use for local business pages and service locations.
|
||||
When not to use: Avoid for non-local marketing pages.
|
||||
Required input: City, address, NAP consistency.
|
||||
Expected output: Prioritized local SEO recommendations.
|
||||
Category: seo
|
||||
`;
|
||||
|
||||
const parsed = parseSkillsRegistry(registrySource);
|
||||
|
||||
assert.equal(parsed.length, 1);
|
||||
const entry = parsed.at(0);
|
||||
assert.ok(entry);
|
||||
assert.equal(entry!.name, "Local SEO Boost");
|
||||
assert.equal(entry!.purpose, "Evaluate visibility for local search with nearby intent.");
|
||||
assert.equal(entry!.category, "seo");
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry throws for missing required fields", () => {
|
||||
const registrySource = `
|
||||
## UX Friction Review
|
||||
Purpose: Review interaction patterns for friction points.
|
||||
When to use: Use for lead capture and booking flows.
|
||||
When not to use: Use only when there is a user journey.
|
||||
Required input: Session flow and target action.
|
||||
Category: ux
|
||||
`;
|
||||
|
||||
assert.throws(
|
||||
() => parseSkillsRegistry(registrySource),
|
||||
/missing required field "Expected output"/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry throws for unknown category", () => {
|
||||
const registrySource = `
|
||||
## Bad Category Example
|
||||
Purpose: Example.
|
||||
When to use: Example scenario.
|
||||
When not to use: Never for this case.
|
||||
Required input: Example data.
|
||||
Expected output: Example output.
|
||||
Category: analytics
|
||||
`;
|
||||
|
||||
assert.throws(
|
||||
() => parseSkillsRegistry(registrySource),
|
||||
/unknown category "analytics"/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry throws for duplicate skill names", () => {
|
||||
const registrySource = `
|
||||
## Local SEO Boost
|
||||
Purpose: Strengthen local SERPs.
|
||||
When to use: Use for local service businesses.
|
||||
When not to use: Not for international-only landing pages.
|
||||
Required input: Name, address, service area.
|
||||
Expected output: Local SEO gaps and quick wins.
|
||||
Category: seo
|
||||
|
||||
## Local SEO Boost
|
||||
Purpose: Another local SEO pass.
|
||||
When to use: Use for new regions.
|
||||
When not to use: Skip for pure lead-gen pages.
|
||||
Required input: Name, address, service area.
|
||||
Expected output: Competitor baseline.
|
||||
Category: seo
|
||||
`;
|
||||
|
||||
assert.throws(
|
||||
() => parseSkillsRegistry(registrySource),
|
||||
/duplicate skill name "Local SEO Boost"/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry accepts all configured categories", () => {
|
||||
assertIncludes(SKILL_CATEGORIES, "design");
|
||||
assertIncludes(SKILL_CATEGORIES, "ux");
|
||||
assertIncludes(SKILL_CATEGORIES, "marketing");
|
||||
assertIncludes(SKILL_CATEGORIES, "copy");
|
||||
assertIncludes(SKILL_CATEGORIES, "seo");
|
||||
assertIncludes(SKILL_CATEGORIES, "offer");
|
||||
|
||||
const registrySource = SKILL_CATEGORIES.map(
|
||||
(category) => `
|
||||
## ${category}-skill
|
||||
Purpose: Valid for ${category}.
|
||||
When to use: Use for ${category} tasks.
|
||||
When not to use: Skip when ${category} is not in scope.
|
||||
Required input: Category inputs.
|
||||
Expected output: Category-specific recommendations.
|
||||
Category: ${category}
|
||||
`,
|
||||
).join("\n\n");
|
||||
|
||||
const parsed = parseSkillsRegistry(registrySource);
|
||||
assert.equal(parsed.length, SKILL_CATEGORIES.length);
|
||||
for (const category of SKILL_CATEGORIES) {
|
||||
const match = parsed.find((entry) => entry.name === `${category}-skill`);
|
||||
assert.ok(match, `Expected parsed entry for ${category}`);
|
||||
assert.equal(match.category, category);
|
||||
}
|
||||
});
|
||||
|
||||
test("loadSkillsRegistry reads skills.md from process.cwd() by default", async () => {
|
||||
await withTempProjectRegistry(
|
||||
`
|
||||
## Offer Writing
|
||||
Purpose: Build offer-focused copy for outreach.
|
||||
When to use: Use before drafting proposals.
|
||||
When not to use: Avoid when no offer exists.
|
||||
Required input: Offer structure and pricing envelope.
|
||||
Expected output: Offer draft and pricing emphasis.
|
||||
Category: offer
|
||||
`,
|
||||
async () => {
|
||||
const parsed = await loadSkillsRegistry();
|
||||
const parsedEntry = parsed.find((entry) => entry.name === "Offer Writing");
|
||||
|
||||
assert.ok(parsedEntry);
|
||||
assert.equal(parsedEntry.category, "offer");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("loadSkillsRegistry accepts an explicit registry path", async () => {
|
||||
const projectRoot = await mkdtemp(`${tmpdir()}${sep}`);
|
||||
const registryPath = join(projectRoot, "seed-skills.md");
|
||||
|
||||
await writeFile(
|
||||
registryPath,
|
||||
`
|
||||
## Design Audit
|
||||
Purpose: Validate design quality for local business pages.
|
||||
When to use: Use for a quick visual prioritization pass.
|
||||
When not to use: Skip when no public page exists.
|
||||
Required input: Homepage URL and target conversion goal.
|
||||
Expected output: Ranked design actions with confidence.
|
||||
Category: design
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
const parsed = await loadSkillsRegistry(registryPath);
|
||||
assert.equal(parsed.at(0)?.name, "Design Audit");
|
||||
} finally {
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("toAuditUsedSkill returns only required audit-facing fields", () => {
|
||||
const skill = {
|
||||
name: "Copy Clarity",
|
||||
purpose: "Reduce complexity and improve readability.",
|
||||
whenToUse: "When existing copy is verbose.",
|
||||
whenNotToUse: "Skip if website is plain text-only.",
|
||||
requiredInput: "Page sections and CTAs.",
|
||||
expectedOutput: "A concise writing pass.",
|
||||
category: "copy",
|
||||
version: "1.0",
|
||||
source: "skills/copy-clarity.md",
|
||||
} satisfies {
|
||||
name: string;
|
||||
purpose: string;
|
||||
whenToUse: string;
|
||||
whenNotToUse: string;
|
||||
requiredInput: string;
|
||||
expectedOutput: string;
|
||||
category: "copy";
|
||||
version: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
const auditUsed = toAuditUsedSkill(skill);
|
||||
const expected: AuditUsedSkill = {
|
||||
name: "Copy Clarity",
|
||||
category: "copy",
|
||||
version: "1.0",
|
||||
source: "skills/copy-clarity.md",
|
||||
};
|
||||
|
||||
assert.deepEqual(auditUsed, expected);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user