mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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[]
|
idempotencyKeys IdempotencyKey[]
|
||||||
devices Device[]
|
devices Device[]
|
||||||
integrationStatuses IntegrationStatus[]
|
integrationStatuses IntegrationStatus[]
|
||||||
|
coachConversations CoachConversation[]
|
||||||
|
coachUsage CoachUsage[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -849,3 +851,86 @@ model RecommendationFeedback {
|
|||||||
@@index([recommendationSeverity, helpful, createdAt])
|
@@index([recommendationSeverity, helpful, createdAt])
|
||||||
@@map("recommendation_feedback")
|
@@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",
|
"app_settings",
|
||||||
"audit_logs",
|
"audit_logs",
|
||||||
"auth_challenges",
|
"auth_challenges",
|
||||||
|
"coach_conversations",
|
||||||
|
"coach_messages",
|
||||||
|
"coach_usage",
|
||||||
"data_backups",
|
"data_backups",
|
||||||
"devices",
|
"devices",
|
||||||
"feedback",
|
"feedback",
|
||||||
|
|||||||
Reference in new issue
Block a user