feat(admin): AI quality preview showing per-(severity x provider) helpful-rate

v1.4.16 phase B5e: ship the `/admin/ai-quality` route that surfaces
the daily aggregator's helpful-rate per (severity x metricSourceType
x provider x prompt-version) bucket. Renders as a compact 7-column
table with severity tinted by clinical-importance colour and the
helpful-rate column tinted green/yellow/orange by 0.8/0.5
thresholds.

New endpoint `GET /api/admin/ai-quality` (requireAdmin-gated) returns
the raw `AppSettings.adminAiInsightsFeedbackSummary` blob so the
v1.4.17 ratchet that consumes the same shape for prompt-tuning can
read what the worker writes without an envelope-reshape step.

Empty state ("no summary yet" / "buckets[] is empty") rendered as a
quiet message rather than a fake zero row so the operator can tell
"no feedback" apart from "all-zeros". Loading + error banners follow
the existing host-metrics pattern.

Section wiring:
  - new `ai-quality` slug in ADMIN_SECTION_SLUGS (between
    integrations and feedback so the IA flows General → Integrations
    → AI Quality → Feedback Inbox)
  - new Sparkles icon entry in admin-shell
  - new switch case in [section]/renderer.tsx
  - EN+DE i18n keys: admin.section.ai-quality.{title,subtitle} +
    admin.aiQuality.{title,loading,loadError,empty,windowLabel,
    generatedAtLabel,col*}

Tests:
  - 5 new ai-quality-section tests (loading, empty-null, empty-buckets,
    table-render with multiple buckets, error)
  - 1 admin-sections smoke test ensures the empty branch renders +
    1 slug-list parity test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 01:44:47 +02:00
1 parent 5117266756
commit 887928211b
9 files changed
+437

No files matched your search

