diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a16c7a..fd4107b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -159,6 +159,11 @@ automatic** (D-021). with the JSON Schema — the contract is always last. The user message comes from the skill's input. `checksums.txt` pins every template; changing one without a version bump fails the tests. +- **Household context** (`harness.BuildContext`): layer 3 holds today's date + and weekday in the household timezone, the Regulars, the Quest log, the + last 60 passed Side quests and the plans of the last four ISO weeks. A + character budget trims the oldest plans first, then the oldest passes; + Regulars and the Quest log are never trimmed. ## Recipe grounding diff --git a/internal/ai/harness/context.go b/internal/ai/harness/context.go new file mode 100644 index 0000000..eae19d6 --- /dev/null +++ b/internal/ai/harness/context.go @@ -0,0 +1,229 @@ +package harness + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "strings" + "time" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// PassedLimit is how many of the most recently passed Side quests the +// household context holds. +const PassedLimit = 60 + +// PlanWeeks is how many ISO weeks of plans, ending with the current week, +// the household context holds. +const PlanWeeks = 4 + +// HouseholdContext is what a skill knows about the household: layer 3 of +// its system prompt. +type HouseholdContext struct { + // Today is the household-local date, in Timezone. + Today store.LocalDate + // Timezone is the household's IANA zone. + Timezone string + // Regulars are in name order. They are never trimmed. + Regulars []RegularDinner + // QuestLog holds the Quest log dinner names, in name order. + QuestLog []string + // Passed holds the names of the most recently passed Side quests, + // oldest first. + Passed []string + // Plans are the planned dinners of the last PlanWeeks weeks, oldest + // first. + Plans []PlannedDinner +} + +// RegularDinner is one Regular. Empty and zero fields are unknown. +type RegularDinner struct { + Name string + Cuisine string + Method string + Minutes int + Favorite bool +} + +// PlannedDinner is the dinner planned for one household-local date. +type PlannedDinner struct { + Date store.LocalDate + Name string +} + +// BuildContext reads the household context from st at instant now, in the +// household's time zone, and trims it until its block (see Block) is at +// most maxChars characters long. Trimming drops the oldest plans first, +// then the oldest passed Side quests; Regulars, the Quest log and today's +// date are never trimmed, so a budget too small for them is an error. +// About four characters make a token. +func BuildContext(ctx context.Context, st *store.Store, now time.Time, maxChars int) (HouseholdContext, error) { + if maxChars <= 0 { + return HouseholdContext{}, fmt.Errorf("household context: budget %d characters is not positive", maxChars) + } + h, err := readContext(ctx, st, now) + if err != nil { + return HouseholdContext{}, fmt.Errorf("household context: %w", err) + } + for { + block, err := h.Block() + if err != nil { + return HouseholdContext{}, err + } + size := utf8.RuneCountInString(block) + switch { + case size <= maxChars: + return h, nil + case len(h.Plans) > 0: + h.Plans = h.Plans[1:] + case len(h.Passed) > 0: + h.Passed = h.Passed[1:] + default: + return HouseholdContext{}, fmt.Errorf("household context: today, Regulars and Quest log need %d characters, over the budget of %d", size, maxChars) + } + } +} + +// readContext reads the untrimmed context in one read transaction, so every +// part comes from the same snapshot. +func readContext(ctx context.Context, st *store.Store, now time.Time) (h HouseholdContext, err error) { + tx, err := st.Reader.BeginTx(ctx, nil) + if err != nil { + return h, fmt.Errorf("begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + + settings, err := q.GetHouseholdSettings(ctx) + if err != nil { + return h, fmt.Errorf("read household settings: %w", err) + } + loc, err := household.LoadZone(settings.Timezone) + if err != nil { + return h, fmt.Errorf("household settings: %w", err) + } + h.Timezone = settings.Timezone + h.Today = household.Today(now, loc) + + regulars, err := q.ListDinnersByStatus(ctx, "regular") + if err != nil { + return h, fmt.Errorf("read Regulars: %w", err) + } + h.Regulars = []RegularDinner{} + for _, d := range regulars { + h.Regulars = append(h.Regulars, RegularDinner{ + Name: d.Name, + Cuisine: nullString(d.Cuisine), + Method: nullString(d.Method), + Minutes: int(d.MinutesTotal.Int64), + Favorite: d.IsFavorite == 1, + }) + } + + questLog, err := q.ListDinnersByStatus(ctx, "quest_log") + if err != nil { + return h, fmt.Errorf("read Quest log: %w", err) + } + h.QuestLog = []string{} + for _, d := range questLog { + h.QuestLog = append(h.QuestLog, d.Name) + } + + passed, err := q.ListRecentDeclinedNames(ctx, PassedLimit) + if err != nil { + return h, fmt.Errorf("read passed Side quests: %w", err) + } + slices.Reverse(passed) // newest first from the query; oldest first here + h.Passed = append([]string{}, passed...) + + thisWeek := household.WeekStart(h.Today) + plans, err := q.ListPlannedDinners(ctx, db.ListPlannedDinnersParams{ + FromDate: addDays(thisWeek, -7*(PlanWeeks-1)).String(), + ToDate: addDays(thisWeek, 7).String(), + }) + if err != nil { + return h, fmt.Errorf("read plans: %w", err) + } + h.Plans = []PlannedDinner{} + for _, p := range plans { + date, err := store.ParseLocalDate(p.Date) + if err != nil { + return h, fmt.Errorf("read plans: %w", err) + } + h.Plans = append(h.Plans, PlannedDinner{Date: date, Name: p.Name}) + } + return h, nil +} + +// Sections returns the context as the sections of the household context +// block, in a fixed order. +func (h HouseholdContext) Sections() []ContextSection { + var regulars, plans []string + for _, r := range h.Regulars { + regulars = append(regulars, r.line()) + } + for _, p := range h.Plans { + plans = append(plans, fmt.Sprintf("%s %s: %s", p.Date, weekday(p.Date).String()[:3], p.Name)) + } + return []ContextSection{ + {Title: "Today", Lines: []string{fmt.Sprintf("%s %s (%s)", weekday(h.Today), h.Today, h.Timezone)}}, + {Title: "Regulars", Lines: regulars}, + {Title: "Quest log", Lines: h.QuestLog}, + {Title: "Passed, oldest first", Lines: h.Passed}, + {Title: "Recent plans", Lines: plans}, + } +} + +// Block renders the context as layer 3 of a system prompt. +func (h HouseholdContext) Block() (string, error) { + return contextBlock(h.Sections()) +} + +// line renders r as "Name (cuisine, method, N min, favorite)", leaving out +// what is unknown. +func (r RegularDinner) line() string { + var attrs []string + if r.Cuisine != "" { + attrs = append(attrs, r.Cuisine) + } + if r.Method != "" { + attrs = append(attrs, r.Method) + } + if r.Minutes > 0 { + attrs = append(attrs, fmt.Sprintf("%d min", r.Minutes)) + } + if r.Favorite { + attrs = append(attrs, "favorite") + } + if len(attrs) == 0 { + return r.Name + } + return fmt.Sprintf("%s (%s)", r.Name, strings.Join(attrs, ", ")) +} + +// weekday returns the day of the week of d. +func weekday(d store.LocalDate) time.Weekday { + y, m, day := d.Date() + return time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Weekday() +} + +// addDays returns d moved by n calendar days. +func addDays(d store.LocalDate, n int) store.LocalDate { + y, m, day := d.Date() + return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC)) +} + +func nullString(s sql.NullString) string { + if !s.Valid { + return "" + } + return s.String +} diff --git a/internal/ai/harness/context_test.go b/internal/ai/harness/context_test.go new file mode 100644 index 0000000..b121eb7 --- /dev/null +++ b/internal/ai/harness/context_test.go @@ -0,0 +1,354 @@ +package harness + +import ( + "context" + "fmt" + "slices" + "strings" + "testing" + "time" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// contextNow is 2026-10-05 01:30 UTC: still Sunday 2026-10-04 in Los +// Angeles, the seeded household's zone, but Monday in UTC. +var contextNow = time.Date(2026, 10, 5, 1, 30, 0, 0, time.UTC) + +// ample is a budget nothing in these tests reaches. +const ample = 1 << 20 + +// emptyHousehold returns a migrated temp store whose household has +// settings in America/Los_Angeles and nothing else. +func emptyHousehold(t *testing.T) *store.Store { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedSettings(ctx, st, "America/Los_Angeles"); err != nil { + t.Fatal(err) + } + return st +} + +// seeder inserts household rows with fixed ids and timestamps. +type seeder struct { + t *testing.T + st *store.Store + n int +} + +func (s *seeder) exec(query string, args ...any) { + s.t.Helper() + if _, err := s.st.Writer.Exec(query, args...); err != nil { + s.t.Fatalf("%s: %v", query, err) + } +} + +func (s *seeder) id(prefix string) string { + s.n++ + return fmt.Sprintf("%s%04d", prefix, s.n) +} + +// dinner inserts a dinner and returns its id. Empty cuisine and method and +// zero minutes are stored as NULL. +func (s *seeder) dinner(name, status, cuisine, method string, minutes int, favorite bool) string { + s.t.Helper() + id := s.id("dinner") + s.exec(`INSERT INTO dinners (id, name, normalized_name, status, origin, method, minutes_total, + cuisine, is_favorite, grounding_status, created_at, updated_at) + VALUES (?, ?, ?, ?, 'manual', NULLIF(?, ''), NULLIF(?, 0), NULLIF(?, ''), ?, 'manual', + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, + id, name, strings.ToLower(name), status, method, minutes, cuisine, favorite) + return id +} + +// pass inserts a passed (declined) Side quest decided at decidedAt. +func (s *seeder) pass(name, decidedAt string) { + s.t.Helper() + batch := s.id("batch") + s.exec(`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at) + VALUES (?, 'manual', 'done', ?, ?)`, batch, decidedAt, decidedAt) + s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status, + decided_at, created_at, updated_at) VALUES (?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`, + s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, decidedAt, decidedAt, decidedAt) +} + +// plan inserts the week plan starting weekStart with one dinner per date; +// an empty name leaves that day without a dinner. +func (s *seeder) plan(weekStart string, days map[string]string) { + s.t.Helper() + planID := s.id("plan") + s.exec(`INSERT INTO week_plans (id, week_start, status, created_at, updated_at) + VALUES (?, ?, 'draft', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, planID, weekStart) + for date, name := range days { + var dinnerID any + if name != "" { + dinnerID = s.dinner(name, "tried", "", "", 0, false) + } + s.exec(`INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) + VALUES (?, ?, ?, ?, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, + s.id("day"), planID, date, dinnerID) + } +} + +// seededHousehold returns a household with Regulars, a Quest log, three +// passes and plans in and around the four plan weeks, which for Sunday +// 2026-10-04 are the weeks of Monday 2026-09-07 to Sunday 2026-10-04. +func seededHousehold(t *testing.T) *store.Store { + t.Helper() + st := emptyHousehold(t) + s := &seeder{t: t, st: st} + s.dinner("Tacos", "regular", "Mexican", "stovetop", 30, true) + s.dinner("Chili", "regular", "Tex-Mex", "crockpot", 240, false) + s.dinner("Baked ziti", "regular", "", "oven", 0, false) + s.dinner("Mystery stew", "regular", "", "", 0, false) + s.dinner("Pho", "quest_log", "Vietnamese", "stovetop", 60, false) + s.dinner("Bibimbap", "quest_log", "", "", 0, false) + s.dinner("Old pie", "archived", "", "", 0, false) + s.pass("Liver and onions", "2026-09-01T12:00:00Z") + s.pass("Tripe soup", "2026-09-20T12:00:00Z") + s.pass("Natto bowl", "2026-09-10T12:00:00Z") + s.plan("2026-08-31", map[string]string{"2026-09-06": "Too old"}) + s.plan("2026-09-14", map[string]string{"2026-09-14": "Lasagna", "2026-09-16": "Curry"}) + s.plan("2026-09-28", map[string]string{"2026-09-28": "Pizza", "2026-09-29": "", "2026-10-04": "Ramen"}) + s.plan("2026-10-05", map[string]string{"2026-10-05": "Next week"}) + return st +} + +const seededBlock = `## Household context +### Today +- Sunday 2026-10-04 (America/Los_Angeles) + +### Regulars +- Baked ziti (oven) +- Chili (Tex-Mex, crockpot, 240 min) +- Mystery stew +- Tacos (Mexican, stovetop, 30 min, favorite) + +### Quest log +- Bibimbap +- Pho + +### Passed, oldest first +- Liver and onions +- Natto bowl +- Tripe soup + +### Recent plans +- 2026-09-14 Mon: Lasagna +- 2026-09-16 Wed: Curry +- 2026-09-28 Mon: Pizza +- 2026-10-04 Sun: Ramen` + +func TestBuildContextSeeded(t *testing.T) { + t.Parallel() + h, err := BuildContext(context.Background(), seededHousehold(t), contextNow, ample) + if err != nil { + t.Fatal(err) + } + block, err := h.Block() + if err != nil { + t.Fatal(err) + } + if block != seededBlock { + t.Fatalf("block:\n%s\n--- want ---\n%s", block, seededBlock) + } + want := RegularDinner{Name: "Tacos", Cuisine: "Mexican", Method: "stovetop", Minutes: 30, Favorite: true} + if got := h.Regulars[3]; got != want { + t.Fatalf("Regulars[3] = %+v, want %+v", got, want) + } +} + +func TestBuildContextUsesHouseholdZone(t *testing.T) { + t.Parallel() + st := emptyHousehold(t) + ctx := context.Background() + for now, want := range map[time.Time]string{ + contextNow: "Sunday 2026-10-04 (America/Los_Angeles)", + // Midnight in Los Angeles (UTC-7 in October) is 07:00 UTC. + time.Date(2026, 10, 5, 6, 59, 59, 0, time.UTC): "Sunday 2026-10-04 (America/Los_Angeles)", + time.Date(2026, 10, 5, 7, 0, 0, 0, time.UTC): "Monday 2026-10-05 (America/Los_Angeles)", + } { + h, err := BuildContext(ctx, st, now, ample) + if err != nil { + t.Fatal(err) + } + if got := h.Sections()[0].Lines[0]; got != want { + t.Errorf("at %s, today = %q, want %q", now, got, want) + } + } +} + +func TestBuildContextKeepsLastPasses(t *testing.T) { + t.Parallel() + st := emptyHousehold(t) + s := &seeder{t: t, st: st} + start := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + var all []string + for i := range PassedLimit + 2 { + name := fmt.Sprintf("Pass %02d", i) + all = append(all, name) + s.pass(name, start.AddDate(0, 0, i).Format(time.RFC3339)) + } + h, err := BuildContext(context.Background(), st, contextNow, ample) + if err != nil { + t.Fatal(err) + } + if want := all[2:]; !slices.Equal(h.Passed, want) { + t.Fatalf("Passed = %q, want the last %d oldest first: %q", h.Passed, PassedLimit, want) + } +} + +func TestBuildContextTrimsOldestPlansThenOldestPasses(t *testing.T) { + t.Parallel() + st := seededHousehold(t) + ctx := context.Background() + full, err := BuildContext(ctx, st, contextNow, ample) + if err != nil { + t.Fatal(err) + } + fullSize := utf8.RuneCountInString(seededBlock) + // lineSize is the size of the block line for item. + lineSize := func(item string) int { return utf8.RuneCountInString("\n- " + item) } + // A section left empty renders "(none)". + none := utf8.RuneCountInString("\n(none)") + floorBlock, err := HouseholdContext{Today: full.Today, Timezone: full.Timezone, + Regulars: full.Regulars, QuestLog: full.QuestLog}.Block() + if err != nil { + t.Fatal(err) + } + floor := utf8.RuneCountInString(floorBlock) + + for _, tc := range []struct { + name string + budget int + wantPlansStart int + wantPassStart int + }{ + {name: "fits exactly", budget: fullSize, wantPlansStart: 0, wantPassStart: 0}, + {name: "one character over", budget: fullSize - 1, wantPlansStart: 1, wantPassStart: 0}, + { + name: "all but newest plan", + budget: fullSize - lineSize("2026-09-14 Mon: Lasagna") - lineSize("2026-09-16 Wed: Curry") - lineSize("2026-09-28 Mon: Pizza"), + wantPlansStart: 3, wantPassStart: 0, + }, + { + name: "no plans, oldest pass", + budget: fullSize - lineSize("2026-09-14 Mon: Lasagna") - lineSize("2026-09-16 Wed: Curry") - + lineSize("2026-09-28 Mon: Pizza") - lineSize("2026-10-04 Sun: Ramen") + none - 1, + wantPlansStart: 4, wantPassStart: 1, + }, + {name: "only what is never trimmed", budget: floor, wantPlansStart: 4, wantPassStart: 3}, + } { + t.Run(tc.name, func(t *testing.T) { + h, err := BuildContext(ctx, st, contextNow, tc.budget) + if err != nil { + t.Fatal(err) + } + block, err := h.Block() + if err != nil { + t.Fatal(err) + } + if size := utf8.RuneCountInString(block); size > tc.budget { + t.Fatalf("block is %d characters, over the budget of %d", size, tc.budget) + } + if want := full.Plans[tc.wantPlansStart:]; !slices.Equal(h.Plans, want) { + t.Errorf("Plans = %v, want %v", h.Plans, want) + } + if want := full.Passed[tc.wantPassStart:]; !slices.Equal(h.Passed, want) { + t.Errorf("Passed = %q, want %q", h.Passed, want) + } + if !slices.Equal(h.Regulars, full.Regulars) || !slices.Equal(h.QuestLog, full.QuestLog) || h.Today != full.Today { + t.Errorf("trimmed what is never trimmed: %+v", h) + } + }) + } +} + +func TestBuildContextBudgetErrors(t *testing.T) { + t.Parallel() + st := seededHousehold(t) + full, err := BuildContext(context.Background(), st, contextNow, ample) + if err != nil { + t.Fatal(err) + } + floorBlock, err := HouseholdContext{Today: full.Today, Timezone: full.Timezone, + Regulars: full.Regulars, QuestLog: full.QuestLog}.Block() + if err != nil { + t.Fatal(err) + } + for _, budget := range []int{0, -1, utf8.RuneCountInString(floorBlock) - 1} { + if _, err := BuildContext(context.Background(), st, contextNow, budget); err == nil { + t.Errorf("budget %d: no error", budget) + } + } +} + +const emptyBlock = `## Household context +### Today +- Sunday 2026-10-04 (America/Los_Angeles) + +### Regulars +(none) + +### Quest log +(none) + +### Passed, oldest first +(none) + +### Recent plans +(none)` + +func TestBuildContextEmptyHousehold(t *testing.T) { + t.Parallel() + h, err := BuildContext(context.Background(), emptyHousehold(t), contextNow, ample) + if err != nil { + t.Fatal(err) + } + block, err := h.Block() + if err != nil { + t.Fatal(err) + } + if block != emptyBlock { + t.Fatalf("block:\n%s\n--- want ---\n%s", block, emptyBlock) + } + // The empty context renders into a whole prompt as layer 3. + p, err := Render("", echoSkill{}, echoInput{Word: "Taco", Context: h.Sections()}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(p.System, "\n\n"+emptyBlock+"\n\n## Output contract\n") { + t.Fatalf("layer 3 is not the empty block:\n%s", p.System) + } +} + +func TestBuildContextNoSettings(t *testing.T) { + t.Parallel() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if _, err := BuildContext(ctx, st, contextNow, ample); err == nil { + t.Fatal("no error without household settings") + } +} diff --git a/internal/db/household_context.sql.go b/internal/db/household_context.sql.go new file mode 100644 index 0000000..d924b27 --- /dev/null +++ b/internal/db/household_context.sql.go @@ -0,0 +1,88 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: household_context.sql + +package db + +import ( + "context" +) + +const listPlannedDinners = `-- name: ListPlannedDinners :many +SELECT plan_days.date, dinners.name +FROM plan_days +JOIN dinners ON dinners.id = plan_days.dinner_id +WHERE plan_days.date >= ?1 AND plan_days.date < ?2 +ORDER BY plan_days.date, plan_days.id +` + +type ListPlannedDinnersParams struct { + FromDate string + ToDate string +} + +type ListPlannedDinnersRow struct { + Date string + Name string +} + +// The planned dinners dated from from_date up to but not including +// to_date, oldest first. Days with no dinner are left out. +func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error) { + rows, err := q.db.QueryContext(ctx, listPlannedDinners, arg.FromDate, arg.ToDate) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPlannedDinnersRow + for rows.Next() { + var i ListPlannedDinnersRow + if err := rows.Scan(&i.Date, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many + +SELECT dinners.name +FROM suggestions +JOIN dinners ON dinners.id = suggestions.dinner_id +WHERE suggestions.status = 'declined' +ORDER BY suggestions.decided_at DESC, suggestions.id DESC +LIMIT ? +` + +// Reads for the AI household context block (internal/ai/harness). +// The names of the most recently declined suggestions, newest first. +func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + items = append(items, name) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/querier.go b/internal/db/querier.go index f283584..c586bb2 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -43,6 +43,12 @@ type Querier interface { // Returns sql.ErrNoRows until the row exists. GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) + // The planned dinners dated from from_date up to but not including + // to_date, oldest first. Days with no dinner are left out. + ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error) + // Reads for the AI household context block (internal/ai/harness). + // The names of the most recently declined suggestions, newest first. + ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) // Stores a partial result of a running job. diff --git a/migrations/queries/household_context.sql b/migrations/queries/household_context.sql new file mode 100644 index 0000000..37ff2d7 --- /dev/null +++ b/migrations/queries/household_context.sql @@ -0,0 +1,19 @@ +-- Reads for the AI household context block (internal/ai/harness). + +-- name: ListRecentDeclinedNames :many +-- The names of the most recently declined suggestions, newest first. +SELECT dinners.name +FROM suggestions +JOIN dinners ON dinners.id = suggestions.dinner_id +WHERE suggestions.status = 'declined' +ORDER BY suggestions.decided_at DESC, suggestions.id DESC +LIMIT ?; + +-- name: ListPlannedDinners :many +-- The planned dinners dated from from_date up to but not including +-- to_date, oldest first. Days with no dinner are left out. +SELECT plan_days.date, dinners.name +FROM plan_days +JOIN dinners ON dinners.id = plan_days.dinner_id +WHERE plan_days.date >= sqlc.arg(from_date) AND plan_days.date < sqlc.arg(to_date) +ORDER BY plan_days.date, plan_days.id;