Household context builder with token budget #57
@@ -159,6 +159,11 @@ automatic** (D-021).
|
|||||||
with the JSON Schema — the contract is always last. The user message comes
|
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
|
from the skill's input. `checksums.txt` pins every template; changing one
|
||||||
without a version bump fails the tests.
|
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
|
## Recipe grounding
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -43,6 +43,12 @@ type Querier interface {
|
|||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, 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)
|
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||||
// Stores a partial result of a running job.
|
// Stores a partial result of a running job.
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user