@@ -0,0 +1,123 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.16 phase B5e — coverage for the admin AI quality preview.
*
* Three branches:
* 1. loading state (Loader2 spinner + label)
* 2. empty state (no summary OR summary with empty buckets)
* 3. table-render (one row per bucket with the helpful-rate column)
*
* The fetch is replaced via a hoisted vi.fn so each test can stage a
* different response; the component reads through TanStack Query but
* we mock the hook to bypass the suspend-and-fetch dance and feed the
* data directly.
*/
const mockQueryState = vi.hoisted(() => ({
data: null as null | object,
isLoading: false,
isError: false,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: mockQueryState.data,
isLoading: mockQueryState.isLoading,
isError: mockQueryState.isError,
}),
}));
vi.mock("@/lib/i18n/context", async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return actual;
});
import { AiQualitySection } from "../ai-quality-section";
function render(node: React.ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<AiQualitySection>", () => {
it("renders the loading state when query.isLoading", () => {
mockQueryState.data = null;
mockQueryState.isLoading = true;
mockQueryState.isError = false;
const html = render(<AiQualitySection />);
expect(html).toContain("Loading feedback summary");
});
it("renders the empty state when summary is null", () => {
mockQueryState.data = null;
mockQueryState.isLoading = false;
mockQueryState.isError = false;
const html = render(<AiQualitySection />);
expect(html).toContain("AI Quality");
expect(html).toContain("No feedback yet");
});
it("renders the empty state when buckets[] is empty", () => {
mockQueryState.data = {
generatedAt: "2026-05-09T04:00:00.000Z",
windowDays: 30,
buckets: [],
};
mockQueryState.isLoading = false;
mockQueryState.isError = false;
const html = render(<AiQualitySection />);
expect(html).toContain("No feedback yet");
});
it("renders one row per bucket with helpful-rate %", () => {
mockQueryState.data = {
generatedAt: "2026-05-09T04:00:00.000Z",
windowDays: 30,
buckets: [
{
severity: "important",
metricSourceType: "bloodPressure",
providerType: "codex",
promptVersion: "4.16.0",
helpful: 8,
notHelpful: 2,
total: 10,
helpfulRate: 0.8,
},
{
severity: "info",
metricSourceType: "weight",
providerType: "openai",
promptVersion: "4.16.0",
helpful: 1,
notHelpful: 4,
total: 5,
helpfulRate: 0.2,
},
],
};
mockQueryState.isLoading = false;
mockQueryState.isError = false;
const html = render(<AiQualitySection />);
expect(html).toContain("important");
expect(html).toContain("bloodPressure");
expect(html).toContain("codex");
expect(html).toContain("80%");
expect(html).toContain("20%");
// Two `data-slot="ai-quality-bucket"` rows.
const matches = html.match(/data-slot="ai-quality-bucket"/g);
expect(matches?.length).toBe(2);
});
it("renders a load-error banner when query.isError", () => {
mockQueryState.data = null;
mockQueryState.isLoading = false;
mockQueryState.isError = true;
const html = render(<AiQualitySection />);
expect(html).toContain("Failed to load AI quality summary");
});
});
@@ -42,6 +42,7 @@ vi.mock("@/hooks/use-auth", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { ADMIN_SECTION_SLUGS } from "../section-slugs";
import { AiQualitySection } from "../ai-quality-section";
import { ApiTokenOverviewSection } from "../api-token-overview-section";
import { BackupsSection } from "../backups-section";
import { DangerZoneSection } from "../danger-zone-section";
@@ -133,4 +134,18 @@ describe("admin sections — SSR smoke", () => {
const html = render(<DangerZoneSection />);
expect(html).toContain("Danger Zone");
});
// v1.4.16 phase B5e — admin AI quality preview. The mocked useQuery
// returns `data: null, isLoading: false` so the empty-state branch
// paints; the table-render branch is exercised by the dedicated
// ai-quality-section test.
it("<AiQualitySection> renders the empty-state when no summary exists", () => {
const html = render(<AiQualitySection />);
expect(html).toContain("AI Quality");
expect(html).toContain("No feedback yet");
});
it("admin slug list contains ai-quality (renderer parity)", () => {
expect(ADMIN_SECTION_SLUGS).toContain("ai-quality");
});
});
+6
View File
@@ -28,6 +28,7 @@ import {
Server,
Settings,
ShieldAlert,
Sparkles,
Users,
type LucideIcon,
} from "lucide-react";
@@ -69,6 +70,11 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
titleKey: "admin.section.integrations.title",
icon: AlertTriangle,
},
{
slug: "ai-quality",
titleKey: "admin.section.ai-quality.title",
icon: Sparkles,
},
{
slug: "feedback",
titleKey: "admin.section.feedback.title",
+202
View File
@@ -0,0 +1,202 @@
"use client";
/**
* `<AiQualitySection>` — admin AI quality preview (v1.4.16 phase B5e).
*
* Surfaces the aggregator-written helpful-rate per (severity x
* provider x prompt-version) bucket. The pg-boss `feedback-aggregator`
* queue writes the underlying summary daily at 04:00 Europe/Berlin
* to `AppSettings.adminAiInsightsFeedbackSummary`; this component
* reads it via `GET /api/admin/ai-quality` (admin-gated).
*
* Empty-state ("no summary yet") rendered as a quiet message rather
* than a fake zero row so the operator can tell "no feedback" apart
* from "all-zeros".
*
* Layout: a compact table with one row per bucket. Mobile (<sm) keeps
* the table — the columns are short enough that horizontal-scroll
* isn't punishing, and the simpler card-list fallback obscures the
* provider comparison that's the whole point of the view.
*/
import { useQuery } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
interface FeedbackBucket {
severity: string;
metricSourceType: string;
providerType: string;
promptVersion: string;
helpful: number;
notHelpful: number;
total: number;
helpfulRate: number;
}
interface FeedbackSummary {
generatedAt: string;
windowDays: number;
buckets: FeedbackBucket[];
}
interface AiQualityResponse {
data: { summary: FeedbackSummary | null } | null;
error?: string | null;
}
const SEVERITY_TINT: Record<string, string> = {
info: "text-dracula-cyan",
suggestion: "text-dracula-purple",
important: "text-dracula-orange",
urgent: "text-dracula-red",
};
function helpfulRateColour(rate: number): string {
if (rate >= 0.8) return "text-dracula-green";
if (rate >= 0.5) return "text-dracula-yellow";
return "text-dracula-orange";
}
export function AiQualitySection() {
const { t } = useTranslations();
const fmt = useFormatters();
const query = useQuery({
queryKey: ["admin", "ai-quality"],
queryFn: async () => {
const res = await fetch("/api/admin/ai-quality");
const json = (await res.json()) as AiQualityResponse;
if (!res.ok) throw new Error(json.error ?? "ai_quality_failed");
return json.data?.summary ?? null;
},
});
if (query.isLoading) {
return (
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{t("admin.aiQuality.loading")}
</span>
</div>
);
}
if (query.isError) {
return (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-xl border p-4 text-sm"
>
{t("admin.aiQuality.loadError")}
</div>
);
}
const summary = query.data;
if (!summary || summary.buckets.length === 0) {
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.aiQuality.title")}
</div>
</div>
<p className="text-muted-foreground mt-3 text-sm">
{t("admin.aiQuality.empty")}
</p>
</div>
);
}
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<div className="text-lg font-semibold">
{t("admin.aiQuality.title")}
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("admin.aiQuality.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.aiQuality.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
</div>
<div className="overflow-x-auto">
<table
className="w-full text-sm"
data-slot="ai-quality-table"
>
<thead>
<tr className="text-muted-foreground border-border border-b text-left text-xs uppercase tracking-wider">
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colSeverity")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colMetric")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colProvider")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.aiQuality.colPromptVersion")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.aiQuality.colHelpful")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.aiQuality.colNotHelpful")}
</th>
<th className="py-2 font-medium tabular-nums">
{t("admin.aiQuality.colHelpfulRate")}
</th>
</tr>
</thead>
<tbody>
{summary.buckets.map((bucket, idx) => {
const ratePct = Math.round(bucket.helpfulRate * 100);
return (
<tr
key={`${bucket.severity}-${bucket.metricSourceType}-${bucket.providerType}-${bucket.promptVersion}-${idx}`}
className="border-border/40 border-b last:border-0"
data-slot="ai-quality-bucket"
>
<td
className={`py-2 pr-3 font-medium ${
SEVERITY_TINT[bucket.severity] ?? ""
}`}
>
{bucket.severity}
</td>
<td className="py-2 pr-3">{bucket.metricSourceType}</td>
<td className="py-2 pr-3">{bucket.providerType}</td>
<td className="text-muted-foreground py-2 pr-3 text-xs">
{bucket.promptVersion}
</td>
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
<td className="py-2 pr-3 tabular-nums">{bucket.notHelpful}</td>
<td
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
bucket.helpfulRate,
)}`}
>
{ratePct}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
+1
View File
@@ -14,6 +14,7 @@ export const ADMIN_SECTION_SLUGS = [
"general",
"services",
"integrations",
"ai-quality",
"feedback",
"reminders",
"users",