mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
5117266756
commit
887928211b
9 files changed
+437
No files matched your search
@@ -1526,6 +1526,21 @@
|
||||
"empty": "Erfassung läuft — Daten erscheinen in 1–2 Minuten.",
|
||||
"loadError": "Host-Metriken konnten nicht geladen werden. Bitte die Seite neu laden."
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "KI-Qualität",
|
||||
"loading": "Feedback-Zusammenfassung wird geladen...",
|
||||
"loadError": "KI-Qualitätszusammenfassung konnte nicht geladen werden. Bitte die Seite neu laden.",
|
||||
"empty": "Noch kein Feedback. Der Aggregator läuft täglich um 04:00 — sobald Nutzer Empfehlungen bewerten, erscheinen hier die Hilfreich-Quoten.",
|
||||
"windowLabel": "Letzte {days} Tage",
|
||||
"generatedAtLabel": "Erstellt",
|
||||
"colSeverity": "Schweregrad",
|
||||
"colMetric": "Metrik",
|
||||
"colProvider": "Provider",
|
||||
"colPromptVersion": "Prompt",
|
||||
"colHelpful": "Hilfreich",
|
||||
"colNotHelpful": "Nicht hilfreich",
|
||||
"colHelpfulRate": "Hilfreich-Quote"
|
||||
},
|
||||
"workerStatus": "Job-Worker",
|
||||
"workerRunning": "Aktiv",
|
||||
"workerStopped": "Gestoppt",
|
||||
@@ -1762,6 +1777,10 @@
|
||||
"title": "Integrationen",
|
||||
"subtitle": "Umami, GlitchTip, Web Push und Bug-Reports konfigurieren."
|
||||
},
|
||||
"ai-quality": {
|
||||
"title": "KI-Qualität",
|
||||
"subtitle": "Hilfreich-Quote pro (Schweregrad x Provider) aus Nutzer-Feedback der letzten 30 Tage."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"subtitle": "Feedback und Bug-Reports der Nutzer sichten."
|
||||
|
||||
@@ -1526,6 +1526,21 @@
|
||||
"empty": "Sampling started — data appears in 1–2 minutes.",
|
||||
"loadError": "Failed to load host metrics. Try refreshing the page."
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "AI Quality",
|
||||
"loading": "Loading feedback summary...",
|
||||
"loadError": "Failed to load AI quality summary. Try refreshing the page.",
|
||||
"empty": "No feedback yet. The aggregator runs daily at 04:00 — once users start rating recommendations, helpful-rate bands appear here.",
|
||||
"windowLabel": "Last {days} days",
|
||||
"generatedAtLabel": "Generated",
|
||||
"colSeverity": "Severity",
|
||||
"colMetric": "Metric",
|
||||
"colProvider": "Provider",
|
||||
"colPromptVersion": "Prompt",
|
||||
"colHelpful": "Helpful",
|
||||
"colNotHelpful": "Not helpful",
|
||||
"colHelpfulRate": "Helpful rate"
|
||||
},
|
||||
"workerStatus": "Job Worker",
|
||||
"workerRunning": "Running",
|
||||
"workerStopped": "Stopped",
|
||||
@@ -1762,6 +1777,10 @@
|
||||
"title": "Integrations",
|
||||
"subtitle": "Configure Umami, GlitchTip, Web Push, and bug reports."
|
||||
},
|
||||
"ai-quality": {
|
||||
"title": "AI Quality",
|
||||
"subtitle": "Helpful-rate per (severity x provider) from user feedback over the last 30 days."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Feedback",
|
||||
"subtitle": "Triage user-submitted feedback and bug reports."
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { JSX } from "react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { AiQualitySection } from "@/components/admin/ai-quality-section";
|
||||
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
|
||||
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
|
||||
import { BackupsSection } from "@/components/admin/backups-section";
|
||||
@@ -83,6 +84,15 @@ export function AdminSectionRenderer({
|
||||
<IntegrationsGroupSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "ai-quality":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.ai-quality.title")}
|
||||
subtitle={t("admin.section.ai-quality.subtitle")}
|
||||
>
|
||||
<AiQualitySection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "feedback":
|
||||
return (
|
||||
<SectionFrame
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5e — admin AI quality preview.
|
||||
*
|
||||
* Returns the latest aggregator-written summary from
|
||||
* `AppSettings.adminAiInsightsFeedbackSummary`. The pg-boss
|
||||
* `feedback-aggregator` queue writes this row daily at 04:00
|
||||
* Europe/Berlin. requireAdmin() gates the endpoint — cross-user
|
||||
* helpful-rate slices are admin-only telemetry per research §3.
|
||||
*
|
||||
* The response payload is intentionally the raw summary blob (no
|
||||
* envelope reshaping) so the v1.4.17 ratchet that consumes it for
|
||||
* prompt-tuning can read the same shape the worker writes.
|
||||
*
|
||||
* Empty state (`null` summary) means the aggregator hasn't run yet
|
||||
* OR there's been no feedback in the rolling window. The admin UI
|
||||
* surfaces a "no data yet" empty state rather than a fake zero row
|
||||
* so the operator can tell "no feedback" apart from "all-zeros".
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { adminAiInsightsFeedbackSummary: true },
|
||||
});
|
||||
|
||||
const summary = settings?.adminAiInsightsFeedbackSummary ?? null;
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.ai_quality.read" },
|
||||
meta: {
|
||||
has_summary: summary !== null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ summary });
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const ADMIN_SECTION_SLUGS = [
|
||||
"general",
|
||||
"services",
|
||||
"integrations",
|
||||
"ai-quality",
|
||||
"feedback",
|
||||
"reminders",
|
||||
"users",
|
||||
|
||||
Reference in new issue
Block a user