Files
MealQuest/internal/plan/draft.go
T
dev_agentandClaude Opus 5.5 45c4f8a803
CI / ci (push) Failing after 5m48s
CI / ci (pull_request) Successful in 6m58s
Wire plan_ready, list_ready, new_suggestions and Send to Google Chat
Connect the features to the notification outbox so real events notify,
each stored by the new notify.EmitTx in the transaction of the change it
is about (Wake after commit):

- plan_ready:{weekStart} (/plan) when a draft job stores the AI's draft;
  a redraft shares the key and a manual swap never emits. The title reads
  "Next week's plan is ready" for the coming week.
- list_ready:{weekStart} (/plan/shopping) when a week's Supply run is
  first created, by generate or after Push to Calendar.
- new_suggestions:{batchId} (/discover) when a scheduled or manual batch
  ends done with cards; replenish batches never notify.
- plan.Service.EmitDayOfReminder(date) emits day_of_reminder:{date} for
  the Feature 20 scheduler.

Add POST /api/v1/shopping/{weekStart}/send-chat: shopping.SendToChat posts
the dinners with needed items and a link through chat.PostSupplyRun,
records sent_to_chat_at, and is idempotent per week unless
{"resend": true}. The Supply run's Send to Google Chat button calls it
("Send again" once sent) and shows failures in an ErrorBanner; the unused
Unavailable placeholder is removed.

Task: f0cd9d50-9c11-41be-a945-7bae9f5f28a4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:42:16 -04:00

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))
}