Feature 04 task 4: Default seeding for AI settings and notification preferences #28

4 changed files with 364 additions and 1 deletions
+38
View File
@@ -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)
}
}
+13 -1
View File
@@ -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) {
+93
View File
@@ -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
})
}
+220
View File
@@ -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)
}
}
}