Improve audit pipeline and outreach review

This commit is contained in:
2026-06-08 22:16:32 +02:00
parent ff18fc202e
commit 1695110e0a
34 changed files with 2792 additions and 238 deletions

View File

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

View File

@@ -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,12 +85,10 @@ 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">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton className="h-20 rounded-md" key={index} />
))}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton className="h-40 rounded-lg" key={index} />
))}
</div>
</section>
);
@@ -87,6 +96,7 @@ function AuditsBoardLoading() {
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">
<h2 className="text-sm font-medium">Noch keine Audits</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sobald neue Audits oder laufende Audit-Generierungen angelegt
wurden, erscheinen sie hier als kompakte Zeilen.
</p>
</article>
<Card>
<CardHeader>
<h2 className="text-sm font-medium">Noch keine Audits</h2>
<CardDescription>
Sobald neue Audits oder laufende Audit-Generierungen angelegt
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>
<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}
>
<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}
</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"
>
{row.kind === "audit"
? getStatusLabel(row.status)
: getGenerationStatusLabel(row)}
</Badge>
<p className="text-muted-foreground">
<span className="inline-flex items-center gap-1">
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardDescription>
{row.kind === "audit" ? "Audit" : "Pipeline"}
</CardDescription>
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
{row.title}
</CardTitle>
</div>
<Badge variant={row.kind === "audit" ? "secondary" : "outline"}>
{row.kind === "audit"
? getStatusLabel(row.status)
: getGenerationStatusLabel(row)}
</Badge>
</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" aria-hidden="true" />
{formatPageCount(row.pageCount)}
</>
) : (
<>
<Activity className="size-3.5" aria-hidden="true" />
{getStageLabel(row.latestStage)}
</>
)}
</p>
</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" ? (
<>
<Files className="size-3.5" />
{formatPageCount(row.pageCount)}
</>
<Link
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" aria-hidden="true" />
Öffnen
</Link>
) : (
<>
<Activity className="size-3.5" />
{getStageLabel(row.latestStage)}
</>
<span className="inline-flex min-h-8 items-center text-sm text-muted-foreground">
Pipeline läuft
</span>
)}
</span>
{row.kind === "generation" && row.errorSummary ? (
<span className="mt-1 block truncate text-xs">
{row.errorSummary}
</span>
) : null}
</p>
<div className="flex justify-end">
{row.kind === "audit" ? (
<Link
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
href={row.detailHref}
>
<SquarePen className="size-4" />
Öffnen
</Link>
) : (
<span className="text-sm text-muted-foreground">Pipeline</span>
)}
</div>
</article>
))}
</div>
</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>
);