mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(coach): add the CoachReminder episodic-memory model
Introduce a third Coach memory type alongside facts (durable traits) and plans (standing if-then habits): a time-anchored "bring this back later" reminder with a due moment and a done state. The free-text note is AES-256-GCM ciphertext via the shared Bytes codec; metric, status, the trigger fields and the dates stay plain so the sweep and the injection picker rank and filter without a per-row decrypt. Migration 0196 is purely additive (status and trigger_kind are app-side closed enums, not DB enums). Register the new note_encrypted column in the encrypted-columns registry and the rotation script so a key rotation can never silently skip it.
This commit is contained in:
1 parent
7d3c528157
commit
9b61769a32
4 files changed
+105
No files matched your search
@@ -0,0 +1,42 @@
|
||||
-- v1.22 (B2/B6) — Coach episodic reminder memory.
|
||||
--
|
||||
-- A time-anchored "bring this back later" intent the Coach captured from a
|
||||
-- conversation. The missing memory type between coach_facts (durable traits)
|
||||
-- and coach_plans (standing if-then habits): an episode with a due moment and
|
||||
-- a done state. The free-text note is AES-256-GCM ciphertext stored as `bytea`
|
||||
-- via the shared Bytes codec — the same shape as coach_plans.if_cue_encrypted.
|
||||
-- metric / status / trigger fields / dates / source stay plain so the daily
|
||||
-- sweep and the injection picker can rank and filter without a per-row decrypt.
|
||||
-- status (proposed | active | due | surfaced | done | dismissed) and
|
||||
-- trigger_kind (date | context) are app-side closed enums, deliberately NOT DB
|
||||
-- enums so this migration stays purely additive. Soft-delete via deleted_at.
|
||||
CREATE TABLE "coach_reminders" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"note_encrypted" BYTEA NOT NULL,
|
||||
"metric" TEXT,
|
||||
"related_plan_id" TEXT,
|
||||
"trigger_kind" TEXT NOT NULL DEFAULT 'date',
|
||||
"due_at" TIMESTAMP(3),
|
||||
"context_cue" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"source" TEXT NOT NULL,
|
||||
"source_conversation_id" TEXT,
|
||||
"last_surfaced_at" TIMESTAMP(3),
|
||||
"surface_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "coach_reminders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- The daily sweep scans a user's active reminders whose due_at has passed; the
|
||||
-- status + deleted_at columns are part of that predicate, so the composite
|
||||
-- index lets the planner serve the sweep + the injection read index-backed.
|
||||
CREATE INDEX "coach_reminders_user_id_status_due_at_deleted_at_idx" ON "coach_reminders"("user_id", "status", "due_at", "deleted_at");
|
||||
|
||||
-- The context-cue evaluation pass filters by trigger_kind + status per user.
|
||||
CREATE INDEX "coach_reminders_user_id_trigger_kind_status_idx" ON "coach_reminders"("user_id", "trigger_kind", "status");
|
||||
|
||||
ALTER TABLE "coach_reminders" ADD CONSTRAINT "coach_reminders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -571,6 +571,7 @@ model User {
|
||||
coachConversations CoachConversation[]
|
||||
coachFacts CoachFact[]
|
||||
coachPlans CoachPlan[]
|
||||
coachReminders CoachReminder[]
|
||||
coachUsage CoachUsage[]
|
||||
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
|
||||
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
|
||||
@@ -3938,6 +3939,56 @@ model CoachPlan {
|
||||
@@map("coach_plans")
|
||||
}
|
||||
|
||||
// v1.22 (B2/B6) — durable, user-scoped EPISODIC reminder memory: a
|
||||
// time-anchored "bring this back later" intent the Coach captured from a
|
||||
// conversation ("remind me about my sleep next week"). This is the middle
|
||||
// memory type that neither CoachFact (durable traits) nor CoachPlan (standing
|
||||
// if-then habits) covers — an episode with a due moment and a done state.
|
||||
//
|
||||
// The free-text note is the user's own framing, encrypted at rest (AES-256-GCM
|
||||
// via the shared Bytes codec, exactly like CoachPlan's cue/action). `metric`,
|
||||
// `status`, the trigger fields and the dates stay plain so the daily sweep and
|
||||
// the injection picker can rank / filter without paying a per-row decrypt.
|
||||
//
|
||||
// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration
|
||||
// purely additive): proposed | active | due | surfaced | done | dismissed.
|
||||
// `triggerKind` is likewise app-side closed: date | context. Soft-delete via
|
||||
// `deletedAt` so a "forget this" action is reversible / auditable.
|
||||
model CoachReminder {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
// What to bring back — the user's own framing, encrypted at rest.
|
||||
noteEncrypted Bytes @map("note_encrypted")
|
||||
// Optional link to the metric / plan it concerns; plain for filtering.
|
||||
metric String?
|
||||
relatedPlanId String? @map("related_plan_id")
|
||||
// Trigger model — app-side closed enum (date | context). For `date`, `dueAt`
|
||||
// carries the fire moment; for `context`, `contextCue` names a closed cue.
|
||||
triggerKind String @default("date") @map("trigger_kind")
|
||||
dueAt DateTime? @map("due_at")
|
||||
contextCue String? @map("context_cue")
|
||||
// Lifecycle: proposed | active | due | surfaced | done | dismissed.
|
||||
status String @default("active")
|
||||
// Provenance — how it was captured (sentinel | extractor | manual | action).
|
||||
source String
|
||||
sourceConversationId String? @map("source_conversation_id")
|
||||
lastSurfacedAt DateTime? @map("last_surfaced_at")
|
||||
// How many times the sweep has surfaced this without the user acting; capped
|
||||
// before auto-dismiss so an ignored reminder cannot nag indefinitely.
|
||||
surfaceCount Int @default(0) @map("surface_count")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// The daily sweep scans active reminders whose dueAt has passed; the status +
|
||||
// deleted_at columns are part of the predicate so the read stays index-backed.
|
||||
@@index([userId, status, dueAt, deletedAt])
|
||||
@@index([userId, triggerKind, status])
|
||||
@@map("coach_reminders")
|
||||
}
|
||||
|
||||
// v1.15.20 — user-authored "about me" self-description the AI surfaces
|
||||
// (Coach system prompt + daily-briefing user prompt) inject as a clearly
|
||||
// delimited, user-provided context block. 1:1 with User and lazily
|
||||
|
||||
@@ -328,6 +328,16 @@ async function main() {
|
||||
results.push(await rotateBytesColumn("CoachPlan", field, prisma.coachPlan));
|
||||
}
|
||||
|
||||
// ───── CoachReminder (Bytes column) ─────
|
||||
// "noteEncrypted"
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachReminder",
|
||||
"noteEncrypted",
|
||||
prisma.coachReminder,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── UserHealthProfile (Bytes columns) ─────
|
||||
// "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted"
|
||||
// "coachFocusEncrypted" "pendingQuestionsEncrypted"
|
||||
|
||||
@@ -112,6 +112,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
{ model: "CoachPlan", field: "ifCueEncrypted", kind: "bytes" },
|
||||
{ model: "CoachPlan", field: "thenActionEncrypted", kind: "bytes" },
|
||||
{ model: "CoachPlan", field: "targetEncrypted", kind: "bytes" },
|
||||
// v1.22 (B2/B6) — Coach episodic reminder free-text note.
|
||||
{ model: "CoachReminder", field: "noteEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── User health profile (Bytes columns) ─────
|
||||
{ model: "UserHealthProfile", field: "aboutMeEncrypted", kind: "bytes" },
|
||||
|
||||
Reference in new issue
Block a user