From 989d13b69c4f00b1f2d94dc595a645a13db195a8 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 14:54:58 -0400 Subject: [PATCH] Seed default AI settings and notification preferences at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On first boot create the ai_settings row (provider anthropic, empty base URL, model and key, temperature 0.7, system prompt DefaultPersonaPrompt from CTXD PROMPTS.MD §4) and a notification_prefs row per event (push on for all five, chat on for plan_ready and list_ready only). Existing rows are never changed; a missing event row is seeded on its own. cmd/server seeds household settings, AI settings and notification preferences through seedDefaults; any failure stops the boot. Co-Authored-By: Claude Opus 5.5 --- cmd/server/boot_test.go | 38 +++++ cmd/server/main.go | 14 +- internal/household/defaults.go | 93 ++++++++++++ internal/household/defaults_test.go | 220 ++++++++++++++++++++++++++++ 4 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 internal/household/defaults.go create mode 100644 internal/household/defaults_test.go diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go index 37dc639..082b579 100644 --- a/cmd/server/boot_test.go +++ b/cmd/server/boot_test.go @@ -330,3 +330,41 @@ func TestSeedSettingsAtBoot(t *testing.T) { t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone) } } + +func TestSeedDefaultsAtBoot(t *testing.T) { + ctx := context.Background() + db, err := openStore(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + if err := seedDefaults(ctx, db, "America/Denver"); err != nil { + t.Fatal(err) + } + + if _, err := household.NewSettingsService(db).Get(ctx); err != nil { + t.Errorf("household settings: %v", err) + } + var provider string + if err := db.Reader.QueryRowContext(ctx, `SELECT provider FROM ai_settings`).Scan(&provider); err != nil { + t.Errorf("AI settings: %v", err) + } else if provider != household.SeedAIProvider { + t.Errorf("AI provider = %q, want %q", provider, household.SeedAIProvider) + } + var prefs int + if err := db.Reader.QueryRowContext(ctx, `SELECT count(*) FROM notification_prefs`).Scan(&prefs); err != nil { + t.Fatal(err) + } + if prefs != 5 { + t.Errorf("%d notification preferences, want 5", prefs) + } + + // A second boot seeds nothing new and succeeds. + if err := seedDefaults(ctx, db, ""); err != nil { + t.Fatalf("reboot: %v", err) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index e6feacc..e532ea8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -59,7 +59,7 @@ func run(cfg config.Config, logger *slog.Logger) error { } }() - if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil { + if err := seedDefaults(ctx, db, cfg.DefaultTZ); err != nil { return err } settings := household.NewSettingsService(db) @@ -153,6 +153,18 @@ func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error return err } +// seedDefaults creates the household settings, AI settings and notification +// preferences that do not exist yet. Existing rows are never changed. +func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error { + if err := seedSettings(ctx, db, defaultTZ); err != nil { + return err + } + if err := household.SeedAISettings(ctx, db); err != nil { + return err + } + return household.SeedNotificationPrefs(ctx, db) +} + // logListening logs the startup line. Only the public URL's origin is logged // so no credential-bearing URL component can reach the log. func logListening(logger *slog.Logger, cfg config.Config) { diff --git a/internal/household/defaults.go b/internal/household/defaults.go new file mode 100644 index 0000000..c74ff80 --- /dev/null +++ b/internal/household/defaults.go @@ -0,0 +1,93 @@ +package household + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4). +// SeedAISettings stores it on first boot and the Admin reset restores it. +const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners +similar to their regular rotation, nudging them to try something a little new each +week. Prefer weeknight-friendly recipes with common grocery-store ingredients. +Flag crock pot meals and anything 30 minutes or less as "quick". Keep explanations +short, warm and specific — mention the household's own regular dinners when you +explain why something fits. Never suggest a dinner they already make or have +declined.` + +// Seed values for the AI settings (CTXD CONTEXT.MD). The base URL and model +// are empty until the household picks a provider preset; there is no key. +const ( + SeedAIProvider = "anthropic" + SeedAIBaseURL = "" + SeedAIModel = "" + SeedAITemperature = 0.7 +) + +// seedNotificationPrefs are the notification preferences for a new +// household, one per event: push for every event, chat only for the plan +// and the shopping list. +var seedNotificationPrefs = []struct { + Event string + Push, Chat bool +}{ + {Event: "plan_ready", Push: true, Chat: true}, + {Event: "list_ready", Push: true, Chat: true}, + {Event: "day_of_reminder", Push: true, Chat: false}, + {Event: "new_suggestions", Push: true, Chat: false}, + {Event: "rotation_review", Push: true, Chat: false}, +} + +// SeedAISettings creates the AI settings row with the seed values and +// DefaultPersonaPrompt if it does not exist. An existing row is never +// changed. +func SeedAISettings(ctx context.Context, st *store.Store) error { + return st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + _, err := q.GetAISettings(ctx) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("household: read AI settings: %w", err) + } + now := store.Now().String() + _, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{ + Provider: SeedAIProvider, + BaseUrl: SeedAIBaseURL, + Model: SeedAIModel, + Temperature: SeedAITemperature, + SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true}, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("household: seed AI settings: %w", err) + } + return nil + }) +} + +// SeedNotificationPrefs creates the seed notification preference row of +// every event that has none. Existing rows are never changed. +func SeedNotificationPrefs(ctx context.Context, st *store.Store) error { + return st.WithTx(ctx, func(tx *sql.Tx) error { + now := store.Now().String() + for _, p := range seedNotificationPrefs { + _, err := tx.ExecContext(ctx, + `INSERT INTO notification_prefs (event, push, chat, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (event) DO NOTHING`, + p.Event, p.Push, p.Chat, now, now) + if err != nil { + return fmt.Errorf("household: seed notification preference %s: %w", p.Event, err) + } + } + return nil + }) +} diff --git a/internal/household/defaults_test.go b/internal/household/defaults_test.go new file mode 100644 index 0000000..ec44132 --- /dev/null +++ b/internal/household/defaults_test.go @@ -0,0 +1,220 @@ +package household + +import ( + "context" + "database/sql" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// notificationPrefs returns the stored notification preferences by event as +// [push, chat]. +func notificationPrefs(t *testing.T, st *store.Store) map[string][2]int64 { + t.Helper() + rows, err := st.Reader.QueryContext(context.Background(), `SELECT event, push, chat FROM notification_prefs`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + got := make(map[string][2]int64) + for rows.Next() { + var event string + var push, chat int64 + if err := rows.Scan(&event, &push, &chat); err != nil { + t.Fatal(err) + } + got[event] = [2]int64{push, chat} + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return got +} + +func TestSeedAISettingsCreatesRow(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := migratedStore(t, t.TempDir()) + + if err := SeedAISettings(ctx, st); err != nil { + t.Fatal(err) + } + got, err := db.New(st.Reader).GetAISettings(ctx) + if err != nil { + t.Fatal(err) + } + want := db.AiSetting{ + ID: 1, + Provider: "anthropic", + BaseUrl: "", + Model: "", + Temperature: 0.7, + SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true}, + CreatedAt: got.CreatedAt, + UpdatedAt: got.UpdatedAt, + } + if got.ID != want.ID || got.Provider != want.Provider || got.BaseUrl != want.BaseUrl || + got.Model != want.Model || got.Temperature != want.Temperature || got.SystemPrompt != want.SystemPrompt || + got.ApiKeyCt != nil || got.ApiKeyLast4.Valid || got.KeyVerifiedAt.Valid { + t.Errorf("seeded AI settings = %+v\nwant %+v", got, want) + } + var created store.Timestamp + if err := created.Scan(got.CreatedAt); err != nil { + t.Errorf("created_at: %v", err) + } + if got.UpdatedAt != got.CreatedAt { + t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt) + } +} + +func TestDefaultPersonaPrompt(t *testing.T) { + t.Parallel() + const want = "You are MealQuest, a dinner planning assistant for one household. Suggest dinners\n" + + "similar to their regular rotation, nudging them to try something a little new each\n" + + "week. Prefer weeknight-friendly recipes with common grocery-store ingredients.\n" + + "Flag crock pot meals and anything 30 minutes or less as \"quick\". Keep explanations\n" + + "short, warm and specific — mention the household's own regular dinners when you\n" + + "explain why something fits. Never suggest a dinner they already make or have\n" + + "declined." + if DefaultPersonaPrompt != want { + t.Errorf("DefaultPersonaPrompt = %q\nwant %q", DefaultPersonaPrompt, want) + } +} + +func TestSeedAISettingsNeverOverwrites(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + + // First boot seeds; the household then configures a provider. + first := migratedStore(t, dir) + if err := SeedAISettings(ctx, first); err != nil { + t.Fatal(err) + } + seeded, err := db.New(first.Reader).GetAISettings(ctx) + if err != nil { + t.Fatal(err) + } + changed, err := db.New(first.Writer).UpsertAISettings(ctx, db.UpsertAISettingsParams{ + Provider: "ollama", + BaseUrl: "http://ollama:11434", + Model: "llama3", + Temperature: 0.2, + SystemPrompt: sql.NullString{String: "Be brief.", Valid: true}, + ApiKeyCt: []byte{1, 2, 3}, + ApiKeyLast4: sql.NullString{String: "wxyz", Valid: true}, + KeyVerifiedAt: sql.NullString{String: store.Now().String(), Valid: true}, + CreatedAt: seeded.CreatedAt, + UpdatedAt: store.Now().String(), + }) + if err != nil { + t.Fatal(err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + + // A later boot reopens the same database and seeds again. + st := migratedStore(t, dir) + if err := SeedAISettings(ctx, st); err != nil { + t.Fatalf("reboot: %v", err) + } + got, err := db.New(st.Reader).GetAISettings(ctx) + if err != nil { + t.Fatal(err) + } + if got.Provider != changed.Provider || got.BaseUrl != changed.BaseUrl || got.Model != changed.Model || + got.Temperature != changed.Temperature || got.SystemPrompt != changed.SystemPrompt || + string(got.ApiKeyCt) != string(changed.ApiKeyCt) || got.ApiKeyLast4 != changed.ApiKeyLast4 || + got.KeyVerifiedAt != changed.KeyVerifiedAt || got.CreatedAt != changed.CreatedAt || got.UpdatedAt != changed.UpdatedAt { + t.Errorf("reboot changed AI settings:\n got %+v\nwant %+v", got, changed) + } +} + +func TestSeedNotificationPrefsCreatesRows(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := migratedStore(t, t.TempDir()) + + if err := SeedNotificationPrefs(ctx, st); err != nil { + t.Fatal(err) + } + want := map[string][2]int64{ + "plan_ready": {1, 1}, + "list_ready": {1, 1}, + "day_of_reminder": {1, 0}, + "new_suggestions": {1, 0}, + "rotation_review": {1, 0}, + } + got := notificationPrefs(t, st) + if len(got) != len(want) { + t.Errorf("seeded %d notification preferences, want %d: %v", len(got), len(want), got) + } + for event, w := range want { + if g, ok := got[event]; !ok || g != w { + t.Errorf("%s [push, chat] = %v (present %t), want %v", event, g, ok, w) + } + } + + var created, updated string + err := st.Reader.QueryRowContext(ctx, `SELECT created_at, updated_at FROM notification_prefs WHERE event = 'plan_ready'`).Scan(&created, &updated) + if err != nil { + t.Fatal(err) + } + var ts store.Timestamp + if err := ts.Scan(created); err != nil { + t.Errorf("created_at: %v", err) + } + if updated != created { + t.Errorf("updated_at = %q, want created_at %q", updated, created) + } +} + +func TestSeedNotificationPrefsNeverOverwrites(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + + // First boot seeds; the household then changes two preferences, and one + // event's row goes missing. + first := migratedStore(t, dir) + if err := SeedNotificationPrefs(ctx, first); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `UPDATE notification_prefs SET push = 0, chat = 0 WHERE event = 'plan_ready'`, + `UPDATE notification_prefs SET chat = 1 WHERE event = 'rotation_review'`, + `DELETE FROM notification_prefs WHERE event = 'new_suggestions'`, + } { + if _, err := first.Writer.ExecContext(ctx, stmt); err != nil { + t.Fatal(err) + } + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + + // A later boot keeps the changed rows and seeds only the missing one. + st := migratedStore(t, dir) + if err := SeedNotificationPrefs(ctx, st); err != nil { + t.Fatalf("reboot: %v", err) + } + want := map[string][2]int64{ + "plan_ready": {0, 0}, + "list_ready": {1, 1}, + "day_of_reminder": {1, 0}, + "new_suggestions": {1, 0}, + "rotation_review": {1, 1}, + } + got := notificationPrefs(t, st) + if len(got) != len(want) { + t.Errorf("%d notification preferences after reboot, want %d: %v", len(got), len(want), got) + } + for event, w := range want { + if g, ok := got[event]; !ok || g != w { + t.Errorf("after reboot %s [push, chat] = %v (present %t), want %v", event, g, ok, w) + } + } +}