Files
HealthLog/src/lib/ai/coach/persistence.ts
T

419 lines
14 KiB
TypeScript

/**
* Persistence helpers for AI Coach conversations.
*
* Every message body crosses this module's encrypt boundary before
* touching the database — the route layer never serialises raw text
* directly. `encrypt()` / `decrypt()` from `src/lib/crypto.ts` stamp the
* active key id into the ciphertext, so rotation works transparently.
*
* `metricSourceJson` is plain text on disk: it carries label-only
* provenance (window names, metric tags, sample counts) and never raw
* values, so it can be queried without decryption for analytics.
*/
import { prisma } from "@/lib/db";
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
import type {
CoachConversationDTO,
CoachConversationDetailDTO,
CoachMessageDTO,
CoachMessageRole,
CoachProvenance,
} from "./types";
const TITLE_MAX = 80;
/**
* Title-from-message — first 80 chars trimmed, ellipsis on overflow.
* Stays plain text (the history rail needs to render without paying
* the per-message decrypt cost), so callers should pass already-
* sanitised input.
*/
export function summariseTitle(input: string): string {
const collapsed = input.replace(/\s+/g, " ").trim();
if (collapsed.length === 0) return "New conversation";
// Spread to a code-point array so the slice respects multi-code-unit
// characters (emoji like "🩺" land as a single grapheme rather than
// a half "?"). The visible-length metric is grapheme count, not
// UTF-16 code units.
const points = [...collapsed];
if (points.length <= TITLE_MAX) return collapsed;
// Cut at TITLE_MAX-1 code points and append a single-character
// ellipsis so the visible width matches TITLE_MAX. Cuts at the word
// boundary when one is within reach of the limit.
const sliced = points.slice(0, TITLE_MAX - 1).join("");
const lastSpace = sliced.lastIndexOf(" ");
const cut = lastSpace > TITLE_MAX - 20 ? sliced.slice(0, lastSpace) : sliced;
return `${cut.trimEnd()}…`;
}
function provenanceToJson(provenance: CoachProvenance | null): string | null {
if (!provenance) return null;
return JSON.stringify(provenance);
}
function provenanceFromJson(raw: string | null): CoachProvenance | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<CoachProvenance>;
if (!parsed || typeof parsed !== "object") return null;
const windows = Array.isArray(parsed.windows)
? (parsed.windows.filter((w) => typeof w === "string") as ReadonlyArray<
CoachProvenance["windows"][number]
>)
: [];
const metrics = Array.isArray(parsed.metrics)
? (parsed.metrics.filter((m) => typeof m === "string") as ReadonlyArray<
CoachProvenance["metrics"][number]
>)
: [];
const counts =
parsed.counts && typeof parsed.counts === "object"
? (parsed.counts as CoachProvenance["counts"])
: undefined;
// v1.4.22 — keyValues are persisted alongside the existing
// windows/metrics/counts envelope so the evidence-block disclosure
// re-renders on conversation reload. Defensive shape check to
// tolerate legacy rows (no keyValues field) without throwing.
let keyValues: CoachProvenance["keyValues"];
if (Array.isArray(parsed.keyValues)) {
const cleaned: Array<{
label: string;
value: string;
unit?: string;
window?: string;
}> = [];
for (const raw of parsed.keyValues) {
if (!raw || typeof raw !== "object") continue;
const candidate = raw as {
label?: unknown;
value?: unknown;
unit?: unknown;
window?: unknown;
};
if (
typeof candidate.label !== "string" ||
typeof candidate.value !== "string"
) {
continue;
}
const entry: {
label: string;
value: string;
unit?: string;
window?: string;
} = { label: candidate.label, value: candidate.value };
if (typeof candidate.unit === "string") entry.unit = candidate.unit;
if (typeof candidate.window === "string")
entry.window = candidate.window;
cleaned.push(entry);
}
if (cleaned.length > 0) keyValues = cleaned;
}
return { windows, metrics, counts, ...(keyValues ? { keyValues } : {}) };
} catch {
return null;
}
}
export interface CreateConversationParams {
userId: string;
title: string;
/**
* v1.27.33 (Document vault P4) — when set, scopes the conversation to a stored
* document (a "chat about a document" thread). Omitted / null = a normal Coach
* conversation (health-record surface). The two never mix.
*/
documentId?: string | null;
}
export interface AppendMessageParams {
conversationId: string;
role: CoachMessageRole;
content: string;
metricSource?: CoachProvenance | null;
providerType?: string | null;
promptVersion?: string | null;
/**
* v1.18.9 — per-turn token count + model the reply was produced with,
* persisted so the quiet token footer survives a reload. Omitted on
* user turns and refusals (no token count to record).
*/
tokensUsed?: number | null;
model?: string | null;
}
/**
* Create a brand-new conversation row owned by `userId`. Caller is
* expected to immediately append the first user message.
*/
export async function createConversation(
params: CreateConversationParams,
): Promise<CoachConversationDTO> {
const row = await prisma.coachConversation.create({
data: {
userId: params.userId,
title: summariseTitle(params.title),
documentId: params.documentId ?? null,
},
});
return {
id: row.id,
title: row.title,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
messageCount: 0,
};
}
/**
* Append one message to an existing conversation. Bumps the parent
* `updatedAt` so the history rail orders by most-recent-activity.
*/
export async function appendMessage(
params: AppendMessageParams,
): Promise<CoachMessageDTO> {
const result = await prisma.$transaction(async (tx) => {
const message = await tx.coachMessage.create({
data: {
conversationId: params.conversationId,
role: params.role,
encryptedContent: encryptToBytes(params.content),
metricSourceJson: provenanceToJson(params.metricSource ?? null),
providerType: params.providerType ?? null,
promptVersion: params.promptVersion ?? null,
tokensUsed: params.tokensUsed ?? null,
model: params.model ?? null,
},
});
await tx.coachConversation.update({
where: { id: params.conversationId },
data: { updatedAt: new Date() },
});
return message;
});
return {
id: result.id,
role: result.role as CoachMessageRole,
content: params.content,
createdAt: result.createdAt.toISOString(),
metricSource: provenanceFromJson(result.metricSourceJson),
providerType: result.providerType,
promptVersion: result.promptVersion,
tokensUsed: result.tokensUsed,
model: result.model,
};
}
export interface RecordProactiveNudgeParams {
userId: string;
/** Conversation title (the nudge headline) — summarised to ≤80 chars. */
title: string;
/** The nudge body, persisted as the initial ASSISTANT message. */
body: string;
}
/**
* v1.18.6 (CCH-02) — record a proactive Coach nudge as a real
* conversation so it shows up in the conversation rail regardless of
* which push channel (if any) the user configured. The proactive cron
* used to dispatch a notification ONLY; with no push channel the nudge
* was entirely invisible.
*
* Creates a fresh conversation and writes the nudge as the initial
* ASSISTANT message in one transaction so a partial write never leaves
* an empty thread in the rail. The body crosses the same
* `encryptToBytes` boundary as every other Coach message, so the nudge
* text is encrypted at rest like a normal reply. Returns the new
* conversation + message ids for the caller's annotation.
*/
export async function recordProactiveNudge(
params: RecordProactiveNudgeParams,
): Promise<{ conversationId: string; messageId: string; createdAt: Date }> {
return prisma.$transaction(async (tx) => {
const conversation = await tx.coachConversation.create({
data: {
userId: params.userId,
title: summariseTitle(params.title),
},
});
const message = await tx.coachMessage.create({
data: {
conversationId: conversation.id,
role: "assistant",
encryptedContent: encryptToBytes(params.body),
metricSourceJson: null,
// Tags the message as a proactive nudge — the cron's frequency gate
// reads this back to cap rail conversations for no-push-channel users.
providerType: "nudge",
promptVersion: null,
},
});
// The conversation's `createdAt` == `updatedAt` on creation, so the
// rail already orders it to the top; no extra `update` needed.
return {
conversationId: conversation.id,
messageId: message.id,
createdAt: message.createdAt,
};
});
}
// Very long threads previously decrypted every message on each open; the
// newest messages up to this cap cover the rendered window without the
// unbounded per-open AES-decrypt cost. The response shape is unchanged —
// the messages array still arrives oldest->newest (see the reverse below).
const CONVERSATION_MESSAGE_DETAIL_CAP = 200;
/**
* Fetch one conversation + its messages, decrypting each body on
* read. Returns null when the conversation does not exist OR when the
* supplied `userId` does not own it — callers should map both cases to
* a 404 to avoid an existence-leak side channel.
*
* Only the newest `CONVERSATION_MESSAGE_DETAIL_CAP` messages are loaded
* and decrypted; the result stays ascending so the response envelope is
* byte-for-byte the same shape callers already consume.
*/
export async function fetchConversationWithMessages(
userId: string,
conversationId: string,
/**
* v1.27.33 (Document vault P4) — optional surface isolation. When provided,
* the fetch additionally requires `documentId` to equal this value, so a Coach
* caller (passes `null`) can never load a document chat and the document chat
* route (passes the document id) can never load a Coach thread. Omitted =
* no `documentId` filter (unchanged behaviour).
*/
opts?: { documentId?: string | null },
): Promise<CoachConversationDetailDTO | null> {
const row = await prisma.coachConversation.findFirst({
where: {
id: conversationId,
userId,
...(opts && "documentId" in opts ? { documentId: opts.documentId } : {}),
},
include: {
messages: {
// Fetch the newest N first, then restore ascending order in code
// so the unbounded per-open decrypt cost is capped without
// changing the oldest->newest contract the client renders.
orderBy: { createdAt: "desc" },
take: CONVERSATION_MESSAGE_DETAIL_CAP,
},
},
});
if (!row) return null;
const orderedMessages = [...row.messages].reverse();
const messages: CoachMessageDTO[] = orderedMessages.map((m) => ({
id: m.id,
role: m.role as CoachMessageRole,
content: decryptFromBytes(m.encryptedContent),
createdAt: m.createdAt.toISOString(),
metricSource: provenanceFromJson(m.metricSourceJson),
providerType: m.providerType,
promptVersion: m.promptVersion,
tokensUsed: m.tokensUsed,
model: m.model,
}));
// v1.11.1 — decrypt the rolling conversation summary (fail-closed: an
// undecryptable row is treated as absent so the chat turn never throws and
// simply falls back to the placeholder).
let summary: string | null = null;
if (row.summaryEncrypted && row.summaryEncrypted.byteLength > 0) {
try {
summary = decryptFromBytes(row.summaryEncrypted);
} catch {
summary = null;
}
}
return {
id: row.id,
title: row.title,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
messageCount: messages.length,
messages,
summary,
};
}
export interface ListConversationsParams {
userId: string;
cursor?: string | null;
limit?: number;
/**
* v1.27.33 (Document vault P4) — optional surface filter. The Coach rail
* passes `null` (only its own health threads); the document sheet passes the
* document id (only that document's chats). Omitted = no `documentId` filter.
*/
documentId?: string | null;
}
/**
* Cursor-paginated list of conversations for the rail. Default limit
* 20, cap 50. Cursor is the id of the last item on the previous page;
* callers receive `nextCursor: null` when they reach the end.
*/
export async function listConversations(
params: ListConversationsParams,
): Promise<{
conversations: CoachConversationDTO[];
nextCursor: string | null;
}> {
const limit = Math.min(Math.max(params.limit ?? 20, 1), 50);
const rows = await prisma.coachConversation.findMany({
where: {
userId: params.userId,
...("documentId" in params ? { documentId: params.documentId } : {}),
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: limit + 1,
...(params.cursor
? {
cursor: { id: params.cursor },
skip: 1,
}
: {}),
include: {
_count: { select: { messages: true } },
},
});
const page = rows.slice(0, limit);
const nextCursor = rows.length > limit ? page[page.length - 1].id : null;
return {
conversations: page.map((r) => ({
id: r.id,
title: r.title,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
messageCount: r._count.messages,
})),
nextCursor,
};
}
/**
* Delete a conversation and every message under it. Returns false when
* the conversation does not exist or is not owned by `userId` — the
* route should map both to 404.
*/
export async function deleteConversation(
userId: string,
conversationId: string,
): Promise<boolean> {
const row = await prisma.coachConversation.findFirst({
where: { id: conversationId, userId },
select: { id: true },
});
if (!row) return false;
await prisma.coachConversation.delete({ where: { id: row.id } });
return true;
}