CI / ci (push) Failing after 5s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
230 lines
6.7 KiB
Go
230 lines
6.7 KiB
Go
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 names (Side quests
|
|
// passed and dinners a Rotation check dropped) 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 most recently passed names, oldest first: Side
|
|
// quests passed and dinners a Rotation check dropped ("Not for us").
|
|
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.ListRecentPassedNames(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
|
|
}
|