Feature 04 task 4: Default seeding for AI settings and notification preferences (#28)
CI / ci (push) Successful in 1m26s
CI / ci (push) Successful in 1m26s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #28.
This commit is contained in:
@@ -330,3 +330,41 @@ func TestSeedSettingsAtBoot(t *testing.T) {
|
|||||||
t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone)
|
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
@@ -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
|
return err
|
||||||
}
|
}
|
||||||
settings := household.NewSettingsService(db)
|
settings := household.NewSettingsService(db)
|
||||||
@@ -153,6 +153,18 @@ func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error
|
|||||||
return err
|
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
|
// logListening logs the startup line. Only the public URL's origin is logged
|
||||||
// so no credential-bearing URL component can reach the log.
|
// so no credential-bearing URL component can reach the log.
|
||||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user