feat(coach): facts management routes (list + forget)

Add the GDPR/trust surface for the durable Coach facts: GET lists the
caller's active facts decrypted, DELETE /[id] soft-deletes one, and a
bulk DELETE clears all — every verb apiHandler + requireAuth +
requireAssistantSurface("coach") + ownership-scoped, with an unknown or
cross-user id collapsing to an idempotent no-op that never leaks
existence. Registered in the OpenAPI contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 10:29:18 +02:00
1 parent 0a5ba025c8
commit 8e2e697370
6 files changed
+759

No files matched your search

+161
View File
@@ -2407,6 +2407,59 @@ paths:
"401": *a1
"422": *a2
"429": *a3
/api/insights/coach/facts:
get:
tags:
- Insights
summary: List the caller's durable Coach facts
description: v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest
first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated
(`requireAssistantSurface("coach")`). Auth via cookie or Bearer; the owner is always narrowed from the session,
never the body. Undecryptable rows are omitted rather than failing the read.
responses:
"200":
description: The caller's active facts.
content:
application/json:
schema:
$ref: "#/components/schemas/CoachFactsList"
"401": *a1
"422": *a2
"429": *a3
delete:
tags:
- Insights
summary: Forget all of the caller's Coach facts
description: "v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns
the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer."
responses:
"200":
description: All active facts cleared; the count is returned.
content:
application/json:
schema:
$ref: "#/components/schemas/CoachFactsCleared"
"401": *a1
"422": *a2
"429": *a3
/api/insights/coach/facts/{id}:
delete:
tags:
- Insights
summary: Forget one Coach fact
description: "v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is
an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account.
Coach-gated. Auth via cookie or Bearer."
responses:
"200":
description: "The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`)."
content:
application/json:
schema:
$ref: "#/components/schemas/CoachFactDeleted"
"401": *a1
"422": *a2
"429": *a3
/api/insights/chat/messages/{id}/feedback:
post:
tags:
@@ -7761,6 +7814,114 @@ components:
- data
- error
additionalProperties: false
CoachFactsList:
type: object
properties:
data:
type: object
properties:
facts:
type: array
items:
type: object
properties:
id:
type: string
category:
type: string
enum:
- preference
- condition
- goal
- constraint
- context
description: "App-side closed category: preference | condition | goal | constraint | context."
text:
type: string
description: Decrypted fact text.
confidence:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: 0..100 server-assigned extraction confidence.
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- category
- text
- confidence
- createdAt
additionalProperties: false
description: The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted.
required:
- facts
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CoachFactsCleared:
type: object
properties:
data:
type: object
properties:
cleared:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Number of active facts soft-deleted by the bulk clear.
required:
- cleared
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CoachFactDeleted:
type: object
properties:
data:
type: object
properties:
deleted:
type: boolean
description: True when a fact owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted
id (idempotent no-op).
required:
- deleted
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CoachMessageFeedbackResponse:
type: object
properties:
@@ -267,6 +267,7 @@ describe("GET /api/analytics", () => {
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ n: BigInt(0) },
] as never);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(prisma.$queryRawUnsafe as any)
.mockResolvedValueOnce([
{
@@ -0,0 +1,57 @@
/**
* DELETE /api/insights/coach/facts/[id] — soft-delete one Coach fact.
*
* v1.11.1 — the per-fact "forget this one thing" delete for the durable
* Coach facts surface.
*
* Ownership + existence privacy: the soft-delete uses `updateMany` scoped
* `where: { id, userId, deletedAt: null }` rather than `update`. A
* cross-user id, an unknown id, or an already-deleted fact all resolve to
* a `count: 0` no-op — the route returns `200 { deleted: false }` and
* never reveals whether the id exists under another account. The matching
* convention is the idempotent-delete one used elsewhere in the tree
* (a not-found delete is a successful no-op, not a 404).
*
* Coach-gated: same `requireAssistantSurface("coach")` kill-switch as the
* collection route.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
interface RouteCtx {
params: Promise<{ id: string }>;
}
export const DELETE = apiHandler(
async (_request: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const { id } = await ctx.params;
// `updateMany` (not `update`) so an unknown / cross-user / already-soft-
// deleted id is a 0-count no-op rather than a P2025 throw — the
// existence channel never leaks across accounts.
const { count } = await prisma.coachFact.updateMany({
where: { id, userId: user.id, deletedAt: null },
data: { deletedAt: new Date() },
});
const deleted = count > 0;
annotate({
action: { name: "coach.facts.deleted" },
meta: { deleted },
});
return apiSuccess({ deleted });
},
);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -0,0 +1,332 @@
/**
* v1.11.1 — `/api/insights/coach/facts` (+ `/[id]`) routes.
*
* The GDPR / "forget what you know about me" management surface for the
* durable Coach facts. Covers:
* - GET lists only ACTIVE facts (deletedAt: null), decrypted, scoped to
* the caller, highest-confidence-then-newest ordered.
* - GET skips an undecryptable row rather than 500ing the whole list.
* - bulk DELETE soft-deletes all active facts (sets deletedAt) and
* returns the count.
* - [id] DELETE soft-deletes the caller's own fact, and a cross-user /
* unknown id is a 0-count no-op that returns `{ deleted: false }`.
* - the `requireAssistantSurface("coach")` gate is present on every verb.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
coachFact: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
// v1.4.31 — `requireAssistantSurface()` gates near the top of each
// handler. Mock the module boundary so flag reads are deterministic; the
// gate-presence assertions below verify the call is actually made.
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
AssistantDisabledError: class extends Error {},
}));
// Mock the codec so the test never needs an encryption key. The "row"
// carries a tagged Uint8Array and the mock maps it back to a string;
// a sentinel buffer triggers a throw to exercise the fail-closed skip.
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: vi.fn((buf: Uint8Array) => {
const tag = Buffer.from(buf).toString("utf8");
if (tag === "__undecryptable__") {
throw new Error("unknown key id");
}
return `decrypted:${tag}`;
}),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
};
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, DELETE } from "../route";
import { DELETE as DELETE_ONE } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
displayName: null,
},
};
const callGet = GET as unknown as () => Promise<Response>;
const callDeleteAll = DELETE as unknown as () => Promise<Response>;
const callDeleteOne = DELETE_ONE as unknown as (
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>;
function bytes(tag: string): Uint8Array {
return new Uint8Array(Buffer.from(tag, "utf8"));
}
function deleteReq(): NextRequest {
return new NextRequest("http://localhost/api/insights/coach/facts/x", {
method: "DELETE",
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireAssistantSurface).mockResolvedValue(undefined as never);
});
describe("GET /api/insights/coach/facts", () => {
it("lists the caller's active facts, decrypted, scoped to the user", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([
{
id: "f1",
category: "goal",
factEncrypted: bytes("lose 5kg"),
confidence: 90,
createdAt: new Date("2026-06-01T00:00:00Z"),
},
{
id: "f2",
category: "preference",
factEncrypted: bytes("morning workouts"),
confidence: 70,
createdAt: new Date("2026-06-02T00:00:00Z"),
},
] as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
facts: Array<{
id: string;
category: string;
text: string;
confidence: number;
}>;
};
};
expect(body.data.facts).toHaveLength(2);
expect(body.data.facts[0]).toMatchObject({
id: "f1",
category: "goal",
text: "decrypted:lose 5kg",
confidence: 90,
});
expect(body.data.facts[1]?.text).toBe("decrypted:morning workouts");
// Ownership-scoped + active-only query.
const where = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0]
?.where as { userId: string; deletedAt: null };
expect(where.userId).toBe("user-1");
expect(where.deletedAt).toBeNull();
});
it("orders highest-confidence then newest", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
await callGet();
const orderBy = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0]
?.orderBy;
expect(orderBy).toEqual([
{ confidence: "desc" },
{ createdAt: "desc" },
]);
});
it("skips an undecryptable row rather than 500ing the whole list", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([
{
id: "f1",
category: "goal",
factEncrypted: bytes("__undecryptable__"),
confidence: 90,
createdAt: new Date("2026-06-01T00:00:00Z"),
},
{
id: "f2",
category: "context",
factEncrypted: bytes("works night shifts"),
confidence: 60,
createdAt: new Date("2026-06-02T00:00:00Z"),
},
] as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { facts: Array<{ id: string; text: string }> };
};
// The undecryptable row is dropped; the good one survives.
expect(body.data.facts).toHaveLength(1);
expect(body.data.facts[0]?.id).toBe("f2");
expect(body.data.facts[0]?.text).toBe("decrypted:works night shifts");
});
it("annotates coach.facts.listed with the count only", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([
{
id: "f1",
category: "goal",
factEncrypted: bytes("a"),
confidence: 50,
createdAt: new Date(),
},
] as never);
await callGet();
const call = vi
.mocked(annotate)
.mock.calls.find(
(c) =>
(c[0] as { action?: { name?: string } })?.action?.name ===
"coach.facts.listed",
);
expect(call).toBeTruthy();
expect((call![0] as { meta?: { count?: number } }).meta).toEqual({
count: 1,
});
});
it("invokes the coach assistant-surface gate", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
await callGet();
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
});
});
describe("DELETE /api/insights/coach/facts — forget all", () => {
it("soft-deletes all active facts and returns the count", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 3,
} as never);
const res = await callDeleteAll();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cleared: number } };
expect(body.data.cleared).toBe(3);
const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as {
where: { userId: string; deletedAt: null };
data: { deletedAt: Date };
};
expect(arg.where.userId).toBe("user-1");
expect(arg.where.deletedAt).toBeNull();
// Soft-delete sets a deletedAt timestamp; the row is not removed.
expect(arg.data.deletedAt).toBeInstanceOf(Date);
});
it("is idempotent — a second clear returns 0", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await callDeleteAll();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cleared: number } };
expect(body.data.cleared).toBe(0);
});
it("invokes the coach assistant-surface gate", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
await callDeleteAll();
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
});
});
describe("DELETE /api/insights/coach/facts/[id] — forget one", () => {
it("soft-deletes the caller's own fact and returns deleted:true", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 1,
} as never);
const res = await callDeleteOne(deleteReq(), {
params: Promise.resolve({ id: "f1" }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: boolean } };
expect(body.data.deleted).toBe(true);
const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as {
where: { id: string; userId: string; deletedAt: null };
data: { deletedAt: Date };
};
// Scoped by BOTH id and userId, active-only.
expect(arg.where.id).toBe("f1");
expect(arg.where.userId).toBe("user-1");
expect(arg.where.deletedAt).toBeNull();
expect(arg.data.deletedAt).toBeInstanceOf(Date);
});
it("returns deleted:false for an unknown / cross-user id (0-count no-op)", async () => {
// updateMany scoped to { id, userId } matches no row owned by the
// caller → count 0 → no other user's row is ever touched.
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await callDeleteOne(deleteReq(), {
params: Promise.resolve({ id: "someone-elses-fact" }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: boolean } };
expect(body.data.deleted).toBe(false);
// The where clause pins userId so the query can never reach a
// foreign row regardless of the id supplied.
const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as {
where: { userId: string };
};
expect(arg.where.userId).toBe("user-1");
});
it("invokes the coach assistant-surface gate", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
await callDeleteOne(deleteReq(), {
params: Promise.resolve({ id: "f1" }),
});
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* GET /api/insights/coach/facts — list the caller's ACTIVE Coach facts.
* DELETE /api/insights/coach/facts — "forget all": soft-delete every active
* fact for the caller.
*
* v1.11.1 — the GDPR / "forget what you know about me" surface for the
* durable Coach facts the assistant extracts during conversations. Facts
* are server-extracted, not user-authored, so this surface is read +
* delete only (no POST / PATCH).
*
* Ownership: every query is scoped `where: { userId, ... }`, so a caller
* can only ever see or clear their own facts. The fact text is decrypted
* on the fly; an undecryptable row (e.g. a key rotated out of the map) is
* skipped rather than 500ing the whole list — the surface stays available
* for the rows that DO decrypt.
*
* Coach-gated: a fact only exists because the Coach extracted it, so the
* management surface sits behind the same `requireAssistantSurface("coach")`
* kill-switch as the rest of the Coach stack.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const rows = await prisma.coachFact.findMany({
where: { userId: user.id, deletedAt: null },
// Highest-confidence first, then newest — mirrors the injection
// ordering so the management list reads in the same priority the
// assistant actually weights the facts.
orderBy: [{ confidence: "desc" }, { createdAt: "desc" }],
select: {
id: true,
category: true,
factEncrypted: true,
confidence: true,
createdAt: true,
},
});
const facts: Array<{
id: string;
category: string;
text: string;
confidence: number;
createdAt: Date;
}> = [];
for (const row of rows) {
let text: string;
try {
text = decryptFromBytes(row.factEncrypted);
} catch {
// Fail closed per row — never surface ciphertext, never 500 the
// whole list because one row's key id is no longer in the map.
continue;
}
facts.push({
id: row.id,
category: row.category,
text,
confidence: row.confidence,
createdAt: row.createdAt,
});
}
annotate({
action: { name: "coach.facts.listed" },
meta: { count: facts.length },
});
return apiSuccess({ facts });
});
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
// Soft-delete every active fact — keeps the rows for audit while
// hiding them from injection. `updateMany` scoped to the caller can
// never touch another user's rows.
const { count } = await prisma.coachFact.updateMany({
where: { userId: user.id, deletedAt: null },
data: { deletedAt: new Date() },
});
annotate({
action: { name: "coach.facts.cleared" },
meta: { cleared: count },
});
return apiSuccess({ cleared: count });
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
+106
View File
@@ -45,6 +45,7 @@ import {
} from "@/lib/validations/medication";
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
@@ -2010,6 +2011,49 @@ const stdResponses = {
},
};
// ── Coach facts (v1.11.1) ────────────────────────────────────────────
// Read + delete surface for the durable facts the Coach extracts. Facts
// are server-extracted, not user-authored, so there is no create/update
// shape — only list, bulk-clear, and single-delete responses.
const coachFactItem = z.object({
id: z.string(),
category: z
.enum(COACH_FACT_CATEGORIES)
.describe(
"App-side closed category: preference | condition | goal | constraint | context.",
),
text: z.string().describe("Decrypted fact text."),
confidence: z
.number()
.int()
.describe("0..100 server-assigned extraction confidence."),
createdAt: z.iso.datetime({ offset: true }),
});
const coachFactsListResponse = z.object({
facts: z
.array(coachFactItem)
.describe(
"The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted.",
),
});
const coachFactsClearedResponse = z.object({
cleared: z
.number()
.int()
.describe("Number of active facts soft-deleted by the bulk clear."),
});
const coachFactDeletedResponse = z.object({
deleted: z
.boolean()
.describe(
"True when a fact owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).",
),
});
// ── Path table ───────────────────────────────────────────────────────
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
@@ -3228,6 +3272,68 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/insights/coach/facts": {
get: {
tags: ["Insights"],
summary: "List the caller's durable Coach facts",
description:
"v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated (`requireAssistantSurface(\"coach\")`). Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.",
responses: {
"200": {
description: "The caller's active facts.",
content: {
"application/json": {
schema: dataEnvelope(coachFactsListResponse, "CoachFactsList"),
},
},
},
...stdResponses,
},
},
delete: {
tags: ["Insights"],
summary: "Forget all of the caller's Coach facts",
description:
"v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer.",
responses: {
"200": {
description: "All active facts cleared; the count is returned.",
content: {
"application/json": {
schema: dataEnvelope(
coachFactsClearedResponse,
"CoachFactsCleared",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/coach/facts/{id}": {
delete: {
tags: ["Insights"],
summary: "Forget one Coach fact",
description:
"v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.",
responses: {
"200": {
description:
"The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).",
content: {
"application/json": {
schema: dataEnvelope(
coachFactDeletedResponse,
"CoachFactDeleted",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/chat/messages/{id}/feedback": {
post: {
tags: ["Insights"],