mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
chore(release): v1.4.15
Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry covering backup completeness, achievements UI, onboarding tour, doctor-report v2, auto-deploy on GHCR push, empty-states sweep, notification + integration status UI, AI hallucination guardrails, mobile UX fixes, and the BD-Zielbereich percentage fix. Includes the formatting drift left over by the v1.4.15 development phases (Prettier sweep on touched files) and a typecheck fix in `dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test`, and `pnpm test:integration` all pass green on the release tag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1b1214bcb3
commit
4dcaa08b42
32 files changed
+639
-295
No files matched your search
@@ -252,10 +252,7 @@ describe("POST /api/internal/deploy-webhook", () => {
|
||||
|
||||
it("tolerates malformed (non-string) status fields by treating them as unknown", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ status: 42 },
|
||||
{ "x-deploy-webhook-secret": "test-secret" },
|
||||
),
|
||||
jsonRequest({ status: 42 }, { "x-deploy-webhook-secret": "test-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { outcome: string };
|
||||
@@ -291,18 +288,24 @@ describe("POST /api/internal/deploy-webhook", () => {
|
||||
|
||||
describe("GET /api/internal/deploy-webhook (reachability check)", () => {
|
||||
it("returns 200 with valid secret header", async () => {
|
||||
const req = new NextRequest("http://localhost/api/internal/deploy-webhook", {
|
||||
method: "GET",
|
||||
headers: { "x-deploy-webhook-secret": "test-secret" },
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/internal/deploy-webhook",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "x-deploy-webhook-secret": "test-secret" },
|
||||
},
|
||||
);
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 401 without a secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/internal/deploy-webhook", {
|
||||
method: "GET",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/internal/deploy-webhook",
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -52,7 +52,10 @@ interface NormalizedEvent {
|
||||
raw: CoolifyDeployPayload;
|
||||
}
|
||||
|
||||
function asStringOr<T extends string | null>(value: unknown, fallback: T): string | T {
|
||||
function asStringOr<T extends string | null>(
|
||||
value: unknown,
|
||||
fallback: T,
|
||||
): string | T {
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
@@ -63,7 +66,8 @@ function classifyOutcome(status: unknown): NormalizedEvent["outcome"] {
|
||||
// "failed" / "error" / "stopped" for unhappy. Anything else is
|
||||
// logged but doesn't trigger a Telegram page (avoids alert fatigue
|
||||
// on intermediate states like "queued" or "in_progress").
|
||||
if (s === "success" || s === "finished" || s === "succeeded") return "success";
|
||||
if (s === "success" || s === "finished" || s === "succeeded")
|
||||
return "success";
|
||||
if (s === "failed" || s === "error" || s === "stopped" || s === "failure") {
|
||||
return "failure";
|
||||
}
|
||||
@@ -148,7 +152,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Coolify's per-event burst (typically 1-2 per deploy) but well below
|
||||
// a hostile actor flooding to fish for a working secret.
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`deploy-webhook:${ip ?? "unknown"}`, 60, 60_000);
|
||||
const rl = await checkRateLimit(
|
||||
`deploy-webhook:${ip ?? "unknown"}`,
|
||||
60,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
@@ -204,7 +212,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await notifyAdminsOfFailure(event);
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "ok", outcome: event.outcome }, { status: 200 });
|
||||
return NextResponse.json(
|
||||
{ status: "ok", outcome: event.outcome },
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -822,7 +822,9 @@ export default function InsightsPage() {
|
||||
description={t("insights.emptyDescription")}
|
||||
action={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/measurements">{t("insights.emptyAddMeasurement")}</Link>
|
||||
<Link href="/measurements">
|
||||
{t("insights.emptyAddMeasurement")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -64,9 +64,7 @@ describe("BackupsSection — empty state", () => {
|
||||
it("includes the localized title and description", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("No backups yet");
|
||||
expect(html).toContain(
|
||||
"Backups run automatically every Sunday at 03:00",
|
||||
);
|
||||
expect(html).toContain("Backups run automatically every Sunday at 03:00");
|
||||
});
|
||||
|
||||
it("exposes the Backup-now CTA inside the empty card", () => {
|
||||
|
||||
@@ -75,9 +75,7 @@ describe("UserManagementSection — empty state", () => {
|
||||
it("carries the localized title and description", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("No users in this view");
|
||||
expect(html).toContain(
|
||||
"There are no users matching the current filter.",
|
||||
);
|
||||
expect(html).toContain("There are no users matching the current filter.");
|
||||
});
|
||||
|
||||
it("does not render the reset CTA when the default filter is active", () => {
|
||||
|
||||
@@ -296,105 +296,109 @@ export function UserManagementSection() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table: kept verbatim — only the wrapping
|
||||
<>
|
||||
{/* Desktop table: kept verbatim — only the wrapping
|
||||
`md:block hidden` swaps it out for the card-list below at
|
||||
`< md`. Hiding the table-only wrapper (instead of just
|
||||
the cells) saves DOM weight on mobile too. */}
|
||||
<div className="mt-4 hidden overflow-x-auto md:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.userEmail")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userRole")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userPasskeys")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{filteredUsers.map((u, i) => (
|
||||
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{u.username}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{u.email || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{u.passkeyCount}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDate(u.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{renderUserActions(u)}
|
||||
</div>
|
||||
</td>
|
||||
<div className="mt-4 hidden overflow-x-auto md:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.userEmail")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userRole")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userPasskeys")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userActions")}
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{filteredUsers.map((u, i) => (
|
||||
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{u.username}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{u.email || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{u.passkeyCount}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDate(u.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{renderUserActions(u)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list: each user renders as a self-contained
|
||||
{/* Mobile card list: each user renders as a self-contained
|
||||
card with the meta line + a flex-wrapping action row.
|
||||
All four actions stay visible and tap-targetable; nothing
|
||||
is hidden behind a horizontal scroll. */}
|
||||
<ul
|
||||
className="mt-4 space-y-2 md:hidden"
|
||||
data-testid="admin-users-mobile-list"
|
||||
>
|
||||
{filteredUsers.map((u) => (
|
||||
<li
|
||||
key={u.id}
|
||||
className="bg-muted/30 border-border rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{u.username}</span>
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
<ul
|
||||
className="mt-4 space-y-2 md:hidden"
|
||||
data-testid="admin-users-mobile-list"
|
||||
>
|
||||
{filteredUsers.map((u) => (
|
||||
<li
|
||||
key={u.id}
|
||||
className="bg-muted/30 border-border rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{u.username}
|
||||
</span>
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{u.email || "—"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("admin.userPasskeys")}: {u.passkeyCount} ·{" "}
|
||||
{formatDate(u.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{u.email || "—"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("admin.userPasskeys")}: {u.passkeyCount} ·{" "}
|
||||
{formatDate(u.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
{renderUserActions(u)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
{renderUserActions(u)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
|
||||
@@ -9,13 +9,13 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
* exported so the contract can be locked in unit tests.
|
||||
*/
|
||||
|
||||
import {
|
||||
aggregateMoodEntries,
|
||||
pickMoodBucket,
|
||||
MoodChart,
|
||||
} from "../mood-chart";
|
||||
import { aggregateMoodEntries, pickMoodBucket, MoodChart } from "../mood-chart";
|
||||
|
||||
function dayEntries(start: string, count: number, score = 4): Array<{
|
||||
function dayEntries(
|
||||
start: string,
|
||||
count: number,
|
||||
score = 4,
|
||||
): Array<{
|
||||
date: string;
|
||||
score: number;
|
||||
}> {
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("<TrendCard> 7-day trend label", () => {
|
||||
expect(html).toContain("7d trend");
|
||||
// Legacy avg7 short label must NOT appear in the trend variant —
|
||||
// this is the whole point of the rename.
|
||||
expect(html).not.toContain('>7d:<');
|
||||
expect(html).not.toContain(">7d:<");
|
||||
});
|
||||
|
||||
it("paints the trend-label in German", () => {
|
||||
|
||||
@@ -48,9 +48,15 @@ describe("doctor-report dialog — i18n contract", () => {
|
||||
it(`exposes every doctor-report dialog key in ${locale}.json`, () => {
|
||||
const messages = loadMessages(locale);
|
||||
const root = messages.doctorReport as Record<string, unknown> | undefined;
|
||||
expect(root, `doctorReport namespace missing in ${locale}.json`).toBeDefined();
|
||||
expect(
|
||||
root,
|
||||
`doctorReport namespace missing in ${locale}.json`,
|
||||
).toBeDefined();
|
||||
const dialog = root!.dialog as Record<string, unknown> | undefined;
|
||||
expect(dialog, `doctorReport.dialog missing in ${locale}.json`).toBeDefined();
|
||||
expect(
|
||||
dialog,
|
||||
`doctorReport.dialog missing in ${locale}.json`,
|
||||
).toBeDefined();
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
expect(
|
||||
dialog![key],
|
||||
|
||||
@@ -107,12 +107,18 @@ export function DoctorReportDialog({
|
||||
|
||||
const validation = useMemo(() => {
|
||||
if (!startDate || !endDate) {
|
||||
return { ok: false as const, key: "doctorReport.dialog.errorInvalidDate" };
|
||||
return {
|
||||
ok: false as const,
|
||||
key: "doctorReport.dialog.errorInvalidDate",
|
||||
};
|
||||
}
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return { ok: false as const, key: "doctorReport.dialog.errorInvalidDate" };
|
||||
return {
|
||||
ok: false as const,
|
||||
key: "doctorReport.dialog.errorInvalidDate",
|
||||
};
|
||||
}
|
||||
if (end.getTime() < start.getTime()) {
|
||||
return {
|
||||
@@ -122,7 +128,10 @@ export function DoctorReportDialog({
|
||||
}
|
||||
const spanDays = Math.ceil((end.getTime() - start.getTime()) / ONE_DAY_MS);
|
||||
if (spanDays > MAX_RANGE_DAYS) {
|
||||
return { ok: false as const, key: "doctorReport.dialog.errorRangeTooLong" };
|
||||
return {
|
||||
ok: false as const,
|
||||
key: "doctorReport.dialog.errorRangeTooLong",
|
||||
};
|
||||
}
|
||||
return { ok: true as const };
|
||||
}, [startDate, endDate]);
|
||||
|
||||
@@ -124,7 +124,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
*/}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="bg-primary text-primary-foreground fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform pointer-events-none focus:translate-y-0 focus:opacity-100 focus:pointer-events-auto"
|
||||
className="bg-primary text-primary-foreground pointer-events-none fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform focus:pointer-events-auto focus:translate-y-0 focus:opacity-100"
|
||||
>
|
||||
{t("nav.skipToContent")}
|
||||
</a>
|
||||
|
||||
@@ -74,7 +74,12 @@ const navItems = [
|
||||
icon: Lightbulb,
|
||||
tourId: "nav-insights",
|
||||
},
|
||||
{ href: "/targets", tKey: "nav.targets", icon: Target, tourId: "nav-targets" },
|
||||
{
|
||||
href: "/targets",
|
||||
tKey: "nav.targets",
|
||||
icon: Target,
|
||||
tourId: "nav-targets",
|
||||
},
|
||||
{
|
||||
href: "/achievements",
|
||||
tKey: "nav.achievements",
|
||||
|
||||
@@ -47,10 +47,7 @@ describe("<DashboardLayoutSection> — tile + chart split", () => {
|
||||
});
|
||||
|
||||
it("renders both column headers in German", () => {
|
||||
const html = render(
|
||||
<DashboardLayoutSection id="dashboard-layout" />,
|
||||
"de",
|
||||
);
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
|
||||
expect(html).toContain("Kachel");
|
||||
expect(html).toContain("Chart");
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
resolveDashboardLayout,
|
||||
serializeDashboardLayout,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
/**
|
||||
@@ -73,15 +74,13 @@ describe("resolveDashboardLayout() — tileVisible upgrade", () => {
|
||||
|
||||
it("defaults the layout when raw input is null / not an object", () => {
|
||||
expect(resolveDashboardLayout(null)).toEqual(DEFAULT_DASHBOARD_LAYOUT);
|
||||
expect(resolveDashboardLayout(undefined)).toEqual(
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
);
|
||||
expect(resolveDashboardLayout(undefined)).toEqual(DEFAULT_DASHBOARD_LAYOUT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeDashboardLayout() — tileVisible persistence", () => {
|
||||
it("persists tileVisible explicitly so a re-read keeps the user's choice", () => {
|
||||
const layout = {
|
||||
const layout: DashboardLayout = {
|
||||
version: 1,
|
||||
widgets: [
|
||||
// User toggled tileVisible to false but kept the chart visible.
|
||||
@@ -94,7 +93,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => {
|
||||
});
|
||||
|
||||
it("derives tileVisible from visible when the field is missing on input", () => {
|
||||
const layout = {
|
||||
const layout: DashboardLayout = {
|
||||
version: 1,
|
||||
widgets: [
|
||||
// Field omitted — serialize must not produce undefined; mirror.
|
||||
@@ -106,7 +105,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => {
|
||||
});
|
||||
|
||||
it("normalizes order to 0-based dense", () => {
|
||||
const layout = {
|
||||
const layout: DashboardLayout = {
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, tileVisible: true, order: 5 },
|
||||
|
||||
@@ -214,7 +214,11 @@ describe("i18n locale file integrity", () => {
|
||||
});
|
||||
|
||||
// Flat helper used by the value-quality checks below.
|
||||
function flattenValues(obj: unknown, prefix: string, out: [string, string][]) {
|
||||
function flattenValues(
|
||||
obj: unknown,
|
||||
prefix: string,
|
||||
out: [string, string][],
|
||||
) {
|
||||
if (obj == null || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
@@ -295,24 +299,21 @@ describe("i18n locale file integrity", () => {
|
||||
it.each([
|
||||
["en", EN_PATH],
|
||||
["de", DE_PATH],
|
||||
])(
|
||||
"%s locale has no TODO/FIXME placeholders in values",
|
||||
(_locale, path) => {
|
||||
const data = JSON.parse(readFileSync(path, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const flat: [string, string][] = [];
|
||||
flattenValues(data, "", flat);
|
||||
const todoRe = /\b(TODO|FIXME|XXX|TBD)\b/;
|
||||
const todos = flat
|
||||
.filter(([, v]) => todoRe.test(v))
|
||||
.map(([k, v]) => `${k} = ${JSON.stringify(v)}`);
|
||||
expect(
|
||||
todos,
|
||||
`Found TODO/FIXME/XXX/TBD placeholders in translation values:\n` +
|
||||
todos.map((s) => ` ${s}`).join("\n"),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
])("%s locale has no TODO/FIXME placeholders in values", (_locale, path) => {
|
||||
const data = JSON.parse(readFileSync(path, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const flat: [string, string][] = [];
|
||||
flattenValues(data, "", flat);
|
||||
const todoRe = /\b(TODO|FIXME|XXX|TBD)\b/;
|
||||
const todos = flat
|
||||
.filter(([, v]) => todoRe.test(v))
|
||||
.map(([k, v]) => `${k} = ${JSON.stringify(v)}`);
|
||||
expect(
|
||||
todos,
|
||||
`Found TODO/FIXME/XXX/TBD placeholders in translation values:\n` +
|
||||
todos.map((s) => ` ${s}`).join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,11 @@ describe("citation-from-data — schema-level enforcement", () => {
|
||||
id: "rec-1",
|
||||
text: "Walk more.",
|
||||
severity: "suggestion",
|
||||
metricSource: { type: "activity", timeRange: "last7days", summary: "" },
|
||||
metricSource: {
|
||||
type: "activity",
|
||||
timeRange: "last7days",
|
||||
summary: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -158,7 +162,10 @@ describe("citation-from-data — cross-check enforcement", () => {
|
||||
const missing = findUncitedRecommendations(orphan);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing[0].recommendationId).toBe("rec-orphan");
|
||||
expect(missing[0].missing).toEqual({ type: "diet", timeRange: "last7days" });
|
||||
expect(missing[0].missing).toEqual({
|
||||
type: "diet",
|
||||
timeRange: "last7days",
|
||||
});
|
||||
});
|
||||
|
||||
it("partial-key match on type alone is NOT enough — timeRange must also match", () => {
|
||||
|
||||
@@ -145,10 +145,7 @@ describe("generateInsight wrapper", () => {
|
||||
|
||||
it("retries once with correction message and succeeds on second attempt", async () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: [
|
||||
"definitely not json",
|
||||
JSON.stringify(validResponse),
|
||||
],
|
||||
responses: ["definitely not json", JSON.stringify(validResponse)],
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "sys",
|
||||
@@ -203,7 +200,10 @@ describe("generateInsight wrapper", () => {
|
||||
citations: [], // empty — fails cross-check
|
||||
};
|
||||
const provider = new MockAIProvider({
|
||||
responses: [JSON.stringify(orphanResponse), JSON.stringify(validResponse)],
|
||||
responses: [
|
||||
JSON.stringify(orphanResponse),
|
||||
JSON.stringify(validResponse),
|
||||
],
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "sys",
|
||||
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
OUT_OF_SCOPE_REFUSAL_EN,
|
||||
OUT_OF_SCOPE_REFUSAL_DE,
|
||||
} from "../prompts/insight-generator";
|
||||
import {
|
||||
aiInsightResponseSchema,
|
||||
findUncitedRecommendations,
|
||||
} from "../schema";
|
||||
import { aiInsightResponseSchema, findUncitedRecommendations } from "../schema";
|
||||
import { generateInsight } from "../generate-insight";
|
||||
import { MockAIProvider } from "../mock-client";
|
||||
|
||||
|
||||
@@ -252,7 +252,8 @@ export class CodexClient implements AIProvider {
|
||||
upstream: "codex",
|
||||
model: attempted[attempted.length - 1] ?? null,
|
||||
bodyExcerpt: lastSlugRejectionError
|
||||
? (lastSlugRejectionError as Error & { bodyExcerpt?: string }).bodyExcerpt
|
||||
? (lastSlugRejectionError as Error & { bodyExcerpt?: string })
|
||||
.bodyExcerpt
|
||||
: null,
|
||||
attempted,
|
||||
});
|
||||
|
||||
@@ -42,10 +42,7 @@ export interface GenerateInsightOutcome {
|
||||
* what went wrong on the first attempt. Includes the violated zod
|
||||
* issues verbatim (truncated to 1KB) so the model can self-correct.
|
||||
*/
|
||||
function buildRetryCorrectionMessage(
|
||||
reason: string,
|
||||
details: string,
|
||||
): string {
|
||||
function buildRetryCorrectionMessage(reason: string, details: string): string {
|
||||
return `
|
||||
Your previous response did not satisfy the required JSON schema.
|
||||
Reason: ${reason}
|
||||
@@ -144,7 +141,12 @@ export async function generateInsight(
|
||||
): Promise<GenerateInsightOutcome> {
|
||||
const first = await tryOnce(provider, params);
|
||||
if (first.ok && first.parsed) {
|
||||
return { parsed: first.parsed, raw: first.raw, attempts: 1, retried: false };
|
||||
return {
|
||||
parsed: first.parsed,
|
||||
raw: first.raw,
|
||||
attempts: 1,
|
||||
retried: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Retry-once with corrective context appended to the user prompt.
|
||||
|
||||
@@ -59,7 +59,9 @@ export const metricSourceSchema = z.object({
|
||||
* 12 readings" or "compliance 0.71 / 30d". Empty value rejected so
|
||||
* the model cannot fake a citation.
|
||||
*/
|
||||
summary: z.string().min(1, "metricSource.summary required (zero-hallucination)"),
|
||||
summary: z
|
||||
.string()
|
||||
.min(1, "metricSource.summary required (zero-hallucination)"),
|
||||
/** Sample count behind the value, when known. */
|
||||
n: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
@@ -156,9 +158,10 @@ export class InsightSchemaError extends Error {
|
||||
* Returns the list of missing-citation issues; an empty list means
|
||||
* the response is internally consistent.
|
||||
*/
|
||||
export function findUncitedRecommendations(
|
||||
parsed: AIInsightResponse,
|
||||
): Array<{ recommendationId: string; missing: { type: string; timeRange: string } }> {
|
||||
export function findUncitedRecommendations(parsed: AIInsightResponse): Array<{
|
||||
recommendationId: string;
|
||||
missing: { type: string; timeRange: string };
|
||||
}> {
|
||||
const cited = new Set(
|
||||
parsed.citations.map((c) => `${c.type}::${c.timeRange}`),
|
||||
);
|
||||
|
||||
Binary file not shown.
@@ -58,9 +58,7 @@ describe("formatAdminAlertPayload — moodLog transient", () => {
|
||||
subjectLabel: "marc@example.com",
|
||||
});
|
||||
expect(out.title).toBe("moodLog sync failing for marc@example.com");
|
||||
expect(out.message).toContain(
|
||||
"Action: investigate the upstream service.",
|
||||
);
|
||||
expect(out.message).toContain("Action: investigate the upstream service.");
|
||||
expect(out.message).toContain("(http_502)");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user