Improve audit pipeline and outreach review
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: TASK-46
|
||||
title: Add Convex specialist fan-out audit pipeline
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-08 09:04'
|
||||
updated_date: '2026-06-08 09:19'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 48000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Implement an evidence-first specialist fan-out/fan-in audit generation pipeline in Convex so audits produce verified, reviewable findings before German copy and publication.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Specialist audit stages run after evidence collection and before German copy
|
||||
- [x] #2 Specialist findings include typed evidence refs and unsupported claims are rejected
|
||||
- [x] #3 Verified findings are persisted separately and surfaced on audit detail pages
|
||||
- [x] #4 Quality review blocks when either model QA or German copy guard fails
|
||||
- [x] #5 Skill summaries use real registry purpose or instructions
|
||||
- [x] #6 Schema, evidence, action-source, persistence, quality gate, and UI tests pass
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add RED tests for specialist schemas, evidence IDs, action ordering, persistence, QA gates, and UI rendering
|
||||
2. Implement schema validators and evidence ledger helpers
|
||||
3. Add auditFindings persistence and detail query joins
|
||||
4. Wire specialist fan-out stages and evidence verifier before German copy
|
||||
5. Make qualityReview model invalid state blocking and improve skill summaries
|
||||
6. Update audit detail UI to render findings with evidence chips
|
||||
7. Run focused tests, typecheck, and full test suite where feasible
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
RED: pnpm exec tsc -p tsconfig.test.json fails because AuditEvidenceInput has no evidenceLedger and lib/ai/schemas exports no specialist/verifier schemas yet. This is the expected missing-feature failure.
|
||||
|
||||
GREEN: Focused audit fan-out/source/UI tests passed 67/67. Full pnpm test passed 384/384. Implemented specialist fan-out stages, evidence ledger, auditFindings persistence, blocking model+guard QA, real skill summaries, and findings-first audit detail UI.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: TASK-47
|
||||
title: Fix evidence verifier audit generation failure
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-08 09:35'
|
||||
updated_date: '2026-06-08 10:07'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 49000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Diagnose and fix the evidenceVerifier stage failure in the Convex specialist fan-out audit pipeline so live audit generation can complete or fail with actionable verifier diagnostics.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Root cause is identified from persisted run or generation evidence
|
||||
- [x] #2 Evidence verifier schema or prompt no longer fails on valid specialist outputs
|
||||
- [x] #3 Audit generation preserves strict evidence gates without schema-induced false failures
|
||||
- [x] #4 Focused and full regression tests pass
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Pull the failing evidenceVerifier error details from Convex run/generation records
|
||||
2. Add a RED regression test for the root cause
|
||||
3. Fix the verifier schema/prompt or fallback behavior at the source
|
||||
4. Run focused fan-out tests and full pnpm test
|
||||
5. Record verification notes and keep task In Progress until user confirms live audit works
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Root cause from Convex auditGenerations/agentRunEvents: all specialist structured-output calls failed before content generation because Azure rejected the response_format schema. The shared evidenceRef object declared sourceUrl as an optional property, but Azure/OpenAI strict structured outputs require every declared property to be listed in required. The verifier then received an empty findings array and failed on the same schema issue.
|
||||
Fix: made Specialist/Verifier output schemas strict-output compatible by requiring sourceUrl and required array fields, added explicit prompt guidance for sourceUrl/status/findings/notes, and replaced rejectedFindings with a narrow rejection schema so unknown/generic rejected claims do not have to pass the publishable finding schema.
|
||||
Verification: RED test reproduced schema.findings[].evidenceRefs[].sourceUrl missing from required; focused schema tests now pass; fan-out/persistence/UI tests pass; pnpm test passes 386/386; git diff --check passes; ESLint on touched source/test files passes.
|
||||
|
||||
Second live failure root cause: after the strict schema fix, specialist stages succeeded, but evidenceVerifier failed with "No object generated: could not parse the response." The persisted verifier prompt contained about 10 full specialist findings and the verifier schema required echoing full verifiedFindings objects back. With the classification profile capped at 1200 output tokens, this made verifier output too large/fragile to parse. Context7 AI SDK docs confirmed AI SDK 6 uses strict OpenAI JSON schema behavior by default; the issue was now output shape/size rather than schema rejection.
|
||||
Fix: changed evidenceVerifier output to compact verifiedFindingIds plus small rejected decisions, then deterministically map accepted IDs back to original specialist findings in the action. This preserves strict evidence gates while removing verifier echoing/mutation of findings.
|
||||
Verification: added RED schema regression for compact verifier IDs and many findings; focused schema/action tests pass; adjacent audit persistence/schema/UI/evidence tests pass; pnpm test passes 387/387; git diff --check passes; ESLint on touched files passes; npx convex dev --once synced the fix to dev deployment.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-48
|
||||
title: Integrate impeccable critique into audit pipeline
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-08 12:02'
|
||||
updated_date: '2026-06-08 12:10'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 50000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Extend the evidence-first audit pipeline with design critique/impeccable-style visual and UX evaluation, especially the critique skill, while keeping verified findings evidence-linked and customer-safe.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Critique/impeccable skill guidance is inspected and translated into bounded audit stages or skill prompts
|
||||
- [x] #2 New critique findings stay evidence-linked and flow through the compact evidence verifier
|
||||
- [x] #3 German copy synthesis consumes only verified critique findings, not raw skill output
|
||||
- [x] #4 Audit UI exposes critique findings with evidence chips and actual skill purpose text
|
||||
- [x] #5 Focused and full regression tests cover the new critique integration
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Inspect impeccable/critique skill guidance and current audit pipeline shape
|
||||
2. Define a compact critique/impeccable stage that maps skill guidance into evidence-backed audit findings
|
||||
3. Add schemas/prompts or stage wiring without expanding verifier output size
|
||||
4. Update UI/tests so critique findings are visible with evidence and real skill purpose
|
||||
5. Run focused and full regression tests, deploy Convex dev, keep task In Progress for live confirmation
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented the impeccable/critique integration as an evidence-bound audit extension. Inspected the local impeccable and critique skills; no project-specific .impeccable.md was present, so the product guidance was translated into bounded audit behavior instead of broad design taste claims. Added the V3 skill registry entry `impeccable-critique`, prioritized it in selected local audit skills, and wired a new Convex `critiqueSpecialist` stage between visual trust and performance/accessibility. The stage is instructed to produce only evidence-linked findings using skillId `impeccable-critique`; the existing compact verifier and German synthesis path remain the gate, so raw specialist output is not customer-facing. UI tests continue to cover evidence chips and real registry purpose text. Verification: focused specialist/evidence tests 45/45 passed; skill/UI tests 15/15 passed; full `pnpm test` 388/388 passed; `git diff --check` passed; targeted ESLint passed; `npx convex dev --once` synced successfully.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
43
backlog/tasks/task-49 - Improve-audit-outreach-email-tone.md
Normal file
43
backlog/tasks/task-49 - Improve-audit-outreach-email-tone.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-49
|
||||
title: Improve audit outreach email tone
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-08 19:30'
|
||||
updated_date: '2026-06-08 19:48'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 51000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add evidence-first, collegial-direct tonal guidelines for generated outreach emails, wire them into the existing German copy stage without extra AI calls, and hard-block unnatural email copy before outreach_ready.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Shared customer tone guidelines capture the selected collegial-direct email style and banned patterns
|
||||
- [x] #2 German copy prompts use the tone guidelines, explicit lead context, at most two verified findings, and no extra AI stage or model call
|
||||
- [x] #3 Deterministic German copy guard blocks unnatural email subjects and bodies while keeping public audit tone checks limited to existing rules
|
||||
- [x] #4 Quality review applies the same first-contact email rubric
|
||||
- [x] #5 Focused and full regression tests cover natural email pass cases, unnatural email failures, source wiring, and no new generation stage
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add failing tests for natural vs. formulaic outreach email tone
|
||||
2. Add shared collegial-direct tone guideline module
|
||||
3. Add deterministic hard guard for email subject/body tone
|
||||
4. Wire guidelines into German copy and quality review prompts without a new AI stage
|
||||
5. Run focused tests, full regression, lint, diff check, and Convex dev sync
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented the evidence-first outreach email tone pass. Added `lib/ai/customer-tone-guidelines.ts` with the selected collegial-direct sender posture, short first-contact email constraints, banned phrases, and prompt helper. Updated German copy generation to remove the old Ich-Ich instruction, include the shared tone section, pass normalized evidence context, and keep the existing generation call structure. Added hard deterministic email tone checks for subject length/pitch patterns, email length, sentence/paragraph count, formulaic Ich-habe/Ich-schlage-vor patterns, brochure language, mini-audit structure, informal address, and missing low-friction asks. Public audit hard guard behavior remains limited to the existing rules. Quality review now explicitly asks whether the email sounds like a real first email from Matthias, not AI sales copy, and whether concrete claims are backed by verified findings. Verification: focused tests 60/60 passed; full `pnpm test` 395/395 passed; targeted ESLint passed; `git diff --check` passed; `npx convex dev --once` synced successfully after fixing the Convex-only typecheck issue by passing `evidenceInput` instead of raw evidence.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: TASK-50
|
||||
title: Refactor dashboard views into compact cards
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-08 19:56'
|
||||
updated_date: '2026-06-08 19:57'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 52000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Implement the planned internal Ops UX refactor for Campaigns, Leads, Audits, and Review Workspace using compact shadcn-style cards, modal/detail disclosure, and accessible status feedback.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Campaigns render as a responsive card grid while preserving existing campaign actions and run logs.
|
||||
- [ ] #2 Leads show compact cards and open the review form in an accessible modal from Mehr anzeigen.
|
||||
- [ ] #3 Audits use responsive cards with detail links for audit rows and non-clickable pipeline states for generation rows.
|
||||
- [ ] #4 Review Workspace uses compact queue cards with a single selected detail editor while preserving existing save, publish, approve, and send flows.
|
||||
- [ ] #5 Relevant tests, lint, and build pass or any remaining blockers are documented.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add failing UI/source tests for card-grid, lead modal, audit cards, and review master-detail
|
||||
2. Implement Campaigns responsive grid and accessible card semantics
|
||||
3. Move Leads inline review details into Dialog modal
|
||||
4. Replace Audits row table with responsive cards
|
||||
5. Convert Review Workspace to queue cards plus selected detail editor
|
||||
6. Run focused tests, then lint/build where feasible
|
||||
7. Record verification notes on TASK-50 without marking Done
|
||||
<!-- SECTION:PLAN:END -->
|
||||
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
type UsedSkill = {
|
||||
id?: string;
|
||||
name: string;
|
||||
purpose?: string;
|
||||
category?: string;
|
||||
@@ -17,6 +18,12 @@ type UsedSkill = {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type SkillSummary = {
|
||||
name: string;
|
||||
purpose: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
type LeadContext = {
|
||||
_id: Id<"leads">;
|
||||
companyName?: string;
|
||||
@@ -35,9 +42,35 @@ type SkillAwareAudit = {
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
usedSkills?: UsedSkill[];
|
||||
skillSummaries?: SkillSummary[];
|
||||
internalSummary?: string | null;
|
||||
};
|
||||
|
||||
type AuditFindingEvidenceRef = {
|
||||
id: string;
|
||||
type:
|
||||
| "crawl_page"
|
||||
| "technical_check"
|
||||
| "screenshot"
|
||||
| "pagespeed"
|
||||
| "jina_excerpt"
|
||||
| "generation_stage";
|
||||
label: string;
|
||||
sourceUrl?: string;
|
||||
};
|
||||
|
||||
type AuditFinding = {
|
||||
_id: string;
|
||||
skillId: string;
|
||||
claim: string;
|
||||
recommendation: string;
|
||||
customerBenefit: string;
|
||||
severity: 1 | 2 | 3;
|
||||
confidence: number;
|
||||
evidenceRefs: AuditFindingEvidenceRef[];
|
||||
reviewStatus: "pending" | "accepted" | "rejected";
|
||||
};
|
||||
|
||||
type CheckedPageScreenshot = {
|
||||
id: Id<"_storage">;
|
||||
url: string;
|
||||
@@ -69,6 +102,7 @@ type CheckedPageEvidence = {
|
||||
type AuditDetailResult = {
|
||||
audit: SkillAwareAudit;
|
||||
lead: LeadContext | null;
|
||||
findings: AuditFinding[];
|
||||
sourceSummaries: {
|
||||
checkedPages: CheckedPageEvidence[];
|
||||
};
|
||||
@@ -121,6 +155,19 @@ function metaSignalText(page: CheckedPageEvidence) {
|
||||
return "Unbekannt";
|
||||
}
|
||||
|
||||
function evidenceTypeLabel(type: AuditFindingEvidenceRef["type"]) {
|
||||
const labels: Record<AuditFindingEvidenceRef["type"], string> = {
|
||||
crawl_page: "Crawl",
|
||||
technical_check: "Technik",
|
||||
screenshot: "Screenshot",
|
||||
pagespeed: "PageSpeed",
|
||||
jina_excerpt: "Reader",
|
||||
generation_stage: "KI-Stufe",
|
||||
};
|
||||
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
function leadSummary(lead: LeadContext | null | undefined) {
|
||||
if (!lead) {
|
||||
return "Kein Lead-Kontext gespeichert";
|
||||
@@ -156,7 +203,20 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
||||
const audit = result?.audit;
|
||||
const lead = result?.lead;
|
||||
|
||||
const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]);
|
||||
const usedSkills = useMemo(() => {
|
||||
const summaries = audit?.skillSummaries ?? [];
|
||||
return (audit?.usedSkills ?? []).map((skill) => {
|
||||
const summary = summaries.find(
|
||||
(candidate) => candidate.name === skill.name || candidate.name === skill.id,
|
||||
);
|
||||
return {
|
||||
...skill,
|
||||
purpose: summary?.purpose ?? skill.purpose,
|
||||
summary: summary?.summary,
|
||||
};
|
||||
});
|
||||
}, [audit]);
|
||||
const findings = useMemo(() => result?.findings ?? [], [result]);
|
||||
const checkedPageEvidence = useMemo(
|
||||
() => result?.sourceSummaries.checkedPages ?? [],
|
||||
[result],
|
||||
@@ -220,6 +280,56 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Geprüfte Befunde</CardTitle>
|
||||
<CardDescription>
|
||||
Verifizierte Aussagen mit konkreten Belegen aus Crawl, Screenshots und Messungen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{findings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Noch keine verifizierten Befunde gespeichert.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-3">
|
||||
{findings.map((finding) => (
|
||||
<li className="rounded-md border p-3 text-sm" key={finding._id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{finding.claim}</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{finding.customerBenefit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant={finding.severity === 3 ? "secondary" : "outline"}>
|
||||
Priorität {finding.severity}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{Math.round(finding.confidence * 100)}% sicher
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3">
|
||||
<span className="font-medium">Empfehlung: </span>
|
||||
{finding.recommendation}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{finding.evidenceRefs.map((ref) => (
|
||||
<Badge variant="outline" key={ref.id}>
|
||||
Quelle: {evidenceTypeLabel(ref.type)} · {ref.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Geprüfte Seiten</CardTitle>
|
||||
@@ -322,6 +432,9 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skill.purpose ?? "Keine Zweckbeschreibung"}
|
||||
</p>
|
||||
{"summary" in skill && skill.summary ? (
|
||||
<p className="text-sm text-muted-foreground">{skill.summary}</p>
|
||||
) : null}
|
||||
<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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
@@ -10,10 +10,21 @@ import Link from "next/link";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
type AuditDashboardRowsResult = FunctionReturnType<typeof api.audits.listDashboardRows>;
|
||||
type AuditRow = Extract<NonNullable<AuditDashboardRowsResult>[number], { kind: "audit" }>;
|
||||
type AuditRow = Extract<
|
||||
NonNullable<AuditDashboardRowsResult>[number],
|
||||
{ kind: "audit" }
|
||||
>;
|
||||
type AuditDashboardRow = NonNullable<AuditDashboardRowsResult>[number];
|
||||
type AuditStatusFilter = "all" | "audit" | "generation" | "failed";
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
draft: "Entwurf",
|
||||
@@ -74,19 +85,18 @@ function AuditsBoardLoading() {
|
||||
<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">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton className="h-20 rounded-md" key={index} />
|
||||
<Skeleton className="h-40 rounded-lg" key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditsBoard() {
|
||||
const dashboardRows = useQuery(api.audits.listDashboardRows, { limit: 100 });
|
||||
const [activeFilter, setActiveFilter] = useState<AuditStatusFilter>("all");
|
||||
const rows = useMemo(() => {
|
||||
if (!dashboardRows) {
|
||||
return [];
|
||||
@@ -94,6 +104,43 @@ export function AuditsBoard() {
|
||||
|
||||
return [...dashboardRows].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}, [dashboardRows]);
|
||||
const statusCounts = useMemo(() => {
|
||||
return {
|
||||
all: rows.length,
|
||||
audit: rows.filter((row) => row.kind === "audit").length,
|
||||
generation: rows.filter((row) => row.kind === "generation").length,
|
||||
failed: rows.filter(
|
||||
(row) => row.kind === "generation" && row.status === "failed",
|
||||
).length,
|
||||
};
|
||||
}, [rows]);
|
||||
const visibleRows = useMemo(() => {
|
||||
if (activeFilter === "audit") {
|
||||
return rows.filter((row) => row.kind === "audit");
|
||||
}
|
||||
|
||||
if (activeFilter === "generation") {
|
||||
return rows.filter((row) => row.kind === "generation");
|
||||
}
|
||||
|
||||
if (activeFilter === "failed") {
|
||||
return rows.filter(
|
||||
(row) => row.kind === "generation" && row.status === "failed",
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [activeFilter, rows]);
|
||||
const auditStatusFilters: Array<{
|
||||
label: string;
|
||||
value: AuditStatusFilter;
|
||||
count: number;
|
||||
}> = [
|
||||
{ label: "Alle", value: "all", count: statusCounts.all },
|
||||
{ label: "Audits", value: "audit", count: statusCounts.audit },
|
||||
{ label: "Pipeline", value: "generation", count: statusCounts.generation },
|
||||
{ label: "Fehlgeschlagen", value: "failed", count: statusCounts.failed },
|
||||
];
|
||||
|
||||
if (dashboardRows === undefined) {
|
||||
return <AuditsBoardLoading />;
|
||||
@@ -107,13 +154,15 @@ export function AuditsBoard() {
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
</header>
|
||||
|
||||
<article className="rounded-lg border p-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-sm font-medium">Noch keine Audits</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<CardDescription>
|
||||
Sobald neue Audits oder laufende Audit-Generierungen angelegt
|
||||
wurden, erscheinen sie hier als kompakte Zeilen.
|
||||
</p>
|
||||
</article>
|
||||
wurden, erscheinen sie hier als kompakte Cards.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -125,74 +174,118 @@ export function AuditsBoard() {
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
</header>
|
||||
|
||||
<section className="space-y-2 overflow-x-auto">
|
||||
<div className="grid min-w-[840px] grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_170px_150px_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>Seiten/Phase</span>
|
||||
<span className="text-right">Aktion</span>
|
||||
<div className="flex flex-wrap gap-2" aria-label="Audit-Filter">
|
||||
{auditStatusFilters.map((filter) => (
|
||||
<button
|
||||
aria-pressed={activeFilter === filter.value}
|
||||
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
|
||||
key={filter.value}
|
||||
onClick={() => setActiveFilter(filter.value)}
|
||||
type="button"
|
||||
>
|
||||
{filter.label}
|
||||
<Badge variant="secondary">{filter.count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((row: AuditDashboardRow) => (
|
||||
<article
|
||||
className="grid min-w-[840px] grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_170px_150px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
<section
|
||||
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||
aria-label="Audit-Cards"
|
||||
>
|
||||
{visibleRows.map((row: AuditDashboardRow) => {
|
||||
const rowTitleId = `audit-row-title-${row.id}`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
aria-labelledby={rowTitleId}
|
||||
className="flex min-w-0 flex-col"
|
||||
key={row.id}
|
||||
>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{row.title}</p>
|
||||
{row.kind === "generation" ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Run {row.runId}
|
||||
</p>
|
||||
) : null}
|
||||
<CardDescription>
|
||||
{row.kind === "audit" ? "Audit" : "Pipeline"}
|
||||
</CardDescription>
|
||||
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
|
||||
{row.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<p className="truncate text-muted-foreground">{row.checkedDomain}</p>
|
||||
<Badge
|
||||
className="h-auto min-h-6 justify-center whitespace-normal text-center"
|
||||
variant="secondary"
|
||||
>
|
||||
<Badge variant={row.kind === "audit" ? "secondary" : "outline"}>
|
||||
{row.kind === "audit"
|
||||
? getStatusLabel(row.status)
|
||||
: getGenerationStatusLabel(row)}
|
||||
</Badge>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col gap-4">
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Domain</p>
|
||||
<p className="mt-1 break-all">{row.checkedDomain}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{row.kind === "audit" ? "Seiten" : "Phase"}
|
||||
</p>
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-muted-foreground">
|
||||
{row.kind === "audit" ? (
|
||||
<>
|
||||
<Files className="size-3.5" />
|
||||
<Files className="size-3.5" aria-hidden="true" />
|
||||
{formatPageCount(row.pageCount)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Activity className="size-3.5" />
|
||||
<Activity className="size-3.5" aria-hidden="true" />
|
||||
{getStageLabel(row.latestStage)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{row.kind === "generation" && row.errorSummary ? (
|
||||
<span className="mt-1 block truncate text-xs">
|
||||
{row.errorSummary}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Slug</p>
|
||||
<p className="mt-1 break-words text-muted-foreground">
|
||||
{row.kind === "generation" ? `Run ${row.runId}` : row.title}
|
||||
</p>
|
||||
</div>
|
||||
{row.kind === "generation" && row.errorSummary ? (
|
||||
<p className="break-words rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{row.errorSummary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex justify-end">
|
||||
{row.kind === "audit" ? (
|
||||
<Link
|
||||
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
|
||||
className="inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-sm text-primary hover:bg-muted"
|
||||
href={row.detailHref}
|
||||
>
|
||||
<SquarePen className="size-4" />
|
||||
<SquarePen className="size-4" aria-hidden="true" />
|
||||
Öffnen
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Pipeline</span>
|
||||
<span className="inline-flex min-h-8 items-center text-sm text-muted-foreground">
|
||||
Pipeline läuft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{visibleRows.length === 0 ? (
|
||||
<Card className="sm:col-span-2 xl:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Treffer</CardTitle>
|
||||
<CardDescription>
|
||||
Für diesen Filter gibt es aktuell keine Audit-Einträge.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -284,13 +284,18 @@ export function CampaignsBoard() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{campaignsSorted.map((campaign) => (
|
||||
<Card key={campaign._id}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{campaignsSorted.map((campaign) => {
|
||||
const campaignTitleId = `campaign-title-${campaign._id}`;
|
||||
|
||||
return (
|
||||
<Card aria-labelledby={campaignTitleId} key={campaign._id}>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate">{campaign.name}</CardTitle>
|
||||
<CardTitle className="truncate" id={campaignTitleId}>
|
||||
{campaign.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate">
|
||||
{formatNiche(campaign)}
|
||||
</CardDescription>
|
||||
@@ -320,10 +325,16 @@ export function CampaignsBoard() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
|
||||
<p className="text-muted-foreground">Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}
|
||||
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Run-Status:{" "}
|
||||
{statusLabel[campaign.currentRunStatus] ??
|
||||
campaign.currentRunStatus}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -357,7 +368,8 @@ export function CampaignsBoard() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -23,7 +23,16 @@ import {
|
||||
} from "@/lib/dashboard-model";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -63,6 +72,7 @@ type LeadReviewPayload = {
|
||||
reviewContactPerson?: string;
|
||||
reviewIsBusinessContactAddress?: boolean;
|
||||
};
|
||||
type LeadStatusFilter = "all" | "high" | "blocked";
|
||||
|
||||
function normalizeTextInput(value: string): string | undefined {
|
||||
const next = value.trim();
|
||||
@@ -132,6 +142,7 @@ function duplicateBadgeVariant(
|
||||
export function LeadsReviewTable() {
|
||||
const leads = useQuery(api.leads.list, { limit: 120 });
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<LeadStatusFilter>("all");
|
||||
|
||||
const sortedLeads = useMemo(() => {
|
||||
if (!leads) {
|
||||
@@ -140,6 +151,30 @@ export function LeadsReviewTable() {
|
||||
|
||||
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}, [leads]);
|
||||
const filteredLeads = useMemo(() => {
|
||||
if (activeFilter === "high") {
|
||||
return sortedLeads.filter((lead) => lead.priority === "high");
|
||||
}
|
||||
|
||||
if (activeFilter === "blocked") {
|
||||
return sortedLeads.filter((lead) => lead.blacklistStatus === "blocked");
|
||||
}
|
||||
|
||||
return sortedLeads;
|
||||
}, [activeFilter, sortedLeads]);
|
||||
const leadStatusFilters: Array<{ label: string; value: LeadStatusFilter; count: number }> = [
|
||||
{ label: "Alle Leads", value: "all", count: sortedLeads.length },
|
||||
{
|
||||
label: "Hohe Priorität",
|
||||
value: "high",
|
||||
count: sortedLeads.filter((lead) => lead.priority === "high").length,
|
||||
},
|
||||
{
|
||||
label: "Gesperrt",
|
||||
value: "blocked",
|
||||
count: sortedLeads.filter((lead) => lead.blacklistStatus === "blocked").length,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
@@ -148,16 +183,52 @@ export function LeadsReviewTable() {
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-wrap gap-2" aria-label="Lead-Filter">
|
||||
{leadStatusFilters.map((filter) => (
|
||||
<button
|
||||
aria-pressed={activeFilter === filter.value}
|
||||
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
|
||||
key={filter.value}
|
||||
onClick={() => setActiveFilter(filter.value)}
|
||||
type="button"
|
||||
>
|
||||
{filter.label}
|
||||
<Badge variant="secondary">{filter.count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid w-full max-w-7xl gap-3">
|
||||
{leads === undefined ? (
|
||||
<p className="rounded-md bg-muted p-4 text-sm">Leads werden geladen…</p>
|
||||
Array.from({ length: 4 }, (_, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<div className="h-5 w-2/3 rounded-md bg-muted" />
|
||||
<div className="h-4 w-1/2 rounded-md bg-muted" />
|
||||
<div className="mt-2 h-12 rounded-md bg-muted" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))
|
||||
) : sortedLeads.length === 0 ? (
|
||||
<p className="rounded-md border p-4 text-sm text-muted-foreground">
|
||||
Keine Leads vorhanden. Bitte zuerst eine Kampagne starten oder
|
||||
importieren.
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<p className="text-sm font-medium">Keine Leads vorhanden</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bitte zuerst eine Kampagne starten oder importieren.
|
||||
</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : filteredLeads.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<p className="text-sm font-medium">Keine Treffer</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Für diesen Filter sind aktuell keine Leads vorhanden.
|
||||
</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
sortedLeads.map((lead) => (
|
||||
filteredLeads.map((lead) => (
|
||||
<LeadReviewRow
|
||||
key={lead._id}
|
||||
lead={lead}
|
||||
@@ -168,7 +239,7 @@ export function LeadsReviewTable() {
|
||||
</div>
|
||||
|
||||
{actionMessage ? (
|
||||
<p className="mx-auto max-w-7xl text-sm text-muted-foreground">
|
||||
<p className="mx-auto max-w-7xl text-sm text-muted-foreground" role="status">
|
||||
{actionMessage}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -183,7 +254,7 @@ function LeadReviewRow({
|
||||
lead: LeadRow;
|
||||
onActionMessage: (value: string) => void;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
|
||||
priority: lead.priority,
|
||||
contactStatus: lead.contactStatus,
|
||||
@@ -279,14 +350,26 @@ function LeadReviewRow({
|
||||
};
|
||||
|
||||
const detailsId = `lead-review-details-${lead._id}`;
|
||||
const titleId = `lead-review-title-${lead._id}`;
|
||||
const priorityId = `lead-priority-${lead._id}`;
|
||||
const contactStatusId = `lead-contact-status-${lead._id}`;
|
||||
const priorityReasonId = `lead-priority-reason-${lead._id}`;
|
||||
const contactReasonId = `lead-contact-reason-${lead._id}`;
|
||||
const notesId = `lead-notes-${lead._id}`;
|
||||
const reviewEmailId = `lead-review-email-${lead._id}`;
|
||||
const reviewSourceId = `lead-review-source-${lead._id}`;
|
||||
const contactPersonId = `lead-contact-person-${lead._id}`;
|
||||
const businessContactId = `lead-business-contact-${lead._id}`;
|
||||
const duplicateStatusId = `lead-duplicate-status-${lead._id}`;
|
||||
const blacklistStatusId = `lead-blacklist-status-${lead._id}`;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card aria-labelledby={titleId}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="grid min-w-0 gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="max-w-full truncate font-medium">
|
||||
<p className="max-w-full truncate font-medium" id={titleId}>
|
||||
{lead.companyName}
|
||||
</p>
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -339,24 +422,35 @@ function LeadReviewRow({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsExpanded((previous) => !previous)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={detailsId}
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
{isExpanded ? "Weniger anzeigen" : "Mehr anzeigen"}
|
||||
Mehr anzeigen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={detailsId}
|
||||
className="grid gap-3 border-t p-4"
|
||||
hidden={!isExpanded}
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-h-[calc(100dvh-2rem)] max-w-5xl overflow-y-auto"
|
||||
id={detailsId}
|
||||
>
|
||||
<DialogHeader>
|
||||
<div>
|
||||
<DialogTitle>{lead.companyName} prüfen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Priorität, Kontaktstatus, Duplikate und Kontaktinformationen bearbeiten.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogCloseButton />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<section className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Priorität</p>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={priorityId}>Priorität</Label>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={draft.priority}
|
||||
@@ -364,7 +458,7 @@ function LeadReviewRow({
|
||||
updateDraft("priority", nextPriority as LeadPriority)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={priorityId}>
|
||||
<SelectValue placeholder="Priorität" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -379,7 +473,7 @@ function LeadReviewRow({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Kontaktstatus</p>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={contactStatusId}>Kontaktstatus</Label>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={draft.contactStatus}
|
||||
@@ -387,7 +481,7 @@ function LeadReviewRow({
|
||||
updateDraft("contactStatus", nextStatus as LeadContactStatus)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={contactStatusId}>
|
||||
<SelectValue placeholder="Kontaktstatus" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -404,8 +498,9 @@ function LeadReviewRow({
|
||||
|
||||
<section className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Prioritätsgrund</p>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={priorityReasonId}>Prioritätsgrund</Label>
|
||||
<Input
|
||||
id={priorityReasonId}
|
||||
value={draft.priorityReason}
|
||||
onChange={(event) => {
|
||||
updateDraft("priorityReason", event.target.value);
|
||||
@@ -413,10 +508,11 @@ function LeadReviewRow({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactReasonId}>
|
||||
Kontaktstatus-Notiz
|
||||
</p>
|
||||
</Label>
|
||||
<Input
|
||||
id={contactReasonId}
|
||||
value={draft.contactStatusReason}
|
||||
onChange={(event) => {
|
||||
updateDraft("contactStatusReason", event.target.value);
|
||||
@@ -424,8 +520,9 @@ function LeadReviewRow({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Notiz</p>
|
||||
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={notesId}>Notiz</Label>
|
||||
<Input
|
||||
id={notesId}
|
||||
value={draft.notes}
|
||||
onChange={(event) => {
|
||||
updateDraft("notes", event.target.value);
|
||||
@@ -443,8 +540,9 @@ function LeadReviewRow({
|
||||
|
||||
<section className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Review-E-Mail</p>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={reviewEmailId}>Review-E-Mail</Label>
|
||||
<Input
|
||||
id={reviewEmailId}
|
||||
value={draft.reviewEmail}
|
||||
onChange={(event) => {
|
||||
updateDraft("reviewEmail", event.target.value);
|
||||
@@ -453,8 +551,9 @@ function LeadReviewRow({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Review-Quelle</p>
|
||||
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={reviewSourceId}>Review-Quelle</Label>
|
||||
<Input
|
||||
id={reviewSourceId}
|
||||
value={draft.reviewEmailSource}
|
||||
onChange={(event) => {
|
||||
updateDraft("reviewEmailSource", event.target.value);
|
||||
@@ -462,28 +561,30 @@ function LeadReviewRow({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Ansprechperson</p>
|
||||
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactPersonId}>Ansprechperson</Label>
|
||||
<Input
|
||||
id={contactPersonId}
|
||||
value={draft.reviewContactPerson}
|
||||
onChange={(event) => {
|
||||
updateDraft("reviewContactPerson", event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground" htmlFor={businessContactId}>
|
||||
<Switch
|
||||
id={businessContactId}
|
||||
checked={draft.reviewIsBusinessContactAddress}
|
||||
onCheckedChange={(checked) => {
|
||||
updateDraft("reviewIsBusinessContactAddress", checked);
|
||||
}}
|
||||
/>
|
||||
Genannte E-Mail als Business-Kontakt
|
||||
</label>
|
||||
</Label>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Duplikatstatus</p>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={duplicateStatusId}>Duplikatstatus</Label>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={draft.duplicateStatus}
|
||||
@@ -491,7 +592,7 @@ function LeadReviewRow({
|
||||
updateDraft("duplicateStatus", nextStatus as LeadDuplicateStatus)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={duplicateStatusId}>
|
||||
<SelectValue placeholder="Duplikatstatus" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -506,7 +607,7 @@ function LeadReviewRow({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Sperrstatus</label>
|
||||
<Label className="text-xs text-muted-foreground" htmlFor={blacklistStatusId}>Sperrstatus</Label>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={draft.blacklistStatus}
|
||||
@@ -514,7 +615,7 @@ function LeadReviewRow({
|
||||
updateDraft("blacklistStatus", nextStatus as LeadBlacklistStatus)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={blacklistStatusId}>
|
||||
<SelectValue placeholder="Sperrstatus" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -557,11 +658,16 @@ function LeadReviewRow({
|
||||
</Button>
|
||||
</div>
|
||||
{rowMessage ? (
|
||||
<p className="text-xs text-muted-foreground">{rowMessage}</p>
|
||||
rowMessage === "Speichern fehlgeschlagen" ? (
|
||||
<p className="text-xs text-destructive" role="alert">{rowMessage}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground" role="status">{rowMessage}</p>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import Link from "next/link";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
@@ -45,6 +45,7 @@ type PendingEmailConfirmation = {
|
||||
sender: string;
|
||||
auditSlug: string | null;
|
||||
};
|
||||
type ReviewStatusFilter = "all" | "ready" | "mail_open";
|
||||
|
||||
const emptyDraft: DraftState = {
|
||||
auditBody: "",
|
||||
@@ -124,6 +125,20 @@ function skillLabel(skill: UsedSkill) {
|
||||
return skill.category ? `${name} · ${skill.category}` : name;
|
||||
}
|
||||
|
||||
function isEmailDraftReady(record: ReviewWorkspaceItem) {
|
||||
const outreach = record.latestOutreach;
|
||||
|
||||
return Boolean(outreach?.emailSubject?.trim() && outreach.emailBody?.trim());
|
||||
}
|
||||
|
||||
function isReadyToSend(record: ReviewWorkspaceItem) {
|
||||
return Boolean(
|
||||
record.latestOutreach &&
|
||||
record.latestOutreach.sendStatus !== "queued" &&
|
||||
isEmailDraftReady(record),
|
||||
);
|
||||
}
|
||||
|
||||
function DetailToggle({
|
||||
isOpen,
|
||||
label,
|
||||
@@ -187,10 +202,40 @@ export function OutreachReviewWorkspace() {
|
||||
const [openRaw, setOpenRaw] = useState<Record<string, boolean>>({});
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<ReviewStatusFilter>("all");
|
||||
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null);
|
||||
const [pendingEmailConfirmation, setPendingEmailConfirmation] =
|
||||
useState<PendingEmailConfirmation | null>(null);
|
||||
|
||||
const rows = useMemo<ReviewWorkspaceItem[]>(() => records ?? [], [records]);
|
||||
const reviewStatusFilters: Array<{ label: string; value: ReviewStatusFilter; count: number }> = [
|
||||
{ label: "Alle Reviews", value: "all", count: rows.length },
|
||||
{
|
||||
label: "Bereit zum Versand",
|
||||
value: "ready",
|
||||
count: rows.filter(isReadyToSend).length,
|
||||
},
|
||||
{
|
||||
label: "Mail offen",
|
||||
value: "mail_open",
|
||||
count: rows.filter((row) => !isEmailDraftReady(row)).length,
|
||||
},
|
||||
];
|
||||
const filteredRows = useMemo(() => {
|
||||
if (activeFilter === "ready") {
|
||||
return rows.filter(isReadyToSend);
|
||||
}
|
||||
|
||||
if (activeFilter === "mail_open") {
|
||||
return rows.filter((row) => !isEmailDraftReady(row));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [activeFilter, rows]);
|
||||
const selectedRecord =
|
||||
filteredRows.find((row) => row.id === selectedRecordId) ??
|
||||
filteredRows[0] ??
|
||||
null;
|
||||
|
||||
if (records === undefined) {
|
||||
return <WorkspaceLoading />;
|
||||
@@ -447,7 +492,7 @@ export function OutreachReviewWorkspace() {
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm">{notice}</p>
|
||||
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm" role="status">{notice}</p>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
@@ -525,8 +570,107 @@ export function OutreachReviewWorkspace() {
|
||||
) : null}
|
||||
</Dialog>
|
||||
|
||||
<section className="space-y-3" aria-label="Review-Queue">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">Review-Queue</h2>
|
||||
<div className="flex flex-wrap gap-2" aria-label="Review-Filter">
|
||||
{reviewStatusFilters.map((filter) => (
|
||||
<button
|
||||
aria-pressed={activeFilter === filter.value}
|
||||
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
|
||||
key={filter.value}
|
||||
onClick={() => setActiveFilter(filter.value)}
|
||||
type="button"
|
||||
>
|
||||
{filter.label}
|
||||
<Badge variant="secondary">{filter.count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredRows.map((record) => {
|
||||
const lead = record.lead;
|
||||
const audit = record.audit;
|
||||
const outreach = record.latestOutreach;
|
||||
const strategy = outreach?.strategy;
|
||||
const publicAuditHref = audit?.slug ? `/audit/${audit.slug}` : null;
|
||||
const queueTitleId = `review-queue-title-${record.id}`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
aria-labelledby={queueTitleId}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col",
|
||||
selectedRecord?.id === record.id ? "border-foreground" : "",
|
||||
)}
|
||||
key={record.id}
|
||||
>
|
||||
<CardHeader className="gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="break-words text-base" id={queueTitleId}>
|
||||
{compactText(lead?.companyName, "Unbenannter Lead")}
|
||||
</CardTitle>
|
||||
<CardDescription className="break-all">
|
||||
{compactText(lead?.websiteDomain ?? lead?.websiteUrl, "Keine Domain")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">{formatStrategy(strategy)}</Badge>
|
||||
<Badge variant="outline">
|
||||
{compactText(lead?.contactStatus, "Kontaktstatus offen")}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{compactText(audit?.status, "Auditstatus offen")}
|
||||
</Badge>
|
||||
<Badge variant={isEmailDraftReady(record) ? "secondary" : "outline"}>
|
||||
{isEmailDraftReady(record) ? "E-Mail bereit" : "Mail offen"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-3">
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{compactText(lead?.priorityReason, "Kein Prioritätsgrund hinterlegt.")}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-wrap gap-2">
|
||||
<Button
|
||||
aria-pressed={selectedRecord?.id === record.id}
|
||||
onClick={() => setSelectedRecordId(record.id)}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
Details prüfen
|
||||
</Button>
|
||||
{publicAuditHref ? (
|
||||
<Button asChild size="sm" type="button" variant="outline">
|
||||
<Link href={publicAuditHref}>
|
||||
<ExternalLink className="size-3.5" aria-hidden="true" />
|
||||
Public-Audit
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{filteredRows.length === 0 ? (
|
||||
<Card className="lg:col-span-2 xl:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Keine Treffer</CardTitle>
|
||||
<CardDescription>
|
||||
Für diesen Review-Filter gibt es aktuell keine Einträge.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-3">
|
||||
{rows.map((record) => {
|
||||
{selectedRecord ? (() => {
|
||||
const record = selectedRecord;
|
||||
const draft = drafts[record.id] ?? getDraft(record);
|
||||
const lead = record.lead;
|
||||
const audit = record.audit;
|
||||
@@ -851,7 +995,7 @@ export function OutreachReviewWorkspace() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
})() : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,19 @@ const auditGenerationParsedJson = v.union(
|
||||
v.string(),
|
||||
v.record(v.string(), auditGenerationParsedValue),
|
||||
);
|
||||
const auditFindingEvidenceRef = v.object({
|
||||
id: v.string(),
|
||||
type: v.union(
|
||||
v.literal("crawl_page"),
|
||||
v.literal("technical_check"),
|
||||
v.literal("screenshot"),
|
||||
v.literal("pagespeed"),
|
||||
v.literal("jina_excerpt"),
|
||||
v.literal("generation_stage"),
|
||||
),
|
||||
label: v.string(),
|
||||
sourceUrl: v.optional(v.string()),
|
||||
});
|
||||
|
||||
type AuditGenerationLead = Pick<
|
||||
Doc<"leads">,
|
||||
@@ -569,6 +582,57 @@ export const persistAuditGenerationResult = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const replaceAuditFindings = internalMutation({
|
||||
args: {
|
||||
auditId: v.id("audits"),
|
||||
runId: v.id("agentRuns"),
|
||||
findings: v.array(
|
||||
v.object({
|
||||
skillId: v.string(),
|
||||
claim: v.string(),
|
||||
recommendation: v.string(),
|
||||
customerBenefit: v.string(),
|
||||
severity: v.union(v.literal(1), v.literal(2), v.literal(3)),
|
||||
confidence: v.number(),
|
||||
evidenceRefs: v.array(auditFindingEvidenceRef),
|
||||
reviewStatus: v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("accepted"),
|
||||
v.literal("rejected"),
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("auditFindings")
|
||||
.withIndex("by_auditId", (q) => q.eq("auditId", args.auditId))
|
||||
.collect();
|
||||
|
||||
for (const finding of existing) {
|
||||
await ctx.db.delete(finding._id);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const finding of args.findings) {
|
||||
await ctx.db.insert("auditFindings", {
|
||||
auditId: args.auditId,
|
||||
runId: args.runId,
|
||||
skillId: finding.skillId,
|
||||
claim: finding.claim,
|
||||
recommendation: finding.recommendation,
|
||||
customerBenefit: finding.customerBenefit,
|
||||
severity: finding.severity,
|
||||
confidence: finding.confidence,
|
||||
evidenceRefs: finding.evidenceRefs,
|
||||
reviewStatus: finding.reviewStatus,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const persistExternalCaptureScreenshot = internalMutation({
|
||||
args: {
|
||||
leadId: v.id("leads"),
|
||||
|
||||
@@ -4,15 +4,20 @@ import { type DataContent, generateObject } from "ai";
|
||||
import { createOpenRouterProvider } from "../lib/ai/openrouter-provider";
|
||||
import { resolveModelProfile } from "../lib/ai/model-profiles";
|
||||
import { loadLocalAuditSkillRegistry } from "../lib/ai/local-audit-skill-registry";
|
||||
import { buildCustomerTonePromptSection } from "../lib/ai/customer-tone-guidelines";
|
||||
import {
|
||||
auditClassificationSchema,
|
||||
auditEvidenceVerificationSchema,
|
||||
auditSummarySchema,
|
||||
auditSpecialistResultSchema,
|
||||
callScriptSchema,
|
||||
emailDraftSchema,
|
||||
emailSubjectSchema,
|
||||
followUpDraftSchema,
|
||||
publicAuditTextSchema,
|
||||
qualityReviewSchema,
|
||||
type AuditSpecialistFinding,
|
||||
type AuditSpecialistResult,
|
||||
} from "../lib/ai/schemas";
|
||||
import {
|
||||
validateCustomerFacingCopy,
|
||||
@@ -320,6 +325,47 @@ const terminalLeadContactStatuses = [
|
||||
"replied",
|
||||
] as const;
|
||||
|
||||
const specialistStageConfigs = [
|
||||
{
|
||||
stage: "localSeoSpecialist",
|
||||
title: "Local SEO Specialist",
|
||||
focus:
|
||||
"NAP, Ort-Leistung-Relevanz, Title/Meta/H1, lokale Vertrauenssignale und Impressum-/Kontaktklarheit.",
|
||||
},
|
||||
{
|
||||
stage: "conversionUxSpecialist",
|
||||
title: "Conversion UX Specialist",
|
||||
focus:
|
||||
"Kontaktpfad, CTA-Sichtbarkeit, Click-to-call, Formularreibung und mobile Handlungsfähigkeit.",
|
||||
},
|
||||
{
|
||||
stage: "visualTrustSpecialist",
|
||||
title: "Visual Trust Specialist",
|
||||
focus:
|
||||
"Erster visueller Eindruck, Hierarchie, Lesbarkeit, Bild-/Team-/Vertrauenssignale aus Screenshots.",
|
||||
},
|
||||
{
|
||||
stage: "critiqueSpecialist",
|
||||
title: "Impeccable Critique Specialist",
|
||||
focus:
|
||||
"Designkritik nach critique/impeccable: visuelle Hierarchie, Informationsarchitektur, kognitive Last, Nielsen-Heuristiken, AI-Slop-/Template-Indizien und persona-nahe Reibung.",
|
||||
guidance:
|
||||
"Nutze fuer passende Befunde skillId impeccable-critique. Liefere keine Heuristik-Score-Tabelle, sondern konkrete, evidence-gebundene Findings. Markenfit, Emotion oder AI-Slop nur behaupten, wenn Screenshot/Text/DOM es stuetzen.",
|
||||
},
|
||||
{
|
||||
stage: "performanceAccessibilitySpecialist",
|
||||
title: "Performance Accessibility Specialist",
|
||||
focus:
|
||||
"Mobile Ladeerfahrung, PageSpeed-Auswirkungen, Tap-Ziele, Kontrast, Labels und einfache Barrieren.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type SpecialistStage = (typeof specialistStageConfigs)[number]["stage"];
|
||||
type VerifierCandidate = {
|
||||
findingId: string;
|
||||
finding: AuditSpecialistFinding;
|
||||
};
|
||||
|
||||
function toAuditGenerationProfileMessage(stage: string, runId: Id<"agentRuns">) {
|
||||
return {
|
||||
level: "info" as const,
|
||||
@@ -374,14 +420,118 @@ function buildMultimodalPrompt(evidence: AuditEvidence, withScreenshots = false)
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatEvidenceLedger(evidence: AuditEvidence) {
|
||||
return evidence.evidenceLedger
|
||||
.slice(0, 24)
|
||||
.map((entry) =>
|
||||
[
|
||||
`id=${entry.id}`,
|
||||
`type=${entry.type}`,
|
||||
`label=${entry.label}`,
|
||||
entry.sourceUrl ? `url=${entry.sourceUrl}` : "",
|
||||
`summary=${entry.summary}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | "),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildSpecialistPrompt(
|
||||
evidence: AuditEvidence,
|
||||
config: (typeof specialistStageConfigs)[number],
|
||||
) {
|
||||
return [
|
||||
`Du bist ${config.title} für lokale Website-Audits.`,
|
||||
`Fokus: ${config.focus}`,
|
||||
"guidance" in config ? config.guidance : "",
|
||||
"Erzeuge nur Befunde, die mit evidenceRefs aus dem Evidence-Ledger belegt sind.",
|
||||
"Nutze keine Unknown-/Unbekannt-Werte als Kundenbefund. Wenn Belege fehlen, liefere keinen Befund.",
|
||||
"Jeder Befund braucht skillId, claim, recommendation, customerBenefit, severity, confidence, evidenceRefs, applies und unknowns.",
|
||||
"Jede evidenceRef braucht id, type, label und sourceUrl; nutze die Ledger-URL oder einen leeren String.",
|
||||
"Antworte mit status, findings und notes; wenn nichts belegt ist, nutze findings: [] und erklaerende notes.",
|
||||
`Unternehmenskontext: ${evidence.companyContext.join(" | ")}`,
|
||||
`Prüfseiten: ${evidence.checkedPages.join(" ; ")}`,
|
||||
`UX-Signale: ${evidence.observedUxSignals.join(" ; ")}`,
|
||||
`Content-Signale: ${evidence.observedContentSignals.join(" ; ")}`,
|
||||
`Technische Signale: ${evidence.observedTechnicalSignals.join(" ; ")}`,
|
||||
`PageSpeed-Folgen: ${evidence.pageSpeedCustomerImplications.join(" ; ")}`,
|
||||
`Evidence-Ledger:\n${formatEvidenceLedger(evidence)}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function toVerifierCandidates(
|
||||
findings: readonly AuditSpecialistFinding[],
|
||||
): VerifierCandidate[] {
|
||||
return findings.slice(0, 12).map((finding, index) => ({
|
||||
findingId: `finding-${index + 1}`,
|
||||
finding,
|
||||
}));
|
||||
}
|
||||
|
||||
function formatVerifierCandidate(candidate: VerifierCandidate) {
|
||||
const { finding, findingId } = candidate;
|
||||
return [
|
||||
`id=${findingId}`,
|
||||
`skillId=${finding.skillId}`,
|
||||
`claim=${finding.claim}`,
|
||||
`recommendation=${finding.recommendation}`,
|
||||
`customerBenefit=${finding.customerBenefit}`,
|
||||
`severity=${finding.severity}`,
|
||||
`confidence=${Math.round(finding.confidence * 100)}%`,
|
||||
`evidenceRefs=${finding.evidenceRefs
|
||||
.map((ref) => `${ref.id} (${ref.type}, ${ref.label})`)
|
||||
.join("; ")}`,
|
||||
finding.unknowns.length > 0 ? `unknowns=${finding.unknowns.join("; ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildEvidenceVerifierPrompt(
|
||||
candidates: readonly VerifierCandidate[],
|
||||
evidence: AuditEvidence,
|
||||
) {
|
||||
return [
|
||||
"Du bist EvidenceQA und verifizierst Audit-Befunde.",
|
||||
"Behalte nur Befunde, die konkrete evidenceRefs besitzen, nicht generisch sind und keine Unknown-Werte als Claim nutzen.",
|
||||
"Lege widersprüchliche CTA/Kontakt/Meta-Aussagen in contradictions offen.",
|
||||
"Antworte mit verifiedFindingIds, rejectedFindings, contradictions und notes.",
|
||||
"verifiedFindingIds enthaelt nur IDs aus den unten gelisteten Befunden.",
|
||||
"Gib keine vollstaendigen verified Findings zurueck; die Anwendung uebernimmt die Originalbefunde anhand der IDs.",
|
||||
"Ein rejectedFinding braucht findingId, skillId, claim und rejectionReason.",
|
||||
`Evidence-Ledger:\n${formatEvidenceLedger(evidence)}`,
|
||||
`Befunde zur Prüfung:\n${candidates.map(formatVerifierCandidate).join("\n\n")}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatVerifiedFindings(findings: readonly AuditSpecialistFinding[]) {
|
||||
return findings
|
||||
.map((finding, index) =>
|
||||
[
|
||||
`${index + 1}. [${finding.skillId}] ${finding.claim}`,
|
||||
`Empfehlung: ${finding.recommendation}`,
|
||||
`Nutzen: ${finding.customerBenefit}`,
|
||||
`Priorität: ${finding.severity}; Sicherheit: ${Math.round(finding.confidence * 100)}%`,
|
||||
`Belege: ${finding.evidenceRefs.map((ref) => `${ref.type}:${ref.label}`).join(", ")}`,
|
||||
].join("\n"),
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function buildGermanCopyPrompt(
|
||||
internalFindings: string,
|
||||
multimodalSummary: string,
|
||||
evidence: AuditEvidence,
|
||||
) {
|
||||
return [
|
||||
"Du bist Senior-Redakteur für lokale Kundengewinnung.",
|
||||
"Erstelle kundenrelevante Texte in deutscher Sprache, im Ich-Ich Kontext,",
|
||||
"mit Beobachtung und konkretem Vorschlag in jedem Stück.",
|
||||
"Erstelle kundenrelevante Texte in deutscher Sprache und nutze ausschließlich verifizierte Befunde als fachliche Grundlage.",
|
||||
"Vermeide mechanische Wiederholungen wie 'Ich habe beobachtet' oder 'Ich schlage vor'.",
|
||||
"PublicSummary und PublicBody dürfen auditartig bleiben, sollen aber natürlich und konkret klingen.",
|
||||
buildCustomerTonePromptSection(),
|
||||
`Lead-/Unternehmenskontext: ${evidence.companyContext.join(" | ")}`,
|
||||
`Geprüfte Seiten: ${evidence.checkedPages.join(" ; ")}`,
|
||||
`Interne Befunde: ${internalFindings}`,
|
||||
`Multimodale Zusammenfassung: ${multimodalSummary}`,
|
||||
"Liefer bitte alle Felder als validiertes JSON gemäß Schema.",
|
||||
@@ -395,6 +545,10 @@ function buildQualityReviewPrompt(
|
||||
return [
|
||||
"Du bist Qualitätssicherungs-Engine für Kundenkommunikation.",
|
||||
"Prüfe Inhalte auf deutsche Sprache, Tonalität, Beobachtung/Suggestion und klare, faktennahe Inhalte.",
|
||||
"Prüfe besonders die E-Mail: Klingt sie wie eine echte Erstmail von Matthias?",
|
||||
"Würde ein lokaler Betrieb sie als hilfreichen Hinweis lesen, nicht als KI-Verkaufstext?",
|
||||
"Ist jede konkrete Behauptung in der E-Mail durch verified findings / verifizierte Befunde gedeckt?",
|
||||
buildCustomerTonePromptSection(),
|
||||
`Interne Befunde: ${internalFindings}`,
|
||||
`Öffentliche Zusammenfassung: ${germanCopy.publicSummary}`,
|
||||
`Öffentlicher Text: ${germanCopy.publicBody}`,
|
||||
@@ -412,13 +566,40 @@ function toSkillSummaries(
|
||||
version?: string;
|
||||
source?: string;
|
||||
}>,
|
||||
registry: Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
purpose?: string;
|
||||
instructions?: string;
|
||||
requiredInput?: string;
|
||||
expectedOutput?: string;
|
||||
category?: string;
|
||||
version?: string;
|
||||
source?: string;
|
||||
}> = [],
|
||||
) {
|
||||
return skills.slice(0, 6).map((skill) => ({
|
||||
name: skill.name,
|
||||
purpose: "Erkenntnisbasiertes Hilfsmodul für die Audit-Bearbeitung.",
|
||||
summary: `${skill.name}${skill.version ? ` (${skill.version})` : ""}${
|
||||
skill.category ? ` aus ${skill.category}` : ""
|
||||
}.`,
|
||||
purpose:
|
||||
registry.find(
|
||||
(candidate) =>
|
||||
(skill.id && candidate.id === skill.id) ||
|
||||
candidate.name === skill.name,
|
||||
)?.purpose ??
|
||||
registry.find(
|
||||
(candidate) =>
|
||||
(skill.id && candidate.id === skill.id) ||
|
||||
candidate.name === skill.name,
|
||||
)?.instructions ??
|
||||
"Zweckbeschreibung nicht verfügbar.",
|
||||
summary: [
|
||||
skill.name,
|
||||
skill.version ? `Version ${skill.version}` : "",
|
||||
skill.category ? `Kategorie ${skill.category}` : "",
|
||||
skill.source ? `Quelle ${skill.source}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -966,7 +1147,13 @@ async function persistAuditStage({
|
||||
runId: Id<"agentRuns">;
|
||||
leadId: Id<"leads">;
|
||||
auditId?: Id<"audits">;
|
||||
stage: "classification" | "multimodalAudit" | "germanCopy" | "qualityReview";
|
||||
stage:
|
||||
| "classification"
|
||||
| SpecialistStage
|
||||
| "evidenceVerifier"
|
||||
| "multimodalAudit"
|
||||
| "germanCopy"
|
||||
| "qualityReview";
|
||||
modelProfile: string;
|
||||
modelId: string;
|
||||
prompt: string;
|
||||
@@ -1051,8 +1238,15 @@ export const processAuditGeneration = internalAction({
|
||||
};
|
||||
let qualityPassed = false;
|
||||
let errors = 0;
|
||||
let currentStep: "audit_generation" | "classification" | "multimodalAudit" | "germanCopy" | "qualityReview" =
|
||||
"audit_generation";
|
||||
let currentStep:
|
||||
| "audit_generation"
|
||||
| "classification"
|
||||
| SpecialistStage
|
||||
| "evidenceVerifier"
|
||||
| "multimodalAudit"
|
||||
| "germanCopy"
|
||||
| "qualityReview" = "audit_generation";
|
||||
let verifiedFindings: AuditSpecialistFinding[] = [];
|
||||
|
||||
try {
|
||||
started = await ctx.runMutation(internal.auditGeneration.startAuditGenerationRun, {
|
||||
@@ -1244,6 +1438,205 @@ export const processAuditGeneration = internalAction({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stage 2: specialist fan-out and evidence verification
|
||||
const specialistSystemPrompt =
|
||||
"Du bist ein spezialisierter Website-Audit-Agent. Antworte ausschließlich als JSON gemäß Schema.";
|
||||
const specialistResults = await Promise.all(
|
||||
specialistStageConfigs.map(async (config): Promise<AuditSpecialistResult> => {
|
||||
const specialistPrompt = buildSpecialistPrompt(evidenceInput, config);
|
||||
const safeSpecialistPrompt = sanitizeAndCapString(
|
||||
specialistPrompt,
|
||||
MAX_PROMPT_BYTES,
|
||||
);
|
||||
currentStep = config.stage;
|
||||
|
||||
await persistAuditStage({
|
||||
ctx,
|
||||
runId: args.runId,
|
||||
leadId: started!.lead._id,
|
||||
...(auditId ? { auditId } : {}),
|
||||
stage: config.stage,
|
||||
modelProfile: "classification",
|
||||
modelId: classificationProfile.modelId,
|
||||
prompt: safeSpecialistPrompt ?? "",
|
||||
systemPrompt: specialistSystemPrompt,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
try {
|
||||
const specialistResult = await generateObject({
|
||||
model: provider(classificationProfile.modelId),
|
||||
system: specialistSystemPrompt,
|
||||
schema: auditSpecialistResultSchema,
|
||||
prompt: safeSpecialistPrompt ?? "",
|
||||
temperature: classificationProfile.temperature,
|
||||
maxOutputTokens: classificationProfile.maxTokens,
|
||||
});
|
||||
|
||||
await persistAuditStage({
|
||||
ctx,
|
||||
runId: args.runId,
|
||||
leadId: started!.lead._id,
|
||||
...(auditId ? { auditId } : {}),
|
||||
stage: config.stage,
|
||||
modelProfile: "classification",
|
||||
modelId: classificationProfile.modelId,
|
||||
prompt: safeSpecialistPrompt ?? "",
|
||||
systemPrompt: specialistSystemPrompt,
|
||||
rawResponse: sanitizeAndCapString(
|
||||
safeStringify(specialistResult.object),
|
||||
MAX_RAW_RESPONSE_BYTES,
|
||||
),
|
||||
parsedJson: sanitizeAndCapParsedJson(specialistResult.object),
|
||||
...withStageUsage(specialistResult.usage),
|
||||
status: "succeeded",
|
||||
finishReason: specialistResult.finishReason,
|
||||
});
|
||||
await recordOpenRouterUsage(ctx, {
|
||||
runId: args.runId,
|
||||
leadId: started!.lead._id,
|
||||
...(auditId ? { auditId } : {}),
|
||||
usage: specialistResult.usage,
|
||||
});
|
||||
|
||||
return specialistResult.object;
|
||||
} catch (error) {
|
||||
const safeErrorSummary = messageFromError(error);
|
||||
await persistAuditStage({
|
||||
ctx,
|
||||
runId: args.runId,
|
||||
leadId: started!.lead._id,
|
||||
...(auditId ? { auditId } : {}),
|
||||
stage: config.stage,
|
||||
modelProfile: "classification",
|
||||
modelId: classificationProfile.modelId,
|
||||
prompt: safeSpecialistPrompt ?? "",
|
||||
systemPrompt: specialistSystemPrompt,
|
||||
status: "failed",
|
||||
errorSummary: safeErrorSummary,
|
||||
});
|
||||
await appendRunEvent(ctx, {
|
||||
runId: args.runId,
|
||||
level: "warning",
|
||||
message: `${config.title} konnte keine Befunde liefern.`,
|
||||
details: [{ label: "Fehler", value: safeErrorSummary }],
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
findings: [],
|
||||
notes: [safeErrorSummary],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const specialistFindings = specialistResults.flatMap((result) =>
|
||||
result.findings.filter((finding) => finding.applies),
|
||||
);
|
||||
const verifierCandidates = toVerifierCandidates(specialistFindings);
|
||||
const verifierPrompt = buildEvidenceVerifierPrompt(
|
||||
verifierCandidates,
|
||||
evidenceInput,
|
||||
);
|
||||
const safeVerifierPrompt = sanitizeAndCapString(
|
||||
verifierPrompt,
|
||||
MAX_PROMPT_BYTES,
|
||||
);
|
||||
const verifierSystemPrompt =
|
||||
"Du bist EvidenceQA. Verifiziere Befunde streng gegen belegte Evidence-Refs.";
|
||||
currentStep = "evidenceVerifier";
|
||||
|
||||
await persistAuditStage({
|
||||
ctx,
|
||||
runId: args.runId,
|
||||
leadId: started.lead._id,
|
||||
...(auditId ? { auditId } : {}),
|
||||
stage: "evidenceVerifier",
|
||||
modelProfile: "classification",
|
||||
modelId: classificationProfile.modelId,
|
||||
prompt: safeVerifierPrompt ?? "",
|
||||
systemPrompt: verifierSystemPrompt,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
try {
|
||||
const verifierResult = await generateObject({
|
||||
model: provider(classificationProfile.modelId),
|
||||
system: verifierSystemPrompt,
|
||||
schema: auditEvidenceVerificationSchema,
|
||||
prompt: safeVerifierPrompt ?? "",
|
||||
temperature: classificationProfile.temperature,
|
||||
maxOutputTokens: classificationProfile.maxTokens,
|
||||
});
|
||||
const verifiedFindingIds = new Set(
|
||||
verifierResult.object.verifiedFindingIds,
|
||||
);
|
||||
verifiedFindings = verifierCandidates
|
||||
.filter((candidate) => verifiedFindingIds.has(candidate.findingId))
|
||||
.map((candidate) => candidate.finding);
|
||||
|
||||
await persistAuditStage({
|
||||
ctx,
|
||||
runId: args.runId,
|
||||
leadId: started.lead._id,
|
||||
...(auditId ? { auditId } : {}),
|
||||
stage: "evidenceVerifier",
|
||||
modelProfile: "classification",
|
||||
modelId: classificationProfile.modelId,
|
||||
prompt: safeVerifierPrompt ?? "",
|
||||
systemPrompt: verifierSystemPrompt,
|
||||
rawResponse: sanitizeAndCapString(
|
||||
safeStringify(verifierResult.object),
|
||||
MAX_RAW_RESPONSE_BYTES,
|
||||
),
|
||||
parsedJson: sanitizeAndCapParsedJson(verifierResult.object),
|
||||
...withStageUsage(verifierResult.usage),
|
||||
status: "succeeded",
|
||||
finishReason: verifierResult.finishReason,
|
||||
});
|
||||
await recordOpenRouterUsage(ctx, {
|
||||
runId: args.runId,
|
||||
leadId: started.lead._id,
|
||||
...(auditId ? { auditId } : {}),
|
||||
usage: verifierResult.usage,
|
||||
});
|
||||
} catch (error) {
|
||||
errors += 1;
|
||||
const safeErrorSummary = messageFromError(error);
|
||||
await persistAuditStage({
|
||||
ctx,
|
||||
runId: args.runId,
|
||||
leadId: started.lead._id,
|
||||
...(auditId ? { auditId } : {}),
|
||||
stage: "evidenceVerifier",
|
||||
modelProfile: "classification",
|
||||
modelId: classificationProfile.modelId,
|
||||
prompt: safeVerifierPrompt ?? "",
|
||||
systemPrompt: verifierSystemPrompt,
|
||||
status: "failed",
|
||||
errorSummary: safeErrorSummary,
|
||||
});
|
||||
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
|
||||
runId: args.runId,
|
||||
status: "failed",
|
||||
errors,
|
||||
errorSummary: "Evidence-Verifikation konnte nicht abgeschlossen werden.",
|
||||
currentStep: "evidenceVerifier",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (verifiedFindings.length === 0) {
|
||||
await ctx.runMutation(internal.auditGeneration.finishAuditGenerationRun, {
|
||||
runId: args.runId,
|
||||
status: "failed",
|
||||
errors: errors + 1,
|
||||
errorSummary: "Keine belegten Audit-Befunde nach Evidence-Verifikation.",
|
||||
currentStep: "evidenceVerifier",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stage 2: multimodal audit summary
|
||||
const multimodalSystemPrompt =
|
||||
"Du bist Prüfanalyst für Conversion-Optimierung mit Fokus auf lokale Unternehmen.";
|
||||
@@ -1454,9 +1847,11 @@ export const processAuditGeneration = internalAction({
|
||||
// Stage 3: german copy generation
|
||||
const germanSystemPrompt =
|
||||
"Du bist fachlicher Texter für lokale Unternehmen im B2B-Kontext.";
|
||||
const verifiedFindingsText = formatVerifiedFindings(verifiedFindings);
|
||||
const germanPrompt = buildGermanCopyPrompt(
|
||||
classificationSummary,
|
||||
verifiedFindingsText,
|
||||
multimodalSummary,
|
||||
evidenceInput,
|
||||
);
|
||||
const safeGermanPrompt = sanitizeAndCapString(germanPrompt, MAX_PROMPT_BYTES);
|
||||
|
||||
@@ -1623,7 +2018,7 @@ export const processAuditGeneration = internalAction({
|
||||
|
||||
// Stage 4: final quality review
|
||||
const qualityPrompt = buildQualityReviewPrompt(
|
||||
classificationSummary,
|
||||
verifiedFindingsText,
|
||||
germanCopyOutput,
|
||||
);
|
||||
const safeQualityPrompt = sanitizeAndCapString(qualityPrompt, MAX_PROMPT_BYTES);
|
||||
@@ -1641,7 +2036,7 @@ export const processAuditGeneration = internalAction({
|
||||
maxOutputTokens: qualityReviewProfile.maxTokens,
|
||||
});
|
||||
|
||||
qualityPassed = guardResult.passed;
|
||||
qualityPassed = qualityResult.object.isValid && guardResult.passed;
|
||||
|
||||
const qualityPayload = {
|
||||
isValid: qualityResult.object.isValid && guardResult.passed,
|
||||
@@ -1776,7 +2171,7 @@ export const processAuditGeneration = internalAction({
|
||||
usedSkills: evidenceInput.selectedSkills
|
||||
.slice(0, 6)
|
||||
.map(toPersistedUsedSkill),
|
||||
skillSummaries: toSkillSummaries(evidenceInput.selectedSkills),
|
||||
skillSummaries: toSkillSummaries(evidenceInput.selectedSkills, skillRegistry),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1784,6 +2179,28 @@ export const processAuditGeneration = internalAction({
|
||||
auditId = persistedAuditId;
|
||||
}
|
||||
|
||||
if (auditId) {
|
||||
await ctx.runMutation(internal.auditGeneration.replaceAuditFindings, {
|
||||
auditId,
|
||||
runId: args.runId,
|
||||
findings: verifiedFindings.slice(0, 12).map((finding) => ({
|
||||
skillId: finding.skillId,
|
||||
claim: finding.claim,
|
||||
recommendation: finding.recommendation,
|
||||
customerBenefit: finding.customerBenefit,
|
||||
severity: finding.severity,
|
||||
confidence: finding.confidence,
|
||||
evidenceRefs: finding.evidenceRefs.slice(0, 6).map((ref) => ({
|
||||
id: ref.id,
|
||||
type: ref.type,
|
||||
label: ref.label,
|
||||
...(ref.sourceUrl ? { sourceUrl: ref.sourceUrl } : {}),
|
||||
})),
|
||||
reviewStatus: "pending" as const,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.outreach.upsertFromAuditGeneration, {
|
||||
leadId: started.lead._id,
|
||||
...(auditId ? { auditId } : {}),
|
||||
|
||||
@@ -313,6 +313,11 @@ export const getDetail = query({
|
||||
.order("desc")
|
||||
.take(DETAIL_EVIDENCE_LIMIT)
|
||||
: [];
|
||||
const findings = await ctx.db
|
||||
.query("auditFindings")
|
||||
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
|
||||
.order("desc")
|
||||
.take(DETAIL_EVIDENCE_LIMIT);
|
||||
|
||||
const pagesByUrl = new Map<string, Doc<"websiteCrawlPages">>();
|
||||
for (const page of crawlPages) {
|
||||
@@ -397,6 +402,7 @@ export const getDetail = query({
|
||||
return {
|
||||
audit,
|
||||
lead,
|
||||
findings,
|
||||
sourceSummaries: {
|
||||
checkedPages,
|
||||
},
|
||||
|
||||
@@ -96,6 +96,12 @@ export const RUN_STATUSES = [
|
||||
] as const;
|
||||
export const AUDIT_GENERATION_STAGES = [
|
||||
"classification",
|
||||
"localSeoSpecialist",
|
||||
"conversionUxSpecialist",
|
||||
"visualTrustSpecialist",
|
||||
"critiqueSpecialist",
|
||||
"performanceAccessibilitySpecialist",
|
||||
"evidenceVerifier",
|
||||
"multimodalAudit",
|
||||
"germanCopy",
|
||||
"qualityReview",
|
||||
|
||||
@@ -180,6 +180,25 @@ const publicAuditOffer = v.object({
|
||||
ctaLabel: v.optional(v.string()),
|
||||
ctaHref: v.optional(v.string()),
|
||||
});
|
||||
const auditFindingEvidenceType = v.union(
|
||||
v.literal("crawl_page"),
|
||||
v.literal("technical_check"),
|
||||
v.literal("screenshot"),
|
||||
v.literal("pagespeed"),
|
||||
v.literal("jina_excerpt"),
|
||||
v.literal("generation_stage"),
|
||||
);
|
||||
const auditFindingEvidenceRef = v.object({
|
||||
id: v.string(),
|
||||
type: auditFindingEvidenceType,
|
||||
label: v.string(),
|
||||
sourceUrl: v.optional(v.string()),
|
||||
});
|
||||
const auditFindingReviewStatus = v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("accepted"),
|
||||
v.literal("rejected"),
|
||||
);
|
||||
const eventDetail = v.object({
|
||||
label: v.string(),
|
||||
value: v.string(),
|
||||
@@ -342,6 +361,24 @@ export default defineSchema({
|
||||
.index("by_auditId_and_viewport", ["auditId", "viewport"])
|
||||
.index("by_storageId", ["storageId"]),
|
||||
|
||||
auditFindings: defineTable({
|
||||
auditId: v.id("audits"),
|
||||
runId: v.id("agentRuns"),
|
||||
skillId: v.string(),
|
||||
claim: v.string(),
|
||||
recommendation: v.string(),
|
||||
customerBenefit: v.string(),
|
||||
severity: v.union(v.literal(1), v.literal(2), v.literal(3)),
|
||||
confidence: v.number(),
|
||||
evidenceRefs: v.array(auditFindingEvidenceRef),
|
||||
reviewStatus: auditFindingReviewStatus,
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_auditId", ["auditId"])
|
||||
.index("by_runId", ["runId"])
|
||||
.index("by_auditId_and_reviewStatus", ["auditId", "reviewStatus"]),
|
||||
|
||||
pageSpeedResults: defineTable({
|
||||
leadId: v.id("leads"),
|
||||
auditId: v.optional(v.id("audits")),
|
||||
|
||||
@@ -72,6 +72,20 @@ export type AuditEvidenceInput = {
|
||||
}>;
|
||||
pageSpeedCustomerImplications: string[];
|
||||
selectedSkills: AuditUsedSkill[];
|
||||
evidenceLedger: AuditEvidenceLedgerEntry[];
|
||||
};
|
||||
|
||||
export type AuditEvidenceLedgerEntry = {
|
||||
id: string;
|
||||
type:
|
||||
| "crawl_page"
|
||||
| "technical_check"
|
||||
| "screenshot"
|
||||
| "pagespeed"
|
||||
| "jina_excerpt";
|
||||
label: string;
|
||||
sourceUrl?: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type AuditEvidenceInputArgs = {
|
||||
@@ -96,6 +110,7 @@ const EXTERNAL_MARKDOWN_LIMIT = 4_000;
|
||||
const V3_LOCAL_AUDIT_PRIORITY = new Map(
|
||||
[
|
||||
"visual-design",
|
||||
"impeccable-critique",
|
||||
"contact-conversion",
|
||||
"local-seo-basics",
|
||||
"performance-experience",
|
||||
@@ -113,6 +128,32 @@ 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 stableEvidencePart(value: unknown) {
|
||||
const normalized = trimAndNormalize(String(value ?? "").toLowerCase())
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^www\./, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
|
||||
return normalized || "source";
|
||||
}
|
||||
|
||||
function evidenceId(type: AuditEvidenceLedgerEntry["type"], ...parts: unknown[]) {
|
||||
return [type, ...parts.map(stableEvidencePart)].join(":");
|
||||
}
|
||||
|
||||
function addEvidenceLedgerEntry(
|
||||
ledger: AuditEvidenceLedgerEntry[],
|
||||
entry: AuditEvidenceLedgerEntry,
|
||||
) {
|
||||
if (!entry.summary || ledger.some((current) => current.id === entry.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ledger.push(entry);
|
||||
}
|
||||
|
||||
function trimAndNormalize(input: unknown): string {
|
||||
if (typeof input !== "string") {
|
||||
return "";
|
||||
@@ -555,6 +596,7 @@ export function buildAuditEvidenceInput(
|
||||
const pageSpeedInputs = args.pageSpeedInputs ?? [];
|
||||
const skillRegistry = args.skillRegistry ?? [];
|
||||
const externalMarkdown = sanitizeExternalMarkdown(args.externalMarkdown);
|
||||
const evidenceLedger: AuditEvidenceLedgerEntry[] = [];
|
||||
|
||||
const companyContext: string[] = [];
|
||||
const checkedPages: string[] = [];
|
||||
@@ -620,6 +662,22 @@ export function buildAuditEvidenceInput(
|
||||
}
|
||||
|
||||
addUniqueCapped(checkedPages, label, CHECKED_PAGES_LIMIT);
|
||||
addEvidenceLedgerEntry(evidenceLedger, {
|
||||
id: evidenceId("crawl_page", page.finalUrl ?? page.sourceUrl, page.pageKind),
|
||||
type: "crawl_page",
|
||||
label,
|
||||
...(page.finalUrl ?? page.sourceUrl ? { sourceUrl: page.finalUrl ?? page.sourceUrl ?? undefined } : {}),
|
||||
summary: sanitizeCustomerText(
|
||||
[
|
||||
title ? `Titel: ${title}` : "",
|
||||
page.metaDescription ? `Meta: ${page.metaDescription}` : "",
|
||||
page.visibleTextExcerpt ?? page.visibleText ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | "),
|
||||
260,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (checkedPages.length === 0 && lead.companyName) {
|
||||
@@ -634,6 +692,44 @@ export function buildAuditEvidenceInput(
|
||||
const pageSpeedInputsOutput = buildPageSpeedAuditInputs(pageSpeedInputs);
|
||||
const pageSpeedCustomerImplications: string[] = [];
|
||||
|
||||
for (const check of technicalChecks) {
|
||||
const summary = [
|
||||
check.usesHttps === true ? "HTTPS vorhanden" : "",
|
||||
check.usesHttps === false ? "HTTPS fehlt" : "",
|
||||
check.missingTitle === true ? "Title fehlt" : "",
|
||||
check.missingMetaDescription === true ? "Meta-Description fehlt" : "",
|
||||
check.hasVisibleContactPath === true ? "Kontaktpfad sichtbar" : "",
|
||||
check.brokenInternalLinkCount !== undefined
|
||||
? `Interne Linkfehler: ${check.brokenInternalLinkCount}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
|
||||
addEvidenceLedgerEntry(evidenceLedger, {
|
||||
id: evidenceId("technical_check", check.finalUrl ?? check.sourceUrl),
|
||||
type: "technical_check",
|
||||
label: `Technik: ${toSafePath(check.finalUrl ?? check.sourceUrl ?? "") || "Seite"}`,
|
||||
...(check.finalUrl ?? check.sourceUrl ? { sourceUrl: check.finalUrl ?? check.sourceUrl ?? undefined } : {}),
|
||||
summary: sanitizeCustomerText(summary, 260),
|
||||
});
|
||||
}
|
||||
|
||||
for (const screenshot of screenshots) {
|
||||
addEvidenceLedgerEntry(evidenceLedger, {
|
||||
id: evidenceId(
|
||||
"screenshot",
|
||||
screenshot.storageId,
|
||||
screenshot.viewport,
|
||||
screenshot.sourceUrl,
|
||||
),
|
||||
type: "screenshot",
|
||||
label: `${screenshot.viewport === "desktop" ? "Desktop" : "Mobil"} Screenshot`,
|
||||
sourceUrl: screenshot.sourceUrl,
|
||||
summary: `${screenshot.viewport} Screenshot ${screenshot.width}x${screenshot.height}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const implication of pageSpeedInputsOutput.customerImplications) {
|
||||
addUniqueCapped(
|
||||
pageSpeedCustomerImplications,
|
||||
@@ -643,6 +739,32 @@ export function buildAuditEvidenceInput(
|
||||
);
|
||||
}
|
||||
|
||||
for (const input of pageSpeedInputs) {
|
||||
const implication = pageSpeedInputsOutput.customerImplications.find(Boolean);
|
||||
addEvidenceLedgerEntry(evidenceLedger, {
|
||||
id: evidenceId("pagespeed", input.strategy, input.sourceUrl, input.status),
|
||||
type: "pagespeed",
|
||||
label: `PageSpeed ${input.strategy}`,
|
||||
sourceUrl: input.sourceUrl,
|
||||
summary: sanitizeCustomerText(
|
||||
implication ??
|
||||
(input.status === "succeeded"
|
||||
? "PageSpeed-Messung erfolgreich"
|
||||
: "PageSpeed-Messung nicht verfügbar"),
|
||||
260,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (externalMarkdown) {
|
||||
addEvidenceLedgerEntry(evidenceLedger, {
|
||||
id: evidenceId("jina_excerpt", externalMarkdown.slice(0, 80)),
|
||||
type: "jina_excerpt",
|
||||
label: "Jina Reader Auszug",
|
||||
summary: sanitizeCustomerText(externalMarkdown, 260),
|
||||
});
|
||||
}
|
||||
|
||||
const selectedSkills = extractSkills(skillRegistry, {
|
||||
...signals.evidenceText,
|
||||
marketing: false,
|
||||
@@ -687,5 +809,6 @@ export function buildAuditEvidenceInput(
|
||||
PAGESPEED_SIGNAL_LIMIT,
|
||||
),
|
||||
selectedSkills,
|
||||
evidenceLedger,
|
||||
};
|
||||
}
|
||||
|
||||
49
lib/ai/customer-tone-guidelines.ts
Normal file
49
lib/ai/customer-tone-guidelines.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const customerToneGuidelines = {
|
||||
senderPosture: "kollegial_direkt",
|
||||
voiceLabel: "kollegial direkt",
|
||||
email: {
|
||||
wordCount: {
|
||||
min: 60,
|
||||
max: 130,
|
||||
},
|
||||
maxSentences: 7,
|
||||
maxParagraphs: 2,
|
||||
subject: {
|
||||
minWords: 2,
|
||||
maxWords: 6,
|
||||
maxCharacters: 55,
|
||||
},
|
||||
bannedPhrases: [
|
||||
"Optimierungspotenziale",
|
||||
"Mehr Sichtbarkeit und bessere Nutzererfahrung",
|
||||
"Ich habe beobachtet",
|
||||
"Ich schlage vor",
|
||||
"Maßnahmen umsetzen",
|
||||
"Conversion-Rate steigern",
|
||||
"Ranking positiv beeinflussen",
|
||||
"Absprungraten senken",
|
||||
"nachhaltig verbessern",
|
||||
"signifikant",
|
||||
],
|
||||
preferredAskExamples: [
|
||||
"Soll ich Ihnen die zwei Punkte kurz schicken?",
|
||||
"Soll ich Ihnen die Stelle kurz als Screenshot schicken?",
|
||||
"Wäre ein kurzer Hinweis dazu hilfreich?",
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function buildCustomerTonePromptSection() {
|
||||
return [
|
||||
"Tonalität für Kunden-E-Mail: kollegial direkt, konkret, ruhig und nicht verkäuferisch.",
|
||||
"Schreibe wie Matthias als lokaler Web-Profi, nicht wie eine Agentur-Broschüre.",
|
||||
"Die E-Mail ist eine erste Kontaktaufnahme: maximal zwei verifizierte Befunde, kein Mini-Audit.",
|
||||
"Betreff: 2-6 Wörter, maximal 55 Zeichen, kein Doppelpunkt, keine Benefit-Kette.",
|
||||
"E-Mail-Text: 60-130 Wörter, maximal 7 Sätze, 1-2 kurze Absätze.",
|
||||
"Starte mit einer konkreten Beobachtung zur Website, nicht mit 'Ich habe beobachtet, dass'.",
|
||||
"Nenne eine praktische Auswirkung in Alltagssprache und ende mit einer weichen Frage.",
|
||||
"Nutze für unbekannte lokale Betriebe formal Sie/Ihnen.",
|
||||
"Ich-Form ist erlaubt, aber nicht als Wiederholungsmuster: kein mehrfaches 'Ich habe...' oder 'Ich schlage vor...'.",
|
||||
`Beispiel für den Abschluss: ${customerToneGuidelines.email.preferredAskExamples[0]}`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { customerToneGuidelines } from "./customer-tone-guidelines";
|
||||
|
||||
const GERMAN_MARKERS = new Set([
|
||||
"ich",
|
||||
"mich",
|
||||
@@ -31,6 +33,12 @@ const GERMAN_MARKERS = new Set([
|
||||
"wenn",
|
||||
"für",
|
||||
"bei",
|
||||
"kurz",
|
||||
"kurzer",
|
||||
"hinweis",
|
||||
"zur",
|
||||
"kontaktseite",
|
||||
"webauftritt",
|
||||
]);
|
||||
|
||||
const ENGLISH_MARKERS = new Set([
|
||||
@@ -120,6 +128,63 @@ const RAW_TECH_PATTERNS = [
|
||||
/\b[0-9a-f]{24}\b/i,
|
||||
];
|
||||
|
||||
const EMAIL_TEMPLATE_PATTERNS = [
|
||||
/\bich habe beobachtet\b/i,
|
||||
/\bmir ist aufgefallen\b/i,
|
||||
/\bich schlage vor\b/i,
|
||||
/\bich empfehle\b/i,
|
||||
];
|
||||
|
||||
const EMAIL_BROCHURE_PATTERNS = [
|
||||
/\bmaßnahmen umsetzen\b/i,
|
||||
/\bconversion[- ]rate steigern\b/i,
|
||||
/\branking positiv beeinflussen\b/i,
|
||||
/\babsprungraten senken\b/i,
|
||||
/\bnachhaltig verbessern\b/i,
|
||||
/\bsignifikant\b/i,
|
||||
/\boptimierungspotenzial(?:e)?\b/i,
|
||||
/\bnutzerzufriedenheit\b/i,
|
||||
/\bsuchmaschinenplatzierung\b/i,
|
||||
];
|
||||
|
||||
const EMAIL_AUDIT_TOPIC_PATTERNS = [
|
||||
/\bmeta[- ]beschreibung\b/i,
|
||||
/\bpage[- ]?speed\b/i,
|
||||
/\bladezeit(?:en)?\b/i,
|
||||
/\bkontaktformular\b/i,
|
||||
/\bcall[- ]to[- ]action\b/i,
|
||||
/\bmobile(?:n|r|s)? gerät/i,
|
||||
/\bdesktop\b/i,
|
||||
/\bh1[- ]?überschrift(?:en)?\b/i,
|
||||
/\bbewertung(?:en)?\b/i,
|
||||
/\bvertrauenssignal(?:e)?\b/i,
|
||||
/\bstrukturierte daten\b/i,
|
||||
];
|
||||
|
||||
const EMAIL_MINI_AUDIT_TRANSITIONS = [
|
||||
/\baußerdem\b/i,
|
||||
/\bzudem\b/i,
|
||||
/\bein weiterer punkt\b/i,
|
||||
/\bschließlich\b/i,
|
||||
/\bdurch die umsetzung\b/i,
|
||||
];
|
||||
|
||||
const EMAIL_LOW_FRICTION_ASK_PATTERNS = [
|
||||
/\bsoll ich ihnen\b/i,
|
||||
/\bwäre (?:das|ein kurzer hinweis)\b/i,
|
||||
/\bdarf ich ihnen\b/i,
|
||||
/\bkann ich ihnen\b/i,
|
||||
/\boffen für\b/i,
|
||||
];
|
||||
|
||||
const INFORMAL_EMAIL_ADDRESS_PATTERNS = [
|
||||
/\bdu\b/i,
|
||||
/\bdir\b/i,
|
||||
/\bdein(?:e[rmns]?)?\b/i,
|
||||
/\beuch\b/i,
|
||||
/\beuer(?:e[rmns]?)?\b/i,
|
||||
];
|
||||
|
||||
export type GermanCopyGuardIssue = {
|
||||
field: string;
|
||||
rule: string;
|
||||
@@ -256,6 +321,178 @@ function hasRawArtifact(value: string): boolean {
|
||||
return RAW_TECH_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function countMatches(value: string, patterns: readonly RegExp[]) {
|
||||
return patterns.reduce(
|
||||
(count, pattern) => count + (pattern.test(value) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function countRegexMatches(value: string, pattern: RegExp) {
|
||||
return value.match(pattern)?.length ?? 0;
|
||||
}
|
||||
|
||||
function countSentences(value: string) {
|
||||
return value
|
||||
.split(/[.!?]+/)
|
||||
.map((sentence) => sentence.trim())
|
||||
.filter(Boolean).length;
|
||||
}
|
||||
|
||||
function countParagraphs(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.split(/\n\s*\n/)
|
||||
.map((paragraph) => paragraph.trim())
|
||||
.filter(Boolean).length;
|
||||
}
|
||||
|
||||
function startsWithTemplateEmailPhrase(value: string) {
|
||||
return new RegExp(
|
||||
String.raw`^\s*(?:(?:guten tag|hallo|sehr geehrte[^,.!?]*|moin)[,.!?\s]+)?(?:${EMAIL_TEMPLATE_PATTERNS.map(
|
||||
(pattern) => pattern.source,
|
||||
).join("|")})`,
|
||||
"i",
|
||||
).test(value);
|
||||
}
|
||||
|
||||
function hasLowFrictionAsk(value: string) {
|
||||
return (
|
||||
value.includes("?") &&
|
||||
EMAIL_LOW_FRICTION_ASK_PATTERNS.some((pattern) => pattern.test(value))
|
||||
);
|
||||
}
|
||||
|
||||
function validateEmailSubjectTone(
|
||||
issues: GermanCopyGuardIssue[],
|
||||
subject: string,
|
||||
) {
|
||||
const trimmed = subject.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const words = tokenizeWords(trimmed);
|
||||
const { subject: subjectRules } = customerToneGuidelines.email;
|
||||
const hasInflatedSubject =
|
||||
/optimierungspotenzial/i.test(trimmed) ||
|
||||
/mehr sichtbarkeit/i.test(trimmed) ||
|
||||
/bessere nutzererfahrung/i.test(trimmed) ||
|
||||
/kundengewinnung/i.test(trimmed) ||
|
||||
/conversion/i.test(trimmed) ||
|
||||
/ranking/i.test(trimmed);
|
||||
|
||||
if (
|
||||
trimmed.length > subjectRules.maxCharacters ||
|
||||
words.length < subjectRules.minWords ||
|
||||
words.length > subjectRules.maxWords ||
|
||||
/:/.test(trimmed) ||
|
||||
hasInflatedSubject
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailSubject",
|
||||
"unnatural_email_subject",
|
||||
"Betreff wirkt zu pitchig, zu lang oder nicht wie eine kurze Erstmail.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateEmailBodyTone(
|
||||
issues: GermanCopyGuardIssue[],
|
||||
body: string,
|
||||
) {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { email } = customerToneGuidelines;
|
||||
const wordCount = tokenizeWords(trimmed).length;
|
||||
if (wordCount < email.wordCount.min || wordCount > email.wordCount.max) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"unnatural_email_length",
|
||||
"E-Mail sollte als Erstkontakt kurz bleiben: 60-130 Wörter.",
|
||||
);
|
||||
}
|
||||
|
||||
if (countSentences(trimmed) > email.maxSentences) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"too_many_email_sentences",
|
||||
"E-Mail enthält zu viele Sätze für eine erste Kontaktaufnahme.",
|
||||
);
|
||||
}
|
||||
|
||||
if (countParagraphs(trimmed) > email.maxParagraphs) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"too_many_email_paragraphs",
|
||||
"E-Mail sollte höchstens zwei kurze Absätze enthalten.",
|
||||
);
|
||||
}
|
||||
|
||||
const templatePhraseCount = EMAIL_TEMPLATE_PATTERNS.reduce(
|
||||
(count, pattern) => count + countRegexMatches(trimmed, new RegExp(pattern.source, "gi")),
|
||||
0,
|
||||
);
|
||||
const firstPersonCount = countRegexMatches(trimmed, /\bich\b/gi);
|
||||
if (
|
||||
startsWithTemplateEmailPhrase(trimmed) ||
|
||||
templatePhraseCount >= 2 ||
|
||||
firstPersonCount > 2
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"formulaic_email_tone",
|
||||
"E-Mail wirkt formelhaft; vermeide wiederholte Ich-habe-/Ich-schlage-vor-Muster.",
|
||||
);
|
||||
}
|
||||
|
||||
if (EMAIL_BROCHURE_PATTERNS.some((pattern) => pattern.test(trimmed))) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"brochure_email_language",
|
||||
"E-Mail klingt nach Broschüre statt nach natürlicher Erstansprache.",
|
||||
);
|
||||
}
|
||||
|
||||
const topicCount = countMatches(trimmed, EMAIL_AUDIT_TOPIC_PATTERNS);
|
||||
const transitionCount = countMatches(trimmed, EMAIL_MINI_AUDIT_TRANSITIONS);
|
||||
if (topicCount >= 4 || transitionCount >= 2) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"email_reads_like_mini_audit",
|
||||
"E-Mail bündelt zu viele Audit-Punkte und sollte höchstens zwei Befunde anreißen.",
|
||||
);
|
||||
}
|
||||
|
||||
if (INFORMAL_EMAIL_ADDRESS_PATTERNS.some((pattern) => pattern.test(trimmed))) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"informal_email_address",
|
||||
"E-Mail sollte unbekannte lokale Betriebe formal mit Sie/Ihnen ansprechen.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasLowFrictionAsk(trimmed)) {
|
||||
addIssue(
|
||||
issues,
|
||||
"emailBody",
|
||||
"missing_low_friction_ask",
|
||||
"E-Mail sollte mit einer kurzen, leicht beantwortbaren Frage enden.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateTextField(
|
||||
issues: GermanCopyGuardIssue[],
|
||||
field: string,
|
||||
@@ -372,10 +609,12 @@ export function validateEmailCopy(email: EmailCopy): GermanCopyGuardResult {
|
||||
const issues: GermanCopyGuardIssue[] = [];
|
||||
|
||||
validateTextField(issues, "emailSubject", email.subject, { skipIfTooShort: true });
|
||||
validateEmailSubjectTone(issues, email.subject);
|
||||
validateTextField(issues, "emailBody", email.body, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
requireIchForm: false,
|
||||
requireObservationAndSuggestion: false,
|
||||
});
|
||||
validateEmailBodyTone(issues, email.body);
|
||||
|
||||
return { passed: issues.length === 0, issues };
|
||||
}
|
||||
@@ -453,13 +692,15 @@ export function validateCustomerFacingCopy(input: GermanCustomerCopy): GermanCop
|
||||
validateTextField(issues, "emailSubject", input.emailSubject, {
|
||||
skipIfTooShort: true,
|
||||
});
|
||||
validateEmailSubjectTone(issues, input.emailSubject);
|
||||
}
|
||||
|
||||
if (input.emailBody !== undefined) {
|
||||
validateTextField(issues, "emailBody", input.emailBody, {
|
||||
requireIchForm: true,
|
||||
requireObservationAndSuggestion: true,
|
||||
requireIchForm: false,
|
||||
requireObservationAndSuggestion: false,
|
||||
});
|
||||
validateEmailBodyTone(issues, input.emailBody);
|
||||
}
|
||||
|
||||
if (input.callScript) {
|
||||
|
||||
@@ -18,6 +18,25 @@ export const LOCAL_AUDIT_SKILL_REGISTRY_SOURCE = [
|
||||
"Lesen auf dem Smartphone\", nicht „sieht altbacken aus\". Kundennutzen: ein moderner,",
|
||||
"ruhiger Auftritt schafft Vertrauen, bevor der erste Satz gelesen wird.",
|
||||
"",
|
||||
"## impeccable-critique",
|
||||
"",
|
||||
"```yaml",
|
||||
"id: impeccable-critique",
|
||||
"title: Impeccable Critique Review",
|
||||
"applies_when: website_exists",
|
||||
"inputs: [desktop_screenshot, mobile_screenshot, markdown, dom]",
|
||||
"outputs: findings",
|
||||
"```",
|
||||
"",
|
||||
"Bewerte die Seite wie ein strenger Design Director: visuelle Hierarchie,",
|
||||
"Informationsarchitektur, kognitive Last, Orientierung, Lesbarkeit, Progressive",
|
||||
"Disclosure und erkennbare AI-Slop-/Template-Muster. Nutze Nielsen-Heuristiken",
|
||||
"als Denkrahmen, aber gib keine Score-Tabelle aus. Befunde müssen beobachtbar und",
|
||||
"belegt sein: z. B. „mehrere gleich laute CTAs konkurrieren im sichtbaren Bereich\"",
|
||||
"statt „Design wirkt beliebig\". Marken- oder Emotionsfit nur nennen, wenn Evidence",
|
||||
"aus Screenshot, Text oder DOM vorliegt. Kundennutzen: eine klarere, weniger",
|
||||
"generische Oberfläche senkt Zweifel und führt Besucher schneller zur Anfrage.",
|
||||
"",
|
||||
"## first-impression-clarity",
|
||||
"",
|
||||
"```yaml",
|
||||
|
||||
@@ -20,6 +20,67 @@ export const v3FindingItemSchema = z.object({
|
||||
|
||||
export const findingItemSchema = legacyFindingItemSchema;
|
||||
|
||||
export const auditFindingEvidenceRefSchema = z.object({
|
||||
id: nonEmptyTextSchema,
|
||||
type: z.enum([
|
||||
"crawl_page",
|
||||
"technical_check",
|
||||
"screenshot",
|
||||
"pagespeed",
|
||||
"jina_excerpt",
|
||||
"generation_stage",
|
||||
]),
|
||||
label: nonEmptyTextSchema,
|
||||
sourceUrl: z.string().trim(),
|
||||
});
|
||||
|
||||
export const auditSpecialistFindingSchema = z
|
||||
.object({
|
||||
skillId: nonEmptyTextSchema,
|
||||
claim: nonEmptyTextSchema,
|
||||
recommendation: nonEmptyTextSchema,
|
||||
customerBenefit: nonEmptyTextSchema,
|
||||
severity: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
||||
confidence: z.number().min(0).max(1),
|
||||
evidenceRefs: z.array(auditFindingEvidenceRefSchema).min(1),
|
||||
applies: z.boolean(),
|
||||
unknowns: z.array(z.string()),
|
||||
})
|
||||
.superRefine((finding, ctx) => {
|
||||
const combined = [
|
||||
finding.claim,
|
||||
finding.recommendation,
|
||||
finding.customerBenefit,
|
||||
].join(" ");
|
||||
if (/\bunbekannt\b|\bunknown\b/i.test(combined)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "unknown-only findings are not valid audit claims",
|
||||
path: ["claim"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const auditSpecialistResultSchema = z.object({
|
||||
status: z.enum(["success", "partial", "skipped", "failed"]),
|
||||
findings: z.array(auditSpecialistFindingSchema),
|
||||
notes: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const auditRejectedFindingSchema = z.object({
|
||||
findingId: nonEmptyTextSchema,
|
||||
skillId: nonEmptyTextSchema,
|
||||
claim: nonEmptyTextSchema,
|
||||
rejectionReason: nonEmptyTextSchema,
|
||||
});
|
||||
|
||||
export const auditEvidenceVerificationSchema = z.object({
|
||||
verifiedFindingIds: z.array(nonEmptyTextSchema),
|
||||
rejectedFindings: z.array(auditRejectedFindingSchema),
|
||||
contradictions: z.array(z.string()),
|
||||
notes: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const internalFindingsSchema = z.object({
|
||||
findings: z.array(findingItemSchema),
|
||||
summary: z.string(),
|
||||
@@ -80,6 +141,10 @@ export const qualityReviewSchema = z.object({
|
||||
|
||||
export type FindingItem = z.infer<typeof findingItemSchema>;
|
||||
export type V3FindingItem = z.infer<typeof v3FindingItemSchema>;
|
||||
export type AuditFindingEvidenceRef = z.infer<typeof auditFindingEvidenceRefSchema>;
|
||||
export type AuditSpecialistFinding = z.infer<typeof auditSpecialistFindingSchema>;
|
||||
export type AuditSpecialistResult = z.infer<typeof auditSpecialistResultSchema>;
|
||||
export type AuditEvidenceVerification = z.infer<typeof auditEvidenceVerificationSchema>;
|
||||
export type InternalFindings = z.infer<typeof internalFindingsSchema>;
|
||||
export type AuditClassification = z.infer<typeof auditClassificationSchema>;
|
||||
export type AuditGenerationResult = z.infer<typeof auditGenerationResultSchema>;
|
||||
|
||||
@@ -227,6 +227,124 @@ test("buildAuditEvidenceInput preserves screenshot references without base64 pay
|
||||
}
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput creates stable evidence ledger refs for source facts", () => {
|
||||
const first = buildAuditEvidenceInput({
|
||||
crawlPages: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com/",
|
||||
pageKind: "homepage",
|
||||
title: "Startseite",
|
||||
metaDescription: "Bäckerei Muster in Berlin",
|
||||
visibleTextExcerpt: "Bäckerei Muster Berlin mit Kontakt und Öffnungszeiten.",
|
||||
hasContactCtaSignal: true,
|
||||
},
|
||||
],
|
||||
technicalChecks: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com/",
|
||||
usesHttps: true,
|
||||
missingMetaDescription: false,
|
||||
hasVisibleContactPath: true,
|
||||
brokenInternalLinkCount: 0,
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
storageId: "storage-home-mobile",
|
||||
sourceUrl: "https://example.com",
|
||||
viewport: "mobile",
|
||||
width: 390,
|
||||
height: 844,
|
||||
mimeType: "image/png",
|
||||
capturedAt: 1_700_000_001_000,
|
||||
},
|
||||
],
|
||||
pageSpeedInputs: [
|
||||
{
|
||||
strategy: "mobile",
|
||||
status: "succeeded",
|
||||
sourceUrl: "https://example.com",
|
||||
normalized: {
|
||||
implications: [
|
||||
"Die wichtigsten Inhalte erscheinen auf dem Smartphone spürbar verzögert.",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
externalMarkdown:
|
||||
"# Startseite\nBäckerei Muster Berlin. Telefon und Öffnungszeiten sind sichtbar.",
|
||||
skillRegistry: SAMPLE_SKILL_REGISTRY,
|
||||
});
|
||||
const second = buildAuditEvidenceInput({
|
||||
...first,
|
||||
crawlPages: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com/",
|
||||
pageKind: "homepage",
|
||||
title: "Startseite",
|
||||
metaDescription: "Bäckerei Muster in Berlin",
|
||||
visibleTextExcerpt: "Bäckerei Muster Berlin mit Kontakt und Öffnungszeiten.",
|
||||
hasContactCtaSignal: true,
|
||||
},
|
||||
],
|
||||
technicalChecks: [
|
||||
{
|
||||
sourceUrl: "https://example.com",
|
||||
finalUrl: "https://example.com/",
|
||||
usesHttps: true,
|
||||
missingMetaDescription: false,
|
||||
hasVisibleContactPath: true,
|
||||
brokenInternalLinkCount: 0,
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
storageId: "storage-home-mobile",
|
||||
sourceUrl: "https://example.com",
|
||||
viewport: "mobile",
|
||||
width: 390,
|
||||
height: 844,
|
||||
mimeType: "image/png",
|
||||
capturedAt: 1_700_000_001_000,
|
||||
},
|
||||
],
|
||||
pageSpeedInputs: [
|
||||
{
|
||||
strategy: "mobile",
|
||||
status: "succeeded",
|
||||
sourceUrl: "https://example.com",
|
||||
normalized: {
|
||||
implications: [
|
||||
"Die wichtigsten Inhalte erscheinen auf dem Smartphone spürbar verzögert.",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
externalMarkdown:
|
||||
"# Startseite\nBäckerei Muster Berlin. Telefon und Öffnungszeiten sind sichtbar.",
|
||||
});
|
||||
|
||||
assert.deepEqual(first.evidenceLedger, second.evidenceLedger);
|
||||
const evidenceTypes = new Set(first.evidenceLedger.map((entry) => entry.type));
|
||||
for (const type of [
|
||||
"crawl_page",
|
||||
"technical_check",
|
||||
"screenshot",
|
||||
"pagespeed",
|
||||
"jina_excerpt",
|
||||
] as const) {
|
||||
assert.equal(evidenceTypes.has(type), true, `${type} evidence should exist.`);
|
||||
}
|
||||
assert.equal(
|
||||
first.evidenceLedger.every((entry) => entry.id.includes("unknown") === false),
|
||||
true,
|
||||
"Evidence IDs should be stable source refs, not unknown placeholders.",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAuditEvidenceInput converts PageSpeed implications into sanitized customer-facing text", () => {
|
||||
const actual = buildAuditEvidenceInput({
|
||||
pageSpeedInputs: [
|
||||
@@ -421,15 +539,16 @@ test("buildAuditEvidenceInput prioritizes local-audit v3 skills before cap", ()
|
||||
const selectedIds = new Set(actual.selectedSkills.map((skill) => skill.id));
|
||||
assert.deepEqual(actual.selectedSkills.map((skill) => skill.id), [
|
||||
"visual-design",
|
||||
"impeccable-critique",
|
||||
"contact-conversion",
|
||||
"local-seo-basics",
|
||||
"performance-experience",
|
||||
"mobile-usability",
|
||||
"conversion-copy",
|
||||
]);
|
||||
assert.equal(actual.selectedSkills.length, 6);
|
||||
for (const id of [
|
||||
"visual-design",
|
||||
"impeccable-critique",
|
||||
"contact-conversion",
|
||||
"local-seo-basics",
|
||||
"performance-experience",
|
||||
@@ -475,6 +594,7 @@ test("buildAuditEvidenceInput gates v3 skills when declared inputs are missing",
|
||||
const selectedIds = new Set(actual.selectedSkills.map((skill) => skill.id));
|
||||
for (const id of [
|
||||
"visual-design",
|
||||
"impeccable-critique",
|
||||
"first-impression-clarity",
|
||||
"contact-conversion",
|
||||
"mobile-usability",
|
||||
|
||||
@@ -5,6 +5,15 @@ import test from "node:test";
|
||||
|
||||
const actionPath = path.join(process.cwd(), "convex", "auditGenerationAction.ts");
|
||||
const actionSource = existsSync(actionPath) ? readFileSync(actionPath, "utf8") : "";
|
||||
const toneGuidelinesPath = path.join(
|
||||
process.cwd(),
|
||||
"lib",
|
||||
"ai",
|
||||
"customer-tone-guidelines.ts",
|
||||
);
|
||||
const toneGuidelinesSource = existsSync(toneGuidelinesPath)
|
||||
? readFileSync(toneGuidelinesPath, "utf8")
|
||||
: "";
|
||||
const generationSourcePath = path.join(process.cwd(), "convex", "auditGeneration.ts");
|
||||
const generationSource = existsSync(generationSourcePath)
|
||||
? readFileSync(generationSourcePath, "utf8")
|
||||
@@ -129,6 +138,12 @@ test("action starts, queries evidence, and runs stage pipeline", () => {
|
||||
test("action includes all required audit stages", () => {
|
||||
for (const stage of [
|
||||
"classification",
|
||||
"localSeoSpecialist",
|
||||
"conversionUxSpecialist",
|
||||
"visualTrustSpecialist",
|
||||
"critiqueSpecialist",
|
||||
"performanceAccessibilitySpecialist",
|
||||
"evidenceVerifier",
|
||||
"multimodalAudit",
|
||||
"germanCopy",
|
||||
"qualityReview",
|
||||
@@ -142,6 +157,159 @@ test("action includes all required audit stages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("specialist fan-out runs after evidence input and before German copy", () => {
|
||||
const evidenceInputIndex = actionSource.indexOf("const evidenceInput = buildAuditEvidenceInput");
|
||||
const fanOutIndex = actionSource.indexOf("Promise.all(\n specialistStageConfigs.map");
|
||||
const verifierIndex = actionSource.indexOf('currentStep = "evidenceVerifier"');
|
||||
const germanCopyIndex = actionSource.indexOf('currentStep = "germanCopy"');
|
||||
|
||||
assert.notEqual(evidenceInputIndex, -1, "Action should build evidence input.");
|
||||
assert.notEqual(germanCopyIndex, -1, "Action should still run German copy.");
|
||||
assert.notEqual(fanOutIndex, -1, "Action should fan out specialist stage configs.");
|
||||
assert.notEqual(verifierIndex, -1, "Action should run the evidence verifier.");
|
||||
assert.equal(
|
||||
fanOutIndex > evidenceInputIndex && fanOutIndex < germanCopyIndex,
|
||||
true,
|
||||
"Specialist fan-out should run after evidence input and before German copy.",
|
||||
);
|
||||
assert.equal(
|
||||
verifierIndex > fanOutIndex && verifierIndex < germanCopyIndex,
|
||||
true,
|
||||
"Evidence verifier should run after specialist fan-out and before German copy.",
|
||||
);
|
||||
});
|
||||
|
||||
test("specialist stages use specialist schemas and verified findings feed German copy", () => {
|
||||
assert.equal(
|
||||
hasStageCall("auditSpecialistResultSchema"),
|
||||
true,
|
||||
"Specialist stages should call generateObject with auditSpecialistResultSchema.",
|
||||
);
|
||||
assert.equal(
|
||||
hasStageCall("auditEvidenceVerificationSchema"),
|
||||
true,
|
||||
"Verifier stage should call generateObject with auditEvidenceVerificationSchema.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/(?:const|let)\s+verifiedFindings\s*[:=]/,
|
||||
"Action should derive verifiedFindings before synthesis.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/verifiedResult?\.?object|verifiedFindingIds/,
|
||||
"Verifier output should use compact finding IDs instead of echoing full findings.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/verifiedFindingIds\.has\(candidate\.findingId\)/,
|
||||
"Action should map verifier-approved IDs back to original specialist findings.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/buildGermanCopyPrompt\(\s*verifiedFindingsText/,
|
||||
"German copy should be generated from verified findings text.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/buildGermanCopyPrompt\(\s*classificationSummary\s*,/,
|
||||
"German copy should no longer use raw classification summary as its primary finding input.",
|
||||
);
|
||||
});
|
||||
|
||||
test("critique specialist translates impeccable critique guidance into the audit fan-out", () => {
|
||||
assert.match(
|
||||
actionSource,
|
||||
/stage:\s*["']critiqueSpecialist["']/,
|
||||
"Action should include a dedicated critique specialist stage.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/impeccable-critique/,
|
||||
"Critique specialist should anchor findings to the impeccable critique skill id.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/kognitive Last|Nielsen|AI-Slop|Informationsarchitektur/,
|
||||
"Critique specialist should include critique guidance beyond generic visual trust.",
|
||||
);
|
||||
});
|
||||
|
||||
test("German copy prompt uses first-contact email tone guidelines without a new AI stage", () => {
|
||||
const buildPromptSource = extractFunctionSource("buildGermanCopyPrompt");
|
||||
|
||||
assert.doesNotMatch(
|
||||
buildPromptSource,
|
||||
/Ich-Ich Kontext/,
|
||||
"German copy prompt should not force formulaic Ich-Ich copy.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/buildCustomerTonePromptSection/,
|
||||
"German copy prompt should inject shared customer tone guidelines.",
|
||||
);
|
||||
assert.match(
|
||||
buildPromptSource,
|
||||
/evidence:\s*AuditEvidence/,
|
||||
"German copy prompt should accept explicit evidence context.",
|
||||
);
|
||||
assert.match(
|
||||
actionSource,
|
||||
/buildGermanCopyPrompt\([\s\S]*verifiedFindingsText[\s\S]*multimodalSummary[\s\S]*evidenceInput[\s\S]*\)/,
|
||||
"German copy prompt should receive the explicit evidence context at the callsite.",
|
||||
);
|
||||
assert.match(
|
||||
toneGuidelinesSource,
|
||||
/kollegial direkt/,
|
||||
"Tone guidelines should lock the selected sender posture.",
|
||||
);
|
||||
assert.match(
|
||||
toneGuidelinesSource,
|
||||
/maximal zwei verifizierte Befunde|max\. zwei verifizierte Befunde/,
|
||||
"Tone guidelines should keep outreach emails to at most two verified findings.",
|
||||
);
|
||||
assert.match(
|
||||
toneGuidelinesSource,
|
||||
/kein Mini-Audit/,
|
||||
"Tone guidelines should explicitly forbid mini-audit emails.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/tone(?:Review|Rewrite|Specialist)|emailToneSpecialist|copyToneSpecialist/,
|
||||
"Tone work should not add another model-backed generation stage.",
|
||||
);
|
||||
});
|
||||
|
||||
test("quality review blocks when model review or German copy guard fails", () => {
|
||||
const qualityPromptSource = extractFunctionSource("buildQualityReviewPrompt");
|
||||
|
||||
assert.match(
|
||||
actionSource,
|
||||
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
||||
"qualityPassed should require both model review validity and German copy guard.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
actionSource,
|
||||
/qualityPassed\s*=\s*guardResult\.passed\s*;/,
|
||||
"qualityPassed must not ignore the model quality review.",
|
||||
);
|
||||
assert.match(
|
||||
qualityPromptSource,
|
||||
/echte Erstmail von Matthias/,
|
||||
"Quality review should apply the selected first-contact email rubric.",
|
||||
);
|
||||
assert.match(
|
||||
qualityPromptSource,
|
||||
/KI-Verkaufstext/,
|
||||
"Quality review should reject AI-like sales copy.",
|
||||
);
|
||||
assert.match(
|
||||
qualityPromptSource,
|
||||
/verified findings|verifizierte Befunde/i,
|
||||
"Quality review should keep concrete claims tied to verified findings.",
|
||||
);
|
||||
});
|
||||
|
||||
test("action handles post-start failure paths in action-level catch", () => {
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
@@ -377,18 +545,18 @@ test("action runs german copy guard and blocks outreach-ready on validation fail
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/qualityPassed\s*=\s*guardResult\.passed/,
|
||||
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
||||
),
|
||||
true,
|
||||
"Only deterministic German copy guard failures should hard-block the audit run.",
|
||||
"Model QA and deterministic German copy guard failures should hard-block the audit run.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(
|
||||
actionSource,
|
||||
/qualityPassed\s*=\s*qualityResult\.object\.isValid\s*&&\s*guardResult\.passed/,
|
||||
/qualityPassed\s*=\s*guardResult\.passed\s*;/,
|
||||
),
|
||||
false,
|
||||
"Subjective model QA warnings should not be combined with guardResult for terminal failure.",
|
||||
"Action must not ignore the model QA validity flag.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(actionSource, /internal\.leads\.reviewUpdateInternal/),
|
||||
|
||||
@@ -96,6 +96,7 @@ test("auditGeneration module exports required mutation contracts", () => {
|
||||
"queueLeadAuditGeneration",
|
||||
"startAuditGenerationRun",
|
||||
"persistAuditGenerationResult",
|
||||
"replaceAuditFindings",
|
||||
"finishAuditGenerationRun",
|
||||
];
|
||||
|
||||
@@ -113,6 +114,7 @@ test("auditGeneration module registers internalMutation contracts", () => {
|
||||
"queueLeadAuditGeneration",
|
||||
"startAuditGenerationRun",
|
||||
"persistAuditGenerationResult",
|
||||
"replaceAuditFindings",
|
||||
"finishAuditGenerationRun",
|
||||
]) {
|
||||
assert.equal(
|
||||
@@ -126,6 +128,47 @@ test("auditGeneration module registers internalMutation contracts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("replaceAuditFindings replaces persisted audit findings with evidence refs", () => {
|
||||
const replaceSource = extractExportSource("replaceAuditFindings");
|
||||
|
||||
assert.equal(
|
||||
hasPattern(replaceSource, /query\("auditFindings"\)/),
|
||||
true,
|
||||
"replaceAuditFindings should query auditFindings.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(replaceSource, /withIndex\("by_auditId"/),
|
||||
true,
|
||||
"replaceAuditFindings should query existing findings by auditId.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(replaceSource, /ctx\.db\.delete\(/),
|
||||
true,
|
||||
"replaceAuditFindings should delete stale findings before inserting replacements.",
|
||||
);
|
||||
assert.equal(
|
||||
hasPattern(replaceSource, /ctx\.db\.insert\(\s*"auditFindings"/),
|
||||
true,
|
||||
"replaceAuditFindings should insert into auditFindings.",
|
||||
);
|
||||
for (const field of [
|
||||
"skillId",
|
||||
"claim",
|
||||
"recommendation",
|
||||
"customerBenefit",
|
||||
"severity",
|
||||
"confidence",
|
||||
"evidenceRefs",
|
||||
"reviewStatus",
|
||||
]) {
|
||||
assert.equal(
|
||||
hasPattern(replaceSource, new RegExp(`${field}:\\s*finding\\.${field}`)),
|
||||
true,
|
||||
`replaceAuditFindings should persist ${field}.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("queueLeadAuditGeneration dedupes pending/running runs and schedules action", () => {
|
||||
const queueSource = extractExportSource("queueLeadAuditGeneration");
|
||||
|
||||
|
||||
@@ -202,3 +202,90 @@ test("audit-generation validators are declared", () => {
|
||||
"auditGenerationStage should include qualityReview.",
|
||||
);
|
||||
});
|
||||
|
||||
test("auditFindings table stores verified specialist findings with evidence refs", () => {
|
||||
const { section, objectBlock } = extractTableSection("auditFindings");
|
||||
|
||||
assertHas(
|
||||
/auditId:\s*v\.id\(["']audits["']\)/,
|
||||
objectBlock,
|
||||
"auditFindings.auditId must be required audit id.",
|
||||
);
|
||||
assertHas(
|
||||
/runId:\s*v\.id\(["']agentRuns["']\)/,
|
||||
objectBlock,
|
||||
"auditFindings.runId must be required run id.",
|
||||
);
|
||||
assertHas(
|
||||
/skillId:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditFindings.skillId must identify the source specialist skill.",
|
||||
);
|
||||
assertHas(
|
||||
/claim:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditFindings.claim must store the verified claim.",
|
||||
);
|
||||
assertHas(
|
||||
/recommendation:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditFindings.recommendation must store the concrete fix.",
|
||||
);
|
||||
assertHas(
|
||||
/customerBenefit:\s*v\.string\(\)/,
|
||||
objectBlock,
|
||||
"auditFindings.customerBenefit must store customer-facing impact.",
|
||||
);
|
||||
assertHas(
|
||||
/severity:\s*v\.union\(\s*v\.literal\(1\),\s*v\.literal\(2\),\s*v\.literal\(3\)\s*\)/,
|
||||
objectBlock,
|
||||
"auditFindings.severity should be a 1-3 literal union.",
|
||||
);
|
||||
assertHas(
|
||||
/confidence:\s*v\.number\(\)/,
|
||||
objectBlock,
|
||||
"auditFindings.confidence must be persisted for review calibration.",
|
||||
);
|
||||
assertHas(
|
||||
/evidenceRefs:\s*v\.array\(\s*auditFindingEvidenceRef\s*\)/,
|
||||
objectBlock,
|
||||
"auditFindings.evidenceRefs must persist typed evidence refs.",
|
||||
);
|
||||
assertHas(
|
||||
/reviewStatus:\s*auditFindingReviewStatus/,
|
||||
objectBlock,
|
||||
"auditFindings.reviewStatus should use a review-status validator.",
|
||||
);
|
||||
assertHas(
|
||||
/index\("by_auditId",\s*\["auditId"\]\)/,
|
||||
section,
|
||||
"auditFindings should have by_auditId index.",
|
||||
);
|
||||
assertHas(
|
||||
/index\("by_runId",\s*\["runId"\]\)/,
|
||||
section,
|
||||
"auditFindings should have by_runId index.",
|
||||
);
|
||||
assertHas(
|
||||
/index\("by_auditId_and_reviewStatus",\s*\["auditId",\s*"reviewStatus"\]\)/,
|
||||
section,
|
||||
"auditFindings should support review-status filtering per audit.",
|
||||
);
|
||||
});
|
||||
|
||||
test("specialist fan-out audit stages are declared in domain", () => {
|
||||
for (const stage of [
|
||||
"localSeoSpecialist",
|
||||
"conversionUxSpecialist",
|
||||
"visualTrustSpecialist",
|
||||
"critiqueSpecialist",
|
||||
"performanceAccessibilitySpecialist",
|
||||
"evidenceVerifier",
|
||||
]) {
|
||||
assertHas(
|
||||
new RegExp(`AUDIT_GENERATION_STAGES\\s*=\\s*\\[[\\s\\S]*["']${stage}["'][\\s\\S]*\\]`),
|
||||
domainSource,
|
||||
`auditGenerationStage should include ${stage}.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parseSkillsRegistry, toAuditUsedSkill } from "../lib/skills-registry";
|
||||
test("parseSkillsRegistry parses v3 yaml metablocks from the MVP registry source", () => {
|
||||
const parsed = parseSkillsRegistry(LOCAL_AUDIT_SKILL_REGISTRY_SOURCE);
|
||||
|
||||
assert.equal(parsed.length, 9);
|
||||
assert.equal(parsed.length, 10);
|
||||
const visualDesign = parsed.find((entry) => entry.id === "visual-design");
|
||||
assert.ok(visualDesign);
|
||||
assert.equal(visualDesign.title, "Visueller Gesamteindruck & Zeitgemäßheit");
|
||||
@@ -23,6 +23,21 @@ test("parseSkillsRegistry parses v3 yaml metablocks from the MVP registry source
|
||||
assert.fail("Expected visual-design instructions to be parsed.");
|
||||
}
|
||||
assert.match(instructions, /Beurteile den ersten visuellen Eindruck/);
|
||||
|
||||
const critique = parsed.find((entry) => entry.id === "impeccable-critique");
|
||||
assert.ok(critique);
|
||||
assert.equal(critique.title, "Impeccable Critique Review");
|
||||
assert.equal(critique.appliesWhen, "website_exists");
|
||||
assert.deepEqual(critique.inputs, [
|
||||
"desktop_screenshot",
|
||||
"mobile_screenshot",
|
||||
"markdown",
|
||||
"dom",
|
||||
]);
|
||||
assert.match(
|
||||
critique.instructions ?? "",
|
||||
/Nielsen|kognitive Last|AI-Slop/,
|
||||
);
|
||||
});
|
||||
|
||||
test("toAuditUsedSkill exposes stable ids for v3 registry entries", () => {
|
||||
|
||||
@@ -228,6 +228,16 @@ test("audits.getDetail returns audit + lead context with null-safe lead lookup",
|
||||
/return\s*{[\s\S]*audit,[\s\S]*lead,[\s\S]*sourceSummaries:[\s\S]*}/,
|
||||
"getDetail should return audit, lead, and sourceSummaries.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/query\("auditFindings"\)[\s\S]*withIndex\("by_auditId"[\s\S]*eq\("auditId",\s*audit\._id\)[\s\S]*take\(DETAIL_EVIDENCE_LIMIT\)/,
|
||||
"getDetail should load persisted findings by auditId.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/return\s*{[\s\S]*audit,[\s\S]*lead,[\s\S]*findings,[\s\S]*sourceSummaries:[\s\S]*}/,
|
||||
"getDetail should return top-level findings for the detail UI.",
|
||||
);
|
||||
hasPattern(
|
||||
sourceFile.getFullText(),
|
||||
/export const getDetail = query\(/,
|
||||
|
||||
@@ -104,6 +104,11 @@ test("audit detail component uses getDetail query and renders skills overview se
|
||||
/const\s+lead\s*=\s*result\?\.lead;/,
|
||||
"AuditDetail should destructure lead from result.lead.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/const\s+findings\s*=/,
|
||||
"AuditDetail should derive findings from result.findings.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/leadSummary\(\s*lead\s*\)/,
|
||||
@@ -141,6 +146,43 @@ test("audit detail component uses getDetail query and renders skills overview se
|
||||
);
|
||||
});
|
||||
|
||||
test("audit detail component renders verified findings before checked-page evidence", async () => {
|
||||
const detailSource = await source("components/audits/audit-detail.tsx");
|
||||
const findingsIndex = detailSource.indexOf("Geprüfte Befunde");
|
||||
const checkedPagesIndex = detailSource.indexOf("Geprüfte Seiten");
|
||||
|
||||
assert.notEqual(findingsIndex, -1, "AuditDetail should render a findings section.");
|
||||
assert.notEqual(checkedPagesIndex, -1, "AuditDetail should still render checked pages.");
|
||||
assert.equal(
|
||||
findingsIndex < checkedPagesIndex,
|
||||
true,
|
||||
"Findings should be rendered before raw checked-page evidence.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/findings\.map/,
|
||||
"AuditDetail should render one row per verified finding.",
|
||||
);
|
||||
for (const field of [
|
||||
"claim",
|
||||
"recommendation",
|
||||
"customerBenefit",
|
||||
"evidenceRefs",
|
||||
"confidence",
|
||||
]) {
|
||||
assert.match(
|
||||
detailSource,
|
||||
new RegExp(field),
|
||||
`AuditDetail should surface finding.${field}.`,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
detailSource,
|
||||
/Evidence|Beleg|Quelle/,
|
||||
"AuditDetail should label evidence chips for each finding.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audit detail component renders compact checked-page evidence", async () => {
|
||||
const detailSource = await source("components/audits/audit-detail.tsx");
|
||||
|
||||
|
||||
181
tests/audit-specialist-schemas.test.ts
Normal file
181
tests/audit-specialist-schemas.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { zodSchema } from "ai";
|
||||
|
||||
import {
|
||||
auditEvidenceVerificationSchema,
|
||||
auditSpecialistResultSchema,
|
||||
} from "../lib/ai/schemas";
|
||||
|
||||
const validFinding = {
|
||||
skillId: "local-seo-basics",
|
||||
claim: "Die Startseite nennt den Standort im sichtbaren Bereich nicht klar.",
|
||||
recommendation: "Ort und wichtigste Leistung in die erste Überschrift aufnehmen.",
|
||||
customerBenefit: "Besucher erkennen schneller, ob das Angebot für sie passt.",
|
||||
severity: 2,
|
||||
confidence: 0.82,
|
||||
evidenceRefs: [
|
||||
{
|
||||
id: "crawl_page:homepage:https-example-com",
|
||||
type: "crawl_page",
|
||||
label: "Startseite",
|
||||
sourceUrl: "https://example.com",
|
||||
},
|
||||
],
|
||||
applies: true,
|
||||
unknowns: [],
|
||||
};
|
||||
|
||||
type JsonSchemaObject = {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchemaObject>;
|
||||
required?: string[];
|
||||
items?: JsonSchemaObject;
|
||||
};
|
||||
|
||||
function assertStrictRequiredProperties(schema: JsonSchemaObject, path = "schema") {
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
const required = new Set(schema.required ?? []);
|
||||
for (const key of Object.keys(schema.properties)) {
|
||||
assert.equal(
|
||||
required.has(key),
|
||||
true,
|
||||
`${path}.${key} must be required for Azure/OpenAI structured outputs`,
|
||||
);
|
||||
assertStrictRequiredProperties(schema.properties[key]!, `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.type === "array" && schema.items) {
|
||||
assertStrictRequiredProperties(schema.items, `${path}[]`);
|
||||
}
|
||||
}
|
||||
|
||||
test("specialist structured-output schemas require every declared property", () => {
|
||||
assertStrictRequiredProperties(
|
||||
zodSchema(auditSpecialistResultSchema).jsonSchema as JsonSchemaObject,
|
||||
);
|
||||
assertStrictRequiredProperties(
|
||||
zodSchema(auditEvidenceVerificationSchema).jsonSchema as JsonSchemaObject,
|
||||
);
|
||||
});
|
||||
|
||||
test("auditSpecialistResultSchema accepts evidence-backed specialist findings", () => {
|
||||
const parsed = auditSpecialistResultSchema.parse({
|
||||
status: "success",
|
||||
findings: [validFinding],
|
||||
notes: ["Lokale Relevanz wurde anhand der Startseite geprüft."],
|
||||
});
|
||||
|
||||
assert.equal(parsed.status, "success");
|
||||
assert.equal(parsed.findings[0]?.evidenceRefs[0]?.type, "crawl_page");
|
||||
});
|
||||
|
||||
test("auditSpecialistResultSchema rejects findings without evidence refs", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
auditSpecialistResultSchema.parse({
|
||||
status: "success",
|
||||
findings: [{ ...validFinding, evidenceRefs: [] }],
|
||||
notes: [],
|
||||
}),
|
||||
/evidenceRefs/,
|
||||
);
|
||||
});
|
||||
|
||||
test("auditSpecialistResultSchema rejects unsupported severity and confidence", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
auditSpecialistResultSchema.parse({
|
||||
status: "success",
|
||||
findings: [{ ...validFinding, severity: 4 }],
|
||||
notes: [],
|
||||
}),
|
||||
/severity/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
auditSpecialistResultSchema.parse({
|
||||
status: "success",
|
||||
findings: [{ ...validFinding, confidence: 1.4 }],
|
||||
notes: [],
|
||||
}),
|
||||
/confidence/,
|
||||
);
|
||||
});
|
||||
|
||||
test("auditSpecialistResultSchema rejects unknown-only findings", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
auditSpecialistResultSchema.parse({
|
||||
status: "success",
|
||||
findings: [
|
||||
{
|
||||
...validFinding,
|
||||
claim: "Kontaktformular: Unbekannt",
|
||||
recommendation: "Unbekannt prüfen.",
|
||||
customerBenefit: "Unbekannt.",
|
||||
evidenceRefs: [
|
||||
{
|
||||
id: "technical_check:unknown",
|
||||
type: "technical_check",
|
||||
label: "Kontaktformular unbekannt",
|
||||
sourceUrl: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
notes: [],
|
||||
}),
|
||||
/unknown/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("auditEvidenceVerificationSchema returns compact verified ids and rejected decisions", () => {
|
||||
const parsed = auditEvidenceVerificationSchema.parse({
|
||||
verifiedFindingIds: ["finding-1"],
|
||||
rejectedFindings: [
|
||||
{
|
||||
findingId: "finding-2",
|
||||
skillId: validFinding.skillId,
|
||||
claim: "Die Seite koennte moderner wirken.",
|
||||
rejectionReason: "Zu generisch und nicht ausreichend belegt.",
|
||||
},
|
||||
],
|
||||
contradictions: [],
|
||||
notes: ["Ein Finding wurde wegen generischer Sprache verworfen."],
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed.verifiedFindingIds, ["finding-1"]);
|
||||
assert.equal(parsed.rejectedFindings[0]?.rejectionReason.includes("generisch"), true);
|
||||
});
|
||||
|
||||
test("auditEvidenceVerificationSchema accepts rejected unknown-only claims", () => {
|
||||
const parsed = auditEvidenceVerificationSchema.parse({
|
||||
verifiedFindingIds: [],
|
||||
rejectedFindings: [
|
||||
{
|
||||
findingId: "finding-1",
|
||||
skillId: "contact-conversion",
|
||||
claim: "Kontaktformular: Unbekannt",
|
||||
rejectionReason: "Unknown-only Befunde duerfen nicht in die Kundencopy.",
|
||||
},
|
||||
],
|
||||
contradictions: [],
|
||||
notes: [],
|
||||
});
|
||||
|
||||
assert.equal(parsed.rejectedFindings.length, 1);
|
||||
});
|
||||
|
||||
test("auditEvidenceVerificationSchema keeps verifier output compact for many findings", () => {
|
||||
const parsed = auditEvidenceVerificationSchema.parse({
|
||||
verifiedFindingIds: Array.from({ length: 12 }, (_, index) => `finding-${index + 1}`),
|
||||
rejectedFindings: [],
|
||||
contradictions: [],
|
||||
notes: ["Full specialist findings stay in application state and are not echoed."],
|
||||
});
|
||||
|
||||
assert.equal(parsed.verifiedFindingIds.length, 12);
|
||||
});
|
||||
38
tests/audits-board-layout.test.ts
Normal file
38
tests/audits-board-layout.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const auditsBoardPath = join(
|
||||
process.cwd(),
|
||||
"components",
|
||||
"audits",
|
||||
"audits-board.tsx",
|
||||
);
|
||||
|
||||
test("AuditsBoard renders dashboard rows as responsive cards", async () => {
|
||||
const source = await readFile(auditsBoardPath, "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /min-w-\[/i);
|
||||
assert.doesNotMatch(source, /overflow-x-auto/i);
|
||||
assert.doesNotMatch(source, /grid-cols-\[minmax/i);
|
||||
|
||||
assert.match(source, /Card/);
|
||||
assert.match(source, /CardHeader/);
|
||||
assert.match(source, /CardTitle/);
|
||||
assert.match(source, /CardContent/);
|
||||
assert.match(source, /className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"/);
|
||||
assert.match(source, /aria-labelledby=\{rowTitleId\}/);
|
||||
assert.match(source, /id=\{rowTitleId\}/);
|
||||
});
|
||||
|
||||
test("AuditsBoard keeps audit detail links and non-clickable pipeline cards", async () => {
|
||||
const source = await readFile(auditsBoardPath, "utf8");
|
||||
|
||||
assert.match(source, /row\.kind === "audit"/);
|
||||
assert.match(source, /href=\{row\.detailHref\}/);
|
||||
assert.match(source, /Öffnen/);
|
||||
assert.match(source, /Pipeline läuft/);
|
||||
assert.match(source, /getGenerationStatusLabel\(row\)/);
|
||||
assert.match(source, /row\.errorSummary/);
|
||||
});
|
||||
@@ -23,7 +23,9 @@ test("campaign board renders campaigns as responsive cards", async () => {
|
||||
assert.doesNotMatch(source, /md:hidden/i);
|
||||
assert.doesNotMatch(source, /md:block/i);
|
||||
|
||||
assert.match(source, /className="grid gap-3"/);
|
||||
assert.match(source, /className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"/);
|
||||
assert.match(source, /<Card[^>]+aria-labelledby=\{campaignTitleId\}/);
|
||||
assert.match(source, /id=\{campaignTitleId\}/);
|
||||
assert.match(source, /openEditDialog\(campaign\)/);
|
||||
assert.match(source, /toggleCampaign\(campaign\)/);
|
||||
assert.match(source, /runCampaign\(campaign\)/);
|
||||
|
||||
27
tests/customer-tone-guidelines.test.ts
Normal file
27
tests/customer-tone-guidelines.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildCustomerTonePromptSection,
|
||||
customerToneGuidelines,
|
||||
} from "../lib/ai/customer-tone-guidelines";
|
||||
|
||||
test("customer tone guidelines capture the selected collegial direct email posture", () => {
|
||||
assert.equal(customerToneGuidelines.senderPosture, "kollegial_direkt");
|
||||
assert.equal(customerToneGuidelines.email.wordCount.min, 60);
|
||||
assert.equal(customerToneGuidelines.email.wordCount.max, 130);
|
||||
assert.equal(customerToneGuidelines.email.subject.maxCharacters, 55);
|
||||
assert.equal(
|
||||
customerToneGuidelines.email.bannedPhrases.includes("Optimierungspotenziale"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("customer tone prompt section gives concrete first-contact email rules", () => {
|
||||
const promptSection = buildCustomerTonePromptSection();
|
||||
|
||||
assert.match(promptSection, /kollegial direkt/);
|
||||
assert.match(promptSection, /maximal zwei verifizierte Befunde/);
|
||||
assert.match(promptSection, /kein Mini-Audit/);
|
||||
assert.match(promptSection, /Soll ich Ihnen die zwei Punkte kurz schicken/);
|
||||
});
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
|
||||
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.",
|
||||
"Ich habe Ihren 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",
|
||||
emailSubject: "Kurzer Website-Hinweis",
|
||||
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.",
|
||||
"Guten Tag, auf Ihrer Kontaktseite ist die Telefonnummer gut sichtbar, der mobile Kontaktbutton liegt aber erst weiter unten. Das kostet Besuchern unterwegs einen extra Schritt, gerade wenn sie schnell anrufen oder einen Termin anfragen wollen. Ich wollte Ihnen das kurz spiegeln, weil es mit wenig Aufwand klarer wirken kann. Mehr braucht es dafür wahrscheinlich nicht. Soll ich Ihnen die zwei Punkte kurz schicken?",
|
||||
callScript: {
|
||||
openingLine: "Hallo, ich bin Matthias von der Webberatung.",
|
||||
callScript: [
|
||||
@@ -34,6 +34,18 @@ test("validateCustomerFacingCopy passes clean German outreach and audit copy", (
|
||||
assert.equal(result.issues.length, 0);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy passes a natural short formal first-contact email", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailSubject: "Kurz zur Kontaktseite",
|
||||
emailBody:
|
||||
"Guten Tag, Ihre Telefonnummer ist auf der Kontaktseite gut auffindbar. Auf dem Handy rutscht der direkte Kontaktweg aber recht weit nach unten, sodass Besucher erst suchen müssen, bevor sie anrufen oder schreiben können. Ich wollte Ihnen das kurz zurückmelden, weil es ein kleiner Hebel für mehr Anfragen sein kann. Es geht nicht um einen großen Umbau. Soll ich Ihnen die Stelle kurz als Screenshot schicken?",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, true);
|
||||
assert.deepEqual(result.issues, []);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy rejects likely non-German copy and reports language", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
@@ -75,11 +87,13 @@ test("validateCustomerFacingCopy flags short English artifact-like snippets in c
|
||||
}
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy requires Ich-form in applicable customer-facing fields", () => {
|
||||
test("validateCustomerFacingCopy requires Ich-form in applicable public audit and follow-up fields", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
auditBody:
|
||||
"Ihre Seite hat eine gute Struktur. Der Kontaktbereich sollte klarer werden.",
|
||||
emailBody:
|
||||
"Guten Tag, Ihre Kontaktseite ist schon klar aufgebaut. Auf dem Handy liegt der direkte Kontaktweg aber recht weit unten, sodass Besucher erst suchen müssen, bevor sie anrufen oder schreiben können. Das ist vermutlich schnell zu beheben und würde den Einstieg einfacher machen. Soll ich Ihnen die konkrete Stelle kurz schicken?",
|
||||
followUp: "Die Website sollte verbessert werden. Setzt bitte einen Kontaktbutton.",
|
||||
});
|
||||
|
||||
@@ -187,10 +201,28 @@ test("validateCustomerFacingCopy strips technical artifacts like model ids and r
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy enforces observation + suggestion style", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
auditBody:
|
||||
"Ihre Website wirkt freundlich und klar.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "auditBody" &&
|
||||
issue.rule === "missing_observation_or_suggestion",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy blocks formulaic observed-and-suggested email copy", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailBody:
|
||||
"Deine Website ist großartig, tolle Arbeit.",
|
||||
"Guten Tag, ich habe beobachtet, dass Ihre Website klare Kontaktinformationen bietet. Ich schlage vor, diese Sichtbarkeit auf allen Seiten beizubehalten. Ich habe beobachtet, dass außerdem die Ladezeiten verbesserungswürdig sind. Ich schlage vor, technische Maßnahmen umzusetzen, damit die Nutzererfahrung nachhaltig verbessert wird. Soll ich Ihnen dazu mehr Informationen senden?",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
@@ -198,13 +230,50 @@ test("validateCustomerFacingCopy enforces observation + suggestion style", () =>
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "emailBody" &&
|
||||
issue.rule === "missing_observation_or_suggestion",
|
||||
issue.rule === "formulaic_email_tone",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy accepts live audit copy with noun suggestion and collaborative close", () => {
|
||||
test("validateCustomerFacingCopy blocks long mini-audit outreach emails", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailBody:
|
||||
"Guten Tag, auf Ihrer Website sind Adresse und Telefonnummer gut sichtbar. Außerdem fehlt eine aussagekräftige Meta-Beschreibung. Zudem sind die Ladezeiten auf mobilen Geräten verbesserungswürdig. Ein weiterer Punkt ist die Nutzerführung mit Call-to-Action-Elementen. Schließlich könnten lokale Vertrauenssignale wie Bewertungen ergänzt werden. Ich empfehle, diese Maßnahmen umzusetzen, um die Conversion-Rate zu steigern, Absprungraten zu senken und Ihr Ranking positiv zu beeinflussen. Soll ich Ihnen eine ausführliche Analyse schicken?",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "emailBody" &&
|
||||
(issue.rule === "email_reads_like_mini_audit" ||
|
||||
issue.rule === "brochure_email_language"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy blocks inflated outreach subject lines", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
...validPayload,
|
||||
emailSubject:
|
||||
"Optimierungspotenziale für Ihre Website: Mehr Sichtbarkeit und bessere Nutzererfahrung",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
issue.field === "emailSubject" &&
|
||||
issue.rule === "unnatural_email_subject",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy blocks old live audit copy that reads like generated outreach", () => {
|
||||
const result = validateCustomerFacingCopy({
|
||||
auditSummary:
|
||||
"Ich habe beobachtet, dass die Website von Diehl & Pape Rechtsanwälte zwar durch ihre klare Spezialisierung und umfassenden Kontaktinformationen überzeugt, jedoch durch langsame Ladezeiten und sichtbare Inhaltsverschiebungen beim Laden an Nutzerkomfort verliert. Ich schlage vor, gezielt die Ladegeschwindigkeit zu optimieren und das Seitenlayout stabil zu gestalten, um das Vertrauen potenzieller Mandanten zu stärken und die Nutzerbindung nachhaltig zu erhöhen.",
|
||||
@@ -228,8 +297,17 @@ test("validateCustomerFacingCopy accepts live audit copy with noun suggestion an
|
||||
"Ich habe beobachtet, dass die Website von Diehl & Pape Rechtsanwälte durch langsame Ladezeiten an Nutzerkomfort verliert. Mein konkreter Vorschlag ist, die Ladegeschwindigkeit gezielt zu optimieren und die Stabilität des Seitenlayouts sicherzustellen.",
|
||||
});
|
||||
|
||||
assert.equal(result.passed, true);
|
||||
assert.deepEqual(result.issues, []);
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(
|
||||
result.issues.some(
|
||||
(issue) =>
|
||||
(issue.field === "emailSubject" &&
|
||||
issue.rule === "unnatural_email_subject") ||
|
||||
(issue.field === "emailBody" &&
|
||||
issue.rule === "formulaic_email_tone"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCustomerFacingCopy is permissive for phone numbers and date values", () => {
|
||||
@@ -238,9 +316,9 @@ test("validateCustomerFacingCopy is permissive for phone numbers and date values
|
||||
"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",
|
||||
emailSubject: "Kurz zum 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.",
|
||||
"Guten Tag, auf Ihrer Seite ist der Termin am 12. Oktober schon erwähnt. Auf dem Handy steht diese Info aber recht weit unten, sodass Besucher sie leicht übersehen können, wenn sie nur schnell nach Öffnungszeiten oder Kontakt suchen. Ich wollte Ihnen das kurz zurückmelden, weil die Stelle ohne großen Umbau klarer werden kann. Mehr als eine kleine Umplatzierung braucht es vermutlich nicht. Soll ich Ihnen den Ausschnitt kurz schicken?",
|
||||
callScript: {
|
||||
openingLine:
|
||||
"Hallo, ich bin Matthias und ich habe eure Seite geprüft.",
|
||||
|
||||
@@ -10,7 +10,7 @@ const leadsReviewPath = join(
|
||||
"leads-review-table.tsx",
|
||||
);
|
||||
|
||||
test("LeadsReviewTable uses compact card summaries with expandable review details", async () => {
|
||||
test("LeadsReviewTable uses compact card summaries with modal review details", async () => {
|
||||
const source = await readFile(leadsReviewPath, "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /<table\b/i);
|
||||
@@ -21,22 +21,22 @@ test("LeadsReviewTable uses compact card summaries with expandable review detail
|
||||
assert.doesNotMatch(source, /<th\b/i);
|
||||
assert.doesNotMatch(source, /min-w-\[/i);
|
||||
|
||||
assert.match(source, /Dialog/);
|
||||
assert.match(source, /DialogContent/);
|
||||
assert.match(source, /DialogHeader/);
|
||||
assert.match(source, /DialogTitle/);
|
||||
assert.match(source, /DialogDescription/);
|
||||
assert.match(source, /max-h-\[calc\(100dvh-2rem\)\]/);
|
||||
assert.match(source, /overflow-y-auto/);
|
||||
assert.match(source, /Mehr anzeigen/);
|
||||
assert.match(source, /Weniger anzeigen/);
|
||||
assert.match(source, /aria-expanded=\{[^}]+\}/);
|
||||
assert.match(source, /aria-controls=\{[^}]+\}/);
|
||||
assert.match(source, /id=\{[^}]+\}/);
|
||||
assert.match(
|
||||
source,
|
||||
/aria-expanded=\{[^}]+\}[\s\S]{0,160}aria-controls=\{[^}]+\}[\s\S]{0,160}(Mehr anzeigen|Weniger anzeigen)/i,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/hidden=\{!?isExpanded\}/,
|
||||
);
|
||||
assert.doesNotMatch(source, /Weniger anzeigen/);
|
||||
assert.doesNotMatch(source, /aria-expanded=\{[^}]+\}/);
|
||||
assert.doesNotMatch(source, /aria-controls=\{[^}]+\}/);
|
||||
assert.doesNotMatch(source, /hidden=\{!?isExpanded\}/);
|
||||
|
||||
const companyNameMatch = source.match(
|
||||
/<p className="([^"]+)">\s*\{lead\.companyName\}\s*<\/p>/,
|
||||
/<p className="([^"]+)"[^>]*>\s*\{lead\.companyName\}\s*<\/p>/,
|
||||
);
|
||||
assert.ok(
|
||||
companyNameMatch !== null &&
|
||||
@@ -110,3 +110,15 @@ test("LeadsReviewTable uses compact card summaries with expandable review detail
|
||||
assert.match(source, /Sperren/);
|
||||
assert.match(source, /Speichern/);
|
||||
});
|
||||
|
||||
test("LeadsReviewTable exposes count filters and live status feedback", async () => {
|
||||
const source = await readFile(leadsReviewPath, "utf8");
|
||||
|
||||
assert.match(source, /leadStatusFilters/);
|
||||
assert.match(source, /setActiveFilter/);
|
||||
assert.match(source, /Alle Leads/);
|
||||
assert.match(source, /Hohe Priorit(?:aet|ät)/);
|
||||
assert.match(source, /Gesperrt/);
|
||||
assert.match(source, /role="status"/);
|
||||
assert.match(source, /role="alert"/);
|
||||
});
|
||||
|
||||
@@ -83,6 +83,21 @@ test("OutreachReviewWorkspace uses the review workspace API and required control
|
||||
].forEach((label) => assert.match(source, new RegExp(label)));
|
||||
});
|
||||
|
||||
test("OutreachReviewWorkspace renders a compact queue with one selected detail editor", async () => {
|
||||
const source = await readFile(outreachWorkspacePath, "utf8");
|
||||
|
||||
assert.match(source, /selectedRecordId/);
|
||||
assert.match(source, /selectedRecord/);
|
||||
assert.match(source, /Details prüfen/);
|
||||
assert.match(source, /Review-Queue/);
|
||||
assert.match(source, /reviewStatusFilters/);
|
||||
assert.match(source, /setActiveFilter/);
|
||||
assert.match(source, /Bereit zum Versand/);
|
||||
assert.match(source, /Mail offen/);
|
||||
assert.match(source, /role="status"/);
|
||||
assert.match(source, /aria-pressed=\{selectedRecord\?\.id === record\.id\}/);
|
||||
});
|
||||
|
||||
test("OutreachReviewWorkspace keeps exactly one recommended email subject and body editor", async () => {
|
||||
const source = await readFile(outreachWorkspacePath, "utf8");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user