feat(coach): scaffold conversation-summary + facts storage

Extract the AES-256-GCM Bytes codec from persistence into a shared
bytes-codec module so the conversation summary and the new personal-fact
store reuse one ArrayBuffer-backed implementation. Add the CoachFact
table (migration 0116) for durable, user-scoped facts the Coach extracts
from conversations — encrypted fact text, plain category/confidence/
source for rank-without-decrypt, soft-delete for a reversible forget.

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:11:19 +02:00
1 parent 7c494b60cb
commit 01d3afcdaf
4 files changed
+92 -24

No files matched your search

@@ -0,0 +1,27 @@
-- v1.11.1 — durable Coach personal facts (Epic B, B-W7).
--
-- Additive, multi-tenant-safe: one new table + index + FK, no column on an
-- existing hot table, no backfill. The fact text is encrypted at rest
-- (AES-256-GCM, same codec as coach_messages.encrypted_content); category /
-- confidence / source stay plain so the injection picker can rank without a
-- per-row decrypt. Soft-delete via deleted_at keeps "forget this" auditable.
CREATE TABLE "coach_facts" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"fact_encrypted" BYTEA NOT NULL,
"category" TEXT NOT NULL,
"confidence" INTEGER NOT NULL DEFAULT 50,
"source_conversation_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "coach_facts_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "coach_facts_user_id_deleted_at_confidence_idx"
ON "coach_facts" ("user_id", "deleted_at", "confidence");
ALTER TABLE "coach_facts"
ADD CONSTRAINT "coach_facts_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
+32
View File
@@ -351,6 +351,7 @@ model User {
devices Device[]
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachUsage CoachUsage[]
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
@@ -2687,6 +2688,37 @@ model CoachMessage {
@@map("coach_messages")
}
// v1.11.1 (Epic B, B-W7) — durable, user-scoped personal facts the Coach
// extracts from conversations: stable preferences, conditions the user states
// about themselves, goals, constraints, durable life context. The fact TEXT is
// encrypted at rest (AES-256-GCM, the same codec as CoachMessage). `category`,
// `confidence`, and `sourceConversationId` stay plain so the injection picker
// can rank without paying a per-row decrypt. Descriptive, never diagnostic.
// Soft-delete via `deletedAt` so a user "forget this" action is reversible /
// auditable and re-extraction can avoid resurrecting it.
model CoachFact {
id String @id @default(cuid())
userId String @map("user_id")
factEncrypted Bytes @map("fact_encrypted")
// App-side closed enum (not a DB enum, to keep the migration purely additive
// and avoid an enum-migration per new category): preference | condition |
// goal | constraint | context.
category String
// 0..100 server-assigned extraction confidence.
confidence Int @default(50)
// Provenance — nullable so a future manual-entry surface can leave it null.
sourceConversationId String? @map("source_conversation_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Soft-delete: hides the fact from injection but keeps the row for audit.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, deletedAt, confidence])
@@map("coach_facts")
}
// 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
+32
View File
@@ -0,0 +1,32 @@
/**
* Shared AES-256-GCM ↔ `Bytes` codec for the Coach's encrypted columns.
*
* `coach_messages.encrypted_content`, `coach_conversations.summary_encrypted`,
* and `coach_facts.fact_encrypted` all store the same payload shape: the
* `encrypt()` string format from `@/lib/crypto` (`"<keyId>.<base64>"`) encoded
* as UTF-8 bytes. Centralised here so there is one ArrayBuffer-backed
* implementation every Coach persistence path shares.
*
* Prisma's `Bytes` type maps to `Uint8Array<ArrayBuffer>`, not Node's
* `Buffer<ArrayBufferLike>`, so we allocate a fresh ArrayBuffer-backed
* `Uint8Array` for writes to keep the structural type stable across Node
* versions.
*/
import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
/** Encrypt a UTF-8 string into the `Bytes` payload the schema stores. */
export function encryptToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(plaintext);
const encoded = Buffer.from(ciphertext, "utf8");
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
out.set(encoded);
return out;
}
/** Decrypt a `Bytes` payload back to its plaintext. Throws on a bad key id. */
export function decryptFromBytes(buf: Uint8Array): string {
const text = Buffer.from(buf).toString("utf8");
return decrypt(text);
}
+1 -24
View File
@@ -10,10 +10,8 @@
* provenance (window names, metric tags, sample counts) and never raw
* values, so it can be queried without decryption for analytics.
*/
import { Buffer } from "node:buffer";
import { prisma } from "@/lib/db";
import { encrypt, decrypt } from "@/lib/crypto";
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
import type {
CoachConversationDTO,
@@ -49,27 +47,6 @@ export function summariseTitle(input: string): string {
return `${cut.trimEnd()}…`;
}
/**
* Encode a UTF-8 string as the AES-256-GCM payload format the schema
* stores in `coach_messages.encrypted_content`.
*
* Prisma's `Bytes` type maps to `Uint8Array<ArrayBuffer>`, not Node's
* `Buffer<ArrayBufferLike>`. We allocate a fresh ArrayBuffer-backed
* Uint8Array so the structural type matches across Node versions.
*/
function encryptToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(plaintext);
const encoded = Buffer.from(ciphertext, "utf8");
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
out.set(encoded);
return out;
}
function decryptFromBytes(buf: Uint8Array): string {
const text = Buffer.from(buf).toString("utf8");
return decrypt(text);
}
function provenanceToJson(provenance: CoachProvenance | null): string | null {
if (!provenance) return null;
return JSON.stringify(provenance);