mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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;
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user