CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
245 lines
8.9 KiB
Go
245 lines
8.9 KiB
Go
package plan
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
// DraftJob is the KindDraftWeek payload: the week to draft, and whether
|
|
// the household confirmed drafting over a synced or changed plan.
|
|
type DraftJob struct {
|
|
WeekStart string `json:"week_start"`
|
|
Confirm bool `json:"confirm"`
|
|
}
|
|
|
|
// draftResult is the KindDraftWeek result: the week drafted and its plan.
|
|
type draftResult struct {
|
|
WeekStart string `json:"week_start"`
|
|
PlanID string `json:"plan_id"`
|
|
}
|
|
|
|
func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
|
|
|
// runDraft drafts the week p.WeekStart: draft_week plans it from the
|
|
// Regulars and the Quest log dinners no other current or later plan has,
|
|
// given the household context and last week's dinners, and the draft is
|
|
// stored as the week's plan if the plan is still as it was before
|
|
// draft_week ran and every dinner it planned is still in the pool. A pool
|
|
// under seven dinners fails with ReasonNotEnoughDinners and draft_week
|
|
// failing with ReasonAIFailed; no week is made up instead.
|
|
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
|
|
weekStart, err := ParseWeekStart(p.WeekStart)
|
|
if err != nil {
|
|
return draftResult{}, fmt.Errorf("plan: draft: %w", err)
|
|
}
|
|
base, err := s.revision(ctx, weekStart)
|
|
if err != nil {
|
|
return draftResult{}, err
|
|
}
|
|
in, err := s.request(ctx, weekStart)
|
|
if err != nil {
|
|
return draftResult{}, err
|
|
}
|
|
week, err := s.draft(ctx, household.PeriodKey(weekStart), in)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return draftResult{}, ctx.Err()
|
|
}
|
|
var few *skills.NotEnoughDinnersError
|
|
if errors.As(err, &few) {
|
|
return draftResult{}, &jobs.Failure{Code: ReasonNotEnoughDinners}
|
|
}
|
|
// Only the code is logged: the error may quote a model's response.
|
|
s.logger.InfoContext(ctx, "week draft failed", "week_start", p.WeekStart, "reason", ReasonAIFailed)
|
|
return draftResult{}, &jobs.Failure{Code: ReasonAIFailed}
|
|
}
|
|
for _, w := range week.Warnings {
|
|
s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w)
|
|
}
|
|
planID, err := s.storeDraft(ctx, weekStart, household.WeekStart(in.Context.Today), p.Confirm, base, week)
|
|
if err != nil {
|
|
return draftResult{}, err
|
|
}
|
|
return draftResult{WeekStart: p.WeekStart, PlanID: planID}, nil
|
|
}
|
|
|
|
// noPlan is the revision of a week with no plan.
|
|
const noPlan = -1
|
|
|
|
// revision returns the revision of the plan of the week starting
|
|
// weekStart, or noPlan when the week has none.
|
|
func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int64, error) {
|
|
cur, err := db.New(s.st.Reader).GetWeekPlan(ctx, weekStart.String())
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return noPlan, nil
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
|
}
|
|
return cur.Revision, nil
|
|
}
|
|
|
|
// request reads what draft_week is given for the week starting weekStart:
|
|
// the household context, the pool, last week's dinners and the dinner
|
|
// time.
|
|
func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) {
|
|
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
|
if err != nil {
|
|
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
|
|
}
|
|
q := db.New(s.st.Reader)
|
|
settings, err := q.GetHouseholdSettings(ctx)
|
|
if err != nil {
|
|
return skills.WeekRequest{}, fmt.Errorf("plan: read household settings: %w", err)
|
|
}
|
|
thisWeek := household.WeekStart(hctx.Today)
|
|
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
|
|
if err != nil {
|
|
return skills.WeekRequest{}, fmt.Errorf("plan: list draft pool: %w", err)
|
|
}
|
|
pool := make([]skills.Candidate, len(rows))
|
|
for i, d := range rows {
|
|
kind := skills.KindRegular
|
|
if d.Status == dinners.StatusQuestLog {
|
|
kind = skills.KindQuestLog
|
|
}
|
|
pool[i] = skills.Candidate{ID: d.ID, Name: d.Name, Kind: kind, Method: d.Method.String, Minutes: int(d.MinutesTotal.Int64)}
|
|
}
|
|
last, err := q.ListWeekPlanDinnerIDs(ctx, addDays(weekStart, -7).String())
|
|
if err != nil {
|
|
return skills.WeekRequest{}, fmt.Errorf("plan: list last week's dinners: %w", err)
|
|
}
|
|
lastWeek := []string{}
|
|
for _, id := range last {
|
|
lastWeek = append(lastWeek, id.String)
|
|
}
|
|
return skills.WeekRequest{
|
|
Context: hctx,
|
|
WeekStart: weekStart,
|
|
Pool: pool,
|
|
LastWeek: lastWeek,
|
|
DinnerTime: settings.DinnerTime,
|
|
}, nil
|
|
}
|
|
|
|
// storeDraft stores week as the plan of the week starting weekStart in one
|
|
// transaction and returns the plan's id. A new plan is draft. A draft
|
|
// replaces the days of an existing plan, keeping the calendar event id of
|
|
// each day whose dinner stays the same and clearing the others'; a draft
|
|
// plan stays draft and a synced or changed one becomes changed, since
|
|
// Calendar no longer shows it. A plan synced since the
|
|
// draft was asked for without confirm is left as it is, failing with
|
|
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
|
|
// revision before draft_week ran, or noPlan), failing with
|
|
// ReasonPlanChanged. The pool is read again, as of thisWeek like the pool
|
|
// draft_week was given: a day whose dinner is no longer in it, or is in it
|
|
// as a Regular rather than the Quest log or the other way round, leaves
|
|
// everything as it is, failing with ReasonPoolChanged.
|
|
//
|
|
// A stored draft emits plan_ready, keyed by the week, in the same
|
|
// transaction, naming the drafted dinners Monday to Sunday. The key is the week's,
|
|
// so only the AI's first draft of a week notifies: a redraft (Rebuild week)
|
|
// stores no second notification. A manual swap never drafts, so never
|
|
// notifies.
|
|
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
|
|
var planID string
|
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
q := db.New(tx)
|
|
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
|
|
if err != nil {
|
|
return fmt.Errorf("plan: list draft pool: %w", err)
|
|
}
|
|
pool := make(map[string]string, len(rows))
|
|
names := make(map[string]string, len(rows))
|
|
for _, d := range rows {
|
|
pool[d.ID] = d.Status
|
|
names[d.ID] = d.Name
|
|
}
|
|
for _, d := range week.Days {
|
|
status, ok := pool[d.DinnerID]
|
|
if !ok || (status == dinners.StatusQuestLog) != d.IsNew {
|
|
return &jobs.Failure{Code: ReasonPoolChanged}
|
|
}
|
|
}
|
|
now := store.Now().String()
|
|
note := sql.NullString{String: week.Note, Valid: week.Note != ""}
|
|
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
|
switch {
|
|
case errors.Is(err, sql.ErrNoRows) && base != noPlan:
|
|
return &jobs.Failure{Code: ReasonPlanChanged}
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
planID = store.NewID()
|
|
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{
|
|
ID: planID,
|
|
WeekStart: weekStart.String(),
|
|
AiNote: note,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
return fmt.Errorf("plan: create week %s: %w", weekStart, err)
|
|
}
|
|
case err != nil:
|
|
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
|
case cur.Status != StatusDraft && !confirm:
|
|
return &jobs.Failure{Code: ReasonConfirmRequired}
|
|
case cur.Revision != base:
|
|
return &jobs.Failure{Code: ReasonPlanChanged}
|
|
default:
|
|
planID = cur.ID
|
|
status := StatusDraft
|
|
if cur.Status != StatusDraft {
|
|
status = StatusChanged
|
|
}
|
|
if err := q.RedraftWeekPlan(ctx, db.RedraftWeekPlanParams{Status: status, AiNote: note, UpdatedAt: now, ID: cur.ID}); err != nil {
|
|
return fmt.Errorf("plan: redraft %s: %w", cur.ID, err)
|
|
}
|
|
}
|
|
for _, d := range week.Days {
|
|
isNew := int64(0)
|
|
if d.IsNew {
|
|
isNew = 1
|
|
}
|
|
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{
|
|
ID: store.NewID(),
|
|
PlanID: planID,
|
|
Date: d.Date,
|
|
DinnerID: sql.NullString{String: d.DinnerID, Valid: true},
|
|
IsNew: isNew,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
|
|
}
|
|
}
|
|
planned := make([]string, len(week.Days))
|
|
for i, d := range week.Days {
|
|
planned[i] = names[d.DinnerID]
|
|
}
|
|
return s.notifier.EmitTx(ctx, tx, chat.PlanReady(weekStart, thisWeek, planned))
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s.notifier.Wake()
|
|
return planID, nil
|
|
}
|
|
|
|
// addDays returns d plus n 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))
|
|
}
|