mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
feat(coach): add CoachConversation, CoachMessage, CoachUsage models
Persist Coach chat threads with AES-256-GCM-encrypted message bodies and a per-user, per-day token-spend ledger. Encryption reuses the active ENCRYPTION_KEYS entry so rotation continues to work without a backfill. metric_source_json carries provenance labels only (window + metric names, never raw values), keeping it queryable for analytics. Cascade-on-user-delete is wired through the FK chain so GDPR delete removes Coach data with the rest of the account. The integration-suite truncation list picks up the new tables. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3bdb4380ad
commit
5527001a01
3 files changed
+167
No files matched your search
@@ -0,0 +1,79 @@
|
||||
-- v1.4.20 — AI Coach persistence + per-day token-spend ledger.
|
||||
--
|
||||
-- Three new tables:
|
||||
-- coach_conversations — one row per chat thread, owned by a user.
|
||||
-- coach_messages — encrypted message text + provenance metadata.
|
||||
-- coach_usage — per-(user, day) token ledger for budget gating.
|
||||
--
|
||||
-- Encryption: `coach_messages.encrypted_content` is AES-256-GCM
|
||||
-- ciphertext written via `src/lib/crypto.ts` under the active
|
||||
-- `ENCRYPTION_KEYS` entry. Key rotation works the same way as for the
|
||||
-- existing encrypted columns (Withings tokens, Telegram bot tokens,
|
||||
-- moodLog API keys). `metric_source_json` is intentionally plain text
|
||||
-- — it stores window + metric labels only ("last30days", "n=12"),
|
||||
-- never raw values, so analytics queries can slice it without a key.
|
||||
--
|
||||
-- GDPR: every table cascades on user delete via FK chains, covered
|
||||
-- end-to-end in tests/integration/cascade-delete.test.ts.
|
||||
|
||||
CREATE TABLE "coach_conversations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "coach_conversations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "coach_conversations_user_id_updated_at_idx"
|
||||
ON "coach_conversations" ("user_id", "updated_at");
|
||||
|
||||
ALTER TABLE "coach_conversations"
|
||||
ADD CONSTRAINT "coach_conversations_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "coach_messages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"conversation_id" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"encrypted_content" BYTEA NOT NULL,
|
||||
"metric_source_json" TEXT,
|
||||
"provider_type" TEXT,
|
||||
"prompt_version" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "coach_messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "coach_messages_conversation_id_created_at_idx"
|
||||
ON "coach_messages" ("conversation_id", "created_at");
|
||||
|
||||
ALTER TABLE "coach_messages"
|
||||
ADD CONSTRAINT "coach_messages_conversation_id_fkey"
|
||||
FOREIGN KEY ("conversation_id") REFERENCES "coach_conversations"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "coach_usage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"date_key" TEXT NOT NULL,
|
||||
"total_tokens" INTEGER NOT NULL DEFAULT 0,
|
||||
"message_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "coach_usage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "coach_usage_user_id_date_key_key"
|
||||
ON "coach_usage" ("user_id", "date_key");
|
||||
|
||||
CREATE INDEX "coach_usage_user_id_date_key_idx"
|
||||
ON "coach_usage" ("user_id", "date_key");
|
||||
|
||||
ALTER TABLE "coach_usage"
|
||||
ADD CONSTRAINT "coach_usage_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -133,6 +133,8 @@ model User {
|
||||
idempotencyKeys IdempotencyKey[]
|
||||
devices Device[]
|
||||
integrationStatuses IntegrationStatus[]
|
||||
coachConversations CoachConversation[]
|
||||
coachUsage CoachUsage[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -849,3 +851,86 @@ model RecommendationFeedback {
|
||||
@@index([recommendationSeverity, helpful, createdAt])
|
||||
@@map("recommendation_feedback")
|
||||
}
|
||||
|
||||
// ─── AI Coach (v1.4.20) ────────────────────────────────────
|
||||
//
|
||||
// Conversation thread between a user and the AI Coach. Messages are
|
||||
// stored encrypted at rest via AES-256-GCM (`src/lib/crypto.ts`) under
|
||||
// the active `ENCRYPTION_KEYS` entry — the Coach inherits the same
|
||||
// key-rotation tooling as the rest of the app. GDPR cascade through
|
||||
// User keeps the model deletion-clean.
|
||||
//
|
||||
// `title` is a short snippet of the first user message (≤80 chars,
|
||||
// non-encrypted) so the history rail can render without paying the
|
||||
// per-message decrypt cost. The snippet is plain text; do not put PII
|
||||
// the user explicitly opted out of into it. The summarisation helper
|
||||
// in `src/lib/ai/coach/persistence.ts` truncates with an ellipsis.
|
||||
model CoachConversation {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
title String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
messages CoachMessage[]
|
||||
|
||||
@@index([userId, updatedAt])
|
||||
@@map("coach_conversations")
|
||||
}
|
||||
|
||||
// One message in a Coach conversation. `role` is "user" or "assistant"
|
||||
// — same shape as the OpenAI/Anthropic chat-completion APIs.
|
||||
//
|
||||
// `encryptedContent` holds the AES-256-GCM ciphertext of the message
|
||||
// body. The encrypt/decrypt helpers stamp the active key id into the
|
||||
// ciphertext so rotation works without a backfill.
|
||||
//
|
||||
// `metricSourceJson` is the provenance envelope ("based on 14d HRV +
|
||||
// 6 BP readings") attached to assistant messages — labels only, NEVER
|
||||
// raw values. The frontend renders source-chip rows from this field.
|
||||
// It is intentionally NOT encrypted: the labels are window+metric
|
||||
// names ("last30days", "bloodPressure", "n=12") and carry no PII; the
|
||||
// stable shape lets future analytics queries slice without a key.
|
||||
//
|
||||
// `providerType` and `promptVersion` mirror the recommendation-
|
||||
// feedback attribution columns so the cross-feature aggregator can
|
||||
// slice helpful-rate per (provider × prompt) for Coach replies too.
|
||||
model CoachMessage {
|
||||
id String @id @default(cuid())
|
||||
conversationId String @map("conversation_id")
|
||||
role String // "user" | "assistant"
|
||||
encryptedContent Bytes @map("encrypted_content")
|
||||
metricSourceJson String? @map("metric_source_json")
|
||||
providerType String? @map("provider_type")
|
||||
promptVersion String? @map("prompt_version")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([conversationId, createdAt])
|
||||
@@map("coach_messages")
|
||||
}
|
||||
|
||||
// Per-user, per-day token-spend ledger for the AI Coach. The dispatcher
|
||||
// reads `totalTokens` against `MAX_TOKENS_PER_USER_PER_DAY` before
|
||||
// hitting any provider, so a runaway client cannot drain the operator's
|
||||
// LLM budget. `dateKey` is the UTC `YYYY-MM-DD` bucket; the unique
|
||||
// constraint enforces one row per (user, day). `messageCount` is a
|
||||
// secondary signal used by the frontend to render a "≈ N replies left"
|
||||
// hint without exposing raw token figures.
|
||||
model CoachUsage {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
dateKey String @map("date_key")
|
||||
totalTokens Int @default(0) @map("total_tokens")
|
||||
messageCount Int @default(0) @map("message_count")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, dateKey])
|
||||
@@index([userId, dateKey])
|
||||
@@map("coach_usage")
|
||||
}
|
||||
@@ -40,6 +40,9 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
|
||||
"app_settings",
|
||||
"audit_logs",
|
||||
"auth_challenges",
|
||||
"coach_conversations",
|
||||
"coach_messages",
|
||||
"coach_usage",
|
||||
"data_backups",
|
||||
"devices",
|
||||
"feedback",
|
||||
|
||||
Reference in new issue
Block a user