Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76a2b611c8 | ||
|
|
955613941f | ||
|
|
dba4b93280 | ||
|
|
3aef21922b | ||
|
|
9b9421bb58 | ||
|
|
0a47b26af6 | ||
|
|
2ed5f69783 | ||
|
|
09456c48a2 | ||
|
|
5291457214 | ||
|
|
ac7453f576 | ||
|
|
4a209623f5 | ||
|
|
cb920714b0 | ||
|
|
a3648397ee | ||
|
|
2b0e5ff01a | ||
|
|
0c89eef577 | ||
|
|
fcbdebd6e8 |
@@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
# Optional: a SearXNG instance the Settings screen suggests as the recipe
|
||||
# search URL until one is saved.
|
||||
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
|
||||
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
make dev
|
||||
```
|
||||
@@ -53,8 +56,9 @@ make dev
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE),
|
||||
trusted proxy list (every entry must be an IP or CIDR) and
|
||||
`MEALQUEST_DEFAULT_TZ`, and
|
||||
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
|
||||
(an absolute `http://` or `https://` URL without credentials, a query or a
|
||||
fragment) and `MEALQUEST_DEFAULT_TZ`, and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
|
||||
+86
-6
@@ -13,16 +13,22 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"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/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"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/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -74,11 +80,40 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), db, cfg.DataDir)
|
||||
// One fetcher serves pages and photos, so its per-host spacing and
|
||||
// page cache cover both.
|
||||
fetcher := recipes.NewFetcher()
|
||||
photos, err := recipes.NewPhotoCache(fetcher, db, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
search, err := recipes.NewSearchSettingsService(db, box)
|
||||
search, err := recipes.NewSearchSettingsService(db, box, cfg.SearXNGURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", config.EnvSearXNGURL, err)
|
||||
}
|
||||
|
||||
ai, err := aisettings.New(db, box, cfg.PublicURL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grounder, err := recipes.NewGrounder(db, search, fetcher, photos, normalizer(ai, guard))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -99,7 +134,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,6 +150,12 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
// With those jobs failed, a dinner still pending with no grounding job
|
||||
// queued or running is failed as interrupted, before any request can
|
||||
// create one.
|
||||
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: router,
|
||||
@@ -196,7 +237,7 @@ func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error
|
||||
return household.SeedNotificationPrefs(ctx, db)
|
||||
}
|
||||
|
||||
// jobKinds enqueues every job kind the server runs.
|
||||
// jobKinds enqueues the framework's job kinds.
|
||||
type jobKinds struct {
|
||||
builtins *jobs.Builtins
|
||||
}
|
||||
@@ -215,8 +256,11 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
||||
return runner, kinds, nil
|
||||
}
|
||||
|
||||
// registerJobKinds registers every job kind's handler on runner. Each
|
||||
// feature's job kinds are registered here.
|
||||
// registerJobKinds registers the framework's job kinds on runner. A feature
|
||||
// service registers its own kinds when it is built, before the runner
|
||||
// starts (dinners.NewService registers dinners.KindGroundDinner and
|
||||
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
|
||||
// plan.KindDraftWeek).
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
@@ -225,6 +269,42 @@ func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
return &jobKinds{builtins: builtins}, nil
|
||||
}
|
||||
|
||||
// normalizer runs normalize_recipe with the AI settings current at each
|
||||
// run, sharing guard.
|
||||
func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer {
|
||||
return func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.NormalizedRecipe{}, err
|
||||
}
|
||||
return skills.NormalizeRecipe(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// sideQuests runs suggest_side_quests with the AI settings current at each
|
||||
// run, sharing guard.
|
||||
func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester {
|
||||
return func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.SideQuests{}, err
|
||||
}
|
||||
return skills.SuggestSideQuests(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// weekDrafter runs draft_week with the AI settings current at each run,
|
||||
// sharing guard.
|
||||
func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter {
|
||||
return func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.Week{}, err
|
||||
}
|
||||
return skills.DraftWeek(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
+4
-4
@@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h
|
||||
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
|
||||
# The Authentik provider's client secret.
|
||||
# MEALQUEST_SEARXNG_URL=http://searxng:8080
|
||||
# The SearXNG recipe-search sidecar on the compose network. Planned; not
|
||||
# read by the service yet. The app takes its recipe-search provider and
|
||||
# base URL from PATCH /api/v1/settings/recipe-search; see
|
||||
# deploy/RUNBOOK.md "Recipe search (SearXNG)".
|
||||
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
||||
# screen pre-fills it as the SearXNG URL until one is saved; the saved
|
||||
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
||||
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
|
||||
|
||||
# --- Planned; not read by the service yet -------------------------------------
|
||||
#
|
||||
|
||||
+16
-8
@@ -417,15 +417,23 @@ The `searxng` service in `deploy/compose.yaml` is a private SearXNG
|
||||
instance for recipe search. On the compose network it is reachable at
|
||||
`http://searxng:8080`; that is the address to configure in the app.
|
||||
|
||||
Starting the sidecar does not connect the app to it. The service does not
|
||||
read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future
|
||||
wiring, and today it does nothing. The recipe-search provider and base URL
|
||||
live in the `recipe_search_settings` row and are set through
|
||||
`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings
|
||||
screen's recipe search; the screen itself is not built yet). Until that row
|
||||
exists, recipe search answers 409 `not_configured`.
|
||||
Starting the sidecar does not connect the app to it. The recipe-search
|
||||
provider and base URL live in the `recipe_search_settings` row and are set
|
||||
through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the
|
||||
recipe search card on Settings → AI assistant). Until that row exists,
|
||||
recipe search answers 409 `not_configured`.
|
||||
|
||||
After the first deploy, from a signed-in session (every non-`GET` request
|
||||
`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`.
|
||||
The service only suggests it: `GET` returns it as `default_base_url`, and
|
||||
the recipe search card pre-fills the SearXNG URL with it while no URL is
|
||||
saved. Nothing is stored until the card is saved, and a saved `base_url`
|
||||
always wins. A set value must be an absolute `http://` or `https://` URL
|
||||
without credentials, a query or a fragment, or the service refuses to
|
||||
start; unset, `default_base_url` is `null`.
|
||||
|
||||
To set it up, open Settings → AI assistant, check that the recipe search
|
||||
card shows SearXNG with `http://searxng:8080`, select **Save recipe search**
|
||||
and then **Test**. Or, from a signed-in session (every non-`GET` request
|
||||
needs the `X-MealQuest-CSRF` header set to `csrfToken` from
|
||||
`GET /api/v1/me`):
|
||||
|
||||
|
||||
+303
-6
@@ -96,12 +96,12 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
|
||||
| `recipe_steps` | recipe_id, position, text. |
|
||||
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
|
||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by. |
|
||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
|
||||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
||||
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
|
||||
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
|
||||
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. |
|
||||
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). |
|
||||
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
|
||||
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
|
||||
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
|
||||
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
|
||||
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
|
||||
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
|
||||
@@ -127,7 +127,7 @@ suggested (deck) ──Try it──▶ quest_log ──planned date passes──
|
||||
│ └──────── explicit decision ─────┤
|
||||
│ ├──▶ regular
|
||||
│ └──▶ archived
|
||||
└──Pass──▶ archived (name kept for dedupe)
|
||||
└──Pass──▶ archived (suggested name kept for dedupe)
|
||||
```
|
||||
|
||||
From `tried`, an explicit decision moves the dinner to `regular`, to
|
||||
@@ -149,9 +149,249 @@ automatic** (D-021).
|
||||
the Brave key (`api_key`, write-only: responses carry only
|
||||
`api_key_set` and `api_key_last4`; an empty string removes it). The
|
||||
result must be usable: SearXNG needs a base_url, Brave a key.
|
||||
Responses also carry the read-only `default_base_url`, the deployment's
|
||||
`MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no
|
||||
base_url is saved; only a PATCH of base_url stores a URL.
|
||||
`POST /api/v1/settings/recipe-search/test` searches "chicken tikka
|
||||
masala" and returns `{query, count}`, 409 `not_configured`, or 502
|
||||
`search_failed` with the provider's error.
|
||||
- `GET`/`PATCH /api/v1/settings/ai` (`aisettings.Service`) — provider,
|
||||
base_url, model, temperature, system_prompt, plus the read-only
|
||||
`api_key_last4` and `key_verified_at`. PATCH requires a preset name as
|
||||
provider, temperature 0–1 in 0.1 steps, a non-empty model and a
|
||||
system_prompt of at most 8000 characters; a fixed-URL preset's base_url
|
||||
must be empty or its own. Provider and base_url cannot change while a
|
||||
key is stored (remove the key first), so a stored key is never sent to
|
||||
an endpoint it was not stored for.
|
||||
`PUT /api/v1/settings/ai/api-key` takes `{api_key}` (write-only, at least
|
||||
8 characters, sealed with the secret box; an empty string removes it) and
|
||||
clears `key_verified_at`; a preset without a fixed base_url takes a key
|
||||
only when its base_url passes `secureurl.Check`. `POST /api/v1/settings/ai/verify` calls
|
||||
`Provider.Verify` and records `key_verified_at`;
|
||||
`GET /api/v1/settings/ai/models` returns `{models: [{id, display_name}]}`
|
||||
live from the provider, with the key redacted from every id and
|
||||
display_name. Both work before a model is chosen
|
||||
(`presets.ForAccount`), answer 409 `not_configured` when the settings
|
||||
cannot build a provider, and 502 with the provider's typed error
|
||||
(`auth_failed`, `rate_limited`, `bad_output`, `provider_error`; 504
|
||||
`timeout`) when the call fails. `POST /api/v1/settings/ai/reset-prompt`
|
||||
restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No
|
||||
response holds the key. `Service.HarnessConfig` reads the settings for
|
||||
each skill run, so a saved change applies to the next run.
|
||||
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
|
||||
`{name}` or `{name, recipe_url}` (an https:// link, kept in
|
||||
`recipe_url` for grounding and never returned in a dinner response, as
|
||||
it may carry a personal token) with status and origin `regular` and
|
||||
grounding_status `pending`, answering 201 with the dinner. The name is
|
||||
always the household's: no part of a link is ever used as a dinner's
|
||||
name, and a link without a name is 400. A name
|
||||
is trimmed, at most 120 characters and must keep a word after
|
||||
`harness.Normalize`, which sets `normalized_name`. A name
|
||||
`harness.Similar` to an unarchived dinner's, or a link one already has
|
||||
(the source_url of the recipe it is grounded in, else its pasted
|
||||
`recipe_url`), is 409 `duplicate_dinner`; archived names may be added
|
||||
again.
|
||||
`GET /api/v1/dinners?status=regular|quest_log` returns
|
||||
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
|
||||
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
|
||||
any other status is 400. The Quest log (`quest_log`) is the dinners
|
||||
accepted and not yet scheduled: one on the plan for the household's
|
||||
current week (by its timezone) or a later week is left out. `PATCH /api/v1/dinners/{id}` edits `name`
|
||||
(deduplicated against the other unarchived dinners), `method`
|
||||
(`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total`
|
||||
(1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values
|
||||
are 400 `invalid_dinner` and change nothing. `DELETE /api/v1/dinners/{id}`
|
||||
sets status `archived` and keeps the row; `POST
|
||||
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
|
||||
the session's subject as `updated_by`; an unknown id is 404.
|
||||
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
|
||||
job and answers with its `grounding_job_id` without waiting for it
|
||||
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
|
||||
still answers 201, with grounding_status `failed` (`grounding_error`)
|
||||
and `grounding_job_id` null. The job grounds the dinner's
|
||||
`recipe_url` directly with `GroundURL`, else its name with `Ground`
|
||||
(see Recipe grounding), and sets grounding_status `grounded` with the
|
||||
recipe's recipe_id, photo_id, method, minutes_total and difficulty,
|
||||
clearing the pasted `recipe_url` (the recipe replaces it), or
|
||||
`failed` with `grounding_error` — also the job's failure code — one of
|
||||
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
|
||||
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
|
||||
`grounding_error`. Each job carries the dinner's `grounding_revision`
|
||||
from when it was enqueued; renaming a dinner raises the revision, and
|
||||
renaming a `pending` one enqueues a job for the new name, clearing its
|
||||
`recipe_url` when the normalized name changes.
|
||||
A job for a dinner archived, no longer `pending` or at another revision
|
||||
does no work, and a job only writes a dinner still unarchived, `pending`
|
||||
and at its revision, so a fix-up made meanwhile wins and an earlier job
|
||||
for an old name — queued or running — never decides the outcome.
|
||||
Archiving or renaming a dinner also stops its running job where it is,
|
||||
so no further search, page fetch, photo download or model call is made
|
||||
for it. A
|
||||
method, minutes_total or difficulty a member saved while the dinner was
|
||||
`pending` is kept; grounding fills only those left empty. At boot, after
|
||||
the job runner fails the jobs a previous process left running, every
|
||||
unarchived dinner still `pending` with no `ground_dinner` job for its revision
|
||||
queued or running is set `failed` with `interrupted`, so no dinner stays
|
||||
pending with nothing left to ground it.
|
||||
`GET /api/v1/dinners/{id}/recipe-candidates`
|
||||
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
||||
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
||||
ranks them; it fetches pages but downloads no photo and runs no model,
|
||||
so `photo_url` is set only for a page already stored. `candidate_id` is
|
||||
an opaque id the server resolves to the page it last offered that dinner
|
||||
(kept in memory, so a restart means fetching the candidates again) at
|
||||
the `grounding_revision` it was searched at, so a candidate offered
|
||||
before any rename is refused even if the name is changed back; the
|
||||
page's URL, which may carry a token, is never returned or cached. An
|
||||
unknown dinner is 404, recipe search not set up 409 `not_configured` and
|
||||
a search failure 502 `search_failed`; any other failure, such as a
|
||||
stored recipe that cannot be read, is 500, logged with a fixed reason
|
||||
and the dinner id only — never a candidate's URL or dependency error
|
||||
text.
|
||||
`POST /api/v1/dinners/{id}/ground` takes exactly one of `{candidate_id}`,
|
||||
`{recipe_url}` (both grounded with `GroundURL`, setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
|
||||
method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at
|
||||
most 500 characters; minutes 1–1440). A manual recipe is stored as typed
|
||||
— no model runs — as a `recipes` row whose source_url is
|
||||
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
|
||||
Recipe built from it, and sets `manual` with its method and minutes, no
|
||||
photo and the dinner's difficulty unchanged. Either clears the dinner's
|
||||
pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with
|
||||
the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
|
||||
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
|
||||
set up 409 `not_configured`, a search or model failure 502
|
||||
`search_failed` / `ai_failed` (only the reason code is logged, never the
|
||||
provider's text), and grounding a candidate or link that runs past 90
|
||||
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
|
||||
`ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
|
||||
`dinner_archived`. A candidate or link is written only if the dinner is
|
||||
still as it was before grounding began: one renamed or grounded some
|
||||
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
|
||||
409 `dinner_archived`, either leaving the dinner unchanged. A method,
|
||||
minutes or difficulty a member edits meanwhile is kept over the recipe's.
|
||||
Any other failure grounding a candidate or link, or recording it on the
|
||||
dinner, is 500, logged with a fixed reason and the dinner id only —
|
||||
never the link, which can carry a personal token, or dependency error
|
||||
text.
|
||||
- Side quest suggestions (`suggest.Service`). `POST
|
||||
/api/v1/suggestions/batches` creates a `manual` batch and enqueues its
|
||||
`suggestion_batch` job, answering 202 `{batch_id, job_id, trigger,
|
||||
status}` without waiting; while a batch is `pending` or `running` it
|
||||
answers 202 with that batch instead, so at most one runs at a time (the
|
||||
check, the batch and its job share one write transaction). The job reads the
|
||||
household context, the deck's names and every declined name, runs
|
||||
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
|
||||
grounds the survivors in the model's order with `Grounder.GroundMany`,
|
||||
as many as are still wanted at a time, until 5 are kept or none are
|
||||
left. A Side quest no recipe grounds is dropped; each round's grounded
|
||||
ones are stored before the next — a dinner with status `suggested`,
|
||||
origin `side_quest`, grounding_status `grounded` with the recipe's
|
||||
recipe_id, photo_id, method, minutes_total and difficulty and the
|
||||
model's cuisine, and a `pending` suggestion with the name it is
|
||||
suggested under, the reason and `cited_regular_ids` — unless, since the
|
||||
job read its input, a dinner it repeats was added, a suggestion it
|
||||
repeats passed, or a Regular it cites renamed or archived (checked in
|
||||
the storing transaction). The job publishes
|
||||
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
||||
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
|
||||
skill or `normalize_recipe` failed; no Side quest is made up instead,
|
||||
D-017), `search_not_configured` or `search_failed`, and the batch is set
|
||||
`failed`; Side quests stored before the failure stay in the deck. If
|
||||
recording the batch's end fails, its job fails; the job runner retries
|
||||
a job's final status write with backoff until it lands, each attempt
|
||||
bounded at 5 seconds; at shutdown an attempt that fails or times out is
|
||||
not retried, and the next start fails the job as interrupted. Recording
|
||||
the batch's end is bounded the same way (one attempt, 5 seconds, made
|
||||
even during shutdown), so a busy writer cannot hold a stopping worker;
|
||||
a batch whose end timed out is left `running` with its job. Each time a
|
||||
batch is started, before the active batch is selected, every batch
|
||||
still `pending` or `running` with no job queued or running (the process
|
||||
stopped, or recording its end failed) is set `failed`, so it does not
|
||||
block the next.
|
||||
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
|
||||
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
|
||||
cited_regular_ids, status, dinner_status, decided_at, created_at}],
|
||||
active_batch, failed_batch}`: the `pending` suggestions whose dinner is
|
||||
still `suggested`, oldest first; the batch `pending` or `running`
|
||||
(`{batch_id, job_id, trigger, status}`, or null); and the newest batch
|
||||
when it `failed` (same shape, or null; a later batch, done or under
|
||||
way, supersedes the failure), read in one snapshot, so a client that
|
||||
did not start the batch can follow its job and learns of a failure it
|
||||
was not following. At most one of the two is set. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
|
||||
suggestion `accepted` and moves its dinner to `quest_log`; `POST
|
||||
/api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives
|
||||
the dinner; every later batch, and the household context's passed list,
|
||||
reads the suggestion's own name as declined, so renaming the archived
|
||||
dinner changes nothing. Both record
|
||||
the session's subject as the dinner's `updated_by` and answer 200
|
||||
`{suggestion, replenish_job_id}`. Repeating a decision changes nothing
|
||||
and answers 200; the opposite decision is 409 `suggestion_decided`, a
|
||||
suggestion whose dinner left the deck some other way 409
|
||||
`dinner_not_suggested`, and an unknown id 404. A decision that leaves
|
||||
fewer than 5 in the deck starts a `replenish` batch, or joins the batch
|
||||
already pending or running, and returns that batch's job id either way
|
||||
(`replenish_job_id` is null when the deck keeps 5 or more). The batch and its job are written in
|
||||
the decision's own transaction, so a decision is never recorded without
|
||||
the replenish it calls for; when they cannot be written, neither is the
|
||||
decision, and a retry decides again.
|
||||
- Weekly plans (`plan.Service`). `{weekStart}` is a household-local Monday
|
||||
(`YYYY-MM-DD`); any other value is 400 `invalid_week`. `GET
|
||||
/api/v1/plans/{weekStart}` returns `{id, week_start, week_label, status,
|
||||
ai_note, calendar_synced_at, updated_at, days}`, or 404 when the week has
|
||||
no plan; `days` are the seven days Monday first, each `{date, dinner: {id,
|
||||
name, photo, minutes, method, origin}, is_new, start_by}`. `is_new` means
|
||||
the dinner was planned from the Quest log; `start_by` is the household's
|
||||
dinner time on that date minus `minutes_total`, computed on read, for a
|
||||
crock-pot dinner with known minutes, else null. `POST
|
||||
/api/v1/plans/{weekStart}/draft` takes `{confirm?}` and answers 202
|
||||
`{jobId}` for a `draft_week` job, or for the one already queued or
|
||||
running for the week, so a week has at most one draft in flight. A
|
||||
confirmed draft while the week's job was asked for without confirm is
|
||||
409 `draft_running` and enqueues nothing. The job runs the `draft_week` skill on every Regular
|
||||
and every Quest log dinner no other plan for the current or a later week
|
||||
has, with the household context and last week's dinners, and stores the
|
||||
week: a new plan is `draft`; a redraft keeps each day's
|
||||
`calendar_event_id`, and a `synced` or `changed` plan becomes `changed`.
|
||||
Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else
|
||||
409 `confirm_required`; a plan synced while an unconfirmed draft runs is
|
||||
left as it is and the job fails `confirm_required`. The job reads the
|
||||
plan's revision before draft_week runs; if the plan was edited (a swap,
|
||||
or another draft stored) by the time the draft is stored, the plan is
|
||||
left as it is and the job fails `plan_changed`. The pool is read again
|
||||
in the same transaction that stores the draft; if a dinner the draft
|
||||
planned has left it meanwhile (archived, `tried`, or a Quest log dinner
|
||||
stored on another current or later week's plan, as by a draft of
|
||||
another week), nothing is stored and the job fails `pool_changed`,
|
||||
which drafting again resolves. A pool under seven
|
||||
dinners fails the job `not_enough_dinners`, draft_week failing
|
||||
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
|
||||
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
|
||||
`is_new`, moves a `synced` plan to `changed`, and answers 200 with the
|
||||
plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
|
||||
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
|
||||
a dinner already on another day of the week 409 `dinner_already_planned`.
|
||||
- Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the
|
||||
`rotation_check` schedule calls for the household-local day just past,
|
||||
moves every `quest_log` dinner planned on that date, on a plan of any
|
||||
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
|
||||
a dinner already checked for that date is left as it is, so it is
|
||||
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
||||
pending checks whose dinner is still tried, oldest `plan_date` first,
|
||||
each `{dinner: {id, name, photo}, plan_date}`. `POST
|
||||
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
|
||||
the dinner a Regular, `drop` ("Not for us") archives it and `not_made`
|
||||
("We didn't make it") returns it to the Quest log; its origin is kept.
|
||||
The check is closed with `decided_at` and the session's `updated_by`,
|
||||
and the answer is `{dinner, plan_date, decision, dinner_status,
|
||||
decided_at, updated_by}`. Any other decision is 400 `invalid_decision`,
|
||||
an unknown dinner or a dinner never checked 404 `not_found`, a dinner
|
||||
whose checks are all decided (including a second decision) 409
|
||||
`no_pending_review`, and one no longer tried 409 `dinner_not_tried`.
|
||||
Keep is the only path from tried to Regular. A drop records the
|
||||
dinner's name on the check (`rotation_reviews.name`), and that name
|
||||
joins the passed names (`ListPassedNames`) the Side quest dedupe and
|
||||
household context use, so it is never suggested again, even after the
|
||||
dinner is renamed.
|
||||
- `/health` — liveness.
|
||||
- Everything else serves the embedded PWA with SPA fallback.
|
||||
|
||||
@@ -214,6 +454,18 @@ automatic** (D-021).
|
||||
dinner when the token-set (Jaccard) similarity of their words is at least
|
||||
0.85. A `Deduper` over the Regulars, Quest log, pending suggestions and
|
||||
passed names rejects a suggestion that repeats any of them.
|
||||
- **Side quest suggestions** (`skills.SuggestSideQuests`, skill
|
||||
`suggest_side_quests` v1): asks for twice the Side quests needed, at the
|
||||
household temperature, with the household context plus the pending Side
|
||||
quests. Malformed replies (bad JSON, a missing field, a bad enum, an
|
||||
empty name) get the one repair turn. Well-formed Side quests that break a
|
||||
rule are dropped, not repaired, and listed in `Dropped` with the rule:
|
||||
one that repeats a Regular, Quest log dinner, pending Side quest, any
|
||||
passed name (the whole history, not only the 60 in the context) or an
|
||||
earlier Side quest of the batch; one citing no Regular or a name that is
|
||||
not a Regular; and one whose reason is over 180 characters or names none
|
||||
of its cited Regulars. Cited names are rewritten as the household writes
|
||||
them. Grounding each survivor in a recipe is the caller's next step.
|
||||
|
||||
## Recipe grounding
|
||||
|
||||
@@ -320,7 +572,10 @@ automatic** (D-021).
|
||||
Rank's per-candidate reasons, photo or normalize failures). Failures
|
||||
that are not the candidates' — search, model provider, database — are
|
||||
returned as they are. `GroundURL(ctx, url)` does the same for one pasted
|
||||
URL. `GroundMany` grounds items 3 at a time and returns one result per
|
||||
URL. `Candidates(ctx, dishName, query, n)` returns the first n of the
|
||||
candidates `Ground` would try, without grounding any. Search failures
|
||||
wrap `recipes.ErrSearchFailed` and model provider failures
|
||||
`recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per
|
||||
item, so one failure never affects its siblings. A recipe is usable only
|
||||
with ingredients, an image and, if given, a positive total time.
|
||||
|
||||
@@ -396,6 +651,48 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
`/settings/household`, `/settings/regulars` and `/settings/calendar` come from
|
||||
CTXD; the other routes are defined in the UI handoff.
|
||||
|
||||
First run: while `household_settings.onboarding_completed_at` (read from
|
||||
`GET /api/v1/settings/household`) is null, every route redirects to
|
||||
`/onboarding`. Onboarding is a full screen outside the app shell, in three
|
||||
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
|
||||
mock 1h, adding and archiving through the dinners API optimistically; Next
|
||||
waits for three saved Regulars, and a common pick is not offered once a
|
||||
Regular has a name `harness.Similar` to it — the PWA's copy of the name
|
||||
rules in `web/src/dinners/names.ts` folds letters with
|
||||
`web/src/dinners/letterFolds.json`, which `gen_folds.py` writes from the
|
||||
same table as the server's, and runs the same cases,
|
||||
`web/src/dinners/testdata/dedupe_cases.json`, as the server's) and
|
||||
notifications (`/onboarding/notifications`: what MealQuest notifies about,
|
||||
the iOS install hint, "Turn on notifications" and "Maybe later"). "Maybe
|
||||
later", and "Turn on notifications" once notifications are enabled, send
|
||||
`PATCH /api/v1/settings/household` with `{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
|
||||
to the time of that update (keeping the first time if it is already set;
|
||||
the field itself is read-only and `false` is refused) and the app goes Home.
|
||||
Completing needs at least three Regulars (`household.MinRegulars`); with
|
||||
fewer the server answers 400 `invalid_settings` naming
|
||||
`onboarding_completed` and changes nothing. Step 3 can be reached with
|
||||
fewer — by its URL, or after another member removes a Regular — so it then
|
||||
shows the server's reason with an "Add Regulars" button back to step 2.
|
||||
Notification permission is never asked for on page load; until Feature 19
|
||||
wires Web Push, "Turn on notifications" asks for nothing and says plainly
|
||||
that notifications are not on yet, offering "Finish setting up" (which
|
||||
completes onboarding as above) instead of a silent success. Offline on the
|
||||
saved pages the settings cannot be read (the service worker never caches `/api/v1/settings`),
|
||||
so the saved pages open as they are.
|
||||
|
||||
Regular dinners (`/settings/regulars`) lists the Regulars from
|
||||
`GET /api/v1/dinners?status=regular`, each with its photo, "method ·
|
||||
minutes", favorite heart and grounding badge ("Finding recipe…" while
|
||||
`pending`, "Recipe found" when `grounded` or `manual`, "Needs a recipe" when
|
||||
`failed`). The add field takes a name or a pasted `https://` recipe link.
|
||||
Tapping a Regular opens an edit sheet (name, method, minutes, difficulty;
|
||||
remove archives it after a confirmation); tapping one that needs a recipe
|
||||
opens "Fix recipe" (pick one of the recipe candidates, paste a link or type
|
||||
the ingredients and steps in). Adds, removals and the heart show at once and
|
||||
are undone if the server refuses them. While any Regular is `pending`, the
|
||||
list is read again every 3 seconds (not while a change is in flight), and
|
||||
polling stops once none is.
|
||||
|
||||
## Boundaries and gotchas
|
||||
|
||||
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
// Package aisettings reads and changes the household's AI settings — the
|
||||
// ai_settings row seeded by household.SeedAISettings — and builds the
|
||||
// provider and harness.Config they describe. The API key is sealed with the
|
||||
// secret box and never leaves the package except to the provider.
|
||||
package aisettings
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
|
||||
"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/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxSystemPromptChars is the longest system prompt accepted, in
|
||||
// characters.
|
||||
MaxSystemPromptChars = 8000
|
||||
// MinKeyLength is the shortest API key accepted, so its last four
|
||||
// characters never give away most of it.
|
||||
MinKeyLength = 8
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotConfigured is returned when the stored AI settings cannot
|
||||
// build a provider, e.g. there is no API key yet. It is joined with
|
||||
// the llm configuration error that says why.
|
||||
ErrNotConfigured = errors.New("aisettings: AI provider is not configured")
|
||||
// ErrProviderFailed is returned when a call to the provider fails. It
|
||||
// is joined with the provider's error, so the llm typed error it
|
||||
// matches says why.
|
||||
ErrProviderFailed = errors.New("aisettings: AI provider call failed")
|
||||
// ErrChanged is returned when the AI settings changed while
|
||||
// their key was being verified, so the result applies to old settings.
|
||||
ErrChanged = errors.New("aisettings: AI settings changed during verification")
|
||||
)
|
||||
|
||||
// Settings is the household's AI configuration as the API shows it. The
|
||||
// API key is never part of it: only its last four characters.
|
||||
type Settings struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
// SystemPrompt is the admin persona, nil when none is stored.
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
// APIKeyLast4 is nil when no key is stored.
|
||||
APIKeyLast4 *string `json:"api_key_last4"`
|
||||
// KeyVerifiedAt is when the stored key was last verified with these
|
||||
// provider settings, nil when it has not been.
|
||||
KeyVerifiedAt *string `json:"key_verified_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Patch changes the settings. A nil field is left as it is.
|
||||
type Patch struct {
|
||||
Provider *string `json:"provider"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
}
|
||||
|
||||
// Model is one model the provider offers.
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// Problem is one invalid field, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every problem with an Patch or API
|
||||
// key, or with the settings it would leave. No problem quotes the key.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid AI settings: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// Service reads and changes the ai_settings row and builds the provider it
|
||||
// names. API keys are sealed with its secret box and never leave it except
|
||||
// to the provider.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
publicURL string
|
||||
// newProvider and newAccount are presets.FromSettings and
|
||||
// presets.ForAccount; tests substitute a fake provider.
|
||||
newProvider harness.NewProvider
|
||||
newAccount func(llm.Settings, string) (presets.Account, error)
|
||||
}
|
||||
|
||||
// New returns the service over st, sealing keys with box.
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, which some presets send.
|
||||
func New(st *store.Store, box *secretbox.Box, publicURL string) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("aisettings: nil store")
|
||||
case box == nil:
|
||||
return nil, errors.New("aisettings: nil secret box")
|
||||
case publicURL == "":
|
||||
return nil, errors.New("aisettings: empty public URL")
|
||||
}
|
||||
return &Service{st: st, box: box, publicURL: publicURL, newProvider: presets.FromSettings, newAccount: presets.ForAccount}, nil
|
||||
}
|
||||
|
||||
// Get returns the settings.
|
||||
func (s *Service) Get(ctx context.Context) (Settings, error) {
|
||||
row, err := s.read(ctx, db.New(s.st.Reader))
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(row), nil
|
||||
}
|
||||
|
||||
// Update applies patch and returns the settings it leaves. The provider
|
||||
// and base URL cannot change while an API key is stored, so the key never
|
||||
// reaches an endpoint it was not stored for. Every problem is reported in
|
||||
// a *ValidationError and nothing changes.
|
||||
func (s *Service) Update(ctx context.Context, patch Patch) (Settings, error) {
|
||||
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
|
||||
return applyPatch(row, patch)
|
||||
})
|
||||
}
|
||||
|
||||
// SetAPIKey seals key and records its last four characters, clearing
|
||||
// key_verified_at. An empty key removes the stored one. A preset without
|
||||
// a fixed base URL takes a key only when its base URL is secure (see
|
||||
// secureurl.Check).
|
||||
func (s *Service) SetAPIKey(ctx context.Context, key string) (Settings, error) {
|
||||
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
|
||||
row.ApiKeyCt, row.ApiKeyLast4, row.KeyVerifiedAt = nil, sql.NullString{}, sql.NullString{}
|
||||
if key == "" {
|
||||
return row, nil
|
||||
}
|
||||
p, _ := presets.Lookup(row.Provider)
|
||||
var reason string
|
||||
switch {
|
||||
case p.Key == presets.KeyNone:
|
||||
reason = row.Provider + " takes no API key"
|
||||
case p.BaseURL == "" && !secureBaseURL(row.BaseUrl):
|
||||
reason = "is sent only over https://, or http:// to localhost: set such a base_url first"
|
||||
case !validHeaderValue(key):
|
||||
reason = "has a character not allowed in a header"
|
||||
case len(key) < MinKeyLength:
|
||||
reason = fmt.Sprintf("must be at least %d characters", MinKeyLength)
|
||||
}
|
||||
if reason != "" {
|
||||
return db.AiSetting{}, &ValidationError{Problems: []Problem{{Field: "api_key", Reason: reason}}}
|
||||
}
|
||||
ct, err := s.box.Encrypt([]byte(key))
|
||||
if err != nil {
|
||||
return db.AiSetting{}, fmt.Errorf("aisettings: seal AI API key: %w", err)
|
||||
}
|
||||
row.ApiKeyCt = ct
|
||||
row.ApiKeyLast4 = sql.NullString{String: key[len(key)-4:], Valid: true}
|
||||
return row, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPrompt restores household.DefaultPersonaPrompt as the system prompt.
|
||||
func (s *Service) ResetPrompt(ctx context.Context) (Settings, error) {
|
||||
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
|
||||
row.SystemPrompt = sql.NullString{String: household.DefaultPersonaPrompt, Valid: true}
|
||||
return row, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Verify checks the stored key with the provider and, when it is
|
||||
// accepted, records key_verified_at. Settings that cannot build a
|
||||
// provider fail with ErrNotConfigured; a provider failure matches
|
||||
// ErrProviderFailed and the llm typed error the provider returned. If the
|
||||
// settings change while the provider is asked, nothing is recorded and the
|
||||
// error is ErrChanged.
|
||||
func (s *Service) Verify(ctx context.Context) (Settings, error) {
|
||||
row, err := s.read(ctx, db.New(s.st.Reader))
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
acct, key, err := s.account(row)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
if err := acct.Verify(ctx); err != nil {
|
||||
return Settings{}, redact(fmt.Errorf("%w: verify: %w", ErrProviderFailed, err), key)
|
||||
}
|
||||
verified := store.Now().String()
|
||||
return s.change(ctx, func(now db.AiSetting) (db.AiSetting, error) {
|
||||
if now.Provider != row.Provider || now.BaseUrl != row.BaseUrl || !bytes.Equal(now.ApiKeyCt, row.ApiKeyCt) {
|
||||
return db.AiSetting{}, ErrChanged
|
||||
}
|
||||
now.KeyVerifiedAt = sql.NullString{String: verified, Valid: true}
|
||||
return now, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Models returns the provider's live model list with the API key removed
|
||||
// from every model's ID and display name. Its errors are Verify's.
|
||||
func (s *Service) Models(ctx context.Context) ([]Model, error) {
|
||||
row, err := s.read(ctx, db.New(s.st.Reader))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acct, key, err := s.account(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
models, err := acct.ListModels(ctx)
|
||||
if err != nil {
|
||||
return nil, redact(fmt.Errorf("%w: list models: %w", ErrProviderFailed, err), key)
|
||||
}
|
||||
out := make([]Model, len(models))
|
||||
for i, m := range models {
|
||||
out[i] = Model{ID: llm.Redact(m.ID, key), DisplayName: llm.Redact(m.DisplayName, key)}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HarnessConfig returns the harness.Config for a skill run with the
|
||||
// current settings, sharing guard. Read it for each run, so a run always
|
||||
// uses the latest saved provider, model, temperature and persona.
|
||||
func (s *Service) HarnessConfig(ctx context.Context, guard *harness.Guard) (harness.Config, error) {
|
||||
row, err := s.read(ctx, db.New(s.st.Reader))
|
||||
if err != nil {
|
||||
return harness.Config{}, err
|
||||
}
|
||||
key, err := s.key(row)
|
||||
if err != nil {
|
||||
return harness.Config{}, err
|
||||
}
|
||||
return harness.Config{
|
||||
Settings: s.llmSettings(row),
|
||||
APIKey: key,
|
||||
NewProvider: s.newProvider,
|
||||
Persona: row.SystemPrompt.String,
|
||||
Store: s.st,
|
||||
Guard: guard,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// account builds the provider's Account from row, returning the key too
|
||||
// so the caller can redact it.
|
||||
func (s *Service) account(row db.AiSetting) (presets.Account, string, error) {
|
||||
key, err := s.key(row)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
acct, err := s.newAccount(s.llmSettings(row), key)
|
||||
if err != nil {
|
||||
return nil, "", redact(fmt.Errorf("%w: %w", ErrNotConfigured, err), key)
|
||||
}
|
||||
return acct, key, nil
|
||||
}
|
||||
|
||||
func (s *Service) llmSettings(row db.AiSetting) llm.Settings {
|
||||
return llm.Settings{
|
||||
Provider: row.Provider,
|
||||
BaseURL: row.BaseUrl,
|
||||
Model: row.Model,
|
||||
Temperature: row.Temperature,
|
||||
PublicURL: s.publicURL,
|
||||
}
|
||||
}
|
||||
|
||||
// key opens row's API key; "" when none is stored.
|
||||
func (s *Service) key(row db.AiSetting) (string, error) {
|
||||
if len(row.ApiKeyCt) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
key, err := s.box.Decrypt(row.ApiKeyCt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("aisettings: open AI API key: %w", err)
|
||||
}
|
||||
return string(key), nil
|
||||
}
|
||||
|
||||
func (s *Service) read(ctx context.Context, q *db.Queries) (db.AiSetting, error) {
|
||||
row, err := q.GetAISettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return db.AiSetting{}, errors.New("aisettings: AI settings are not seeded")
|
||||
}
|
||||
if err != nil {
|
||||
return db.AiSetting{}, fmt.Errorf("aisettings: read AI settings: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// change saves edit's result for the current row in one transaction.
|
||||
func (s *Service) change(ctx context.Context, edit func(db.AiSetting) (db.AiSetting, error)) (Settings, error) {
|
||||
var saved db.AiSetting
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := s.read(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := edit(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
saved, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
||||
Provider: next.Provider,
|
||||
BaseUrl: next.BaseUrl,
|
||||
Model: next.Model,
|
||||
Temperature: next.Temperature,
|
||||
SystemPrompt: next.SystemPrompt,
|
||||
ApiKeyCt: next.ApiKeyCt,
|
||||
ApiKeyLast4: next.ApiKeyLast4,
|
||||
KeyVerifiedAt: next.KeyVerifiedAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: store.Now().String(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("aisettings: save AI settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(saved), nil
|
||||
}
|
||||
|
||||
// applyPatch returns row with patch applied, validated.
|
||||
func applyPatch(row db.AiSetting, patch Patch) (db.AiSetting, error) {
|
||||
var problems []Problem
|
||||
fail := func(field, reason string) {
|
||||
problems = append(problems, Problem{Field: field, Reason: reason})
|
||||
}
|
||||
if patch == (Patch{}) {
|
||||
fail("settings", "no settings to change")
|
||||
}
|
||||
before := row
|
||||
if patch.Provider != nil {
|
||||
if _, ok := presets.Lookup(*patch.Provider); ok {
|
||||
row.Provider = *patch.Provider
|
||||
} else {
|
||||
fail("provider", "must be one of "+strings.Join(presets.Names(), ", "))
|
||||
}
|
||||
}
|
||||
if patch.BaseURL != nil {
|
||||
row.BaseUrl = *patch.BaseURL
|
||||
}
|
||||
if patch.Model != nil {
|
||||
switch m := *patch.Model; {
|
||||
case strings.TrimSpace(m) == "":
|
||||
fail("model", "must not be empty")
|
||||
case !validHeaderValue(m):
|
||||
fail("model", "must not contain control characters")
|
||||
default:
|
||||
row.Model = m
|
||||
}
|
||||
}
|
||||
if patch.Temperature != nil {
|
||||
t := *patch.Temperature
|
||||
tenths := math.Round(t * 10)
|
||||
if t < 0 || t > 1 || math.Abs(t*10-tenths) > 1e-9 {
|
||||
fail("temperature", "must be between 0 and 1 in steps of 0.1")
|
||||
} else {
|
||||
row.Temperature = tenths / 10
|
||||
}
|
||||
}
|
||||
if patch.SystemPrompt != nil {
|
||||
if n := utf8.RuneCountInString(*patch.SystemPrompt); n > MaxSystemPromptChars {
|
||||
fail("system_prompt", fmt.Sprintf("must be at most %d characters, got %d", MaxSystemPromptChars, n))
|
||||
} else {
|
||||
row.SystemPrompt = sql.NullString{String: *patch.SystemPrompt, Valid: true}
|
||||
}
|
||||
}
|
||||
if p, ok := presets.Lookup(row.Provider); ok && len(problems) == 0 {
|
||||
switch {
|
||||
case p.BaseURL != "" && row.BaseUrl != "" && row.BaseUrl != p.BaseURL:
|
||||
fail("base_url", fmt.Sprintf("is fixed for %s: must be empty or %s", p.Name, p.BaseURL))
|
||||
case p.BaseURL == "" && row.BaseUrl != "":
|
||||
// The preset's own check of the URL; its errors never quote it.
|
||||
if _, err := presets.ForAccount(llm.Settings{Provider: p.Name, BaseURL: row.BaseUrl}, ""); err != nil {
|
||||
fail("base_url", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
// The stored key is only ever sent where it was stored for: moving it
|
||||
// to another provider or server would let a changed base URL receive
|
||||
// it. Remove the key, change the endpoint, then store the new key.
|
||||
if len(problems) == 0 && len(row.ApiKeyCt) > 0 {
|
||||
if row.Provider != before.Provider {
|
||||
fail("provider", "cannot change while an API key is stored: remove the key first")
|
||||
}
|
||||
if row.BaseUrl != before.BaseUrl {
|
||||
fail("base_url", "cannot change while an API key is stored: remove the key first")
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return db.AiSetting{}, &ValidationError{Problems: problems}
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func settingsFrom(row db.AiSetting) Settings {
|
||||
return Settings{
|
||||
Provider: row.Provider,
|
||||
BaseURL: row.BaseUrl,
|
||||
Model: row.Model,
|
||||
Temperature: row.Temperature,
|
||||
SystemPrompt: nullString(row.SystemPrompt),
|
||||
APIKeyLast4: nullString(row.ApiKeyLast4),
|
||||
KeyVerifiedAt: nullString(row.KeyVerifiedAt),
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
|
||||
// secureBaseURL reports whether base is a URL a key may be sent to.
|
||||
func secureBaseURL(base string) bool {
|
||||
u, err := url.Parse(base)
|
||||
return base != "" && err == nil && secureurl.Check(u) == nil
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether value has no control character other
|
||||
// than tab.
|
||||
func validHeaderValue(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// redactedError is an error with the API key removed from its text. It
|
||||
// does not unwrap, because the original may hold the key; Is still matches
|
||||
// the sentinels the original wraps.
|
||||
type redactedError struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *redactedError) Error() string { return e.text }
|
||||
|
||||
// Is reports whether the original error matches target.
|
||||
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
||||
|
||||
// redact returns err with key removed from its text; an empty key returns
|
||||
// err unchanged.
|
||||
func redact(err error, key string) error {
|
||||
if key == "" {
|
||||
return err
|
||||
}
|
||||
return &redactedError{text: llm.Redact(err.Error(), key), err: err}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package aisettings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
|
||||
"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/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const testKey = "sk-ant-test-secret-key-41ab"
|
||||
|
||||
// newService returns a Service over a migrated store with the seed AI
|
||||
// settings.
|
||||
func newService(t *testing.T) (*Service, *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.SeedAISettings(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := make([]byte, secretbox.KeySize)
|
||||
rand.Read(raw)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := New(st, box, "https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, st
|
||||
}
|
||||
|
||||
// useFake makes s build f for every run, verification and model list, and
|
||||
// returns a func reporting the settings and key each build was given.
|
||||
func useFake(s *Service, f *llm.Fake) func() ([]llm.Settings, []string) {
|
||||
var mu sync.Mutex
|
||||
var settings []llm.Settings
|
||||
var keys []string
|
||||
record := func(set llm.Settings, key string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
settings = append(settings, set)
|
||||
keys = append(keys, key)
|
||||
}
|
||||
s.newProvider = func(set llm.Settings, key string) (llm.Provider, error) {
|
||||
record(set, key)
|
||||
return f, nil
|
||||
}
|
||||
s.newAccount = func(set llm.Settings, key string) (presets.Account, error) {
|
||||
record(set, key)
|
||||
return f, nil
|
||||
}
|
||||
return func() ([]llm.Settings, []string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return settings, keys
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
// echoSkill asks the model to repeat a word; its prompt is the harness's
|
||||
// test template prompts/echo.v1.tmpl.
|
||||
type echoSkill struct{}
|
||||
|
||||
type echoInput struct{ Word string }
|
||||
|
||||
type echoOutput struct {
|
||||
Echo string `json:"echo"`
|
||||
}
|
||||
|
||||
func (echoSkill) Name() string { return "echo" }
|
||||
func (echoSkill) Version() int { return 1 }
|
||||
func (echoSkill) PromptInput(in echoInput) (harness.PromptInput, error) {
|
||||
return harness.PromptInput{Data: in}, nil
|
||||
}
|
||||
func (echoSkill) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "echo", Schema: json.RawMessage(
|
||||
`{"type":"object","properties":{"echo":{"type":"string"}},"required":["echo"],"additionalProperties":false}`)}
|
||||
}
|
||||
func (echoSkill) Validate(echoOutput) []string { return nil }
|
||||
func (echoSkill) PostProcess(out echoOutput) (echoOutput, error) { return out, nil }
|
||||
func (echoSkill) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// A PATCHed model and temperature are what the next skill run uses: the
|
||||
// provider is built with them, the request carries the temperature and the
|
||||
// ai_runs row records both. A second PATCH changes the run after it.
|
||||
func TestPatchedModelAndTemperatureUsedByNextRun(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, st := newService(t)
|
||||
f := &llm.Fake{}
|
||||
built := useFake(s, f)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i, want := range []struct {
|
||||
model string
|
||||
temp float64
|
||||
}{{"claude-patched", 0.3}, {"claude-repatched", 1}} {
|
||||
if _, err := s.Update(ctx, Patch{Model: ptr(want.model), Temperature: ptr(want.temp)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := s.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.QueueGenerate(llm.GenerateResult{Response: llm.Response{JSON: json.RawMessage(`{"echo":"hi"}`)}})
|
||||
if _, err := harness.Run(ctx, cfg, echoSkill{}, "test", echoInput{Word: "hi"}); err != nil {
|
||||
t.Fatalf("run %d: %v", i, err)
|
||||
}
|
||||
|
||||
settings, keys := built()
|
||||
if got := settings[i]; got.Provider != "anthropic" || got.Model != want.model || got.Temperature != want.temp || keys[i] != testKey {
|
||||
t.Errorf("run %d provider built with %+v, key ok %v; want model %s at %v", i, got, keys[i] == testKey, want.model, want.temp)
|
||||
}
|
||||
req := f.Requests()[i]
|
||||
if req.Temperature == nil || *req.Temperature != want.temp {
|
||||
t.Errorf("run %d request temperature = %v, want %v", i, req.Temperature, want.temp)
|
||||
}
|
||||
if !strings.HasPrefix(req.System, household.DefaultPersonaPrompt[:40]) {
|
||||
t.Errorf("run %d system prompt does not start with the persona: %.80q", i, req.System)
|
||||
}
|
||||
var model string
|
||||
var temp float64
|
||||
err = st.Reader.QueryRowContext(ctx, `SELECT model, temperature FROM ai_runs ORDER BY created_at DESC, rowid DESC LIMIT 1`).Scan(&model, &temp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if model != want.model || temp != want.temp {
|
||||
t.Errorf("run %d ai_runs row = %s at %v, want %s at %v", i, model, temp, want.model, want.temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateValidates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
patch Patch
|
||||
field string
|
||||
}{
|
||||
{"empty", Patch{}, "settings"},
|
||||
{"unknown provider", Patch{Provider: ptr("gemini")}, "provider"},
|
||||
{"temperature above 1", Patch{Temperature: ptr(1.1)}, "temperature"},
|
||||
{"negative temperature", Patch{Temperature: ptr(-0.1)}, "temperature"},
|
||||
{"temperature between steps", Patch{Temperature: ptr(0.35)}, "temperature"},
|
||||
{"empty model", Patch{Model: ptr(" ")}, "model"},
|
||||
{"long system prompt", Patch{SystemPrompt: ptr(strings.Repeat("ü", MaxSystemPromptChars+1))}, "system_prompt"},
|
||||
{"other base URL for a fixed preset", Patch{BaseURL: ptr("https://api.example/v1")}, "base_url"},
|
||||
{"bad base URL", Patch{Provider: ptr("ollama"), BaseURL: ptr("ftp://ollama:11434")}, "base_url"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s, _ := newService(t)
|
||||
before, err := s.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.Update(ctx, c.patch)
|
||||
var invalid *ValidationError
|
||||
if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != c.field {
|
||||
t.Fatalf("Update = %v, want one %s problem", err, c.field)
|
||||
}
|
||||
if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) {
|
||||
t.Errorf("settings changed by a refused patch: %+v, %v", after, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccepts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
for _, temp := range []float64{0, 0.1, 0.2, 0.3, 0.6, 0.7, 0.9, 1} {
|
||||
got, err := s.Update(ctx, Patch{Temperature: ptr(temp)})
|
||||
if err != nil || got.Temperature != temp {
|
||||
t.Errorf("temperature %v = %v, %v", temp, got.Temperature, err)
|
||||
}
|
||||
}
|
||||
prompt := strings.Repeat("ü", MaxSystemPromptChars)
|
||||
if got, err := s.Update(ctx, Patch{SystemPrompt: &prompt}); err != nil || *got.SystemPrompt != prompt {
|
||||
t.Errorf("system prompt of %d characters: %v", MaxSystemPromptChars, err)
|
||||
}
|
||||
for _, p := range presets.Names() {
|
||||
patch := Patch{Provider: ptr(p), BaseURL: ptr("")}
|
||||
if pr, _ := presets.Lookup(p); pr.BaseURL == "" {
|
||||
patch.BaseURL = ptr("http://llm.local:11434")
|
||||
}
|
||||
if got, err := s.Update(ctx, patch); err != nil || got.Provider != p {
|
||||
t.Errorf("provider %s: %+v, %v", p, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// While a key is stored the provider and base URL cannot change, so the
|
||||
// key never reaches an endpoint it was not stored for; the model,
|
||||
// temperature and system prompt can, keeping the verification.
|
||||
func TestEndpointLockedWhileKeyStored(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
f := &llm.Fake{}
|
||||
useFake(s, f)
|
||||
if _, err := s.Update(ctx, Patch{Provider: ptr("openai_compatible"), BaseURL: ptr("https://llm.example/v1")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.QueueVerify(nil)
|
||||
if got, err := s.Verify(ctx); err != nil || got.KeyVerifiedAt == nil {
|
||||
t.Fatalf("Verify = %+v, %v", got, err)
|
||||
}
|
||||
got, err := s.Update(ctx, Patch{Model: ptr("m"), Temperature: ptr(0.5), SystemPrompt: ptr("p")})
|
||||
if err != nil || got.KeyVerifiedAt == nil {
|
||||
t.Errorf("model change = %+v, %v; want verification kept", got, err)
|
||||
}
|
||||
before, err := s.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
patch Patch
|
||||
field string
|
||||
}{
|
||||
{Patch{BaseURL: ptr("https://attacker.example/v1")}, "base_url"},
|
||||
{Patch{Provider: ptr("openai"), BaseURL: ptr("")}, "provider"},
|
||||
{Patch{Provider: ptr("ollama"), BaseURL: ptr("http://localhost:11434")}, "provider"},
|
||||
} {
|
||||
var invalid *ValidationError
|
||||
_, err := s.Update(ctx, c.patch)
|
||||
if !errors.As(err, &invalid) || !slices.ContainsFunc(invalid.Problems, func(p Problem) bool { return p.Field == c.field }) {
|
||||
t.Errorf("Update(%+v) with a key stored = %v, want a %s problem", c.patch, err, c.field)
|
||||
}
|
||||
}
|
||||
if after, err := s.Get(ctx); err != nil || !reflect.DeepEqual(after, before) {
|
||||
t.Errorf("refused endpoint change altered the settings: %+v, %v", after, err)
|
||||
}
|
||||
|
||||
// Removing the key frees the endpoint; a new key is unverified.
|
||||
if _, err := s.SetAPIKey(ctx, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Update(ctx, Patch{Provider: ptr("openai"), BaseURL: ptr("")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = s.SetAPIKey(ctx, "sk-another-key-9999")
|
||||
if err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "9999" {
|
||||
t.Errorf("new key = %+v, %v; want it unverified", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A key is never stored for a base URL it would cross the network to in
|
||||
// plain text.
|
||||
func TestSetAPIKeyNeedsSecureBaseURL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, c := range []struct {
|
||||
base string
|
||||
ok bool
|
||||
}{
|
||||
{"", false},
|
||||
{"http://llm.lan:8080/v1", false},
|
||||
{"http://127.0.0.1:8080/v1", true},
|
||||
{"https://llm.example/v1", true},
|
||||
} {
|
||||
s, _ := newService(t)
|
||||
patch := Patch{Provider: ptr("openai_compatible")}
|
||||
if c.base != "" {
|
||||
patch.BaseURL = ptr(c.base)
|
||||
}
|
||||
if _, err := s.Update(ctx, patch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := s.SetAPIKey(ctx, testKey)
|
||||
var invalid *ValidationError
|
||||
if c.ok != (err == nil) || (!c.ok && !errors.As(err, &invalid)) {
|
||||
t.Errorf("SetAPIKey with base URL %q = %v, want ok %v", c.base, err, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyReturnsProviderTypedError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
f := &llm.Fake{}
|
||||
useFake(s, f)
|
||||
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []error{llm.ErrAuth, llm.NewRateLimitedError(time.Second, testKey), llm.NewProviderError(500, []byte("bad "+testKey), testKey)} {
|
||||
f.QueueVerify(want)
|
||||
_, err := s.Verify(ctx)
|
||||
if !errors.Is(err, ErrProviderFailed) || !errors.Is(err, want) {
|
||||
t.Errorf("Verify = %v, want ErrProviderFailed and %v", err, want)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), testKey) {
|
||||
t.Errorf("Verify error holds the key: %v", err)
|
||||
}
|
||||
}
|
||||
if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil {
|
||||
t.Errorf("failed verification recorded: %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A key error that quotes the key, as a misbehaving adapter might, is
|
||||
// redacted by the service.
|
||||
func TestVerifyRedactsKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
f := &llm.Fake{}
|
||||
useFake(s, f)
|
||||
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.QueueVerify(errors.Join(llm.ErrAuth, errors.New("rejected "+testKey)))
|
||||
_, err := s.Verify(ctx)
|
||||
if !errors.Is(err, llm.ErrAuth) || strings.Contains(err.Error(), testKey) {
|
||||
t.Errorf("Verify = %v, want ErrAuth without the key", err)
|
||||
}
|
||||
f.QueueListModels(llm.ListModelsResult{Err: errors.New("rejected " + testKey)})
|
||||
if _, err := s.Models(ctx); !errors.Is(err, ErrProviderFailed) || strings.Contains(err.Error(), testKey) {
|
||||
t.Errorf("Models = %v, want ErrProviderFailed without the key", err)
|
||||
}
|
||||
f.QueueListModels(llm.ListModelsResult{Models: []llm.Model{{ID: "m-" + testKey, DisplayName: "Model " + testKey}}})
|
||||
models, err := s.Models(ctx)
|
||||
if err != nil || len(models) != 1 || models[0] != (Model{ID: "m-[REDACTED]", DisplayName: "Model [REDACTED]"}) {
|
||||
t.Errorf("Models = %+v, %v, want the key redacted", models, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings changed while the provider verifies the key are not marked
|
||||
// verified.
|
||||
func TestVerifyRefusesChangedSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
if _, err := s.SetAPIKey(ctx, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.newAccount = func(llm.Settings, string) (presets.Account, error) {
|
||||
return changingAccount{s: s}, nil
|
||||
}
|
||||
if _, err := s.Verify(ctx); !errors.Is(err, ErrChanged) {
|
||||
t.Fatalf("Verify = %v, want ErrChanged", err)
|
||||
}
|
||||
if got, err := s.Get(ctx); err != nil || got.KeyVerifiedAt != nil || *got.APIKeyLast4 != "5678" {
|
||||
t.Errorf("settings after a changed verification = %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// changingAccount replaces the stored key while it verifies.
|
||||
type changingAccount struct{ s *Service }
|
||||
|
||||
func (a changingAccount) Verify(ctx context.Context) error {
|
||||
_, err := a.s.SetAPIKey(ctx, "sk-replaced-5678")
|
||||
return err
|
||||
}
|
||||
|
||||
func (changingAccount) ListModels(context.Context) ([]llm.Model, error) { return nil, nil }
|
||||
|
||||
func TestNotConfigured(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
// The seed is anthropic with no key; the real presets refuse it.
|
||||
if _, err := s.Verify(ctx); !errors.Is(err, ErrNotConfigured) || !errors.Is(err, llm.ErrMissingKey) {
|
||||
t.Errorf("Verify = %v, want ErrNotConfigured and ErrMissingKey", err)
|
||||
}
|
||||
if _, err := s.Models(ctx); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Errorf("Models = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAPIKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, st := newService(t)
|
||||
got, err := s.SetAPIKey(ctx, testKey)
|
||||
if err != nil || got.APIKeyLast4 == nil || *got.APIKeyLast4 != "41ab" {
|
||||
t.Fatalf("SetAPIKey = %+v, %v", got, err)
|
||||
}
|
||||
var ct []byte
|
||||
if err := st.Reader.QueryRowContext(ctx, `SELECT api_key_ct FROM ai_settings`).Scan(&ct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(ct), testKey) {
|
||||
t.Error("the key is stored in plain text")
|
||||
}
|
||||
if key, err := s.key(mustRow(t, s)); err != nil || key != testKey {
|
||||
t.Errorf("stored key opens to %q, %v", key, err)
|
||||
}
|
||||
|
||||
for _, bad := range []string{"short", "sk-bad\r\nkey"} {
|
||||
var invalid *ValidationError
|
||||
if _, err := s.SetAPIKey(ctx, bad); !errors.As(err, &invalid) {
|
||||
t.Errorf("SetAPIKey(%q) = %v, want a ValidationError", bad, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama takes no key: neither storing one nor switching to it with one.
|
||||
var invalid *ValidationError
|
||||
if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); !errors.As(err, &invalid) {
|
||||
t.Errorf("switch to ollama with a key = %v, want a ValidationError", err)
|
||||
}
|
||||
if got, err := s.SetAPIKey(ctx, ""); err != nil || got.APIKeyLast4 != nil {
|
||||
t.Fatalf("remove key = %+v, %v", got, err)
|
||||
}
|
||||
if _, err := s.Update(ctx, Patch{Provider: ptr("ollama"), BaseURL: ptr("http://ollama:11434")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SetAPIKey(ctx, testKey); !errors.As(err, &invalid) {
|
||||
t.Errorf("SetAPIKey for ollama = %v, want a ValidationError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPrompt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := newService(t)
|
||||
if _, err := s.Update(ctx, Patch{SystemPrompt: ptr("")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.ResetPrompt(ctx)
|
||||
if err != nil || got.SystemPrompt == nil || *got.SystemPrompt != household.DefaultPersonaPrompt {
|
||||
t.Errorf("ResetPrompt = %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRow(t *testing.T, s *Service) db.AiSetting {
|
||||
t.Helper()
|
||||
row, err := s.read(context.Background(), db.New(s.st.Reader))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// PassedLimit is how many of the most recently passed Side quests the
|
||||
// household context holds.
|
||||
// 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,
|
||||
@@ -34,8 +34,8 @@ type HouseholdContext struct {
|
||||
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 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.
|
||||
@@ -137,7 +137,7 @@ func readContext(ctx context.Context, st *store.Store, now time.Time) (h Househo
|
||||
h.QuestLog = append(h.QuestLog, d.Name)
|
||||
}
|
||||
|
||||
passed, err := q.ListRecentDeclinedNames(ctx, PassedLimit)
|
||||
passed, err := q.ListRecentPassedNames(ctx, PassedLimit)
|
||||
if err != nil {
|
||||
return h, fmt.Errorf("read passed Side quests: %w", err)
|
||||
}
|
||||
|
||||
@@ -82,9 +82,9 @@ func (s *seeder) pass(name, decidedAt string) {
|
||||
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)
|
||||
s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, name, reason, cited_regular_ids, status,
|
||||
decided_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`,
|
||||
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, name, decidedAt, decidedAt, decidedAt)
|
||||
}
|
||||
|
||||
// plan inserts the week plan starting weekStart with one dinner per date;
|
||||
|
||||
@@ -1,10 +1,70 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestSharedDedupeCases runs web/src/dinners/testdata/dedupe_cases.json,
|
||||
// which the PWA's copy of Normalize and Similar (web/src/dinners/names.ts)
|
||||
// runs too, so the two cannot drift apart on these names.
|
||||
func TestSharedDedupeCases(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../../web/src/dinners/testdata/dedupe_cases.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cases struct {
|
||||
Normalize []struct{ Name, Want string }
|
||||
Similar []struct {
|
||||
A, B string
|
||||
Want bool
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases.Normalize) == 0 || len(cases.Similar) == 0 {
|
||||
t.Fatal("dedupe_cases.json has no cases")
|
||||
}
|
||||
for _, c := range cases.Normalize {
|
||||
if got := Normalize(c.Name); got != c.Want {
|
||||
t.Errorf("Normalize(%q) = %q, want %q", c.Name, got, c.Want)
|
||||
}
|
||||
}
|
||||
for _, c := range cases.Similar {
|
||||
if got := Similar(c.A, c.B); got != c.Want {
|
||||
t.Errorf("Similar(%q, %q) = %v, want %v", c.A, c.B, got, c.Want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLetterFoldsMatchPWA checks that the PWA's letter-fold table, which
|
||||
// gen_folds.py writes beside folds_table.go, is letterFolds exactly; run
|
||||
// go generate when it fails.
|
||||
func TestLetterFoldsMatchPWA(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../../web/src/dinners/letterFolds.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pwa map[string]string
|
||||
if err := json.Unmarshal(raw, &pwa); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for r, want := range letterFolds {
|
||||
if got, ok := pwa[string(r)]; !ok || got != want {
|
||||
t.Errorf("PWA folds %U to %q (present %v), want %q", r, got, ok, want)
|
||||
}
|
||||
}
|
||||
for k := range pwa {
|
||||
if utf8.RuneCountInString(k) != 1 || letterFolds[[]rune(k)[0]] == "" {
|
||||
t.Errorf("PWA folds %q, which letterFolds does not", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, want string
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate folds_table.go, the letter-fold table used by harness.Normalize.
|
||||
"""Generate folds_table.go, the letter-fold table used by harness.Normalize,
|
||||
and its copy web/src/dinners/letterFolds.json, used by the PWA's
|
||||
normalizeName (web/src/dinners/names.ts). TestLetterFoldsMatchPWA fails when
|
||||
the two differ.
|
||||
|
||||
Run from this directory with `go generate ./internal/ai/harness` (or
|
||||
`python3 gen_folds.py`; gofmt must be on PATH). It uses only the Python
|
||||
@@ -28,11 +31,14 @@ Hangul syllables are left out: their decomposition is into conjoining jamo,
|
||||
not combining marks.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
|
||||
|
||||
EXTRA = {
|
||||
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
|
||||
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
|
||||
@@ -110,6 +116,9 @@ def main():
|
||||
with open("folds_table.go", "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out) + "\n")
|
||||
subprocess.run(["gofmt", "-w", "folds_table.go"], check=True)
|
||||
with open(PWA_TABLE, "w", encoding="ascii") as f:
|
||||
json.dump({chr(cp): folded for cp, folded in entries}, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"regexp"
|
||||
"slices"
|
||||
@@ -115,6 +116,11 @@ func Lookup(name string) (Preset, bool) {
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Names returns every preset's name, sorted.
|
||||
func Names() []string {
|
||||
return slices.Sorted(maps.Keys(all))
|
||||
}
|
||||
|
||||
// all is every preset, by Settings.Provider name.
|
||||
var all = map[string]Preset{
|
||||
"anthropic": {
|
||||
@@ -181,19 +187,40 @@ var all = map[string]Preset{
|
||||
// preset's OmitTemperature matches. It is wrapped in llm.NewRetrying, so
|
||||
// Generate retries transient vendor failures and has the default timeout.
|
||||
func FromSettings(s llm.Settings, key string) (llm.Provider, error) {
|
||||
cp, err := fromSettings(s, key)
|
||||
cp, err := fromSettings(s, key, true)
|
||||
if err != nil {
|
||||
return nil, redact(err, key)
|
||||
}
|
||||
return llm.NewRetrying(cp), nil
|
||||
}
|
||||
|
||||
func fromSettings(s llm.Settings, key string) (*configured, error) {
|
||||
// Account is what a provider offers before a model is chosen: the live
|
||||
// model list and a check of the credentials.
|
||||
type Account interface {
|
||||
ListModels(ctx context.Context) ([]llm.Model, error)
|
||||
Verify(ctx context.Context) error
|
||||
}
|
||||
|
||||
// ForAccount returns the Account s describes, using key as its API key. It
|
||||
// checks s and key as FromSettings does except that s.Model may be empty,
|
||||
// so the household can verify its key and pick from the live model list
|
||||
// before it has chosen a model. Neither call is retried.
|
||||
func ForAccount(s llm.Settings, key string) (Account, error) {
|
||||
cp, err := fromSettings(s, key, false)
|
||||
if err != nil {
|
||||
return nil, redact(err, key)
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
// fromSettings builds the provider s describes; needModel is whether
|
||||
// s.Model is required.
|
||||
func fromSettings(s llm.Settings, key string, needModel bool) (*configured, error) {
|
||||
p, ok := Lookup(s.Provider)
|
||||
if !ok {
|
||||
return nil, llm.ErrUnknownProvider
|
||||
}
|
||||
if err := check(p, s, key); err != nil {
|
||||
if err := check(p, s, key, needModel); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", p.Name, err)
|
||||
}
|
||||
base := p.BaseURL
|
||||
@@ -233,8 +260,9 @@ func fromSettings(s llm.Settings, key string) (*configured, error) {
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
// check validates s and key against p. Its errors never quote a value.
|
||||
func check(p Preset, s llm.Settings, key string) error {
|
||||
// check validates s and key against p, and that s names a model when
|
||||
// needModel is set. Its errors never quote a value.
|
||||
func check(p Preset, s llm.Settings, key string, needModel bool) error {
|
||||
switch {
|
||||
case p.Key == KeyRequired && key == "":
|
||||
return llm.ErrMissingKey
|
||||
@@ -249,7 +277,7 @@ func check(p Preset, s llm.Settings, key string) error {
|
||||
case p.BaseURL != "" && s.BaseURL != "" && s.BaseURL != p.BaseURL:
|
||||
return fmt.Errorf("%w: base URL is fixed by the preset", llm.ErrInvalidSetting)
|
||||
}
|
||||
if s.Model == "" {
|
||||
if needModel && s.Model == "" {
|
||||
return llm.ErrMissingModel
|
||||
}
|
||||
if p.RequiresPublicURL && s.PublicURL == "" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -660,3 +661,35 @@ func TestFromSettingsRetriesTransportTimeout(t *testing.T) {
|
||||
t.Errorf("sent %d requests, want 2", st.sent)
|
||||
}
|
||||
}
|
||||
|
||||
// ForAccount builds without a model, so a key can be verified and the
|
||||
// model list read before a model is chosen, but keeps every other check.
|
||||
func TestForAccount(t *testing.T) {
|
||||
if _, err := ForAccount(llm.Settings{Provider: "openai"}, testKey); err != nil {
|
||||
t.Errorf("ForAccount without a model = %v", err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
s llm.Settings
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{llm.Settings{Provider: "gemini"}, testKey, llm.ErrUnknownProvider},
|
||||
{llm.Settings{Provider: "openai"}, "", llm.ErrMissingKey},
|
||||
{llm.Settings{Provider: "ollama"}, "", llm.ErrMissingBaseURL},
|
||||
{llm.Settings{Provider: "openrouter"}, testKey, llm.ErrMissingPublicURL},
|
||||
} {
|
||||
if _, err := ForAccount(c.s, c.key); !errors.Is(err, c.want) {
|
||||
t.Errorf("ForAccount(%s) = %v, want %v", c.s.Provider, err, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := FromSettings(llm.Settings{Provider: "openai"}, testKey); !errors.Is(err, llm.ErrMissingModel) {
|
||||
t.Errorf("FromSettings without a model = %v, want ErrMissingModel", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNames(t *testing.T) {
|
||||
want := []string{"anthropic", "ollama", "openai", "openai_compatible", "openrouter", "xai"}
|
||||
if got := Names(); !slices.Equal(got, want) {
|
||||
t.Errorf("Names = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
// Aliased: the package tests name a fixture household.
|
||||
hh "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// NoteLimit is the most characters a drafted week's note may have.
|
||||
const NoteLimit = 160
|
||||
|
||||
// WeekLength is how many dinners a drafted week holds, one a day from
|
||||
// Monday to Sunday.
|
||||
const WeekLength = 7
|
||||
|
||||
// Candidate kinds: a Regular or a Quest log dinner.
|
||||
const (
|
||||
KindRegular = "regular"
|
||||
KindQuestLog = "quest_log"
|
||||
)
|
||||
|
||||
// WeekRequest is what draft_week reads.
|
||||
type WeekRequest struct {
|
||||
// Context is the household context (see harness.BuildContext).
|
||||
Context harness.HouseholdContext
|
||||
// WeekStart is the Monday of the week to draft, household-local.
|
||||
WeekStart store.LocalDate
|
||||
// Pool holds the dinners the week may be drafted from. It must hold at
|
||||
// least WeekLength dinners, each once.
|
||||
Pool []Candidate
|
||||
// LastWeek holds the IDs of the dinners planned in the week before
|
||||
// WeekStart. They need not be in Pool.
|
||||
LastWeek []string
|
||||
// DinnerTime is the household's dinner time, "HH:MM", from which a
|
||||
// crock-pot dinner's StartBy is computed.
|
||||
DinnerTime string
|
||||
}
|
||||
|
||||
// Candidate is one dinner of the pool.
|
||||
type Candidate struct {
|
||||
ID string
|
||||
Name string
|
||||
// Kind is KindRegular or KindQuestLog.
|
||||
Kind string
|
||||
// Method is one of stovetop, oven, crockpot, grill and other, or ""
|
||||
// when unknown.
|
||||
Method string
|
||||
// Minutes from prep to table, or 0 when unknown.
|
||||
Minutes int
|
||||
}
|
||||
|
||||
// NotEnoughDinnersError is a pool too small to fill a week. DraftWeek
|
||||
// returns it before any LLM call.
|
||||
type NotEnoughDinnersError struct {
|
||||
// Pool is how many dinners the pool holds.
|
||||
Pool int
|
||||
}
|
||||
|
||||
func (e *NotEnoughDinnersError) Error() string {
|
||||
return fmt.Sprintf("not enough dinners: the pool holds %d, a week needs %d", e.Pool, WeekLength)
|
||||
}
|
||||
|
||||
// Week is draft_week's output.
|
||||
type Week struct {
|
||||
// Days are the week's dinners, Monday to Sunday.
|
||||
Days []WeekDay `json:"days"`
|
||||
// Note is a short word on the week, at most NoteLimit characters. It
|
||||
// may be empty.
|
||||
Note string `json:"note"`
|
||||
// Warnings say what the draft did that the rules discourage but allow,
|
||||
// such as repeating last week's dinners. It is never part of the
|
||||
// model's reply.
|
||||
Warnings []string `json:"-"`
|
||||
}
|
||||
|
||||
// WeekDay is one day's dinner.
|
||||
type WeekDay struct {
|
||||
// Date is the household-local date, YYYY-MM-DD.
|
||||
Date string `json:"date"`
|
||||
// DinnerID is the ID of a dinner of the pool.
|
||||
DinnerID string `json:"dinner_id"`
|
||||
// IsNew is true for a Quest log dinner. It is computed from the pool,
|
||||
// never part of the model's reply.
|
||||
IsNew bool `json:"-"`
|
||||
// StartBy is when a crock-pot dinner must start to be ready at the
|
||||
// household's dinner time; it is zero for any other dinner and for a
|
||||
// crock-pot dinner whose minutes are unknown. It is computed, never
|
||||
// part of the model's reply.
|
||||
StartBy time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// DraftWeek runs draft_week v1 on in and returns a week of seven dinners
|
||||
// from the pool, one a day from Monday to Sunday, none twice. A pool of
|
||||
// fewer than WeekLength dinners fails with *NotEnoughDinnersError before
|
||||
// any LLM call, and any other invalid request fails before it too. scope
|
||||
// names the week (see harness.Run).
|
||||
func DraftWeek(ctx context.Context, cfg harness.Config, scope string, in WeekRequest) (Week, error) {
|
||||
s, err := newDraftWeek(in)
|
||||
if err != nil {
|
||||
return Week{}, err
|
||||
}
|
||||
return harness.Run(ctx, cfg, s, scope, in)
|
||||
}
|
||||
|
||||
// draftWeek is the skill. It holds its input, the week's dates and the
|
||||
// pool by ID because validation and post-processing compare the output
|
||||
// with them.
|
||||
type draftWeek struct {
|
||||
in WeekRequest
|
||||
dates [WeekLength]store.LocalDate
|
||||
pool map[string]Candidate
|
||||
zone *time.Location
|
||||
}
|
||||
|
||||
var _ harness.Skill[WeekRequest, Week] = draftWeek{}
|
||||
|
||||
// newDraftWeek checks in and returns the skill for it.
|
||||
func newDraftWeek(in WeekRequest) (draftWeek, error) {
|
||||
pool := map[string]Candidate{}
|
||||
for i, c := range in.Pool {
|
||||
if err := c.check(); err != nil {
|
||||
return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: %w", i+1, err)
|
||||
}
|
||||
if _, dup := pool[c.ID]; dup {
|
||||
return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: id %q is in the pool twice", i+1, c.ID)
|
||||
}
|
||||
pool[c.ID] = c
|
||||
}
|
||||
if len(pool) < WeekLength {
|
||||
return draftWeek{}, &NotEnoughDinnersError{Pool: len(pool)}
|
||||
}
|
||||
dates, err := hh.WeekDays(in.WeekStart)
|
||||
if err != nil {
|
||||
return draftWeek{}, fmt.Errorf("draft_week: %w", err)
|
||||
}
|
||||
zone, err := hh.LoadZone(in.Context.Timezone)
|
||||
if err != nil {
|
||||
return draftWeek{}, fmt.Errorf("draft_week: %w", err)
|
||||
}
|
||||
// The dinner time is checked here so a bad one fails before the LLM
|
||||
// call rather than in PostProcess.
|
||||
if _, err := hh.LocalTime(zone, in.WeekStart, in.DinnerTime); err != nil {
|
||||
return draftWeek{}, fmt.Errorf("draft_week: dinner time: %w", err)
|
||||
}
|
||||
return draftWeek{in: in, dates: dates, pool: pool, zone: zone}, nil
|
||||
}
|
||||
|
||||
// check reports a candidate the pool cannot hold.
|
||||
func (c Candidate) check() error {
|
||||
switch {
|
||||
case strings.TrimSpace(c.ID) == "" || strings.ContainsAny(c.ID, "\r\n"):
|
||||
return fmt.Errorf("id %q is empty or has a line break", c.ID)
|
||||
case strings.TrimSpace(c.Name) == "":
|
||||
return fmt.Errorf("%s has an empty name", c.ID)
|
||||
case c.Kind != KindRegular && c.Kind != KindQuestLog:
|
||||
return fmt.Errorf("%s kind %q is not %s or %s", c.ID, c.Kind, KindRegular, KindQuestLog)
|
||||
case c.Method != "" && !slices.Contains(methods, c.Method):
|
||||
return fmt.Errorf("%s method %q is not one of %s", c.ID, c.Method, strings.Join(methods, ", "))
|
||||
case c.Minutes < 0:
|
||||
return fmt.Errorf("%s minutes %d is negative", c.ID, c.Minutes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (draftWeek) Name() string { return "draft_week" }
|
||||
|
||||
func (draftWeek) Version() int { return 1 }
|
||||
|
||||
func (draftWeek) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// weekPrompt is the template data of draft_week.v1.tmpl.
|
||||
type weekPrompt struct {
|
||||
// Days are the week's dates, Monday to Sunday, e.g. "Mon 2026-10-12".
|
||||
Days []string
|
||||
}
|
||||
|
||||
func (s draftWeek) PromptInput(in WeekRequest) (harness.PromptInput, error) {
|
||||
var days []string
|
||||
for _, d := range s.dates {
|
||||
y, m, day := d.Date()
|
||||
days = append(days, time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Format("Mon")+" "+d.String())
|
||||
}
|
||||
var pool []string
|
||||
for _, c := range in.Pool {
|
||||
pool = append(pool, c.line(slices.Contains(in.LastWeek, c.ID)))
|
||||
}
|
||||
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Candidate pool", Lines: pool})
|
||||
return harness.PromptInput{Data: weekPrompt{Days: days}, Context: sections}, nil
|
||||
}
|
||||
|
||||
// line renders c as `id "ID": Name (Regular, method, N min, planned last
|
||||
// week)`, leaving out what is unknown.
|
||||
func (c Candidate) line(lastWeek bool) string {
|
||||
attrs := []string{"Regular"}
|
||||
if c.Kind == KindQuestLog {
|
||||
attrs = []string{"Quest log"}
|
||||
}
|
||||
if c.Method != "" {
|
||||
attrs = append(attrs, c.Method)
|
||||
}
|
||||
if c.Minutes > 0 {
|
||||
attrs = append(attrs, fmt.Sprintf("%d min", c.Minutes))
|
||||
}
|
||||
if lastWeek {
|
||||
attrs = append(attrs, "planned last week")
|
||||
}
|
||||
return fmt.Sprintf("id %q: %s (%s)", c.ID, oneLine(c.Name), strings.Join(attrs, ", "))
|
||||
}
|
||||
|
||||
func (draftWeek) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "draft_week", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {"type": "string"},
|
||||
"dinner_id": {"type": "string"}
|
||||
},
|
||||
"required": ["date", "dinner_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"note": {"type": "string"}
|
||||
},
|
||||
"required": ["days", "note"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a day, requiring each of its fields as the schema
|
||||
// does.
|
||||
func (d *WeekDay) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "date", "dinner_id"); err != nil {
|
||||
return fmt.Errorf("day %s", err)
|
||||
}
|
||||
type plain WeekDay
|
||||
return json.Unmarshal(data, (*plain)(d))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring each of its fields as the
|
||||
// schema does.
|
||||
func (w *Week) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "days", "note"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain Week
|
||||
return json.Unmarshal(data, (*plain)(w))
|
||||
}
|
||||
|
||||
// Validate finds every broken rule: a week that is not exactly the seven
|
||||
// dates Monday to Sunday in order, a dinner not in the pool or planned
|
||||
// twice, or a note over NoteLimit characters.
|
||||
func (s draftWeek) Validate(out Week) []string {
|
||||
var problems []string
|
||||
if n := len(out.Days); n != WeekLength {
|
||||
problems = append(problems, fmt.Sprintf("days has %d entries; give exactly %d, one for each date from %s to %s",
|
||||
n, WeekLength, s.dates[0], s.dates[WeekLength-1]))
|
||||
}
|
||||
first := map[string]int{} // dinner ID -> first day planning it
|
||||
for i, d := range out.Days {
|
||||
if i < WeekLength && d.Date != s.dates[i].String() {
|
||||
problems = append(problems, fmt.Sprintf("day %d date %q is not %s; give the dates %s to %s in order",
|
||||
i+1, d.Date, s.dates[i], s.dates[0], s.dates[WeekLength-1]))
|
||||
}
|
||||
c, ok := s.pool[d.DinnerID]
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf("day %d dinner_id %q is not in the candidate pool; use only ids from it", i+1, d.DinnerID))
|
||||
continue
|
||||
}
|
||||
if j, dup := first[d.DinnerID]; dup {
|
||||
problems = append(problems, fmt.Sprintf("day %d dinner_id %q (%s) is also on day %d; plan each dinner at most once",
|
||||
i+1, d.DinnerID, oneLine(c.Name), j+1))
|
||||
continue
|
||||
}
|
||||
first[d.DinnerID] = i
|
||||
}
|
||||
if n := utf8.RuneCountInString(out.Note); n > NoteLimit {
|
||||
problems = append(problems, fmt.Sprintf("note has %d characters; keep it to at most %d", n, NoteLimit))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PostProcess tidies the note, marks each Quest log dinner new, computes
|
||||
// each crock-pot dinner's StartBy, and warns when the week repeats more of
|
||||
// last week's dinners than the pool made necessary.
|
||||
func (s draftWeek) PostProcess(out Week) (Week, error) {
|
||||
result := Week{Note: oneLine(out.Note)}
|
||||
var repeats []string
|
||||
for i, d := range out.Days {
|
||||
c := s.pool[d.DinnerID]
|
||||
day := WeekDay{Date: d.Date, DinnerID: d.DinnerID, IsNew: c.Kind == KindQuestLog}
|
||||
if c.Method == "crockpot" && c.Minutes > 0 {
|
||||
dinner, err := hh.LocalTime(s.zone, s.dates[i], s.in.DinnerTime)
|
||||
if err != nil {
|
||||
return Week{}, err
|
||||
}
|
||||
day.StartBy = dinner.Add(-time.Duration(c.Minutes) * time.Minute)
|
||||
}
|
||||
if slices.Contains(s.in.LastWeek, d.DinnerID) {
|
||||
repeats = append(repeats, oneLine(c.Name))
|
||||
}
|
||||
result.Days = append(result.Days, day)
|
||||
}
|
||||
fresh := 0
|
||||
for id := range s.pool {
|
||||
if !slices.Contains(s.in.LastWeek, id) {
|
||||
fresh++
|
||||
}
|
||||
}
|
||||
if allowed := max(0, WeekLength-fresh); len(repeats) > allowed {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("repeats %d of last week's dinners (%s) though the pool needed at most %d",
|
||||
len(repeats), strings.Join(repeats, ", "), allowed))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// weekOf asks for the week of Monday 2026-10-12 from a pool of eight, two
|
||||
// of them planned last week.
|
||||
var weekOf = WeekRequest{
|
||||
Context: harness.HouseholdContext{
|
||||
Today: date(2026, 10, 8),
|
||||
Timezone: "America/New_York",
|
||||
Regulars: []harness.RegularDinner{
|
||||
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240},
|
||||
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true},
|
||||
},
|
||||
QuestLog: []string{"Pad Thai"},
|
||||
Passed: []string{},
|
||||
Plans: []harness.PlannedDinner{
|
||||
{Date: date(2026, 10, 5), Name: "Chicken Fajitas"},
|
||||
{Date: date(2026, 10, 6), Name: "Fish Tacos"},
|
||||
},
|
||||
},
|
||||
WeekStart: date(2026, 10, 12),
|
||||
Pool: []Candidate{
|
||||
{ID: "chili", Name: "Beef Chili", Kind: KindRegular, Method: "crockpot", Minutes: 240},
|
||||
{ID: "fajitas", Name: "Chicken Fajitas", Kind: KindRegular, Method: "stovetop", Minutes: 30},
|
||||
{ID: "bolognese", Name: "Spaghetti Bolognese", Kind: KindRegular, Method: "stovetop", Minutes: 45},
|
||||
{ID: "salmon", Name: "Sheet Pan Salmon", Kind: KindRegular, Method: "oven"},
|
||||
{ID: "tacos", Name: "Fish Tacos", Kind: KindRegular, Minutes: 25},
|
||||
{ID: "padthai", Name: "Pad Thai", Kind: KindQuestLog, Method: "stovetop", Minutes: 35},
|
||||
{ID: "roast", Name: "Pot Roast", Kind: KindRegular, Method: "crockpot"},
|
||||
{ID: "shawarma", Name: "Chicken Shawarma Bowls", Kind: KindQuestLog, Method: "oven", Minutes: 40},
|
||||
},
|
||||
LastWeek: []string{"fajitas", "tacos", "gone"},
|
||||
DinnerTime: "17:30",
|
||||
}
|
||||
|
||||
// weekDates are the dates of weekOf, Monday to Sunday.
|
||||
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||
|
||||
// week renders a reply planning ids on dates, in order.
|
||||
func week(note string, dates []string, ids ...string) string {
|
||||
var days []string
|
||||
for i, id := range ids {
|
||||
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], id))
|
||||
}
|
||||
return fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
|
||||
}
|
||||
|
||||
// clean plans no dinner of last week.
|
||||
var clean = []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"}
|
||||
|
||||
// wantWeek is the output for a reply planning ids on weekDates with note.
|
||||
func wantWeek(note string, ids ...string) Week {
|
||||
newYork, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
out := Week{Note: note}
|
||||
for i, id := range ids {
|
||||
day := WeekDay{Date: weekDates[i], DinnerID: id, IsNew: id == "padthai" || id == "shawarma"}
|
||||
if id == "chili" {
|
||||
y, m, d := date(2026, 10, 12+i).Date()
|
||||
day.StartBy = time.Date(y, m, d, 13, 30, 0, 0, newYork)
|
||||
}
|
||||
out.Days = append(out.Days, day)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// draft runs DraftWeek on in with f and checks f was called exactly calls
|
||||
// times, each at the household temperature with the draft_week schema.
|
||||
func draft(t *testing.T, f *llm.Fake, in WeekRequest, calls int) (Week, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := DraftWeek(context.Background(), testConfig(t, f), "2026-W42", in)
|
||||
reqs := f.Requests()
|
||||
if len(reqs) != calls {
|
||||
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d scripted Generate results unused", g)
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||
}
|
||||
if req.Schema == nil || req.Schema.Name != "draft_week" {
|
||||
t.Fatalf("request %d schema = %v, want draft_week", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func assertWeek(t *testing.T, got, want Week) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("output = %+v\nwant %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftWeekValid(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(week(" A cosy week,\nwith Pad Thai midweek. ", weekDates, clean...)))
|
||||
out, reqs, err := draft(t, &f, weekOf, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertWeek(t, out, wantWeek("A cosy week, with Pad Thai midweek.", clean...))
|
||||
if !out.Days[0].StartBy.Equal(time.Date(2026, 10, 12, 17, 30, 0, 0, time.UTC)) {
|
||||
t.Errorf("Beef Chili StartBy = %v, want 13:30 EDT", out.Days[0].StartBy)
|
||||
}
|
||||
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, "draft_week_valid.system.golden", system)
|
||||
golden(t, "draft_week_valid.user.golden", user)
|
||||
for _, part := range []string{
|
||||
"## Household context",
|
||||
"### Candidate pool",
|
||||
`- id "chili": Beef Chili (Regular, crockpot, 240 min)`,
|
||||
`- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)`,
|
||||
`- id "salmon": Sheet Pan Salmon (Regular, oven)`,
|
||||
`- id "tacos": Fish Tacos (Regular, 25 min, planned last week)`,
|
||||
`- id "padthai": Pad Thai (Quest log, stovetop, 35 min)`,
|
||||
"## Output contract",
|
||||
`"dinner_id"`,
|
||||
} {
|
||||
if !strings.Contains(system, part) {
|
||||
t.Errorf("system prompt lacks %q", part)
|
||||
}
|
||||
}
|
||||
if strings.Index(system, "### Candidate pool") > strings.Index(system, "## Output contract") {
|
||||
t.Error("the output contract is not after the candidate pool")
|
||||
}
|
||||
want := "Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18."
|
||||
if user != want {
|
||||
t.Errorf("user message = %q, want %q", user, want)
|
||||
}
|
||||
}
|
||||
|
||||
// repaired checks a first reply is repaired once, quoting it and the
|
||||
// problems wanted.
|
||||
func repaired(t *testing.T, first string, want ...string) {
|
||||
t.Helper()
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(first), reply(week("Fixed.", weekDates, clean...)))
|
||||
out, reqs, err := draft(t, &f, weekOf, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertWeek(t, out, wantWeek("Fixed.", clean...))
|
||||
repair := reqs[1].Messages
|
||||
if len(repair) != 3 || repair[1].Content != first {
|
||||
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
|
||||
}
|
||||
for _, w := range want {
|
||||
if !strings.Contains(repair[2].Content, w) {
|
||||
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftWeekUnknownIDRepaired(t *testing.T) {
|
||||
ids := slices.Clone(clean)
|
||||
ids[2] = "lasagna"
|
||||
repaired(t, week("A week.", weekDates, ids...),
|
||||
`day 3 dinner_id "lasagna" is not in the candidate pool; use only ids from it`)
|
||||
}
|
||||
|
||||
func TestDraftWeekDuplicateRepaired(t *testing.T) {
|
||||
ids := slices.Clone(clean)
|
||||
ids[5] = "chili"
|
||||
repaired(t, week("A week.", weekDates, ids...),
|
||||
`day 6 dinner_id "chili" (Beef Chili) is also on day 1; plan each dinner at most once`)
|
||||
}
|
||||
|
||||
func TestDraftWeekWrongDatesTwiceFails(t *testing.T) {
|
||||
sundayFirst := append([]string{"2026-10-11"}, weekDates[:6]...)
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(week("A week.", sundayFirst, clean...)), reply(week("A week.", sundayFirst, clean...)))
|
||||
out, _, err := draft(t, &f, weekOf, 2)
|
||||
var bad *harness.BadOutputError
|
||||
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want a BadOutputError", err)
|
||||
}
|
||||
if !slices.Contains(bad.Repair, `day 1 date "2026-10-11" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`) {
|
||||
t.Fatalf("repair problems = %q", bad.Repair)
|
||||
}
|
||||
if out.Days != nil {
|
||||
t.Fatalf("output = %+v, want none", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftWeekNotEnoughDinners(t *testing.T) {
|
||||
small := weekOf
|
||||
small.Pool = slices.Clone(weekOf.Pool[:6])
|
||||
var f llm.Fake
|
||||
_, _, err := draft(t, &f, small, 0)
|
||||
var short *NotEnoughDinnersError
|
||||
if !errors.As(err, &short) || short.Pool != 6 {
|
||||
t.Fatalf("err = %v, want a NotEnoughDinnersError for a pool of 6", err)
|
||||
}
|
||||
if err.Error() != "not enough dinners: the pool holds 6, a week needs 7" {
|
||||
t.Fatalf("err = %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftWeekLastWeekRepeats(t *testing.T) {
|
||||
// The pool has six dinners not planned last week, so the week needs one
|
||||
// of last week's two and may plan it without a warning.
|
||||
one := []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"}
|
||||
both := []string{"chili", "padthai", "bolognese", "tacos", "shawarma", "roast", "fajitas"}
|
||||
for name, tc := range map[string]struct {
|
||||
ids []string
|
||||
warn []string
|
||||
}{
|
||||
"needed repeat": {one, nil},
|
||||
"avoidable both": {both, []string{"repeats 2 of last week's dinners (Fish Tacos, Chicken Fajitas) though the pool needed at most 1"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(week("A week.", weekDates, tc.ids...)))
|
||||
out, _, err := draft(t, &f, weekOf, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := wantWeek("A week.", tc.ids...)
|
||||
want.Warnings = tc.warn
|
||||
assertWeek(t, out, want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekValidate(t *testing.T) {
|
||||
s, err := newDraftWeek(weekOf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reply := func(note string, ids ...string) Week {
|
||||
var w Week
|
||||
if err := w.UnmarshalJSON([]byte(week(note, weekDates, ids...))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return w
|
||||
}
|
||||
if p := s.Validate(reply(strings.Repeat("é", NoteLimit), clean...)); len(p) != 0 {
|
||||
t.Fatalf("valid output has problems: %q", p)
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
out Week
|
||||
want []string
|
||||
}{
|
||||
"empty": {Week{}, []string{"days has 0 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"}},
|
||||
"six days": {reply("", clean[:6]...), []string{
|
||||
"days has 6 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18",
|
||||
}},
|
||||
"eight days": {
|
||||
Week{Days: append(reply("", clean...).Days, WeekDay{Date: "2026-10-19", DinnerID: "tacos"})},
|
||||
[]string{"days has 8 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"},
|
||||
},
|
||||
"long note": {reply(strings.Repeat("a", NoteLimit+1), clean...), []string{"note has 161 characters; keep it to at most 160"}},
|
||||
"dates out of order": {
|
||||
Week{Days: []WeekDay{
|
||||
{weekDates[1], "chili", false, time.Time{}}, {weekDates[0], "padthai", false, time.Time{}},
|
||||
{weekDates[2], "bolognese", false, time.Time{}}, {weekDates[3], "salmon", false, time.Time{}},
|
||||
{weekDates[4], "shawarma", false, time.Time{}}, {weekDates[5], "roast", false, time.Time{}},
|
||||
{weekDates[6], "fajitas", false, time.Time{}},
|
||||
}},
|
||||
[]string{
|
||||
`day 1 date "2026-10-13" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`,
|
||||
`day 2 date "2026-10-12" is not 2026-10-13; give the dates 2026-10-12 to 2026-10-18 in order`,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := s.Validate(tc.out); !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftWeekRequestRejected(t *testing.T) {
|
||||
with := func(change func(*WeekRequest)) WeekRequest {
|
||||
in := weekOf
|
||||
in.Pool = slices.Clone(weekOf.Pool)
|
||||
change(&in)
|
||||
return in
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
in WeekRequest
|
||||
want string
|
||||
}{
|
||||
"not a Monday": {with(func(in *WeekRequest) { in.WeekStart = date(2026, 10, 13) }),
|
||||
"draft_week: household: week start 2026-10-13 is a Tuesday, want Monday"},
|
||||
"bad kind": {with(func(in *WeekRequest) { in.Pool[0].Kind = "suggestion" }),
|
||||
`draft_week: pool dinner 1: chili kind "suggestion" is not regular or quest_log`},
|
||||
"bad method": {with(func(in *WeekRequest) { in.Pool[1].Method = "air fryer" }),
|
||||
`draft_week: pool dinner 2: fajitas method "air fryer" is not one of stovetop, oven, crockpot, grill, other`},
|
||||
"empty id": {with(func(in *WeekRequest) { in.Pool[2].ID = " " }),
|
||||
`draft_week: pool dinner 3: id " " is empty or has a line break`},
|
||||
"id twice": {with(func(in *WeekRequest) { in.Pool[3].ID = "chili" }),
|
||||
`draft_week: pool dinner 4: id "chili" is in the pool twice`},
|
||||
"bad dinner time": {with(func(in *WeekRequest) { in.DinnerTime = "5:30pm" }),
|
||||
`draft_week: dinner time: household: invalid time "5:30pm": want HH:MM`},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var f llm.Fake
|
||||
if _, _, err := draft(t, &f, tc.in, 0); err == nil || err.Error() != tc.want {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,5 @@
|
||||
# version, rename its template to match and add a line for the new version.
|
||||
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
|
||||
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
|
||||
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
||||
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{{/* draft_week drafts the household's weekly plan: one dinner a day, Monday to Sunday, from a candidate pool. */}}
|
||||
{{define "instructions" -}}
|
||||
You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context.
|
||||
|
||||
Rules:
|
||||
- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD.
|
||||
- Use only dinners from the candidate pool, each by its id exactly as the pool writes it.
|
||||
- Plan each dinner at most once in the week.
|
||||
- Avoid the dinners marked "planned last week" whenever the pool has enough others.
|
||||
- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one.
|
||||
|
||||
Give:
|
||||
- days: the 7 days, each with date and dinner_id.
|
||||
- note: one sentence of at most 160 characters about the week.
|
||||
|
||||
One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household):
|
||||
{"date": "2026-01-05", "dinner_id": "d1"}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
Draft the week of {{range $i, $d := .Days}}{{if $i}}, {{end}}{{$d}}{{end}}.
|
||||
{{- end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{/* suggest_side_quests drafts Side quests: new dinners for the household to try, each based on its Regulars. */}}
|
||||
{{define "instructions" -}}
|
||||
You suggest Side quests: new dinners for the household to try, based on the dinners it already makes.
|
||||
|
||||
Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch.
|
||||
|
||||
Give for each Side quest:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex".
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars.
|
||||
- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household):
|
||||
{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
Suggest {{.Count}} Side quests.
|
||||
{{- end}}
|
||||
@@ -0,0 +1,421 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// ReasonLimit is the most characters a Side quest's reason, its "Why
|
||||
// this?", may have.
|
||||
const ReasonLimit = 180
|
||||
|
||||
// SideQuestsRequest is what suggest_side_quests reads.
|
||||
type SideQuestsRequest struct {
|
||||
// Context is the household context (see harness.BuildContext). It must
|
||||
// hold at least one Regular, since every Side quest cites one.
|
||||
Context harness.HouseholdContext
|
||||
// Needed is how many Side quests the caller wants to keep. The skill
|
||||
// asks the model for twice as many, since validation and grounding
|
||||
// drop some. It must be positive.
|
||||
Needed int
|
||||
// Pending holds the names of the Side quests not yet tried or passed.
|
||||
Pending []string
|
||||
// Declined holds every passed name — each passed Side quest and each
|
||||
// dinner a Rotation check dropped — not only the recent ones Context
|
||||
// holds.
|
||||
Declined []string
|
||||
}
|
||||
|
||||
// SideQuest is one suggested new dinner. Method, EstMinutes and Difficulty
|
||||
// are the model's estimates until the dinner is grounded in a recipe.
|
||||
type SideQuest struct {
|
||||
Name string `json:"name"`
|
||||
Cuisine string `json:"cuisine"`
|
||||
// Method is one of stovetop, oven, crockpot, grill and other.
|
||||
Method string `json:"method"`
|
||||
EstMinutes int `json:"est_minutes"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// Reason is the "Why this?": at most ReasonLimit characters, naming at
|
||||
// least one of CitedRegulars.
|
||||
Reason string `json:"reason"`
|
||||
// CitedRegulars are names of the household's Regulars, as the
|
||||
// household writes them.
|
||||
CitedRegulars []string `json:"cited_regulars"`
|
||||
// SearchQuery is a web search query that finds a recipe for the dinner.
|
||||
SearchQuery string `json:"search_query"`
|
||||
}
|
||||
|
||||
// SideQuests is suggest_side_quests' output.
|
||||
type SideQuests struct {
|
||||
// Suggestions are the Side quests that passed every rule, in the
|
||||
// model's order. There may be more or fewer than Needed.
|
||||
Suggestions []SideQuest `json:"suggestions"`
|
||||
// Dropped are the Side quests the model gave that broke a rule, in the
|
||||
// model's order. It is never part of the model's reply.
|
||||
Dropped []DroppedSideQuest `json:"-"`
|
||||
}
|
||||
|
||||
// DroppedSideQuest is a Side quest dropped by PostProcess.
|
||||
type DroppedSideQuest struct {
|
||||
Name string
|
||||
// Why says which rule it broke.
|
||||
Why string
|
||||
}
|
||||
|
||||
// SuggestSideQuests runs suggest_side_quests v1 on in and returns the Side
|
||||
// quests that cite only real Regulars, give a short reason naming one of
|
||||
// them, and repeat no Regular, Quest log dinner, pending or passed Side
|
||||
// quest, nor each other. The rest are in Dropped. scope names the batch
|
||||
// (see harness.Run).
|
||||
func SuggestSideQuests(ctx context.Context, cfg harness.Config, scope string, in SideQuestsRequest) (SideQuests, error) {
|
||||
return harness.Run(ctx, cfg, suggestSideQuests{in: in}, scope, in)
|
||||
}
|
||||
|
||||
// suggestSideQuests is the skill. It holds its input because validation
|
||||
// and post-processing compare the output with it.
|
||||
type suggestSideQuests struct {
|
||||
in SideQuestsRequest
|
||||
}
|
||||
|
||||
var _ harness.Skill[SideQuestsRequest, SideQuests] = suggestSideQuests{}
|
||||
|
||||
func (suggestSideQuests) Name() string { return "suggest_side_quests" }
|
||||
|
||||
func (suggestSideQuests) Version() int { return 1 }
|
||||
|
||||
func (suggestSideQuests) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// sideQuestsPrompt is the template data of suggest_side_quests.v1.tmpl.
|
||||
type sideQuestsPrompt struct {
|
||||
// Count is how many Side quests to ask for.
|
||||
Count int
|
||||
}
|
||||
|
||||
// requested is how many Side quests the model is asked for.
|
||||
func (s suggestSideQuests) requested() int { return 2 * s.in.Needed }
|
||||
|
||||
func (suggestSideQuests) PromptInput(in SideQuestsRequest) (harness.PromptInput, error) {
|
||||
if in.Needed <= 0 {
|
||||
return harness.PromptInput{}, fmt.Errorf("%d Side quests needed; the number must be positive", in.Needed)
|
||||
}
|
||||
if len(in.Context.Regulars) == 0 {
|
||||
return harness.PromptInput{}, errors.New("household has no Regulars for Side quests to be based on")
|
||||
}
|
||||
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Pending Side quests", Lines: in.Pending})
|
||||
return harness.PromptInput{Data: sideQuestsPrompt{Count: suggestSideQuests{in: in}.requested()}, Context: sections}, nil
|
||||
}
|
||||
|
||||
func (suggestSideQuests) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "suggest_side_quests", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"cuisine": {"type": "string"},
|
||||
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
||||
"est_minutes": {"type": "integer"},
|
||||
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
|
||||
"reason": {"type": "string"},
|
||||
"cited_regulars": {"type": "array", "items": {"type": "string"}},
|
||||
"search_query": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["suggestions"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a Side quest, requiring each of its fields as the
|
||||
// schema does: encoding/json would otherwise read a missing or null one as
|
||||
// empty.
|
||||
func (q *SideQuest) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"); err != nil {
|
||||
return fmt.Errorf("suggestion %s", err)
|
||||
}
|
||||
type plain SideQuest
|
||||
return json.Unmarshal(data, (*plain)(q))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its one field as the schema
|
||||
// does.
|
||||
func (out *SideQuests) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "suggestions"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain SideQuests
|
||||
return json.Unmarshal(data, (*plain)(out))
|
||||
}
|
||||
|
||||
// Validate finds what makes the reply unusable as a whole: a wrong number
|
||||
// of Side quests or one that is malformed. A well-formed Side quest that
|
||||
// breaks a rule is dropped by PostProcess instead.
|
||||
func (s suggestSideQuests) Validate(out SideQuests) []string {
|
||||
var problems []string
|
||||
switch n := len(out.Suggestions); {
|
||||
case n == 0 && len(out.Dropped) == 0:
|
||||
problems = append(problems, fmt.Sprintf("suggestions is empty; give %d Side quests", s.requested()))
|
||||
case n > s.requested():
|
||||
problems = append(problems, fmt.Sprintf("suggestions has %d entries; give at most %d Side quests", n, s.requested()))
|
||||
}
|
||||
for i, q := range out.Suggestions {
|
||||
for _, f := range []struct{ field, value string }{
|
||||
{"name", q.Name}, {"cuisine", q.Cuisine}, {"reason", q.Reason}, {"search_query", q.SearchQuery},
|
||||
} {
|
||||
if strings.TrimSpace(f.value) == "" {
|
||||
problems = append(problems, fmt.Sprintf("suggestion %d has an empty %s; give one", i+1, f.field))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(q.Name) != "" && harness.Normalize(q.Name) == "" {
|
||||
problems = append(problems, fmt.Sprintf("suggestion %d name %q has only filler words; name the dish", i+1, q.Name))
|
||||
}
|
||||
if !slices.Contains(methods, q.Method) {
|
||||
problems = append(problems, fmt.Sprintf("suggestion %d method %q is not one of %s", i+1, q.Method, strings.Join(methods, ", ")))
|
||||
}
|
||||
if q.EstMinutes <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("suggestion %d est_minutes %d is not positive; give the minutes from prep to table", i+1, q.EstMinutes))
|
||||
}
|
||||
if !slices.Contains(difficulties, q.Difficulty) {
|
||||
problems = append(problems, fmt.Sprintf("suggestion %d difficulty %q is not one of %s", i+1, q.Difficulty, strings.Join(difficulties, ", ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PostProcess tidies every Side quest's text, writes its cited Regulars as
|
||||
// the household does, and moves each one that breaks a rule to Dropped
|
||||
// (see drop).
|
||||
func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) {
|
||||
regulars := regularNames{exact: map[string][]string{}, normalized: map[string][]string{}}
|
||||
var names []string
|
||||
for _, r := range s.in.Context.Regulars {
|
||||
regulars.add(r.Name)
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
regulars.all = names
|
||||
known := harness.NewDeduper(harness.DedupeSources{
|
||||
Regulars: names,
|
||||
QuestLog: s.in.Context.QuestLog,
|
||||
Pending: s.in.Pending,
|
||||
Declined: s.in.Declined,
|
||||
})
|
||||
result := SideQuests{Suggestions: []SideQuest{}}
|
||||
for _, q := range out.Suggestions {
|
||||
q = SideQuest{
|
||||
Name: oneLine(q.Name),
|
||||
Cuisine: oneLine(q.Cuisine),
|
||||
Method: q.Method,
|
||||
EstMinutes: q.EstMinutes,
|
||||
Difficulty: q.Difficulty,
|
||||
Reason: oneLine(q.Reason),
|
||||
CitedRegulars: q.CitedRegulars,
|
||||
SearchQuery: oneLine(q.SearchQuery),
|
||||
}
|
||||
cited, why := drop(q, regulars, known, result.Suggestions)
|
||||
if why != "" {
|
||||
result.Dropped = append(result.Dropped, DroppedSideQuest{Name: q.Name, Why: why})
|
||||
continue
|
||||
}
|
||||
q.CitedRegulars = cited
|
||||
result.Suggestions = append(result.Suggestions, q)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// regularNames finds the Regular a cited name means. Names need not be
|
||||
// unique, even ignoring case, so each key holds every Regular with it.
|
||||
type regularNames struct {
|
||||
// exact is keyed by the name in lower case on one line.
|
||||
exact map[string][]string
|
||||
// normalized is keyed by harness.Normalize of the name.
|
||||
normalized map[string][]string
|
||||
// all holds every Regular's name, in the household's order.
|
||||
all []string
|
||||
}
|
||||
|
||||
func exactName(name string) string { return strings.ToLower(oneLine(name)) }
|
||||
|
||||
func (r regularNames) add(name string) {
|
||||
r.exact[exactName(name)] = append(r.exact[exactName(name)], name)
|
||||
if n := harness.Normalize(name); n != "" {
|
||||
r.normalized[n] = append(r.normalized[n], name)
|
||||
}
|
||||
}
|
||||
|
||||
// find returns the one Regular cited means, or "" and why there is none.
|
||||
// A Regular named exactly cited, ignoring case and spacing, comes first;
|
||||
// failing that, cited may name a Regular by its normalized name.
|
||||
func (r regularNames) find(cited string) (string, string) {
|
||||
matches := r.exact[exactName(cited)]
|
||||
if len(matches) == 0 {
|
||||
matches = r.normalized[harness.Normalize(cited)]
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", fmt.Sprintf("cites %q, which is not a Regular", cited)
|
||||
case 1:
|
||||
return matches[0], ""
|
||||
default:
|
||||
return "", fmt.Sprintf("cites %q, which could be any of the Regulars %q", cited, matches)
|
||||
}
|
||||
}
|
||||
|
||||
// drop returns why q must be dropped, or "" and its cited Regulars as the
|
||||
// household writes them when it may be kept. q is dropped when it repeats
|
||||
// a known dinner or a Side quest already kept, cites no Regular or one
|
||||
// regulars finds no single Regular for, or has a reason over ReasonLimit
|
||||
// characters or naming none of its cited Regulars.
|
||||
func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []SideQuest) ([]string, string) {
|
||||
if known.IsDuplicate(q.Name) {
|
||||
return nil, "repeats a Regular, Quest log dinner, pending or passed Side quest"
|
||||
}
|
||||
for _, k := range kept {
|
||||
if harness.Similar(q.Name, k.Name) {
|
||||
return nil, fmt.Sprintf("repeats the Side quest %q in the same batch", k.Name)
|
||||
}
|
||||
}
|
||||
if len(q.CitedRegulars) == 0 {
|
||||
return nil, "cites no Regular"
|
||||
}
|
||||
var cited []string
|
||||
for _, c := range q.CitedRegulars {
|
||||
name, why := regulars.find(c)
|
||||
if why != "" {
|
||||
return nil, why
|
||||
}
|
||||
cited = append(cited, name)
|
||||
}
|
||||
if n := utf8.RuneCountInString(q.Reason); n > ReasonLimit {
|
||||
return nil, fmt.Sprintf("reason has %d characters, over %d", n, ReasonLimit)
|
||||
}
|
||||
named := regulars.named(q.Reason)
|
||||
if !slices.ContainsFunc(cited, func(name string) bool { return slices.Contains(named, name) }) {
|
||||
return nil, "reason names none of its cited Regulars"
|
||||
}
|
||||
return cited, ""
|
||||
}
|
||||
|
||||
// named returns the Regulars text names, by one rule. A mention of a
|
||||
// Regular is a place where text spells its name, ignoring case and spacing,
|
||||
// with no letter, mark or digit just outside it: the start or end of text,
|
||||
// white space, punctuation or a symbol must border it, so "Beef
|
||||
// Chili-inspired" and "Beef Chili's" mention "Beef Chili" while "Beef
|
||||
// Chilis" does not, nor does "Cafe" with a combining accent after it
|
||||
// mention "Cafe". Punctuation at either end of the name, with any spacing
|
||||
// beside it, is optional, and a mention takes in as much of it as text
|
||||
// spells, spaced in any way, with the border still holding after it: "Easy
|
||||
// Beef Chili!" is mentioned by "Easy Beef Chili." too, by "Easy Beef Chili
|
||||
// !" with its "!", and by "Easy Beef Chili!x" without its "!", and "Beef
|
||||
// Chili!!" by "Beef Chili!" with one "!". A
|
||||
// combining mark is part of its letter, never punctuation. A mention names
|
||||
// its Regular unless a mention of a different Regular covers the same text
|
||||
// or more, so "Your Easy Beef Chili." names "Easy Beef Chili" and not "Beef
|
||||
// Chili", and text that two Regulars spell alike names neither there.
|
||||
func (r regularNames) named(text string) []string {
|
||||
type mention struct {
|
||||
name string
|
||||
start, end int
|
||||
}
|
||||
text = exactName(text)
|
||||
bordered := func(start, end int) bool {
|
||||
before, _ := utf8.DecodeLastRuneInString(text[:start])
|
||||
after, _ := utf8.DecodeRuneInString(text[end:])
|
||||
return (start == 0 || !isWord(before)) && (end == len(text) || !isWord(after))
|
||||
}
|
||||
var mentions []mention
|
||||
for _, name := range r.all {
|
||||
full := exactName(name)
|
||||
core := strings.TrimFunc(full, isOuter)
|
||||
if core == "" {
|
||||
continue
|
||||
}
|
||||
lead := full[:len(full)-len(strings.TrimLeftFunc(full, isOuter))]
|
||||
trail := full[len(lead)+len(core):]
|
||||
for at := 0; ; {
|
||||
i := strings.Index(text[at:], core)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
start, end := at+i, at+i+len(core)
|
||||
at = start + 1
|
||||
if !bordered(start, end) {
|
||||
continue
|
||||
}
|
||||
start = spelledBefore(text, start, lead, func(s int) bool { return bordered(s, end) })
|
||||
end = spelledAfter(text, end, trail, func(e int) bool { return bordered(start, e) })
|
||||
mentions = append(mentions, mention{name, start, end})
|
||||
}
|
||||
}
|
||||
var names []string
|
||||
for _, m := range mentions {
|
||||
covered := slices.ContainsFunc(mentions, func(o mention) bool {
|
||||
return o.name != m.name && o.start <= m.start && o.end >= m.end
|
||||
})
|
||||
if !covered && !slices.Contains(names, m.name) {
|
||||
names = append(names, m.name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// spelledAfter returns where the longest start of outer that text spells
|
||||
// from at ends, ignoring spacing, among those after which bordered holds,
|
||||
// or at when there are none.
|
||||
func spelledAfter(text string, at int, outer string, bordered func(int) bool) int {
|
||||
end := at
|
||||
for _, c := range strings.Join(strings.Fields(outer), "") {
|
||||
at = len(text) - len(strings.TrimLeftFunc(text[at:], unicode.IsSpace))
|
||||
if !strings.HasPrefix(text[at:], string(c)) {
|
||||
break
|
||||
}
|
||||
at += utf8.RuneLen(c)
|
||||
if bordered(at) {
|
||||
end = at
|
||||
}
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
// spelledBefore returns where the longest end of outer that text spells up
|
||||
// to at starts, ignoring spacing, among those before which bordered holds,
|
||||
// or at when there are none.
|
||||
func spelledBefore(text string, at int, outer string, bordered func(int) bool) int {
|
||||
start := at
|
||||
punct := []rune(strings.Join(strings.Fields(outer), ""))
|
||||
for i := len(punct) - 1; i >= 0; i-- {
|
||||
at = len(strings.TrimRightFunc(text[:at], unicode.IsSpace))
|
||||
if !strings.HasSuffix(text[:at], string(punct[i])) {
|
||||
break
|
||||
}
|
||||
at -= utf8.RuneLen(punct[i])
|
||||
if bordered(at) {
|
||||
start = at
|
||||
}
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
// isWord reports whether c is a letter, a mark on one or a digit.
|
||||
func isWord(c rune) bool { return unicode.IsLetter(c) || unicode.IsMark(c) || unicode.IsDigit(c) }
|
||||
|
||||
// isOuter reports whether c may be left off the end of a name: punctuation
|
||||
// or white space.
|
||||
func isOuter(c rune) bool { return unicode.IsPunct(c) || unicode.IsSpace(c) }
|
||||
@@ -0,0 +1,488 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
func date(y int, m time.Month, d int) store.LocalDate {
|
||||
return store.LocalDateOf(time.Date(y, m, d, 0, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
// household asks for two Side quests, so four are requested.
|
||||
var household = SideQuestsRequest{
|
||||
Context: harness.HouseholdContext{
|
||||
Today: date(2026, 10, 6),
|
||||
Timezone: "America/New_York",
|
||||
Regulars: []harness.RegularDinner{
|
||||
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240},
|
||||
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true},
|
||||
{Name: "Spaghetti Bolognese", Cuisine: "Italian", Method: "stovetop", Minutes: 45},
|
||||
},
|
||||
QuestLog: []string{"Pad Thai"},
|
||||
Passed: []string{"Chicken Tikka Masala"},
|
||||
Plans: []harness.PlannedDinner{
|
||||
{Date: date(2026, 10, 5), Name: "Chicken Fajitas"},
|
||||
{Date: date(2026, 10, 6), Name: "Beef Chili"},
|
||||
},
|
||||
},
|
||||
Needed: 2,
|
||||
Pending: []string{"Shrimp Scampi"},
|
||||
Declined: []string{"Chicken Tikka Masala", "Fish Tacos"},
|
||||
}
|
||||
|
||||
// sideQuest renders one Side quest of a reply.
|
||||
func sideQuest(name, reason string, cited ...string) string {
|
||||
c, _ := json.Marshal(append([]string{}, cited...))
|
||||
r, _ := json.Marshal(reason)
|
||||
return fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":%s,"cited_regulars":%s,"search_query":%q}`,
|
||||
name, r, c, strings.ToLower(name)+" recipe")
|
||||
}
|
||||
|
||||
// batch renders a reply holding the Side quests given.
|
||||
func batch(quests ...string) string {
|
||||
return `{"suggestions":[` + strings.Join(quests, ",") + `]}`
|
||||
}
|
||||
|
||||
var (
|
||||
shawarma = sideQuest("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas")
|
||||
ragu = sideQuest("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese")
|
||||
verde = sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili")
|
||||
bulgogi = sideQuest("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili")
|
||||
)
|
||||
|
||||
// want returns the Side quest that sideQuest renders, with cited as the
|
||||
// household writes them.
|
||||
func want(name, reason string, cited ...string) SideQuest {
|
||||
return SideQuest{
|
||||
Name: name, Cuisine: "Fusion", Method: "stovetop", EstMinutes: 35, Difficulty: "easy",
|
||||
Reason: reason, CitedRegulars: cited, SearchQuery: strings.ToLower(name) + " recipe",
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
wantShawarma = want("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas")
|
||||
wantRagu = want("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese")
|
||||
wantVerde = want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili")
|
||||
wantBulgogi = want("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili")
|
||||
)
|
||||
|
||||
// suggest runs SuggestSideQuests on household with f and checks f was
|
||||
// called exactly calls times, each at the household temperature.
|
||||
func suggest(t *testing.T, f *llm.Fake, in SideQuestsRequest, calls int) (SideQuests, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := SuggestSideQuests(context.Background(), testConfig(t, f), "2026-W41", in)
|
||||
reqs := f.Requests()
|
||||
if len(reqs) != calls {
|
||||
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d scripted Generate results unused", g)
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||
}
|
||||
if req.Schema == nil || req.Schema.Name != "suggest_side_quests" {
|
||||
t.Fatalf("request %d schema = %v, want suggest_side_quests", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func assertSideQuests(t *testing.T, got, want SideQuests) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("output = %+v\nwant %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsCleanBatch(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(shawarma, ragu, verde, bulgogi)))
|
||||
out, reqs, err := suggest(t, &f, household, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi}})
|
||||
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, "suggest_side_quests_clean.system.golden", system)
|
||||
golden(t, "suggest_side_quests_clean.user.golden", user)
|
||||
for _, part := range []string{
|
||||
"## Household context",
|
||||
"- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)",
|
||||
"### Quest log\n- Pad Thai",
|
||||
"### Passed, oldest first\n- Chicken Tikka Masala",
|
||||
"- 2026-10-06 Tue: Beef Chili",
|
||||
"### Pending Side quests\n- Shrimp Scampi",
|
||||
"## Output contract",
|
||||
`"cited_regulars"`,
|
||||
`"search_query"`,
|
||||
} {
|
||||
if !strings.Contains(system, part) {
|
||||
t.Errorf("system prompt lacks %q", part)
|
||||
}
|
||||
}
|
||||
if strings.Index(system, "## Household context") > strings.Index(system, "## Output contract") {
|
||||
t.Error("the output contract is not after the household context")
|
||||
}
|
||||
if user != "Suggest 4 Side quests." {
|
||||
t.Errorf("user message = %q, want twice the 2 needed", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsPersonaFirst(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(shawarma)))
|
||||
cfg := testConfig(t, &f)
|
||||
cfg.Persona = "You are the household's cheerful kitchen helper."
|
||||
if _, err := SuggestSideQuests(context.Background(), cfg, "2026-W41", household); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if system := f.Requests()[0].System; !strings.HasPrefix(system, cfg.Persona+"\n\n## Instructions\n") {
|
||||
t.Fatalf("system prompt does not start with the persona then the instructions:\n%s", system)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsInventedRegularDropped(t *testing.T) {
|
||||
invented := sideQuest("Meatloaf Wellington", "Your Grandma's Meatloaf, wrapped in pastry.", "Grandma's Meatloaf")
|
||||
partly := sideQuest("Fajita Meatballs", "Chicken Fajitas meets your Grandma's Meatloaf.", "Chicken Fajitas", "Grandma's Meatloaf")
|
||||
none := sideQuest("Mushroom Risotto", "Creamy and comforting.")
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(invented, shawarma, partly, none)))
|
||||
out, _, err := suggest(t, &f, household, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{wantShawarma},
|
||||
Dropped: []DroppedSideQuest{
|
||||
{Name: "Meatloaf Wellington", Why: `cites "Grandma's Meatloaf", which is not a Regular`},
|
||||
{Name: "Fajita Meatballs", Why: `cites "Grandma's Meatloaf", which is not a Regular`},
|
||||
{Name: "Mushroom Risotto", Why: "cites no Regular"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsSharedNormalizedRegularNotRewritten(t *testing.T) {
|
||||
in := household
|
||||
in.Context.Regulars = append(slices.Clone(household.Context.Regulars),
|
||||
harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
|
||||
plain := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili")
|
||||
easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "EASY beef chili")
|
||||
either := sideQuest("Tamale Bake", "Your Beef Chili under a cornbread crust.", "Beef Chili!")
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(plain, easy, either)))
|
||||
out, _, err := suggest(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{
|
||||
want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili"),
|
||||
want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"),
|
||||
},
|
||||
Dropped: []DroppedSideQuest{
|
||||
{Name: "Tamale Bake", Why: `cites "Beef Chili!", which could be any of the Regulars ["Beef Chili" "Easy Beef Chili"]`},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsReasonNamesResolvedRegular(t *testing.T) {
|
||||
in := household
|
||||
in.Context.Regulars = append(slices.Clone(household.Context.Regulars),
|
||||
harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
|
||||
other := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Easy Beef Chili")
|
||||
easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili")
|
||||
plain := sideQuest("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili")
|
||||
inside := sideQuest("Tamale Bake", "Your Easy Beef Chili under a cornbread crust.", "Beef Chili")
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(other, easy, plain, inside)))
|
||||
out, _, err := suggest(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{
|
||||
want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"),
|
||||
want("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili"),
|
||||
},
|
||||
Dropped: []DroppedSideQuest{
|
||||
{Name: "Chili Verde", Why: "reason names none of its cited Regulars"},
|
||||
{Name: "Tamale Bake", Why: "reason names none of its cited Regulars"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsReasonMentionRule(t *testing.T) {
|
||||
const none = "reason names none of its cited Regulars"
|
||||
for name, tc := range map[string]struct {
|
||||
extra []string // Regulars added to household's
|
||||
reason string
|
||||
cited string
|
||||
why string // "" when the Side quest is kept
|
||||
}{
|
||||
"exact name": {nil, "Like your Beef Chili, gone green.", "Beef Chili", ""},
|
||||
"other case and spacing": {nil, "Like your beef CHILI, gone green.", "Beef Chili", ""},
|
||||
"sentence punctuation": {nil, `Green, like "Beef Chili" (yours).`, "Beef Chili", ""},
|
||||
"possessive": {nil, "Beef Chili's spices, gone green.", "Beef Chili", ""},
|
||||
"joined by punctuation": {nil, "A Beef Chili-inspired stew.", "Beef Chili", ""},
|
||||
"no mention": {nil, "Grilled skewers with a peanut sauce.", "Beef Chili", none},
|
||||
"letters run on": {[]string{"Ham"}, "Like your Hamburgers, glazed.", "Ham", none},
|
||||
"stored punctuation written": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Easy Beef Chili!", ""},
|
||||
"stored punctuation as period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Easy Beef Chili!", ""},
|
||||
"inside stored punctuation": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Beef Chili", none},
|
||||
"inside before period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Beef Chili", none},
|
||||
"longer name cited": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Easy Beef Chili", ""},
|
||||
"shorter inside longer": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Beef Chili", none},
|
||||
"longer not mentioned": {[]string{"Easy Beef Chili"}, "Your Beef Chili over corn chips.", "Easy Beef Chili", none},
|
||||
"shorter also on its own": {[]string{"Easy Beef Chili"}, "The Beef Chili, not the Easy Beef Chili.", "Beef Chili", ""},
|
||||
"punctuation-only difference": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili!", ""},
|
||||
"bare name inside punctuated": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili", none},
|
||||
"spelled alike by both": {[]string{"Beef Chili!"}, "Corn chips under your Beef Chili.", "Beef Chili", none},
|
||||
"punctuation then a letter": {[]string{"Beef Chili!"}, "Your Beef Chili!X is our inspiration.", "Beef Chili!", none},
|
||||
"letter before punctuation": {[]string{"¡Beef Chili"}, "Your X¡Beef Chili is our inspiration.", "¡Beef Chili", none},
|
||||
"spacing before punctuation": {[]string{"Beef Chili!"}, "Your Beef Chili ! over corn chips.", "Beef Chili!", ""},
|
||||
"spacing after punctuation": {[]string{"¡Beef Chili"}, "Your ¡ Beef Chili is our inspiration.", "¡Beef Chili", ""},
|
||||
"stored spacing left off": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Beef Chili !", ""},
|
||||
"inside unspaced punctuation": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Chili!", none},
|
||||
"stored punctuation partly": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Beef Chili!!", ""},
|
||||
"inside partial punctuation": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Chili!", none},
|
||||
"partly then a letter": {[]string{"Beef Chili!!", "Chili!"}, "Your Beef Chili!!X is our inspiration.", "Chili!", none},
|
||||
"leading punctuation partly": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡¡Beef Chili", ""},
|
||||
"inside partial leading": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡Beef", none},
|
||||
"stored symbol left off": {[]string{"Green Chili 🌶"}, "Like your Green Chili, gone green.", "Green Chili 🌶", none},
|
||||
"stored accent left off": {[]string{"Chicken Cafe\u0301"}, "Like your Chicken Cafe, gone green.", "Chicken Cafe\u0301", none},
|
||||
"accent written after name": {[]string{"Chicken Cafe"}, "Like your Chicken Cafe\u0301, gone green.", "Chicken Cafe", none},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
in := household
|
||||
in.Context.Regulars = slices.Clone(household.Context.Regulars)
|
||||
for _, r := range tc.extra {
|
||||
in.Context.Regulars = append(in.Context.Regulars, harness.RegularDinner{Name: r, Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
|
||||
}
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(sideQuest("Frito Pie", tc.reason, tc.cited))))
|
||||
out, _, err := suggest(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := SideQuests{Suggestions: []SideQuest{want("Frito Pie", oneLine(tc.reason), tc.cited)}}
|
||||
if tc.why != "" {
|
||||
result = SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "Frito Pie", Why: tc.why}}}
|
||||
}
|
||||
assertSideQuests(t, out, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsDuplicatesDropped(t *testing.T) {
|
||||
declined := sideQuest("The Best Homemade Chicken Tikka-Masala", "Spiced like your Chicken Fajitas.", "Chicken Fajitas")
|
||||
oldDeclined := sideQuest("Fish Tacos!", "Tortillas like your Chicken Fajitas.", "Chicken Fajitas")
|
||||
regular := sideQuest("Easy Beef Chili", "Just like your Beef Chili.", "Beef Chili")
|
||||
questLog := sideQuest("Pad Thai", "Noodles like your Spaghetti Bolognese.", "Spaghetti Bolognese")
|
||||
pending := sideQuest("Shrimp Scampi", "Pasta like your Spaghetti Bolognese.", "Spaghetti Bolognese")
|
||||
again := sideQuest("Chicken Shawarma Bowls", "Chicken Fajitas, but Middle Eastern.", "Chicken Fajitas")
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(declined, oldDeclined, regular, questLog, pending, shawarma, again)))
|
||||
in := household
|
||||
in.Needed = 4
|
||||
out, _, err := suggest(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known := "repeats a Regular, Quest log dinner, pending or passed Side quest"
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{wantShawarma},
|
||||
Dropped: []DroppedSideQuest{
|
||||
{Name: "The Best Homemade Chicken Tikka-Masala", Why: known},
|
||||
{Name: "Fish Tacos!", Why: known},
|
||||
{Name: "Easy Beef Chili", Why: known},
|
||||
{Name: "Pad Thai", Why: known},
|
||||
{Name: "Shrimp Scampi", Why: known},
|
||||
{Name: "Chicken Shawarma Bowls", Why: `repeats the Side quest "Chicken Shawarma Bowls" in the same batch`},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsReasonRulesDropped(t *testing.T) {
|
||||
long := sideQuest("Beef Stroganoff", "Your Beef Chili"+strings.Repeat(" and more", 20)+".", "Beef Chili")
|
||||
unnamed := sideQuest("Chicken Satay", "Grilled skewers with a peanut sauce.", "Chicken Fajitas")
|
||||
wrongOne := sideQuest("Beef Tacos", "Like your Beef Chili, in a shell.", "Chicken Fajitas")
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(long, unnamed, ragu, wrongOne)))
|
||||
out, _, err := suggest(t, &f, household, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{wantRagu},
|
||||
Dropped: []DroppedSideQuest{
|
||||
{Name: "Beef Stroganoff", Why: "reason has 196 characters, over 180"},
|
||||
{Name: "Chicken Satay", Why: "reason names none of its cited Regulars"},
|
||||
{Name: "Beef Tacos", Why: "reason names none of its cited Regulars"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsAllDroppedIsNotRepaired(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(batch(sideQuest("Pad Thai", "Like your Beef Chili.", "Beef Chili"))))
|
||||
out, _, err := suggest(t, &f, household, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{
|
||||
Suggestions: []SideQuest{},
|
||||
Dropped: []DroppedSideQuest{{Name: "Pad Thai", Why: "repeats a Regular, Quest log dinner, pending or passed Side quest"}},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsMalformedRepairedOnce(t *testing.T) {
|
||||
for name, tc := range map[string]struct{ first, want string }{
|
||||
"truncated JSON": {
|
||||
`{"suggestions":[` + shawarma + `,{"name":"Beef`,
|
||||
"reply does not decode: unexpected EOF",
|
||||
},
|
||||
"omitted field": {
|
||||
batch(strings.Replace(shawarma, `"cuisine":"Fusion",`, ``, 1)),
|
||||
`suggestion has no "cuisine" field; give every field the schema requires`,
|
||||
},
|
||||
"unknown field": {
|
||||
batch(strings.Replace(shawarma, `"easy",`, `"easy","spice":"mild",`, 1)),
|
||||
`suggestion has unknown field "spice"`,
|
||||
},
|
||||
"bad method": {
|
||||
batch(strings.Replace(shawarma, `"stovetop"`, `"air fryer"`, 1)),
|
||||
`suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(tc.first), reply(batch(shawarma, ragu)))
|
||||
out, reqs, err := suggest(t, &f, household, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}})
|
||||
repair := reqs[1].Messages
|
||||
if len(repair) != 3 || repair[1].Content != tc.first {
|
||||
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
|
||||
}
|
||||
if !strings.Contains(repair[2].Content, tc.want) {
|
||||
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsFillerNameRepaired(t *testing.T) {
|
||||
filler := sideQuest("The.", "Like your Beef Chili.", "Beef Chili")
|
||||
first := batch(filler, filler)
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(first), reply(batch(shawarma, ragu)))
|
||||
out, reqs, err := suggest(t, &f, household, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}})
|
||||
repair := reqs[1].Messages
|
||||
if len(repair) != 3 || repair[1].Content != first {
|
||||
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`suggestion 1 name "The." has only filler words; name the dish`,
|
||||
`suggestion 2 name "The." has only filler words; name the dish`,
|
||||
} {
|
||||
if !strings.Contains(repair[2].Content, want) {
|
||||
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSideQuestsMalformedTwiceFails(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(`{"suggestions":`), reply(`{"suggestions":[]}`))
|
||||
out, _, err := suggest(t, &f, household, 2)
|
||||
var bad *harness.BadOutputError
|
||||
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want a BadOutputError", err)
|
||||
}
|
||||
if !slices.Equal(bad.Repair, []string{"suggestions is empty; give 4 Side quests"}) {
|
||||
t.Fatalf("repair problems = %q", bad.Repair)
|
||||
}
|
||||
if out.Suggestions != nil {
|
||||
t.Fatalf("output = %+v, want none", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSideQuestsValidate(t *testing.T) {
|
||||
s := suggestSideQuests{in: household}
|
||||
if p := s.Validate(SideQuests{Suggestions: []SideQuest{wantShawarma}}); len(p) != 0 {
|
||||
t.Fatalf("valid output has problems: %q", p)
|
||||
}
|
||||
if p := s.Validate(SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "x", Why: "y"}}}); len(p) != 0 {
|
||||
t.Fatalf("output whose Side quests were all dropped has problems: %q", p)
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
out SideQuests
|
||||
want []string
|
||||
}{
|
||||
"empty": {SideQuests{}, []string{"suggestions is empty; give 4 Side quests"}},
|
||||
"too many": {
|
||||
SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi, wantShawarma}},
|
||||
[]string{"suggestions has 5 entries; give at most 4 Side quests"},
|
||||
},
|
||||
"malformed": {
|
||||
SideQuests{Suggestions: []SideQuest{{Name: " ", Method: "air fryer", EstMinutes: 0, Difficulty: "trivial", CitedRegulars: []string{}}}},
|
||||
[]string{
|
||||
"suggestion 1 has an empty name; give one",
|
||||
"suggestion 1 has an empty cuisine; give one",
|
||||
"suggestion 1 has an empty reason; give one",
|
||||
"suggestion 1 has an empty search_query; give one",
|
||||
`suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`,
|
||||
"suggestion 1 est_minutes 0 is not positive; give the minutes from prep to table",
|
||||
`suggestion 1 difficulty "trivial" is not one of easy, medium, hard`,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := s.Validate(tc.out); !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSideQuestsPromptInputRejects(t *testing.T) {
|
||||
noRegulars := household
|
||||
noRegulars.Context.Regulars = nil
|
||||
none := household
|
||||
none.Needed = 0
|
||||
for name, tc := range map[string]struct {
|
||||
in SideQuestsRequest
|
||||
want string
|
||||
}{
|
||||
"no Regulars": {noRegulars, "household has no Regulars for Side quests to be based on"},
|
||||
"none needed": {none, "0 Side quests needed; the number must be positive"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := suggestSideQuests{in: tc.in}.PromptInput(tc.in)
|
||||
if err == nil || err.Error() != tc.want {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
## Instructions
|
||||
You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context.
|
||||
|
||||
Rules:
|
||||
- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD.
|
||||
- Use only dinners from the candidate pool, each by its id exactly as the pool writes it.
|
||||
- Plan each dinner at most once in the week.
|
||||
- Avoid the dinners marked "planned last week" whenever the pool has enough others.
|
||||
- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one.
|
||||
|
||||
Give:
|
||||
- days: the 7 days, each with date and dinner_id.
|
||||
- note: one sentence of at most 160 characters about the week.
|
||||
|
||||
One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household):
|
||||
{"date": "2026-01-05", "dinner_id": "d1"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Thursday 2026-10-08 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
(none)
|
||||
|
||||
### Recent plans
|
||||
- 2026-10-05 Mon: Chicken Fajitas
|
||||
- 2026-10-06 Tue: Fish Tacos
|
||||
|
||||
### Candidate pool
|
||||
- id "chili": Beef Chili (Regular, crockpot, 240 min)
|
||||
- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)
|
||||
- id "bolognese": Spaghetti Bolognese (Regular, stovetop, 45 min)
|
||||
- id "salmon": Sheet Pan Salmon (Regular, oven)
|
||||
- id "tacos": Fish Tacos (Regular, 25 min, planned last week)
|
||||
- id "padthai": Pad Thai (Quest log, stovetop, 35 min)
|
||||
- id "roast": Pot Roast (Regular, crockpot)
|
||||
- id "shawarma": Chicken Shawarma Bowls (Quest log, oven, 40 min)
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "draft_week" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"dinner_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"date",
|
||||
"dinner_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"note": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"days",
|
||||
"note"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18.
|
||||
@@ -0,0 +1,110 @@
|
||||
## Instructions
|
||||
You suggest Side quests: new dinners for the household to try, based on the dinners it already makes.
|
||||
|
||||
Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch.
|
||||
|
||||
Give for each Side quest:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex".
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars.
|
||||
- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household):
|
||||
{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Tuesday 2026-10-06 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)
|
||||
- Spaghetti Bolognese (Italian, stovetop, 45 min)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
- Chicken Tikka Masala
|
||||
|
||||
### Recent plans
|
||||
- 2026-10-05 Mon: Chicken Fajitas
|
||||
- 2026-10-06 Tue: Beef Chili
|
||||
|
||||
### Pending Side quests
|
||||
- Shrimp Scampi
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "suggest_side_quests" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"cuisine": {
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"cited_regulars": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"cuisine",
|
||||
"method",
|
||||
"est_minutes",
|
||||
"difficulty",
|
||||
"reason",
|
||||
"cited_regulars",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"suggestions"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 4 Side quests.
|
||||
@@ -35,6 +35,8 @@ const (
|
||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
||||
|
||||
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
|
||||
|
||||
EnvSearXNGURL = "MEALQUEST_SEARXNG_URL"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
@@ -73,6 +75,13 @@ type Config struct {
|
||||
// validates it only while the settings do not exist yet, and ignores it
|
||||
// once they do. Empty when unset.
|
||||
DefaultTZ string
|
||||
|
||||
// SearXNGURL is the deployment's SearXNG instance, which the Settings
|
||||
// screen suggests while no recipe-search URL is saved. It is optional
|
||||
// and validated by recipes.NewSearchSettingsService: the compose
|
||||
// network's http://searxng:8080 is neither https nor loopback, so the
|
||||
// secure-URL rule does not apply. Empty when unset.
|
||||
SearXNGURL string
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
||||
|
||||
DefaultTZ: r.optional(EnvDefaultTZ),
|
||||
|
||||
SearXNGURL: r.optional(EnvSearXNGURL),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
|
||||
@@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) {
|
||||
EnvOIDCAllowedGroups: " mealquest-admin ,family",
|
||||
|
||||
EnvDefaultTZ: "Europe/London",
|
||||
|
||||
EnvSearXNGURL: "http://searxng:8080",
|
||||
}
|
||||
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
if err != nil {
|
||||
@@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.DefaultTZ != "Europe/London" {
|
||||
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
|
||||
}
|
||||
if cfg.SearXNGURL != "http://searxng:8080" {
|
||||
t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
+32
-1
@@ -106,13 +106,22 @@ func TestDinners(t *testing.T) {
|
||||
Cuisine: str("Mexican"),
|
||||
IsFavorite: 0,
|
||||
RecipeID: str(recipe.ID),
|
||||
RecipeUrl: str("https://example.com/tacos"),
|
||||
PhotoID: str(photo.ID),
|
||||
GroundingStatus: "grounded",
|
||||
UpdatedBy: str("alex@example.com"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
want := db.Dinner(create)
|
||||
want := db.Dinner{
|
||||
ID: create.ID, Name: create.Name, NormalizedName: create.NormalizedName,
|
||||
Status: create.Status, Origin: create.Origin, Method: create.Method,
|
||||
MinutesTotal: create.MinutesTotal, Difficulty: create.Difficulty,
|
||||
Cuisine: create.Cuisine, IsFavorite: create.IsFavorite, RecipeID: create.RecipeID,
|
||||
PhotoID: create.PhotoID, GroundingStatus: create.GroundingStatus,
|
||||
UpdatedBy: create.UpdatedBy, CreatedAt: created, UpdatedAt: created,
|
||||
RecipeUrl: create.RecipeUrl,
|
||||
}
|
||||
got, err := q.CreateDinner(ctx, create)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDinner: %v", err)
|
||||
@@ -181,6 +190,7 @@ func TestDinners(t *testing.T) {
|
||||
Difficulty: str("medium"),
|
||||
Cuisine: sql.NullString{},
|
||||
RecipeID: sql.NullString{},
|
||||
RecipeUrl: str("https://example.com/tacos"),
|
||||
PhotoID: str(photo.ID),
|
||||
GroundingStatus: "failed",
|
||||
UpdatedBy: str("sam@example.com"),
|
||||
@@ -197,6 +207,7 @@ func TestDinners(t *testing.T) {
|
||||
Cuisine: update.Cuisine, IsFavorite: 0, RecipeID: update.RecipeID,
|
||||
PhotoID: update.PhotoID, GroundingStatus: update.GroundingStatus,
|
||||
UpdatedBy: update.UpdatedBy, CreatedAt: created, UpdatedAt: updated,
|
||||
RecipeUrl: create.RecipeUrl, // UpdateDinner leaves the recipe link
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("UpdateDinner returned %+v, want %+v", got, want)
|
||||
@@ -227,6 +238,17 @@ func TestDinners(t *testing.T) {
|
||||
_, err = q.SetDinnerFavorite(ctx, db.SetDinnerFavoriteParams{ID: "missing", IsFavorite: 1, UpdatedAt: updated})
|
||||
mustNoRows(t, "SetDinnerFavorite missing", err)
|
||||
|
||||
for _, wantFav := range []int64{1, 0} {
|
||||
got, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
|
||||
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: updated,
|
||||
})
|
||||
if err != nil || got.IsFavorite != wantFav || got.UpdatedBy != str("alex@example.com") {
|
||||
t.Fatalf("ToggleDinnerFavorite = %+v, %v; want is_favorite %d", got, err, wantFav)
|
||||
}
|
||||
}
|
||||
_, err = q.ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{ID: "missing", UpdatedAt: updated})
|
||||
mustNoRows(t, "ToggleDinnerFavorite missing", err)
|
||||
|
||||
const archivedAt = "2026-10-05T12:00:00.000Z"
|
||||
got, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
||||
ID: create.ID, UpdatedBy: str("alex@example.com"), UpdatedAt: archivedAt,
|
||||
@@ -240,6 +262,15 @@ func TestDinners(t *testing.T) {
|
||||
}
|
||||
assertIDs(t, "archived", listIDs("archived"), create.ID)
|
||||
assertIDs(t, "tried after archive", listIDs("tried"))
|
||||
unarchived, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUnarchivedDinners: %v", err)
|
||||
}
|
||||
var unarchivedIDs []string
|
||||
for _, d := range unarchived {
|
||||
unarchivedIDs = append(unarchivedIDs, d.Dinner.ID)
|
||||
}
|
||||
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
|
||||
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
|
||||
mustNoRows(t, "ArchiveDinner missing", err)
|
||||
}
|
||||
|
||||
+440
-23
@@ -16,7 +16,7 @@ SET status = 'archived',
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type ArchiveDinnerParams struct {
|
||||
@@ -46,21 +46,37 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const countRegularDinners = `-- name: CountRegularDinners :one
|
||||
SELECT COUNT(*) FROM dinners
|
||||
WHERE status = 'regular'
|
||||
`
|
||||
|
||||
// How many Regulars the household has; archived dinners are not counted.
|
||||
func (q *Queries) CountRegularDinners(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countRegularDinners)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createDinner = `-- name: CreateDinner :one
|
||||
INSERT INTO dinners (
|
||||
id, name, normalized_name, status, origin, method, minutes_total,
|
||||
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
|
||||
updated_by, created_at, updated_at
|
||||
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
|
||||
grounding_status, updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?
|
||||
?, ?, ?, ?
|
||||
)
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type CreateDinnerParams struct {
|
||||
@@ -75,6 +91,7 @@ type CreateDinnerParams struct {
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
RecipeUrl sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
@@ -95,6 +112,7 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
||||
arg.Cuisine,
|
||||
arg.IsFavorite,
|
||||
arg.RecipeID,
|
||||
arg.RecipeUrl,
|
||||
arg.PhotoID,
|
||||
arg.GroundingStatus,
|
||||
arg.UpdatedBy,
|
||||
@@ -119,12 +137,85 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const failOrphanedPendingDinnerGroundings = `-- name: FailOrphanedPendingDinnerGroundings :execrows
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = ?1,
|
||||
updated_at = ?2
|
||||
WHERE grounding_status = 'pending'
|
||||
AND status <> 'archived'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs
|
||||
WHERE jobs.kind = ?3
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
||||
)
|
||||
`
|
||||
|
||||
type FailOrphanedPendingDinnerGroundingsParams struct {
|
||||
GroundingError sql.NullString
|
||||
UpdatedAt string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// Fails every unarchived dinner still pending whose grounding job for its
|
||||
// current grounding_revision is no longer queued or running: the process
|
||||
// stopped before the job, or before its handler recorded a result. An
|
||||
// archived dinner is left as it is; archiving stopped its grounding. Run at
|
||||
// boot, after the job runner fails the jobs a previous process left running.
|
||||
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const failPendingDinnerGrounding = `-- name: FailPendingDinnerGrounding :execrows
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?
|
||||
`
|
||||
|
||||
type FailPendingDinnerGroundingParams struct {
|
||||
GroundingError sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
// Records why the background grounding of an unarchived dinner still
|
||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
||||
// grounded some other way, renamed or archived meanwhile is left as it is
|
||||
// (0 rows).
|
||||
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding,
|
||||
arg.GroundingError,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getDinner = `-- name: GetDinner :one
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
@@ -148,12 +239,164 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const groundDinner = `-- name: GroundDinner :one
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?1,
|
||||
recipe_url = NULL,
|
||||
photo_id = ?2,
|
||||
method = CASE WHEN method IS ?3
|
||||
THEN ?4 ELSE method END,
|
||||
minutes_total = CASE WHEN minutes_total IS ?5
|
||||
THEN ?6 ELSE minutes_total END,
|
||||
difficulty = CASE WHEN difficulty IS ?7
|
||||
THEN ?8 ELSE difficulty END,
|
||||
grounding_status = ?9,
|
||||
grounding_error = NULL,
|
||||
updated_by = ?10,
|
||||
updated_at = ?11
|
||||
WHERE id = ?12
|
||||
AND status <> 'archived'
|
||||
AND name = ?13
|
||||
AND normalized_name = ?14
|
||||
AND grounding_status = ?15
|
||||
AND grounding_revision = ?16
|
||||
AND recipe_id IS ?17
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type GroundDinnerParams struct {
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
ObservedMethod sql.NullString
|
||||
Method sql.NullString
|
||||
ObservedMinutesTotal sql.NullInt64
|
||||
MinutesTotal sql.NullInt64
|
||||
ObservedDifficulty sql.NullString
|
||||
Difficulty sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
ObservedName string
|
||||
ObservedNormalizedName string
|
||||
ObservedGroundingStatus string
|
||||
ObservedGroundingRevision int64
|
||||
ObservedRecipeID sql.NullString
|
||||
}
|
||||
|
||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
// link. It writes only an unarchived dinner still as it was observed
|
||||
// before grounding (same name, normalized_name, grounding_status,
|
||||
// grounding_revision and recipe_id), so a dinner renamed, archived or
|
||||
// grounded some other way meanwhile is left as it is. That dinner, or an
|
||||
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
||||
// no longer as observed is kept: a member edited it while it was grounding.
|
||||
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, groundDinner,
|
||||
arg.RecipeID,
|
||||
arg.PhotoID,
|
||||
arg.ObservedMethod,
|
||||
arg.Method,
|
||||
arg.ObservedMinutesTotal,
|
||||
arg.MinutesTotal,
|
||||
arg.ObservedDifficulty,
|
||||
arg.Difficulty,
|
||||
arg.GroundingStatus,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.ObservedName,
|
||||
arg.ObservedNormalizedName,
|
||||
arg.ObservedGroundingStatus,
|
||||
arg.ObservedGroundingRevision,
|
||||
arg.ObservedRecipeID,
|
||||
)
|
||||
var i Dinner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?1,
|
||||
recipe_url = NULL,
|
||||
photo_id = ?2,
|
||||
method = COALESCE(method, ?3),
|
||||
minutes_total = COALESCE(minutes_total, ?4),
|
||||
difficulty = COALESCE(difficulty, ?5),
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = ?6
|
||||
WHERE id = ?7
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?8
|
||||
`
|
||||
|
||||
type GroundPendingDinnerParams struct {
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
// Records the background grounding of an unarchived dinner still pending at
|
||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
// minutes_total or difficulty already set is kept: a pending dinner has
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, groundPendingDinner,
|
||||
arg.RecipeID,
|
||||
arg.PhotoID,
|
||||
arg.Method,
|
||||
arg.MinutesTotal,
|
||||
arg.Difficulty,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at FROM dinners
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE status = ?
|
||||
ORDER BY normalized_name, id
|
||||
`
|
||||
@@ -184,6 +427,125 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
); 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 listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
|
||||
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
|
||||
FROM dinners
|
||||
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
|
||||
WHERE dinners.status <> 'archived'
|
||||
ORDER BY dinners.id
|
||||
`
|
||||
|
||||
type ListUnarchivedDinnersRow struct {
|
||||
Dinner Dinner
|
||||
RecipeSourceUrl sql.NullString
|
||||
}
|
||||
|
||||
// Every dinner a new or renamed dinner must not repeat, with the
|
||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
||||
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUnarchivedDinnersRow
|
||||
for rows.Next() {
|
||||
var i ListUnarchivedDinnersRow
|
||||
if err := rows.Scan(
|
||||
&i.Dinner.ID,
|
||||
&i.Dinner.Name,
|
||||
&i.Dinner.NormalizedName,
|
||||
&i.Dinner.Status,
|
||||
&i.Dinner.Origin,
|
||||
&i.Dinner.Method,
|
||||
&i.Dinner.MinutesTotal,
|
||||
&i.Dinner.Difficulty,
|
||||
&i.Dinner.Cuisine,
|
||||
&i.Dinner.IsFavorite,
|
||||
&i.Dinner.RecipeID,
|
||||
&i.Dinner.PhotoID,
|
||||
&i.Dinner.GroundingStatus,
|
||||
&i.Dinner.UpdatedBy,
|
||||
&i.Dinner.CreatedAt,
|
||||
&i.Dinner.UpdatedAt,
|
||||
&i.Dinner.RecipeUrl,
|
||||
&i.Dinner.GroundingError,
|
||||
&i.Dinner.GroundingRevision,
|
||||
&i.RecipeSourceUrl,
|
||||
); 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 listUnplannedQuestLogDinners = `-- name: ListUnplannedQuestLogDinners :many
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE status = 'quest_log'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM plan_days
|
||||
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||
WHERE plan_days.dinner_id = dinners.id
|
||||
AND week_plans.week_start >= ?1
|
||||
)
|
||||
ORDER BY normalized_name, id
|
||||
`
|
||||
|
||||
// The Quest log: dinners with status quest_log that no plan for the week
|
||||
// starting from_week_start or later has on any day.
|
||||
func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUnplannedQuestLogDinners, fromWeekStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Dinner
|
||||
for rows.Next() {
|
||||
var i Dinner
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -204,7 +566,7 @@ SET is_favorite = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type SetDinnerFavoriteParams struct {
|
||||
@@ -240,6 +602,52 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const toggleDinnerFavorite = `-- name: ToggleDinnerFavorite :one
|
||||
UPDATE dinners
|
||||
SET is_favorite = 1 - is_favorite,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type ToggleDinnerFavoriteParams struct {
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, toggleDinnerFavorite, arg.UpdatedBy, arg.UpdatedAt, arg.ID)
|
||||
var i Dinner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -255,29 +663,33 @@ SET name = ?,
|
||||
difficulty = ?,
|
||||
cuisine = ?,
|
||||
recipe_id = ?,
|
||||
recipe_url = ?,
|
||||
photo_id = ?,
|
||||
grounding_status = ?,
|
||||
grounding_revision = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at
|
||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
||||
`
|
||||
|
||||
type UpdateDinnerParams struct {
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
RecipeID sql.NullString
|
||||
RecipeUrl sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
GroundingRevision int64
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
@@ -292,8 +704,10 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
||||
arg.Difficulty,
|
||||
arg.Cuisine,
|
||||
arg.RecipeID,
|
||||
arg.RecipeUrl,
|
||||
arg.PhotoID,
|
||||
arg.GroundingStatus,
|
||||
arg.GroundingRevision,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
@@ -316,6 +730,9 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -52,20 +52,26 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
|
||||
const listRecentPassedNames = `-- name: ListRecentPassedNames :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
|
||||
SELECT name FROM (
|
||||
SELECT name, decided_at, id
|
||||
FROM suggestions
|
||||
WHERE status = 'declined'
|
||||
UNION ALL
|
||||
SELECT name, decided_at, id
|
||||
FROM rotation_reviews
|
||||
WHERE decision = 'drop'
|
||||
)
|
||||
ORDER BY decided_at DESC, 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)
|
||||
// The most recent names the household turned down, newest first, as
|
||||
// ListPassedNames has them.
|
||||
func (q *Queries) ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRecentPassedNames, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+22
-16
@@ -51,22 +51,25 @@ type ChatSetting struct {
|
||||
}
|
||||
|
||||
type Dinner struct {
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
RecipeUrl sql.NullString
|
||||
GroundingError sql.NullString
|
||||
GroundingRevision int64
|
||||
}
|
||||
|
||||
type HouseholdSetting struct {
|
||||
@@ -225,6 +228,7 @@ type RotationReview struct {
|
||||
PlanDate string
|
||||
Status string
|
||||
Decision sql.NullString
|
||||
Name sql.NullString
|
||||
DecidedAt sql.NullString
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
@@ -286,6 +290,7 @@ type Suggestion struct {
|
||||
ID string
|
||||
DinnerID string
|
||||
BatchID string
|
||||
Name string
|
||||
Reason string
|
||||
CitedRegularIds string
|
||||
Status string
|
||||
@@ -312,4 +317,5 @@ type WeekPlan struct {
|
||||
CalendarSyncedAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
Revision int64
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: plans.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createWeekPlan = `-- name: CreateWeekPlan :exec
|
||||
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
|
||||
VALUES (?, ?, 'draft', ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateWeekPlanParams struct {
|
||||
ID string
|
||||
WeekStart string
|
||||
AiNote sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createWeekPlan,
|
||||
arg.ID,
|
||||
arg.WeekStart,
|
||||
arg.AiNote,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getActiveDraftJob = `-- name: GetActiveDraftJob :one
|
||||
SELECT id, CAST(json_extract(payload, '$.confirm') = 1 AS INTEGER) AS confirmed
|
||||
FROM jobs
|
||||
WHERE kind = ?1
|
||||
AND status IN ('queued', 'running')
|
||||
AND json_extract(payload, '$.week_start') = ?2
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetActiveDraftJobParams struct {
|
||||
Kind string
|
||||
WeekStart string
|
||||
}
|
||||
|
||||
type GetActiveDraftJobRow struct {
|
||||
ID string
|
||||
Confirmed int64
|
||||
}
|
||||
|
||||
// The job of kind queued or running for the week starting week_start,
|
||||
// with whether its payload has confirm. StartDraft enqueues a draft only
|
||||
// when there is none, so there is at most one. Returns sql.ErrNoRows when
|
||||
// there is none.
|
||||
func (q *Queries) GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getActiveDraftJob, arg.Kind, arg.WeekStart)
|
||||
var i GetActiveDraftJobRow
|
||||
err := row.Scan(&i.ID, &i.Confirmed)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWeekPlan = `-- name: GetWeekPlan :one
|
||||
SELECT id, week_start, status, ai_note, ai_run_id, calendar_synced_at, created_at, updated_at, revision FROM week_plans
|
||||
WHERE week_start = ?
|
||||
`
|
||||
|
||||
// The plan of the week starting week_start. Returns sql.ErrNoRows when
|
||||
// there is none.
|
||||
func (q *Queries) GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error) {
|
||||
row := q.db.QueryRowContext(ctx, getWeekPlan, weekStart)
|
||||
var i WeekPlan
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WeekStart,
|
||||
&i.Status,
|
||||
&i.AiNote,
|
||||
&i.AiRunID,
|
||||
&i.CalendarSyncedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Revision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listDraftPool = `-- name: ListDraftPool :many
|
||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
||||
WHERE status = 'regular'
|
||||
OR (status = 'quest_log' AND NOT EXISTS (
|
||||
SELECT 1 FROM plan_days
|
||||
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||
WHERE plan_days.dinner_id = dinners.id
|
||||
AND week_plans.week_start >= ?1
|
||||
AND week_plans.week_start <> ?2
|
||||
))
|
||||
ORDER BY normalized_name, id
|
||||
`
|
||||
|
||||
type ListDraftPoolParams struct {
|
||||
FromWeekStart string
|
||||
WeekStart string
|
||||
}
|
||||
|
||||
// The dinners a draft of the week starting week_start may plan: every
|
||||
// Regular, and every Quest log dinner no other plan for the week starting
|
||||
// from_week_start or later has on any day.
|
||||
func (q *Queries) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDraftPool, arg.FromWeekStart, arg.WeekStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Dinner
|
||||
for rows.Next() {
|
||||
var i Dinner
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
); 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 listPlanDays = `-- name: ListPlanDays :many
|
||||
SELECT plan_days.date, plan_days.is_new, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
|
||||
FROM plan_days
|
||||
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||
WHERE plan_days.plan_id = ?
|
||||
ORDER BY plan_days.date
|
||||
`
|
||||
|
||||
type ListPlanDaysRow struct {
|
||||
Date string
|
||||
IsNew int64
|
||||
Dinner Dinner
|
||||
}
|
||||
|
||||
// The days of plan plan_id with their dinners, Monday first. A day with
|
||||
// no dinner is left out.
|
||||
func (q *Queries) ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listPlanDays, planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListPlanDaysRow
|
||||
for rows.Next() {
|
||||
var i ListPlanDaysRow
|
||||
if err := rows.Scan(
|
||||
&i.Date,
|
||||
&i.IsNew,
|
||||
&i.Dinner.ID,
|
||||
&i.Dinner.Name,
|
||||
&i.Dinner.NormalizedName,
|
||||
&i.Dinner.Status,
|
||||
&i.Dinner.Origin,
|
||||
&i.Dinner.Method,
|
||||
&i.Dinner.MinutesTotal,
|
||||
&i.Dinner.Difficulty,
|
||||
&i.Dinner.Cuisine,
|
||||
&i.Dinner.IsFavorite,
|
||||
&i.Dinner.RecipeID,
|
||||
&i.Dinner.PhotoID,
|
||||
&i.Dinner.GroundingStatus,
|
||||
&i.Dinner.UpdatedBy,
|
||||
&i.Dinner.CreatedAt,
|
||||
&i.Dinner.UpdatedAt,
|
||||
&i.Dinner.RecipeUrl,
|
||||
&i.Dinner.GroundingError,
|
||||
&i.Dinner.GroundingRevision,
|
||||
); 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 listWeekPlanDinnerIDs = `-- name: ListWeekPlanDinnerIDs :many
|
||||
SELECT plan_days.dinner_id
|
||||
FROM plan_days
|
||||
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||
WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL
|
||||
ORDER BY plan_days.date
|
||||
`
|
||||
|
||||
// The ids of the dinners planned in the week starting week_start, Monday
|
||||
// first.
|
||||
func (q *Queries) ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listWeekPlanDinnerIDs, weekStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []sql.NullString
|
||||
for rows.Next() {
|
||||
var dinner_id sql.NullString
|
||||
if err := rows.Scan(&dinner_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, dinner_id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec
|
||||
UPDATE week_plans
|
||||
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
|
||||
revision = revision + 1,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type MarkWeekPlanEditedParams struct {
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||
// changed, any other keeps its status.
|
||||
func (q *Queries) MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error {
|
||||
_, err := q.db.ExecContext(ctx, markWeekPlanEdited, arg.UpdatedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const putPlanDay = `-- name: PutPlanDay :exec
|
||||
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (plan_id, date) DO UPDATE
|
||||
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at
|
||||
`
|
||||
|
||||
type PutPlanDayParams struct {
|
||||
ID string
|
||||
PlanID string
|
||||
Date string
|
||||
DinnerID sql.NullString
|
||||
IsNew int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
|
||||
// when the day exists.
|
||||
func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error {
|
||||
_, err := q.db.ExecContext(ctx, putPlanDay,
|
||||
arg.ID,
|
||||
arg.PlanID,
|
||||
arg.Date,
|
||||
arg.DinnerID,
|
||||
arg.IsNew,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const redraftWeekPlan = `-- name: RedraftWeekPlan :exec
|
||||
UPDATE week_plans
|
||||
SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type RedraftWeekPlanParams struct {
|
||||
Status string
|
||||
AiNote sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records a new draft over plan id: its status and note, raising its
|
||||
// revision.
|
||||
func (q *Queries) RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error {
|
||||
_, err := q.db.ExecContext(ctx, redraftWeekPlan,
|
||||
arg.Status,
|
||||
arg.AiNote,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const swapPlanDay = `-- name: SwapPlanDay :execrows
|
||||
UPDATE plan_days
|
||||
SET dinner_id = ?, is_new = ?, updated_at = ?
|
||||
WHERE plan_id = ? AND date = ?
|
||||
`
|
||||
|
||||
type SwapPlanDayParams struct {
|
||||
DinnerID sql.NullString
|
||||
IsNew int64
|
||||
UpdatedAt string
|
||||
PlanID string
|
||||
Date string
|
||||
}
|
||||
|
||||
// Plans dinner_id on an existing day of plan_id; a date the plan does not
|
||||
// have is left as it is (0 rows).
|
||||
func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, swapPlanDay,
|
||||
arg.DinnerID,
|
||||
arg.IsNew,
|
||||
arg.UpdatedAt,
|
||||
arg.PlanID,
|
||||
arg.Date,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
+139
-2
@@ -21,20 +21,68 @@ type Querier interface {
|
||||
// Drops references to the runs created before the cutoff, so the purge
|
||||
// can delete them.
|
||||
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||
// How many suggestions ListDeck returns.
|
||||
CountDeck(ctx context.Context) (int64, error)
|
||||
// How many Regulars the household has; archived dinners are not counted.
|
||||
CountRegularDinners(ctx context.Context) (int64, error)
|
||||
// How many Rotation checks dinner_id has had, pending or decided.
|
||||
CountRotationReviews(ctx context.Context, dinnerID string) (int64, error)
|
||||
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
|
||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||
// A recipe the household typed in. Its source_url is a mealquest:manual:
|
||||
// URN rather than a page, so it never matches a fetched page.
|
||||
CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error)
|
||||
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||
CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error
|
||||
// name is the name the dish is suggested under, kept whatever its dinner is
|
||||
// later renamed.
|
||||
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
|
||||
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
|
||||
CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error
|
||||
// Records the household's decision on a pending Rotation check, with the
|
||||
// dinner's name for a drop and NULL otherwise; one already decided is left
|
||||
// as it is (0 rows).
|
||||
DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error)
|
||||
// Records the household's decision on a pending suggestion; one already
|
||||
// decided is left as it is (0 rows).
|
||||
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
||||
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||
// Fails every unarchived dinner still pending whose grounding job for its
|
||||
// current grounding_revision is no longer queued or running: the process
|
||||
// stopped before the job, or before its handler recorded a result. An
|
||||
// archived dinner is left as it is; archiving stopped its grounding. Run at
|
||||
// boot, after the job runner fails the jobs a previous process left running.
|
||||
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
|
||||
// Fails every batch still pending or running whose job is no longer queued
|
||||
// or running: the process stopped, or recording the batch's end failed,
|
||||
// before its handler recorded a result. Run before reading the active batch.
|
||||
FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error)
|
||||
// Records why the background grounding of an unarchived dinner still
|
||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
||||
// grounded some other way, renamed or archived meanwhile is left as it is
|
||||
// (0 rows).
|
||||
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
|
||||
// Fails every job left running, at boot before any worker starts.
|
||||
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||
// Records a pending or running batch as done or failed; a batch already
|
||||
// finished is left as it is (0 rows).
|
||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||
// The job of kind queued or running for the week starting week_start,
|
||||
// with whether its payload has confirm. StartDraft enqueues a draft only
|
||||
// when there is none, so there is at most one. Returns sql.ErrNoRows when
|
||||
// there is none.
|
||||
GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error)
|
||||
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
|
||||
// when there is none.
|
||||
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetChatSettings(ctx context.Context) (ChatSetting, error)
|
||||
GetDinner(ctx context.Context, id string) (Dinner, error)
|
||||
@@ -43,6 +91,13 @@ type Querier interface {
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
|
||||
GetJob(ctx context.Context, id string) (Job, error)
|
||||
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
||||
// is none.
|
||||
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
||||
// The pending Rotation check of dinner_id. MarkTried makes one only for a
|
||||
// dinner in the Quest log, which it moves to tried, so a dinner has at
|
||||
// most one. Returns sql.ErrNoRows when there is none.
|
||||
GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error)
|
||||
GetPhoto(ctx context.Context, id string) (Photo, error)
|
||||
// Finds stored image bytes by content hash. The schema does not make sha256
|
||||
// unique, so the earliest-created (lowest id) match is returned.
|
||||
@@ -51,20 +106,102 @@ type Querier interface {
|
||||
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
|
||||
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
|
||||
// The id of the job that runs batch batch_id.
|
||||
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
|
||||
// The plan of the week starting week_start. Returns sql.ErrNoRows when
|
||||
// there is none.
|
||||
GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error)
|
||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
// link. It writes only an unarchived dinner still as it was observed
|
||||
// before grounding (same name, normalized_name, grounding_status,
|
||||
// grounding_revision and recipe_id), so a dinner renamed, archived or
|
||||
// grounded some other way meanwhile is left as it is. That dinner, or an
|
||||
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
||||
// no longer as observed is kept: a member edited it while it was grounding.
|
||||
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
|
||||
// Records the background grounding of an unarchived dinner still pending at
|
||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
// minutes_total or difficulty already set is kept: a pending dinner has
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
// The pending suggestions whose dinner is still suggested, oldest first,
|
||||
// with their dinners.
|
||||
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||
// The dinners a draft of the week starting week_start may plan: every
|
||||
// Regular, and every Quest log dinner no other plan for the week starting
|
||||
// from_week_start or later has on any day.
|
||||
ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error)
|
||||
// Every name the household turned down, oldest decision first: the name
|
||||
// each declined suggestion was suggested under, which renaming its dinner
|
||||
// does not change, and the name each dinner a Rotation check dropped
|
||||
// ("Not for us") had when dropped, which renaming it does not change.
|
||||
ListPassedNames(ctx context.Context) ([]string, error)
|
||||
// The pending Rotation checks whose dinner is still tried, oldest plan
|
||||
// date first, with their dinners.
|
||||
ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error)
|
||||
// The days of plan plan_id with their dinners, Monday first. A day with
|
||||
// no dinner is left out.
|
||||
ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, 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)
|
||||
// The most recent names the household turned down, newest first, as
|
||||
// ListPassedNames has them.
|
||||
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// The ids of the dinners with status quest_log planned on date, on a plan
|
||||
// of any status, that have no Rotation check for date yet.
|
||||
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
|
||||
// Every dinner a new or renamed dinner must not repeat, with the
|
||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
||||
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
|
||||
// The Quest log: dinners with status quest_log that no plan for the week
|
||||
// starting from_week_start or later has on any day.
|
||||
ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error)
|
||||
// The ids of the dinners planned in the week starting week_start, Monday
|
||||
// first.
|
||||
ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error)
|
||||
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||
// status is left as it is (0 rows).
|
||||
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
|
||||
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||
// changed, any other keeps its status.
|
||||
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
||||
// Moves a dinner still suggested to status (quest_log or archived),
|
||||
// recording updated_by; a dinner no longer suggested is left as it is
|
||||
// (0 rows).
|
||||
MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error)
|
||||
// Moves a dinner still tried to status (regular, archived or quest_log)
|
||||
// by a Rotation check decision, recording updated_by; its origin is kept.
|
||||
// A dinner no longer tried is left as it is (0 rows).
|
||||
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
|
||||
// Stores a partial result of a running job.
|
||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
|
||||
// when the day exists.
|
||||
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
|
||||
// Records a new draft over plan id: its status and note, raising its
|
||||
// revision.
|
||||
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||
// Marks a pending batch running; a batch in any other status is left as
|
||||
// it is (0 rows).
|
||||
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
|
||||
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
|
||||
// Plans dinner_id on an existing day of plan_id; a date the plan does not
|
||||
// have is left as it is (0 rows).
|
||||
SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
|
||||
@@ -10,6 +10,67 @@ import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createManualRecipe = `-- name: CreateManualRecipe :one
|
||||
INSERT INTO recipes (
|
||||
id, source_url, title, total_minutes, raw_jsonld, method,
|
||||
minutes_total, fetched_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
)
|
||||
RETURNING id, source_url, site_name, title, total_minutes, prep_minutes, cook_minutes, yield, raw_jsonld, fetched_at, created_at, updated_at, photo_id, method, difficulty, minutes_total
|
||||
`
|
||||
|
||||
type CreateManualRecipeParams struct {
|
||||
ID string
|
||||
SourceUrl string
|
||||
Title string
|
||||
TotalMinutes sql.NullInt64
|
||||
RawJsonld string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
FetchedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// A recipe the household typed in. Its source_url is a mealquest:manual:
|
||||
// URN rather than a page, so it never matches a fetched page.
|
||||
func (q *Queries) CreateManualRecipe(ctx context.Context, arg CreateManualRecipeParams) (Recipe, error) {
|
||||
row := q.db.QueryRowContext(ctx, createManualRecipe,
|
||||
arg.ID,
|
||||
arg.SourceUrl,
|
||||
arg.Title,
|
||||
arg.TotalMinutes,
|
||||
arg.RawJsonld,
|
||||
arg.Method,
|
||||
arg.MinutesTotal,
|
||||
arg.FetchedAt,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i Recipe
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SourceUrl,
|
||||
&i.SiteName,
|
||||
&i.Title,
|
||||
&i.TotalMinutes,
|
||||
&i.PrepMinutes,
|
||||
&i.CookMinutes,
|
||||
&i.Yield,
|
||||
&i.RawJsonld,
|
||||
&i.FetchedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PhotoID,
|
||||
&i.Method,
|
||||
&i.Difficulty,
|
||||
&i.MinutesTotal,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createRecipeIngredient = `-- name: CreateRecipeIngredient :exec
|
||||
INSERT INTO recipe_ingredients (
|
||||
id, recipe_id, position, raw_text, name, qty, unit, note,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: rotation_reviews.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const countRotationReviews = `-- name: CountRotationReviews :one
|
||||
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?
|
||||
`
|
||||
|
||||
// How many Rotation checks dinner_id has had, pending or decided.
|
||||
func (q *Queries) CountRotationReviews(ctx context.Context, dinnerID string) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countRotationReviews, dinnerID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createRotationReview = `-- name: CreateRotationReview :exec
|
||||
INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?)
|
||||
`
|
||||
|
||||
type CreateRotationReviewParams struct {
|
||||
ID string
|
||||
DinnerID string
|
||||
PlanDate string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createRotationReview,
|
||||
arg.ID,
|
||||
arg.DinnerID,
|
||||
arg.PlanDate,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const decideRotationReview = `-- name: DecideRotationReview :execrows
|
||||
UPDATE rotation_reviews
|
||||
SET status = 'decided',
|
||||
decision = ?1,
|
||||
name = ?2,
|
||||
decided_at = ?3,
|
||||
updated_by = ?4,
|
||||
updated_at = ?3
|
||||
WHERE id = ?5 AND status = 'pending'
|
||||
`
|
||||
|
||||
type DecideRotationReviewParams struct {
|
||||
Decision sql.NullString
|
||||
Name sql.NullString
|
||||
Now sql.NullString
|
||||
UpdatedBy sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records the household's decision on a pending Rotation check, with the
|
||||
// dinner's name for a drop and NULL otherwise; one already decided is left
|
||||
// as it is (0 rows).
|
||||
func (q *Queries) DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, decideRotationReview,
|
||||
arg.Decision,
|
||||
arg.Name,
|
||||
arg.Now,
|
||||
arg.UpdatedBy,
|
||||
arg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getPendingRotationReview = `-- name: GetPendingRotationReview :one
|
||||
SELECT id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at FROM rotation_reviews
|
||||
WHERE dinner_id = ? AND status = 'pending'
|
||||
`
|
||||
|
||||
// The pending Rotation check of dinner_id. MarkTried makes one only for a
|
||||
// dinner in the Quest log, which it moves to tried, so a dinner has at
|
||||
// most one. Returns sql.ErrNoRows when there is none.
|
||||
func (q *Queries) GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error) {
|
||||
row := q.db.QueryRowContext(ctx, getPendingRotationReview, dinnerID)
|
||||
var i RotationReview
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.DinnerID,
|
||||
&i.PlanDate,
|
||||
&i.Status,
|
||||
&i.Decision,
|
||||
&i.Name,
|
||||
&i.DecidedAt,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listPendingRotationReviews = `-- name: ListPendingRotationReviews :many
|
||||
SELECT rotation_reviews.plan_date, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
|
||||
FROM rotation_reviews
|
||||
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
|
||||
WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried'
|
||||
ORDER BY rotation_reviews.plan_date, rotation_reviews.id
|
||||
`
|
||||
|
||||
type ListPendingRotationReviewsRow struct {
|
||||
PlanDate string
|
||||
Dinner Dinner
|
||||
}
|
||||
|
||||
// The pending Rotation checks whose dinner is still tried, oldest plan
|
||||
// date first, with their dinners.
|
||||
func (q *Queries) ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listPendingRotationReviews)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListPendingRotationReviewsRow
|
||||
for rows.Next() {
|
||||
var i ListPendingRotationReviewsRow
|
||||
if err := rows.Scan(
|
||||
&i.PlanDate,
|
||||
&i.Dinner.ID,
|
||||
&i.Dinner.Name,
|
||||
&i.Dinner.NormalizedName,
|
||||
&i.Dinner.Status,
|
||||
&i.Dinner.Origin,
|
||||
&i.Dinner.Method,
|
||||
&i.Dinner.MinutesTotal,
|
||||
&i.Dinner.Difficulty,
|
||||
&i.Dinner.Cuisine,
|
||||
&i.Dinner.IsFavorite,
|
||||
&i.Dinner.RecipeID,
|
||||
&i.Dinner.PhotoID,
|
||||
&i.Dinner.GroundingStatus,
|
||||
&i.Dinner.UpdatedBy,
|
||||
&i.Dinner.CreatedAt,
|
||||
&i.Dinner.UpdatedAt,
|
||||
&i.Dinner.RecipeUrl,
|
||||
&i.Dinner.GroundingError,
|
||||
&i.Dinner.GroundingRevision,
|
||||
); 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 listTriedCandidates = `-- name: ListTriedCandidates :many
|
||||
SELECT DISTINCT dinners.id
|
||||
FROM plan_days
|
||||
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||
WHERE plan_days.date = ?1
|
||||
AND dinners.status = 'quest_log'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM rotation_reviews
|
||||
WHERE rotation_reviews.dinner_id = dinners.id
|
||||
AND rotation_reviews.plan_date = plan_days.date
|
||||
)
|
||||
ORDER BY dinners.id
|
||||
`
|
||||
|
||||
// The ids of the dinners with status quest_log planned on date, on a plan
|
||||
// of any status, that have no Rotation check for date yet.
|
||||
func (q *Queries) ListTriedCandidates(ctx context.Context, date string) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listTriedCandidates, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markDinnerTried = `-- name: MarkDinnerTried :execrows
|
||||
UPDATE dinners
|
||||
SET status = 'tried', updated_at = ?
|
||||
WHERE id = ? AND status = 'quest_log'
|
||||
`
|
||||
|
||||
type MarkDinnerTriedParams struct {
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||
// status is left as it is (0 rows).
|
||||
func (q *Queries) MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, markDinnerTried, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const moveTriedDinner = `-- name: MoveTriedDinner :execrows
|
||||
UPDATE dinners
|
||||
SET status = ?1, updated_by = ?2, updated_at = ?3
|
||||
WHERE id = ?4 AND status = 'tried'
|
||||
`
|
||||
|
||||
type MoveTriedDinnerParams struct {
|
||||
Status string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Moves a dinner still tried to status (regular, archived or quest_log)
|
||||
// by a Rotation check decision, recording updated_by; its origin is kept.
|
||||
// A dinner no longer tried is left as it is (0 rows).
|
||||
func (q *Queries) MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, moveTriedDinner,
|
||||
arg.Status,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: suggestions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const countDeck = `-- name: CountDeck :one
|
||||
SELECT COUNT(*)
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
|
||||
`
|
||||
|
||||
// How many suggestions ListDeck returns.
|
||||
func (q *Queries) CountDeck(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countDeck)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createSuggestion = `-- name: CreateSuggestion :exec
|
||||
INSERT INTO suggestions (
|
||||
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, 'pending', ?, ?
|
||||
)
|
||||
`
|
||||
|
||||
type CreateSuggestionParams struct {
|
||||
ID string
|
||||
DinnerID string
|
||||
BatchID string
|
||||
Name string
|
||||
Reason string
|
||||
CitedRegularIds string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// name is the name the dish is suggested under, kept whatever its dinner is
|
||||
// later renamed.
|
||||
func (q *Queries) CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createSuggestion,
|
||||
arg.ID,
|
||||
arg.DinnerID,
|
||||
arg.BatchID,
|
||||
arg.Name,
|
||||
arg.Reason,
|
||||
arg.CitedRegularIds,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createSuggestionBatch = `-- name: CreateSuggestionBatch :one
|
||||
INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', ?, ?)
|
||||
RETURNING id, "trigger", status, ai_run_id, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateSuggestionBatchParams struct {
|
||||
ID string
|
||||
Trigger string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error) {
|
||||
row := q.db.QueryRowContext(ctx, createSuggestionBatch,
|
||||
arg.ID,
|
||||
arg.Trigger,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i SuggestionBatch
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.AiRunID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const decideSuggestion = `-- name: DecideSuggestion :execrows
|
||||
UPDATE suggestions
|
||||
SET status = ?1, decided_at = ?2, updated_at = ?2
|
||||
WHERE id = ?3 AND status = 'pending'
|
||||
`
|
||||
|
||||
type DecideSuggestionParams struct {
|
||||
Status string
|
||||
Now sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records the household's decision on a pending suggestion; one already
|
||||
// decided is left as it is (0 rows).
|
||||
func (q *Queries) DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, decideSuggestion, arg.Status, arg.Now, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const failOrphanedSuggestionBatches = `-- name: FailOrphanedSuggestionBatches :execrows
|
||||
UPDATE suggestion_batches
|
||||
SET status = 'failed', updated_at = ?1
|
||||
WHERE status IN ('pending', 'running')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs
|
||||
WHERE jobs.kind = ?2
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id
|
||||
)
|
||||
`
|
||||
|
||||
type FailOrphanedSuggestionBatchesParams struct {
|
||||
UpdatedAt string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// Fails every batch still pending or running whose job is no longer queued
|
||||
// or running: the process stopped, or recording the batch's end failed,
|
||||
// before its handler recorded a result. Run before reading the active batch.
|
||||
func (q *Queries) FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failOrphanedSuggestionBatches, arg.UpdatedAt, arg.Kind)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const finishSuggestionBatch = `-- name: FinishSuggestionBatch :execrows
|
||||
UPDATE suggestion_batches
|
||||
SET status = ?, updated_at = ?
|
||||
WHERE id = ? AND status IN ('pending', 'running')
|
||||
`
|
||||
|
||||
type FinishSuggestionBatchParams struct {
|
||||
Status string
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records a pending or running batch as done or failed; a batch already
|
||||
// finished is left as it is (0 rows).
|
||||
func (q *Queries) FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, finishSuggestionBatch, arg.Status, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getActiveSuggestionBatch = `-- name: GetActiveSuggestionBatch :one
|
||||
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
|
||||
WHERE status IN ('pending', 'running')
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
|
||||
// when there is none.
|
||||
func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error) {
|
||||
row := q.db.QueryRowContext(ctx, getActiveSuggestionBatch)
|
||||
var i SuggestionBatch
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.AiRunID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one
|
||||
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
||||
// is none.
|
||||
func (q *Queries) GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error) {
|
||||
row := q.db.QueryRowContext(ctx, getLatestSuggestionBatch)
|
||||
var i SuggestionBatch
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.AiRunID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSuggestion = `-- name: GetSuggestion :one
|
||||
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.id = ?
|
||||
`
|
||||
|
||||
type GetSuggestionRow struct {
|
||||
Suggestion Suggestion
|
||||
Dinner Dinner
|
||||
}
|
||||
|
||||
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
|
||||
func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getSuggestion, id)
|
||||
var i GetSuggestionRow
|
||||
err := row.Scan(
|
||||
&i.Suggestion.ID,
|
||||
&i.Suggestion.DinnerID,
|
||||
&i.Suggestion.BatchID,
|
||||
&i.Suggestion.Name,
|
||||
&i.Suggestion.Reason,
|
||||
&i.Suggestion.CitedRegularIds,
|
||||
&i.Suggestion.Status,
|
||||
&i.Suggestion.DecidedAt,
|
||||
&i.Suggestion.CreatedAt,
|
||||
&i.Suggestion.UpdatedAt,
|
||||
&i.Dinner.ID,
|
||||
&i.Dinner.Name,
|
||||
&i.Dinner.NormalizedName,
|
||||
&i.Dinner.Status,
|
||||
&i.Dinner.Origin,
|
||||
&i.Dinner.Method,
|
||||
&i.Dinner.MinutesTotal,
|
||||
&i.Dinner.Difficulty,
|
||||
&i.Dinner.Cuisine,
|
||||
&i.Dinner.IsFavorite,
|
||||
&i.Dinner.RecipeID,
|
||||
&i.Dinner.PhotoID,
|
||||
&i.Dinner.GroundingStatus,
|
||||
&i.Dinner.UpdatedBy,
|
||||
&i.Dinner.CreatedAt,
|
||||
&i.Dinner.UpdatedAt,
|
||||
&i.Dinner.RecipeUrl,
|
||||
&i.Dinner.GroundingError,
|
||||
&i.Dinner.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one
|
||||
SELECT id FROM jobs
|
||||
WHERE kind = ?1
|
||||
AND json_extract(payload, '$.batch_id') = ?2
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetSuggestionBatchJobIDParams struct {
|
||||
Kind string
|
||||
BatchID string
|
||||
}
|
||||
|
||||
// The id of the job that runs batch batch_id.
|
||||
func (q *Queries) GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getSuggestionBatchJobID, arg.Kind, arg.BatchID)
|
||||
var id string
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const listDeck = `-- name: ListDeck :many
|
||||
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
|
||||
ORDER BY suggestions.created_at, suggestions.id
|
||||
`
|
||||
|
||||
type ListDeckRow struct {
|
||||
Suggestion Suggestion
|
||||
Dinner Dinner
|
||||
}
|
||||
|
||||
// The pending suggestions whose dinner is still suggested, oldest first,
|
||||
// with their dinners.
|
||||
func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDeck)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListDeckRow
|
||||
for rows.Next() {
|
||||
var i ListDeckRow
|
||||
if err := rows.Scan(
|
||||
&i.Suggestion.ID,
|
||||
&i.Suggestion.DinnerID,
|
||||
&i.Suggestion.BatchID,
|
||||
&i.Suggestion.Name,
|
||||
&i.Suggestion.Reason,
|
||||
&i.Suggestion.CitedRegularIds,
|
||||
&i.Suggestion.Status,
|
||||
&i.Suggestion.DecidedAt,
|
||||
&i.Suggestion.CreatedAt,
|
||||
&i.Suggestion.UpdatedAt,
|
||||
&i.Dinner.ID,
|
||||
&i.Dinner.Name,
|
||||
&i.Dinner.NormalizedName,
|
||||
&i.Dinner.Status,
|
||||
&i.Dinner.Origin,
|
||||
&i.Dinner.Method,
|
||||
&i.Dinner.MinutesTotal,
|
||||
&i.Dinner.Difficulty,
|
||||
&i.Dinner.Cuisine,
|
||||
&i.Dinner.IsFavorite,
|
||||
&i.Dinner.RecipeID,
|
||||
&i.Dinner.PhotoID,
|
||||
&i.Dinner.GroundingStatus,
|
||||
&i.Dinner.UpdatedBy,
|
||||
&i.Dinner.CreatedAt,
|
||||
&i.Dinner.UpdatedAt,
|
||||
&i.Dinner.RecipeUrl,
|
||||
&i.Dinner.GroundingError,
|
||||
&i.Dinner.GroundingRevision,
|
||||
); 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 listPassedNames = `-- name: ListPassedNames :many
|
||||
SELECT name FROM (
|
||||
SELECT name, decided_at, id
|
||||
FROM suggestions
|
||||
WHERE status = 'declined'
|
||||
UNION ALL
|
||||
SELECT name, decided_at, id
|
||||
FROM rotation_reviews
|
||||
WHERE decision = 'drop'
|
||||
)
|
||||
ORDER BY decided_at, id
|
||||
`
|
||||
|
||||
// Every name the household turned down, oldest decision first: the name
|
||||
// each declined suggestion was suggested under, which renaming its dinner
|
||||
// does not change, and the name each dinner a Rotation check dropped
|
||||
// ("Not for us") had when dropped, which renaming it does not change.
|
||||
func (q *Queries) ListPassedNames(ctx context.Context) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listPassedNames)
|
||||
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
|
||||
}
|
||||
|
||||
const moveSuggestedDinner = `-- name: MoveSuggestedDinner :execrows
|
||||
UPDATE dinners
|
||||
SET status = ?1, updated_by = ?2, updated_at = ?3
|
||||
WHERE id = ?4 AND status = 'suggested'
|
||||
`
|
||||
|
||||
type MoveSuggestedDinnerParams struct {
|
||||
Status string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Moves a dinner still suggested to status (quest_log or archived),
|
||||
// recording updated_by; a dinner no longer suggested is left as it is
|
||||
// (0 rows).
|
||||
func (q *Queries) MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, moveSuggestedDinner,
|
||||
arg.Status,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const startSuggestionBatch = `-- name: StartSuggestionBatch :execrows
|
||||
UPDATE suggestion_batches
|
||||
SET status = 'running', updated_at = ?
|
||||
WHERE id = ? AND status = 'pending'
|
||||
`
|
||||
|
||||
type StartSuggestionBatchParams struct {
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Marks a pending batch running; a batch in any other status is left as
|
||||
// it is (0 rows).
|
||||
func (q *Queries) StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, startSuggestionBatch, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
// Package dinners keeps the household's dinners: adding Regulars by name or
|
||||
// recipe link, listing Regulars and the Quest log, editing, archiving and
|
||||
// favoriting, and grounding each dinner in a recipe. Names are deduplicated with the harness's dinner-name rules,
|
||||
// so the Regulars the household types and the Side quests the AI suggests
|
||||
// are compared the same way.
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"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/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Dinner statuses (the dinners.status column).
|
||||
const (
|
||||
StatusSuggested = "suggested"
|
||||
StatusQuestLog = "quest_log"
|
||||
StatusTried = "tried"
|
||||
StatusRegular = "regular"
|
||||
StatusArchived = "archived"
|
||||
)
|
||||
|
||||
// Limits on what a member may enter.
|
||||
const (
|
||||
MaxNameChars = 120
|
||||
MaxRecipeURL = 2048
|
||||
MaxMinutes = 1440 // a day; a crock-pot dinner fits
|
||||
photoListWidth = 400 // the stored photo size lists show
|
||||
)
|
||||
|
||||
// Methods and Difficulties are the values the dinners table allows.
|
||||
var (
|
||||
Methods = []string{"stovetop", "oven", "crockpot", "grill", "other"}
|
||||
Difficulties = []string{"easy", "medium", "hard"}
|
||||
)
|
||||
|
||||
// listable are the statuses List answers for.
|
||||
var listable = []string{StatusRegular, StatusQuestLog}
|
||||
|
||||
// ErrNotFound is returned for an unknown dinner id.
|
||||
var ErrNotFound = errors.New("dinner not found")
|
||||
|
||||
// Dinner is a dinner as the API shows it. It has no recipe_url: a pasted
|
||||
// link may carry a personal token, so it stays in the database for
|
||||
// grounding and never leaves the server.
|
||||
type Dinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Origin string `json:"origin"`
|
||||
Method *string `json:"method"`
|
||||
MinutesTotal *int64 `json:"minutes_total"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
Cuisine *string `json:"cuisine"`
|
||||
IsFavorite bool `json:"is_favorite"`
|
||||
RecipeID *string `json:"recipe_id"`
|
||||
PhotoURL *string `json:"photo_url"`
|
||||
GroundingStatus string `json:"grounding_status"`
|
||||
// GroundingError is why grounding failed, one of the Reason codes;
|
||||
// null unless GroundingStatus is failed.
|
||||
GroundingError *string `json:"grounding_error"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewRegular is a Regular to add: its Name, which the household always
|
||||
// gives, and optionally the RecipeURL to ground it in. The name is never
|
||||
// taken from the link: a pasted link may carry a personal token anywhere in
|
||||
// it, and the name is shown in every dinner response.
|
||||
type NewRegular struct {
|
||||
Name *string
|
||||
RecipeURL *string
|
||||
}
|
||||
|
||||
// Patch edits a dinner. Nil fields are left as they are.
|
||||
type Patch struct {
|
||||
Name *string
|
||||
Method *string
|
||||
MinutesTotal *int
|
||||
Difficulty *string
|
||||
}
|
||||
|
||||
// Problem is one invalid field, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every invalid field of a request.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid dinner: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func invalid(field, reason string) *ValidationError {
|
||||
return &ValidationError{Problems: []Problem{{Field: field, Reason: reason}}}
|
||||
}
|
||||
|
||||
// DuplicateError is returned when a new or renamed dinner repeats an
|
||||
// unarchived one: its name is harness.Similar, or it has the same recipe
|
||||
// link.
|
||||
type DuplicateError struct {
|
||||
Existing Dinner
|
||||
}
|
||||
|
||||
func (e *DuplicateError) Error() string {
|
||||
return fmt.Sprintf("%q is already a dinner (%s)", e.Existing.Name, e.Existing.Status)
|
||||
}
|
||||
|
||||
// Service reads and changes dinners.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
grounder *recipes.Grounder
|
||||
logger *slog.Logger
|
||||
groundJob *jobs.Kind[GroundJob]
|
||||
|
||||
// groundingsMu guards groundings: the KindGroundDinner jobs running,
|
||||
// by dinner id, so archiving or renaming a dinner can stop them.
|
||||
groundingsMu sync.Mutex
|
||||
groundings map[string]map[*runningGrounding]struct{}
|
||||
|
||||
// offerKey keys the candidate ids Candidates offers, so they are
|
||||
// opaque and stable for this process.
|
||||
offerKey []byte
|
||||
// offersMu guards offers and offerSearches. offers holds, by dinner
|
||||
// id, the candidates last offered by Candidates until they expire, so
|
||||
// a candidate id is resolved here and its URL never leaves the server.
|
||||
// offerSearches holds the Candidates searches running, by dinner id,
|
||||
// so clearing a dinner's offers keeps them from storing stale ones.
|
||||
offersMu sync.Mutex
|
||||
offers map[string]*offerSet
|
||||
offerSearches map[string]map[*offerSearch]struct{}
|
||||
}
|
||||
|
||||
// NewService returns a service over st that grounds dinners with grounder
|
||||
// and registers the KindGroundDinner job on runner, which must not have
|
||||
// started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("dinners: nil store")
|
||||
case grounder == nil:
|
||||
return nil, errors.New("dinners: nil grounder")
|
||||
case runner == nil:
|
||||
return nil, errors.New("dinners: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("dinners: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
|
||||
rand.Read(s.offerKey) // never fails: it crashes the program instead
|
||||
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.groundJob = kind
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Created is a new Regular with the id of the job grounding it, which is
|
||||
// nil when the job could not be enqueued and the dinner's grounding is
|
||||
// failed instead.
|
||||
type Created struct {
|
||||
Dinner
|
||||
GroundingJobID *string `json:"grounding_job_id"`
|
||||
}
|
||||
|
||||
// CreateRegular adds a Regular named r.Name, keeping r.RecipeURL for
|
||||
// grounding when given, with status and origin regular and grounding_status
|
||||
// pending, recording updatedBy, and enqueues the KindGroundDinner job that grounds it. It returns without
|
||||
// waiting for the job. A name repeating an unarchived dinner is a
|
||||
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
|
||||
// returned with grounding_status failed (ReasonGroundingError) and no job
|
||||
// id, so the household can fix its recipe by hand.
|
||||
func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy string) (Created, error) {
|
||||
d, err := s.createRegular(ctx, r, updatedBy)
|
||||
if err != nil {
|
||||
return Created{}, err
|
||||
}
|
||||
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
|
||||
if err != nil {
|
||||
return Created{}, err
|
||||
}
|
||||
if jobID == nil {
|
||||
row, err := db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), d.ID)
|
||||
if err != nil {
|
||||
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
|
||||
}
|
||||
return Created{Dinner: fromRow(row)}, nil
|
||||
}
|
||||
return Created{Dinner: fromRow(d), GroundingJobID: jobID}, nil
|
||||
}
|
||||
|
||||
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id
|
||||
// at grounding_revision revision and returns its id. When the job cannot be
|
||||
// enqueued, the dinner's grounding is failed with ReasonGroundingError, so
|
||||
// it is not left pending with no job, and the id is nil.
|
||||
func (s *Service) enqueueGrounding(ctx context.Context, id string, revision int64) (*string, error) {
|
||||
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id, Revision: revision})
|
||||
if err == nil {
|
||||
return &jobID, nil
|
||||
}
|
||||
// The error is a database error, never a dependency's text.
|
||||
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
|
||||
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: id,
|
||||
GroundingRevision: revision,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return db.Dinner{}, errors.New("dinners: create: no member to record as updated_by")
|
||||
}
|
||||
if r.Name == nil {
|
||||
return db.Dinner{}, invalid("name", "give the dinner's name, with or without a recipe_url")
|
||||
}
|
||||
name, err := checkName(*r.Name)
|
||||
if err != nil {
|
||||
return db.Dinner{}, err
|
||||
}
|
||||
var recipeURL sql.NullString
|
||||
if r.RecipeURL != nil {
|
||||
u, err := checkRecipeURL(*r.RecipeURL)
|
||||
if err != nil {
|
||||
return db.Dinner{}, err
|
||||
}
|
||||
recipeURL = sql.NullString{String: u.String(), Valid: true}
|
||||
}
|
||||
|
||||
var row db.Dinner
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
|
||||
return err
|
||||
}
|
||||
now := store.Now().String()
|
||||
var err error
|
||||
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: StatusRegular,
|
||||
Origin: "regular",
|
||||
RecipeUrl: recipeURL,
|
||||
GroundingStatus: "pending",
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return db.Dinner{}, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// List returns the dinners with status, which must be regular or
|
||||
// quest_log, ordered by normalized name. The Quest log is the dinners
|
||||
// accepted and not yet scheduled, so a quest_log dinner on the plan for the
|
||||
// household's current week or a later one is left out of it.
|
||||
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
|
||||
if !slices.Contains(listable, status) {
|
||||
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
|
||||
}
|
||||
var rows []db.Dinner
|
||||
var err error
|
||||
if status == StatusQuestLog {
|
||||
rows, err = s.listQuestLog(ctx)
|
||||
} else {
|
||||
rows, err = db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
|
||||
}
|
||||
out := make([]Dinner, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i] = fromRow(row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listQuestLog reads the Quest log in one read transaction, so the
|
||||
// household's current week and the plans come from the same snapshot.
|
||||
func (s *Service) listQuestLog(ctx context.Context) (rows []db.Dinner, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, 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 nil, fmt.Errorf("read household settings: %w", err)
|
||||
}
|
||||
loc, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("household settings: %w", err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
|
||||
return q.ListUnplannedQuestLogDinners(ctx, thisWeek.String())
|
||||
}
|
||||
|
||||
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
|
||||
// *ValidationError and a new name repeating another unarchived dinner a
|
||||
// *DuplicateError; either changes nothing. Renaming a dinner raises its
|
||||
// grounding_revision and drops the candidates offered for its old name:
|
||||
// each was offered at an earlier revision, so none of them grounds it.
|
||||
// Renaming a dinner still pending also enqueues a fresh KindGroundDinner
|
||||
// job that grounds the new name, and clears its recipe_url when the
|
||||
// normalized name changes: a job queued or running for the old name or
|
||||
// link carries the old revision, so it can no longer write the dinner, and
|
||||
// one running is stopped.
|
||||
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
|
||||
}
|
||||
name, err := p.validate()
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
|
||||
var row db.Dinner
|
||||
var renamed, regrounding bool
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetDinner(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
params := db.UpdateDinnerParams{
|
||||
ID: cur.ID,
|
||||
Name: cur.Name,
|
||||
NormalizedName: cur.NormalizedName,
|
||||
Status: cur.Status,
|
||||
Origin: cur.Origin,
|
||||
Method: cur.Method,
|
||||
MinutesTotal: cur.MinutesTotal,
|
||||
Difficulty: cur.Difficulty,
|
||||
Cuisine: cur.Cuisine,
|
||||
RecipeID: cur.RecipeID,
|
||||
RecipeUrl: cur.RecipeUrl,
|
||||
PhotoID: cur.PhotoID,
|
||||
GroundingStatus: cur.GroundingStatus,
|
||||
GroundingRevision: cur.GroundingRevision,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
}
|
||||
if p.Name != nil {
|
||||
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
|
||||
return err
|
||||
}
|
||||
params.Name = name
|
||||
params.NormalizedName = harness.Normalize(name)
|
||||
if name != cur.Name {
|
||||
params.GroundingRevision++
|
||||
}
|
||||
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
|
||||
// The link was the old dinner's recipe; the new name is
|
||||
// grounded by name, and a link can be given by hand.
|
||||
params.RecipeUrl = sql.NullString{}
|
||||
}
|
||||
}
|
||||
if p.Method != nil {
|
||||
params.Method = sql.NullString{String: *p.Method, Valid: true}
|
||||
}
|
||||
if p.MinutesTotal != nil {
|
||||
params.MinutesTotal = sql.NullInt64{Int64: int64(*p.MinutesTotal), Valid: true}
|
||||
}
|
||||
if p.Difficulty != nil {
|
||||
params.Difficulty = sql.NullString{String: *p.Difficulty, Valid: true}
|
||||
}
|
||||
row, err = q.UpdateDinner(ctx, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: update %s: %w", id, err)
|
||||
}
|
||||
renamed = row.GroundingRevision != cur.GroundingRevision
|
||||
regrounding = renamed && cur.GroundingStatus == GroundingPending
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if renamed {
|
||||
updateCommitted()
|
||||
s.clearOffers(row.ID, row.GroundingRevision)
|
||||
}
|
||||
if regrounding {
|
||||
s.stopGroundings(row.ID, row.GroundingRevision)
|
||||
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if jobID == nil {
|
||||
row, err = db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), row.ID)
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// Archive sets dinner id's status to archived, recording updatedBy. The row
|
||||
// is kept, so its name still counts for Side quest dedupe. A
|
||||
// KindGroundDinner job running for it is stopped, and its candidates no
|
||||
// longer ground it.
|
||||
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
|
||||
}
|
||||
row, err := db.New(s.st.Writer).ArchiveDinner(ctx, db.ArchiveDinnerParams{
|
||||
ID: id,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Dinner{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
|
||||
}
|
||||
s.stopGroundings(row.ID, math.MaxInt64)
|
||||
s.clearOffers(row.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// ToggleFavorite flips dinner id's is_favorite, recording updatedBy.
|
||||
func (s *Service) ToggleFavorite(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: favorite: no member to record as updated_by")
|
||||
}
|
||||
row, err := db.New(s.st.Writer).ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
|
||||
ID: id,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Dinner{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("dinners: favorite %s: %w", id, err)
|
||||
}
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// validate checks every field of p and returns its trimmed name.
|
||||
func (p Patch) validate() (string, error) {
|
||||
var problems []Problem
|
||||
var name string
|
||||
if p.Name == nil && p.Method == nil && p.MinutesTotal == nil && p.Difficulty == nil {
|
||||
return "", invalid("patch", "no fields to change")
|
||||
}
|
||||
if p.Name != nil {
|
||||
n, err := checkName(*p.Name)
|
||||
var verr *ValidationError
|
||||
if errors.As(err, &verr) {
|
||||
problems = append(problems, verr.Problems...)
|
||||
}
|
||||
name = n
|
||||
}
|
||||
if p.Method != nil && !slices.Contains(Methods, *p.Method) {
|
||||
problems = append(problems, Problem{"method", "must be one of " + strings.Join(Methods, ", ")})
|
||||
}
|
||||
if p.MinutesTotal != nil && (*p.MinutesTotal < 1 || *p.MinutesTotal > MaxMinutes) {
|
||||
problems = append(problems, Problem{"minutes_total", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
|
||||
}
|
||||
if p.Difficulty != nil && !slices.Contains(Difficulties, *p.Difficulty) {
|
||||
problems = append(problems, Problem{"difficulty", "must be one of " + strings.Join(Difficulties, ", ")})
|
||||
}
|
||||
if problems != nil {
|
||||
return "", &ValidationError{Problems: problems}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// checkName returns raw trimmed, refusing a name that is empty, too long or
|
||||
// has no words left once normalized (so it could not be deduplicated).
|
||||
func checkName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case name == "":
|
||||
return "", invalid("name", "must not be empty")
|
||||
case utf8.RuneCountInString(name) > MaxNameChars:
|
||||
return "", invalid("name", fmt.Sprintf("must be at most %d characters", MaxNameChars))
|
||||
case harness.Normalize(name) == "":
|
||||
return "", invalid("name", "must name a dish, not only filler words")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// checkRecipeURL parses raw as an absolute https:// link to a recipe page,
|
||||
// the only kind recipe grounding fetches.
|
||||
func checkRecipeURL(raw string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if len(raw) > MaxRecipeURL {
|
||||
return nil, invalid("recipe_url", fmt.Sprintf("must be at most %d bytes", MaxRecipeURL))
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, invalid("recipe_url", "is not a URL")
|
||||
case u.Scheme != "https" || u.Hostname() == "":
|
||||
return nil, invalid("recipe_url", "must be an https:// link")
|
||||
case u.User != nil:
|
||||
return nil, invalid("recipe_url", "must not carry a user name or password")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// checkUnique returns a *DuplicateError when name is harness.Similar to an
|
||||
// unarchived dinner other than self, or recipeURL is one's recipe link: the
|
||||
// source_url of the recipe it is grounded in, else the recipe_url still
|
||||
// awaiting grounding.
|
||||
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
||||
rows, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Dinner.ID == self {
|
||||
continue
|
||||
}
|
||||
link := row.Dinner.RecipeUrl
|
||||
if row.RecipeSourceUrl.Valid {
|
||||
link = row.RecipeSourceUrl
|
||||
}
|
||||
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
|
||||
return &DuplicateError{Existing: fromRow(row.Dinner)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PhotoURL is where the API serves a list-sized copy of photo id.
|
||||
func PhotoURL(id string) string {
|
||||
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
|
||||
}
|
||||
|
||||
func fromRow(r db.Dinner) Dinner {
|
||||
d := Dinner{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Status: r.Status,
|
||||
Origin: r.Origin,
|
||||
Method: nullString(r.Method),
|
||||
Difficulty: nullString(r.Difficulty),
|
||||
Cuisine: nullString(r.Cuisine),
|
||||
IsFavorite: r.IsFavorite == 1,
|
||||
RecipeID: nullString(r.RecipeID),
|
||||
GroundingStatus: r.GroundingStatus,
|
||||
GroundingError: nullString(r.GroundingError),
|
||||
UpdatedBy: nullString(r.UpdatedBy),
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
if r.MinutesTotal.Valid {
|
||||
d.MinutesTotal = &r.MinutesTotal.Int64
|
||||
}
|
||||
if r.PhotoID.Valid {
|
||||
u := PhotoURL(r.PhotoID.String)
|
||||
d.PhotoURL = &u
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package dinners_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
)
|
||||
|
||||
// TestCreateRegularNeverNamesFromLink: a Regular added with a recipe link
|
||||
// is named only by the household, so no part of the link — host, path,
|
||||
// query or fragment — is stored as its name or shown in a dinner or a
|
||||
// duplicate error, even a token of plain letters in the path.
|
||||
func TestCreateRegularNeverNamesFromLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#lookup"
|
||||
// Each leak holds a character no ULID has (Crockford base32 has no I, L,
|
||||
// O, U, "-", "." or "/"), so a dinner's id never matches one.
|
||||
leaks := []string{link, "c.example", "stew-key", "abcdefghijklmno", "sig", "zyxwvut", "lookup"}
|
||||
check := func(what, text string) {
|
||||
t.Helper()
|
||||
lower := strings.ToLower(text)
|
||||
for _, leak := range leaks {
|
||||
if strings.Contains(lower, leak) {
|
||||
t.Errorf("%s contains %q: %s", what, leak, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(link)}, member)
|
||||
var verr *dinners.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("link without a name: err = %v, want a *ValidationError", err)
|
||||
}
|
||||
check("validation error", verr.Error())
|
||||
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(" Sunday stew "), RecipeURL: ptr(link)}, member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Name != "Sunday stew" {
|
||||
t.Errorf("name = %q, want the household's %q", c.Name, "Sunday stew")
|
||||
}
|
||||
created, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("create response", string(created))
|
||||
row := e.dinner(t, c.ID)
|
||||
check("stored name", row.Name+" "+row.NormalizedName)
|
||||
if row.RecipeUrl.String != link {
|
||||
t.Errorf("stored recipe_url = %q, want %q kept for grounding", row.RecipeUrl.String, link)
|
||||
}
|
||||
|
||||
list, err := e.svc.List(t.Context(), dinners.StatusRegular)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("list response", string(listed))
|
||||
|
||||
// The same link under another name is still a duplicate, and its error
|
||||
// names the existing dinner by the household's name.
|
||||
_, err = e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Weeknight chili"), RecipeURL: ptr(link)}, member)
|
||||
var dup *dinners.DuplicateError
|
||||
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
|
||||
t.Fatalf("same link again: err = %v, want a duplicate of %s", err, c.ID)
|
||||
}
|
||||
check("duplicate error", dup.Error())
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SetGroundTimeout sets the deadline of grounding by hand until t ends. A
|
||||
// test calling it must not run in parallel.
|
||||
func SetGroundTimeout(t *testing.T, d time.Duration) {
|
||||
old := groundTimeout
|
||||
groundTimeout = d
|
||||
t.Cleanup(func() { groundTimeout = old })
|
||||
}
|
||||
|
||||
// SetOfferLifetime sets how long candidates ground a dinner until t ends.
|
||||
// A test calling it must not run in parallel.
|
||||
func SetOfferLifetime(t *testing.T, d time.Duration) {
|
||||
old := offerLifetime
|
||||
offerLifetime = d
|
||||
t.Cleanup(func() { offerLifetime = old })
|
||||
}
|
||||
|
||||
// SetUpdateCommitted runs f in Update once a rename is committed, before
|
||||
// the dinner's offers are cleared, until t ends. A test calling it must
|
||||
// not run in parallel.
|
||||
func SetUpdateCommitted(t *testing.T, f func()) {
|
||||
old := updateCommitted
|
||||
updateCommitted = f
|
||||
t.Cleanup(func() { updateCommitted = old })
|
||||
}
|
||||
|
||||
// AdvanceClock moves the service's clock d ahead of real time until t
|
||||
// ends, without firing any timer. A test calling it must not run in
|
||||
// parallel.
|
||||
func AdvanceClock(t *testing.T, d time.Duration) {
|
||||
old := now
|
||||
now = func() time.Time { return old().Add(d) }
|
||||
t.Cleanup(func() { now = old })
|
||||
}
|
||||
|
||||
// HasOffers reports whether the service holds candidates offered for
|
||||
// dinner id.
|
||||
func (s *Service) HasOffers(id string) bool {
|
||||
s.offersMu.Lock()
|
||||
defer s.offersMu.Unlock()
|
||||
_, ok := s.offers[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// StoreOffers stores urls as dinner id's offers, as a Candidates search
|
||||
// that read the dinner at grounding_revision revision would on finishing.
|
||||
func (s *Service) StoreOffers(id string, urls map[string]string, revision int64) {
|
||||
keep, done := s.trackOffers(id)
|
||||
defer done()
|
||||
keep(urls, revision)
|
||||
}
|
||||
|
||||
// OfferCount is the number of dinners whose candidates the service holds.
|
||||
func (s *Service) OfferCount() int {
|
||||
s.offersMu.Lock()
|
||||
defer s.offersMu.Unlock()
|
||||
return len(s.offers)
|
||||
}
|
||||
|
||||
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
|
||||
// goroutine, as the job runner would, and returns the grounding status it
|
||||
// reports.
|
||||
func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) {
|
||||
r, err := s.runGroundJob(ctx, p, nil)
|
||||
return r.GroundingStatus, err
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// KindGroundDinner is the job that grounds a new Regular in a recipe.
|
||||
const KindGroundDinner = "ground_dinner"
|
||||
|
||||
// Grounding statuses (the dinners.grounding_status column).
|
||||
const (
|
||||
GroundingPending = "pending"
|
||||
GroundingGrounded = "grounded"
|
||||
GroundingFailed = "failed"
|
||||
GroundingManual = "manual"
|
||||
)
|
||||
|
||||
// Reasons a dinner's background grounding failed, stored in
|
||||
// dinners.grounding_error and as the KindGroundDinner job's failure code.
|
||||
const (
|
||||
// ReasonNoRecipe: no page the search found holds a usable recipe.
|
||||
ReasonNoRecipe = "no_recipe_found"
|
||||
// ReasonRecipeUnusable: the dinner's recipe_url holds no usable recipe.
|
||||
ReasonRecipeUnusable = "recipe_unusable"
|
||||
// ReasonSearchNotConfigured: recipe search is not set up.
|
||||
ReasonSearchNotConfigured = "search_not_configured"
|
||||
// ReasonSearchFailed: the search provider failed.
|
||||
ReasonSearchFailed = "search_failed"
|
||||
// ReasonAIFailed: normalize_recipe's model provider could not be built
|
||||
// or called.
|
||||
ReasonAIFailed = "ai_failed"
|
||||
// ReasonInterrupted: the server stopped while grounding.
|
||||
ReasonInterrupted = "interrupted"
|
||||
// ReasonGroundingError: any other failure, such as the database's.
|
||||
ReasonGroundingError = "grounding_error"
|
||||
)
|
||||
|
||||
// Reasons are every grounding failure reason.
|
||||
var Reasons = []string{
|
||||
ReasonNoRecipe, ReasonRecipeUnusable, ReasonSearchNotConfigured, ReasonSearchFailed,
|
||||
ReasonAIFailed, ReasonInterrupted, ReasonGroundingError,
|
||||
}
|
||||
|
||||
// Limits on candidates and on a recipe entered by hand.
|
||||
const (
|
||||
MaxCandidates = 3
|
||||
MaxManualLines = 100
|
||||
MaxManualLineChar = 500
|
||||
// manualSourcePrefix starts the source_url of a recipe entered by hand,
|
||||
// followed by the recipe's id; it is never a fetchable page.
|
||||
manualSourcePrefix = "mealquest:manual:"
|
||||
)
|
||||
|
||||
// GroundTimeout bounds grounding a candidate or link by hand, which runs in
|
||||
// the request: the page fetch (recipes.Timeout), its photo download and
|
||||
// normalize_recipe (harness.DefaultTimeout), with some slack.
|
||||
const GroundTimeout = 90 * time.Second
|
||||
|
||||
// groundTimeout is GroundTimeout; tests shorten it.
|
||||
var groundTimeout = GroundTimeout
|
||||
|
||||
// ErrGroundTimeout is returned by Ground when grounding a candidate or link
|
||||
// runs past GroundTimeout; the dinner is left as it was.
|
||||
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
|
||||
|
||||
// ErrGroundFailed wraps any unexpected failure grounding a dinner by hand.
|
||||
// Its text may quote the link and dependency errors, so it is never
|
||||
// logged.
|
||||
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
|
||||
|
||||
// ErrCandidatesFailed wraps any unexpected failure finding a dinner's
|
||||
// candidates. Its text may quote a candidate's URL and dependency errors,
|
||||
// so it is never logged.
|
||||
var ErrCandidatesFailed = errors.New("dinners: finding recipe candidates failed")
|
||||
|
||||
// ErrArchived is grounding an archived dinner by hand.
|
||||
var ErrArchived = errors.New("dinner archived")
|
||||
|
||||
// ErrGroundConflict is a candidate or link grounded for a dinner that was
|
||||
// renamed or grounded some other way while it was grounding.
|
||||
var ErrGroundConflict = errors.New("dinner changed while its recipe was grounding")
|
||||
|
||||
// GroundJob is the KindGroundDinner payload: the dinner to ground and its
|
||||
// grounding_revision when the job was enqueued.
|
||||
type GroundJob struct {
|
||||
DinnerID string `json:"dinner_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
|
||||
// groundResult is the KindGroundDinner result: the dinner's grounding
|
||||
// status once the job ends. It is not grounded by this job when the dinner
|
||||
// was grounded by hand before the job finished, renamed (a later job
|
||||
// grounds it) or archived; the job then reports the status it found.
|
||||
type groundResult struct {
|
||||
DinnerID string `json:"dinner_id"`
|
||||
GroundingStatus string `json:"grounding_status"`
|
||||
}
|
||||
|
||||
func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// runGroundJob grounds a pending dinner as its creation asked: its
|
||||
// recipe_url with GroundURL, else its name with Ground. Success records
|
||||
// the recipe and photo with grounding_status grounded, and the recipe's
|
||||
// method, minutes and difficulty where a member has not set them; failure
|
||||
// records grounding_status failed with its Reason, which is also the job's
|
||||
// failure code. A dinner archived, no longer pending, or at a
|
||||
// grounding_revision other than p.Revision is not grounded at all, and
|
||||
// either result is recorded only while the dinner is still unarchived,
|
||||
// pending and at p.Revision: a dinner grounded by hand meanwhile is left as
|
||||
// it is, and one renamed or archived meanwhile is left pending — for the
|
||||
// job its rename enqueued, or for none. Archiving or renaming the dinner
|
||||
// stops the grounding where it is, so no further search, fetch, photo
|
||||
// download or model call is made for it.
|
||||
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
|
||||
// Tracked before the dinner is read: an archive or rename committed
|
||||
// after the read finds this job to stop.
|
||||
groundCtx, done := s.trackGrounding(ctx, p.DinnerID, p.Revision)
|
||||
defer done()
|
||||
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
|
||||
if err != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
|
||||
}
|
||||
if row.Status == StatusArchived || row.GroundingStatus != GroundingPending || row.GroundingRevision != p.Revision {
|
||||
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
|
||||
}
|
||||
|
||||
var gr recipes.Grounded
|
||||
if row.RecipeUrl.Valid {
|
||||
gr, err = s.grounder.GroundURL(groundCtx, row.RecipeUrl.String)
|
||||
} else {
|
||||
gr, err = s.grounder.Ground(groundCtx, row.Name, row.Name)
|
||||
}
|
||||
if err != nil && ctx.Err() == nil && errors.Is(context.Cause(groundCtx), errGroundingStopped) {
|
||||
return s.currentGrounding(ctx, row.ID)
|
||||
}
|
||||
if err != nil {
|
||||
reason := reasonFor(err, row.RecipeUrl.Valid)
|
||||
if ctx.Err() != nil {
|
||||
reason = ReasonInterrupted
|
||||
}
|
||||
// Only the reason is logged: the error may quote a dependency's
|
||||
// response, which jobs never store, return or log.
|
||||
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
|
||||
write := context.WithoutCancel(ctx)
|
||||
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: reason, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: p.Revision,
|
||||
}); failErr != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return groundResult{}, ctx.Err()
|
||||
}
|
||||
return groundResult{}, &jobs.Failure{Code: reason}
|
||||
}
|
||||
|
||||
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: p.Revision,
|
||||
})
|
||||
if err != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return s.currentGrounding(ctx, row.ID)
|
||||
}
|
||||
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
|
||||
}
|
||||
|
||||
// currentGrounding is the KindGroundDinner result of a job that did not
|
||||
// ground dinner id: the grounding status the dinner has now.
|
||||
func (s *Service) currentGrounding(ctx context.Context, id string) (groundResult, error) {
|
||||
cur, err := db.New(s.st.Reader).GetDinner(ctx, id)
|
||||
if err != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", id, err)
|
||||
}
|
||||
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
|
||||
}
|
||||
|
||||
// errGroundingStopped is the cause a running KindGroundDinner job's
|
||||
// grounding is cancelled with when its dinner is archived or renamed.
|
||||
var errGroundingStopped = errors.New("dinners: grounding stopped: dinner archived or renamed")
|
||||
|
||||
// runningGrounding is a KindGroundDinner job running for a dinner at
|
||||
// revision, with the cancel of its grounding.
|
||||
type runningGrounding struct {
|
||||
revision int64
|
||||
cancel context.CancelCauseFunc
|
||||
}
|
||||
|
||||
// trackGrounding returns a context for grounding dinnerID at revision that
|
||||
// stopGroundings cancels, and the func to call when the job ends.
|
||||
func (s *Service) trackGrounding(ctx context.Context, dinnerID string, revision int64) (context.Context, func()) {
|
||||
groundCtx, cancel := context.WithCancelCause(ctx)
|
||||
r := &runningGrounding{revision: revision, cancel: cancel}
|
||||
s.groundingsMu.Lock()
|
||||
if s.groundings[dinnerID] == nil {
|
||||
s.groundings[dinnerID] = make(map[*runningGrounding]struct{})
|
||||
}
|
||||
s.groundings[dinnerID][r] = struct{}{}
|
||||
s.groundingsMu.Unlock()
|
||||
return groundCtx, func() {
|
||||
s.groundingsMu.Lock()
|
||||
delete(s.groundings[dinnerID], r)
|
||||
if len(s.groundings[dinnerID]) == 0 {
|
||||
delete(s.groundings, dinnerID)
|
||||
}
|
||||
s.groundingsMu.Unlock()
|
||||
cancel(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// stopGroundings cancels, with errGroundingStopped, the grounding of every
|
||||
// KindGroundDinner job running for dinnerID at a revision below below. Call
|
||||
// it once the change that makes those jobs stale is committed.
|
||||
func (s *Service) stopGroundings(dinnerID string, below int64) {
|
||||
s.groundingsMu.Lock()
|
||||
defer s.groundingsMu.Unlock()
|
||||
for r := range s.groundings[dinnerID] {
|
||||
if r.revision < below {
|
||||
r.cancel(errGroundingStopped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FailInterruptedGroundings fails, with ReasonInterrupted, every unarchived
|
||||
// dinner still pending whose KindGroundDinner job is no longer queued or
|
||||
// running: a previous process stopped before enqueuing the job or before
|
||||
// its handler recorded a result. An archived dinner is left pending, since
|
||||
// archiving, not a restart, stopped its grounding. Call it once at boot, after the job runner's
|
||||
// Start has failed the jobs that process left running, so no dinner stays
|
||||
// pending with nothing left to ground it. It returns how many it failed.
|
||||
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
|
||||
n, err := db.New(s.st.Writer).FailOrphanedPendingDinnerGroundings(ctx, db.FailOrphanedPendingDinnerGroundingsParams{
|
||||
GroundingError: sql.NullString{String: ReasonInterrupted, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
Kind: KindGroundDinner,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("dinners: fail interrupted groundings: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
s.logger.WarnContext(ctx, "failed dinner groundings interrupted by restart", "count", n)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// reasonFor is the Reason for a grounding error; fromURL says whether the
|
||||
// dinner's recipe_url was grounded rather than its name.
|
||||
func reasonFor(err error, fromURL bool) string {
|
||||
var ungroundable *recipes.ErrUngroundable
|
||||
switch {
|
||||
case errors.As(err, &ungroundable) && fromURL:
|
||||
return ReasonRecipeUnusable
|
||||
case errors.As(err, &ungroundable):
|
||||
return ReasonNoRecipe
|
||||
case errors.Is(err, recipes.ErrSearchNotConfigured):
|
||||
return ReasonSearchNotConfigured
|
||||
case errors.Is(err, recipes.ErrSearchFailed):
|
||||
return ReasonSearchFailed
|
||||
case errors.Is(err, recipes.ErrNormalizeFailed):
|
||||
return ReasonAIFailed
|
||||
}
|
||||
return ReasonGroundingError
|
||||
}
|
||||
|
||||
// Candidate is a recipe page offered to ground a dinner in.
|
||||
type Candidate struct {
|
||||
// CandidateID is an opaque id for the page, resolved by the server:
|
||||
// Fix.CandidateID grounds it. The page's URL is never returned, as it
|
||||
// may carry a token.
|
||||
CandidateID string `json:"candidate_id"`
|
||||
Title string `json:"title"`
|
||||
SiteName string `json:"site_name"`
|
||||
// PhotoURL is the cached photo of a page already grounded, else null.
|
||||
PhotoURL *string `json:"photo_url"`
|
||||
Minutes *int `json:"minutes"`
|
||||
}
|
||||
|
||||
// offerLifetime is how long the candidates Candidates offers a dinner
|
||||
// ground it; tests shorten it.
|
||||
var offerLifetime = 30 * time.Minute
|
||||
|
||||
// now is the clock offer lifetimes are measured on; tests advance it.
|
||||
var now = time.Now
|
||||
|
||||
// updateCommitted runs in Update once a rename is committed, before the
|
||||
// dinner's offers are cleared; tests use it to act in between.
|
||||
var updateCommitted = func() {}
|
||||
|
||||
// offerSet is the candidates last offered for a dinner: the page URL of
|
||||
// each candidate id, found by searching the dinner's name at its
|
||||
// grounding_revision revision. They ground the dinner until deadline, and
|
||||
// only while it is still at that revision, which every rename raises;
|
||||
// expiry drops the set from offers once deadline has passed.
|
||||
type offerSet struct {
|
||||
urls map[string]string
|
||||
revision int64
|
||||
deadline time.Time
|
||||
expiry *time.Timer
|
||||
}
|
||||
|
||||
// offerSearch is a Candidates search running for a dinner. It stores no
|
||||
// offers found at a grounding_revision below below, which clearing the
|
||||
// dinner's offers raises.
|
||||
type offerSearch struct {
|
||||
below int64
|
||||
}
|
||||
|
||||
// Candidates searches for dinner id's name and returns up to MaxCandidates
|
||||
// recipe pages to ground it in, best first; the list may be empty. They
|
||||
// replace the dinner's earlier candidates: one not offered again no longer
|
||||
// grounds it. They ground it for offerLifetime, and not at all once it is
|
||||
// grounded by hand, renamed or archived, even while this search runs. A
|
||||
// missing dinner is ErrNotFound; any other failure wraps
|
||||
// ErrCandidatesFailed, and a search that fails also wraps
|
||||
// recipes.ErrSearchFailed.
|
||||
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
|
||||
// Tracked before the dinner is read: grounding, renaming or archiving
|
||||
// it after the read keeps this search from storing what it found.
|
||||
keep, done := s.trackOffers(id)
|
||||
defer done()
|
||||
row, err := s.get(ctx, id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
|
||||
}
|
||||
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
|
||||
}
|
||||
out := make([]Candidate, len(offers))
|
||||
urls := make(map[string]string, len(offers))
|
||||
for i, o := range offers {
|
||||
cid := s.candidateID(row.ID, o.URL)
|
||||
urls[cid] = o.URL
|
||||
out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
|
||||
if o.PhotoID != "" {
|
||||
u := PhotoURL(o.PhotoID)
|
||||
out[i].PhotoURL = &u
|
||||
}
|
||||
}
|
||||
if row.Status != StatusArchived {
|
||||
keep(urls, row.GroundingRevision)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// trackOffers registers a Candidates search for dinner id. It returns keep,
|
||||
// which stores urls, found for the dinner at grounding_revision revision,
|
||||
// as its offers for offerLifetime unless clearOffers has run for it since
|
||||
// with a revision above that, or offers found at a later revision are
|
||||
// stored, and done, to call when the search ends.
|
||||
func (s *Service) trackOffers(id string) (keep func(urls map[string]string, revision int64), done func()) {
|
||||
search := &offerSearch{}
|
||||
s.offersMu.Lock()
|
||||
if s.offerSearches[id] == nil {
|
||||
s.offerSearches[id] = make(map[*offerSearch]struct{})
|
||||
}
|
||||
s.offerSearches[id][search] = struct{}{}
|
||||
s.offersMu.Unlock()
|
||||
keep = func(urls map[string]string, revision int64) {
|
||||
s.offersMu.Lock()
|
||||
defer s.offersMu.Unlock()
|
||||
if revision < search.below {
|
||||
return
|
||||
}
|
||||
if set := s.offers[id]; set != nil && set.revision > revision {
|
||||
// Found at a later revision, so the earlier search's offers
|
||||
// are already stale.
|
||||
return
|
||||
}
|
||||
s.dropOffers(id)
|
||||
set := &offerSet{urls: urls, revision: revision, deadline: now().Add(offerLifetime)}
|
||||
// expiry waits for offersMu, so it finds set stored even when it
|
||||
// fires at once, and drops set only, never offers stored later.
|
||||
set.expiry = time.AfterFunc(offerLifetime, func() {
|
||||
s.offersMu.Lock()
|
||||
defer s.offersMu.Unlock()
|
||||
if s.offers[id] == set {
|
||||
delete(s.offers, id)
|
||||
}
|
||||
})
|
||||
s.offers[id] = set
|
||||
}
|
||||
done = func() {
|
||||
s.offersMu.Lock()
|
||||
delete(s.offerSearches[id], search)
|
||||
if len(s.offerSearches[id]) == 0 {
|
||||
delete(s.offerSearches, id)
|
||||
}
|
||||
s.offersMu.Unlock()
|
||||
}
|
||||
return keep, done
|
||||
}
|
||||
|
||||
// clearOffers drops dinner id's offers found at a grounding_revision below
|
||||
// below and keeps every Candidates search running for it from storing
|
||||
// such offers, so none of them grounds it. Offers found at below or above
|
||||
// are kept. Call it with math.MaxInt64 once the dinner is grounded by hand
|
||||
// or archived, and with its new revision once it is renamed: a search
|
||||
// that read the rename meanwhile keeps its offers.
|
||||
func (s *Service) clearOffers(id string, below int64) {
|
||||
s.offersMu.Lock()
|
||||
defer s.offersMu.Unlock()
|
||||
if set := s.offers[id]; set != nil && set.revision < below {
|
||||
s.dropOffers(id)
|
||||
}
|
||||
for search := range s.offerSearches[id] {
|
||||
search.below = max(search.below, below)
|
||||
}
|
||||
}
|
||||
|
||||
// dropOffers drops dinner id's offers and stops their expiry. The caller
|
||||
// holds offersMu.
|
||||
func (s *Service) dropOffers(id string) {
|
||||
if set := s.offers[id]; set != nil {
|
||||
set.expiry.Stop()
|
||||
delete(s.offers, id)
|
||||
}
|
||||
}
|
||||
|
||||
// candidateID is the opaque id of the page at rawURL as a candidate for
|
||||
// dinner id: the same page is offered under the same id until the service
|
||||
// restarts, and the id reveals nothing of the URL.
|
||||
func (s *Service) candidateID(id, rawURL string) string {
|
||||
mac := hmac.New(sha256.New, s.offerKey)
|
||||
mac.Write([]byte(id + "\x00" + rawURL))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16])
|
||||
}
|
||||
|
||||
// candidateURL is the page URL of candidate cid last offered for dinner d.
|
||||
// A cid not among the dinner's live offers, or whose page is not a valid
|
||||
// recipe link, is a *ValidationError. Offers past their deadline, or found
|
||||
// at a grounding_revision other than d's, are not live, even before they
|
||||
// are dropped: d was renamed since they were found, if only to its name
|
||||
// then.
|
||||
func (s *Service) candidateURL(d db.Dinner, cid string) (string, error) {
|
||||
var raw string
|
||||
var ok bool
|
||||
s.offersMu.Lock()
|
||||
if set := s.offers[d.ID]; set != nil && set.revision == d.GroundingRevision && now().Before(set.deadline) {
|
||||
raw, ok = set.urls[cid]
|
||||
}
|
||||
s.offersMu.Unlock()
|
||||
if ok {
|
||||
if u, err := checkRecipeURL(raw); err == nil {
|
||||
return u.String(), nil
|
||||
}
|
||||
}
|
||||
return "", invalid("candidate_id", "is not a candidate from recipe-candidates; reload the candidates")
|
||||
}
|
||||
|
||||
// Fix grounds a dinner by hand: exactly one of a candidate from
|
||||
// Candidates, a pasted recipe link or a recipe typed in.
|
||||
type Fix struct {
|
||||
CandidateID *string
|
||||
RecipeURL *string
|
||||
Manual *ManualRecipe
|
||||
}
|
||||
|
||||
// ManualRecipe is a recipe typed in by the household.
|
||||
type ManualRecipe struct {
|
||||
// Ingredients are the ingredient lines, at least one.
|
||||
Ingredients []string
|
||||
// Steps are the instructions, in order; there may be none.
|
||||
Steps []string
|
||||
Minutes *int
|
||||
Method *string
|
||||
}
|
||||
|
||||
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
|
||||
// link is grounded with recipes.Grounder.GroundURL and sets
|
||||
// grounding_status grounded, with the recipe's photo, method, minutes and
|
||||
// difficulty; one a member changed while it was grounding is kept. A
|
||||
// manual recipe is stored as typed — no model runs — and sets
|
||||
// grounding_status manual with its method and minutes; it has no photo,
|
||||
// and the dinner keeps its difficulty. An invalid f is a *ValidationError
|
||||
// and an archived dinner ErrArchived. A failure grounding a candidate or
|
||||
// link, or recording it on the dinner, wraps ErrGroundFailed; a page that
|
||||
// cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner
|
||||
// renamed, archived or grounded some other way while a candidate or link
|
||||
// was grounding is ErrGroundConflict or ErrArchived. A missing dinner is
|
||||
// ErrNotFound; any other failure, by candidate, link or manual recipe,
|
||||
// wraps ErrGroundFailed. None of these changes the dinner.
|
||||
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
|
||||
}
|
||||
link, manual, err := f.validate()
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if manual != nil {
|
||||
return s.groundManual(ctx, id, *manual, updatedBy)
|
||||
}
|
||||
cur, err := s.get(ctx, id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
if cur.Status == StatusArchived {
|
||||
return Dinner{}, ErrArchived
|
||||
}
|
||||
if f.CandidateID != nil {
|
||||
if link, err = s.candidateURL(cur, *f.CandidateID); err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
}
|
||||
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
|
||||
defer cancel()
|
||||
gr, err := s.grounder.GroundURL(groundCtx, link)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
|
||||
return Dinner{}, ErrGroundTimeout
|
||||
}
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
GroundingStatus: GroundingGrounded,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: cur.ID,
|
||||
ObservedName: cur.Name,
|
||||
ObservedNormalizedName: cur.NormalizedName,
|
||||
ObservedGroundingStatus: cur.GroundingStatus,
|
||||
ObservedGroundingRevision: cur.GroundingRevision,
|
||||
ObservedRecipeID: cur.RecipeID,
|
||||
ObservedMethod: cur.Method,
|
||||
ObservedMinutesTotal: cur.MinutesTotal,
|
||||
ObservedDifficulty: cur.Difficulty,
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Dinner{}, s.groundRejected(ctx, id)
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
s.clearOffers(cur.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// groundRejected is the error for dinner id when it was not as observed
|
||||
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing
|
||||
// to read it wraps ErrGroundFailed.
|
||||
func (s *Service) groundRejected(ctx context.Context, id string) error {
|
||||
cur, err := s.get(ctx, id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
if cur.Status == StatusArchived {
|
||||
return ErrArchived
|
||||
}
|
||||
return ErrGroundConflict
|
||||
}
|
||||
|
||||
// groundManual stores m as dinner id's recipe, with its ingredients and
|
||||
// steps, and records it on the dinner, all in one transaction; the
|
||||
// dinner's candidates then no longer ground it. A missing or archived
|
||||
// dinner is ErrNotFound or ErrArchived; any other failure wraps
|
||||
// ErrGroundFailed.
|
||||
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
|
||||
var row db.Dinner
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetDinner(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
if cur.Status == StatusArchived {
|
||||
return ErrArchived
|
||||
}
|
||||
ld, err := json.Marshal(manualJSONLD{
|
||||
Context: "https://schema.org",
|
||||
Type: "Recipe",
|
||||
Name: cur.Name,
|
||||
Ingredients: m.Ingredients,
|
||||
Instructions: m.Steps,
|
||||
TotalTime: fmt.Sprintf("PT%dM", *m.Minutes),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: encode manual recipe: %w", err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
recipeID := store.NewID()
|
||||
minutes := sql.NullInt64{Int64: int64(*m.Minutes), Valid: true}
|
||||
method := sql.NullString{String: *m.Method, Valid: true}
|
||||
rec, err := q.CreateManualRecipe(ctx, db.CreateManualRecipeParams{
|
||||
ID: recipeID,
|
||||
SourceUrl: manualSourcePrefix + recipeID,
|
||||
Title: cur.Name,
|
||||
TotalMinutes: minutes,
|
||||
RawJsonld: string(ld),
|
||||
Method: method,
|
||||
MinutesTotal: minutes,
|
||||
FetchedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: store manual recipe: %w", err)
|
||||
}
|
||||
for i, line := range m.Ingredients {
|
||||
if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
|
||||
ID: store.NewID(),
|
||||
RecipeID: rec.ID,
|
||||
Position: int64(i),
|
||||
RawText: line,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("dinners: store manual ingredient %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
for i, text := range m.Steps {
|
||||
if err := q.CreateRecipeStep(ctx, db.CreateRecipeStepParams{
|
||||
ID: store.NewID(),
|
||||
RecipeID: rec.ID,
|
||||
Position: int64(i),
|
||||
Text: text,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("dinners: store manual step %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
|
||||
RecipeID: sql.NullString{String: rec.ID, Valid: true},
|
||||
Method: method,
|
||||
MinutesTotal: minutes,
|
||||
Difficulty: cur.Difficulty,
|
||||
GroundingStatus: GroundingManual,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: cur.ID,
|
||||
ObservedName: cur.Name,
|
||||
ObservedNormalizedName: cur.NormalizedName,
|
||||
ObservedGroundingStatus: cur.GroundingStatus,
|
||||
ObservedGroundingRevision: cur.GroundingRevision,
|
||||
ObservedRecipeID: cur.RecipeID,
|
||||
ObservedMethod: cur.Method,
|
||||
ObservedMinutesTotal: cur.MinutesTotal,
|
||||
ObservedDifficulty: cur.Difficulty,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: ground %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrArchived) {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
s.clearOffers(row.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// manualJSONLD is the schema.org Recipe stored as a manual recipe's
|
||||
// raw_jsonld, so it reads like a fetched page's.
|
||||
type manualJSONLD struct {
|
||||
Context string `json:"@context"`
|
||||
Type string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
Ingredients []string `json:"recipeIngredient"`
|
||||
Instructions []string `json:"recipeInstructions"`
|
||||
TotalTime string `json:"totalTime"`
|
||||
}
|
||||
|
||||
// validate checks f and returns the link to ground, or the manual recipe
|
||||
// with its lines trimmed.
|
||||
func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
|
||||
given := 0
|
||||
for _, set := range []bool{f.CandidateID != nil, f.RecipeURL != nil, f.Manual != nil} {
|
||||
if set {
|
||||
given++
|
||||
}
|
||||
}
|
||||
if given != 1 {
|
||||
return "", nil, invalid("ground", "give exactly one of candidate_id, recipe_url and manual")
|
||||
}
|
||||
switch {
|
||||
case f.CandidateID != nil:
|
||||
// Ground resolves the candidate to its page.
|
||||
return "", nil, nil
|
||||
case f.RecipeURL != nil:
|
||||
u, err := checkRecipeURL(*f.RecipeURL)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return u.String(), nil, nil
|
||||
}
|
||||
m, err := f.Manual.validate()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "", &m, nil
|
||||
}
|
||||
|
||||
// validate checks every field of m and returns it with its lines trimmed.
|
||||
func (m ManualRecipe) validate() (ManualRecipe, error) {
|
||||
var problems []Problem
|
||||
ings, ingProblem := checkLines(m.Ingredients, 1)
|
||||
if ingProblem != "" {
|
||||
problems = append(problems, Problem{"manual.ingredients", ingProblem})
|
||||
}
|
||||
steps, stepProblem := checkLines(m.Steps, 0)
|
||||
if stepProblem != "" {
|
||||
problems = append(problems, Problem{"manual.steps", stepProblem})
|
||||
}
|
||||
if m.Minutes == nil || *m.Minutes < 1 || *m.Minutes > MaxMinutes {
|
||||
problems = append(problems, Problem{"manual.minutes", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
|
||||
}
|
||||
if m.Method == nil || !slices.Contains(Methods, *m.Method) {
|
||||
problems = append(problems, Problem{"manual.method", "must be one of " + strings.Join(Methods, ", ")})
|
||||
}
|
||||
if problems != nil {
|
||||
return ManualRecipe{}, &ValidationError{Problems: problems}
|
||||
}
|
||||
return ManualRecipe{Ingredients: ings, Steps: steps, Minutes: m.Minutes, Method: m.Method}, nil
|
||||
}
|
||||
|
||||
// checkLines returns lines trimmed, or why they are refused: fewer than
|
||||
// least, more than MaxManualLines, or one blank or over MaxManualLineChar.
|
||||
func checkLines(lines []string, least int) ([]string, string) {
|
||||
if len(lines) < least {
|
||||
return nil, fmt.Sprintf("must have at least %d line", least)
|
||||
}
|
||||
if len(lines) > MaxManualLines {
|
||||
return nil, fmt.Sprintf("must have at most %d lines", MaxManualLines)
|
||||
}
|
||||
out := make([]string, len(lines))
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case line == "":
|
||||
return nil, fmt.Sprintf("line %d is blank", i+1)
|
||||
case utf8.RuneCountInString(line) > MaxManualLineChar:
|
||||
return nil, fmt.Sprintf("line %d is over %d characters", i+1, MaxManualLineChar)
|
||||
}
|
||||
out[i] = line
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// get reads dinner id, or ErrNotFound.
|
||||
func (s *Service) get(ctx context.Context, id string) (db.Dinner, error) {
|
||||
row, err := db.New(s.st.Reader).GetDinner(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return db.Dinner{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return db.Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func nullInt(v *int) sql.NullInt64 {
|
||||
if v == nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: int64(*v), Valid: true}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,8 @@ import (
|
||||
)
|
||||
|
||||
// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4).
|
||||
// SeedAISettings stores it on first boot and the Admin reset restores it.
|
||||
// SeedAISettings stores it on first boot and aisettings.Service.ResetPrompt
|
||||
// restores it.
|
||||
const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners
|
||||
similar to their regular rotation, nudging them to try something a little new each
|
||||
week. Prefer weeknight-friendly recipes with common grocery-store ingredients.
|
||||
|
||||
@@ -68,8 +68,18 @@ type SettingsPatch struct {
|
||||
SuggestionsTime *string
|
||||
ChatAutosendDow *int
|
||||
ChatAutosendTime *string
|
||||
// OnboardingCompleted, when true, records that the household finished
|
||||
// onboarding: onboarding_completed_at becomes the time of the update,
|
||||
// unless it is already set, when the first time is kept. Onboarding is
|
||||
// never undone, so false is invalid, and it cannot first be completed
|
||||
// with fewer than MinRegulars Regulars.
|
||||
OnboardingCompleted *bool
|
||||
}
|
||||
|
||||
// MinRegulars is how many Regulars the household must have to complete
|
||||
// onboarding.
|
||||
const MinRegulars = 3
|
||||
|
||||
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
@@ -162,7 +172,8 @@ func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
|
||||
// Update validates patch, applies it, records updatedBy (the signed-in
|
||||
// member's subject, which must not be empty) and publishes the new settings
|
||||
// to every subscriber. An invalid patch is a *ValidationError and changes
|
||||
// nothing.
|
||||
// nothing, as does completing onboarding with fewer than MinRegulars
|
||||
// Regulars.
|
||||
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
|
||||
if updatedBy == "" {
|
||||
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
|
||||
@@ -208,6 +219,19 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
||||
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
||||
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
|
||||
n, err := q.CountRegularDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: count Regulars: %w", err)
|
||||
}
|
||||
if n < MinRegulars {
|
||||
return &ValidationError{Problems: []Problem{{
|
||||
Field: "onboarding_completed",
|
||||
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
|
||||
}}}
|
||||
}
|
||||
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
|
||||
}
|
||||
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: update settings: %w", err)
|
||||
@@ -291,6 +315,9 @@ func (p SettingsPatch) validate() error {
|
||||
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
|
||||
}
|
||||
}
|
||||
if p.OnboardingCompleted != nil && !*p.OnboardingCompleted {
|
||||
fail("onboarding_completed", "onboarding cannot be undone (want true)")
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &ValidationError{Problems: problems}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ package household
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
@@ -34,12 +37,34 @@ func migratedStore(t *testing.T, dir string) *store.Store {
|
||||
// seededService returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
func seededService(t *testing.T) *SettingsService {
|
||||
t.Helper()
|
||||
svc, _ := seededServiceStore(t)
|
||||
return svc
|
||||
}
|
||||
|
||||
// seededServiceStore is seededService with its store.
|
||||
func seededServiceStore(t *testing.T) (*SettingsService, *store.Store) {
|
||||
t.Helper()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewSettingsService(st)
|
||||
return NewSettingsService(st), st
|
||||
}
|
||||
|
||||
// addDinners stores n dinners with status.
|
||||
func addDinners(t *testing.T, st *store.Store, status string, n int) {
|
||||
t.Helper()
|
||||
for range n {
|
||||
id := store.NewID()
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: id, Name: "Dinner " + id, NormalizedName: "dinner " + strings.ToLower(id),
|
||||
Status: status, Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -217,6 +242,70 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCompletesOnboardingOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := seededServiceStore(t)
|
||||
addDinners(t, st, "regular", MinRegulars)
|
||||
|
||||
got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != got.UpdatedAt {
|
||||
t.Fatalf("onboarding_completed_at = %v, want the update time %s", got.OnboardingCompletedAt, got.UpdatedAt)
|
||||
}
|
||||
first := *got.OnboardingCompletedAt
|
||||
|
||||
// Completing again keeps the first time.
|
||||
got, err = svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "alex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first {
|
||||
t.Errorf("second completion: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first)
|
||||
}
|
||||
|
||||
// Other changes leave it alone.
|
||||
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "sam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first {
|
||||
t.Errorf("after another change: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateOnboardingNeedsMinRegulars: onboarding cannot be completed
|
||||
// with fewer than MinRegulars Regulars; archived and Quest log dinners do
|
||||
// not count.
|
||||
func TestUpdateOnboardingNeedsMinRegulars(t *testing.T) {
|
||||
t.Parallel()
|
||||
for regulars := range MinRegulars {
|
||||
t.Run(fmt.Sprintf("%d Regulars", regulars), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := seededServiceStore(t)
|
||||
addDinners(t, st, "regular", regulars)
|
||||
addDinners(t, st, "archived", MinRegulars)
|
||||
addDinners(t, st, "quest_log", MinRegulars)
|
||||
|
||||
_, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
|
||||
var verr *ValidationError
|
||||
if !errors.As(err, &verr) || len(verr.Problems) != 1 || verr.Problems[0].Field != "onboarding_completed" {
|
||||
t.Fatalf("Update = %v, want a ValidationError on onboarding_completed", err)
|
||||
}
|
||||
got, err := svc.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.OnboardingCompletedAt != nil {
|
||||
t.Errorf("onboarding_completed_at = %s after a refused completion, want null", *got.OnboardingCompletedAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRequiresMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -259,6 +348,7 @@ func TestUpdateValidation(t *testing.T) {
|
||||
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
|
||||
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
|
||||
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
|
||||
{"onboarding undone", SettingsPatch{OnboardingCompleted: ptr(false)}, []string{"onboarding_completed"}},
|
||||
{
|
||||
"every problem is reported",
|
||||
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
|
||||
|
||||
+136
-41
@@ -54,14 +54,30 @@ const (
|
||||
CodeUnknownKind = "unknown_kind"
|
||||
)
|
||||
|
||||
// Claim retry backoff. A worker whose claim fails with a database error
|
||||
// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued
|
||||
// when the error clears is claimed without waiting for a wake token.
|
||||
// Retry backoff. A worker whose claim or final job write fails with a
|
||||
// database error retries after retryMin, doubling up to retryMax, so a job
|
||||
// queued when the error clears is claimed without waiting for a wake token,
|
||||
// and a job the worker has finished is never left running.
|
||||
const (
|
||||
claimRetryMin = 50 * time.Millisecond
|
||||
claimRetryMax = 5 * time.Second
|
||||
retryMin = 50 * time.Millisecond
|
||||
retryMax = 5 * time.Second
|
||||
)
|
||||
|
||||
// FinalWriteTimeout bounds each attempt at a job's final write, and at a
|
||||
// handler's own last write, so a writer connection that stays busy cannot
|
||||
// hold a stopping worker, and with it shutdown, open.
|
||||
const FinalWriteTimeout = 5 * time.Second
|
||||
|
||||
// FinalWriteContext returns the context for one attempt at a final write
|
||||
// of a job: it is not cancelled with ctx, so a job that finishes during
|
||||
// shutdown is still recorded, but it ends after FinalWriteTimeout. A
|
||||
// handler makes its own last write, the one recording what the job did,
|
||||
// with it too, and returns the error when the attempt times out, so its
|
||||
// worker can stop; the next Start fails the job as interrupted.
|
||||
func FinalWriteContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), FinalWriteTimeout)
|
||||
}
|
||||
|
||||
// codePattern is the form of every failure code: lower-case snake case.
|
||||
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
|
||||
@@ -72,6 +88,10 @@ var ErrNotFound = errors.New("jobs: job not found")
|
||||
// registered handler.
|
||||
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
||||
|
||||
// ErrServiceOnly is returned by Runner.Enqueue for a kind made ServiceOnly,
|
||||
// whose jobs only its own service enqueues.
|
||||
var ErrServiceOnly = errors.New("jobs: job kind is enqueued only by its service")
|
||||
|
||||
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
||||
// not decode into the kind's payload type.
|
||||
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
||||
@@ -114,11 +134,13 @@ type Job struct {
|
||||
// never quotes an error.
|
||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||
|
||||
// handler is a registered kind: run runs a job, and check reports whether
|
||||
// a payload decodes into the kind's payload type.
|
||||
// handler is a registered kind: run runs a job, check reports whether a
|
||||
// payload decodes into the kind's payload type, and serviceOnly is set when
|
||||
// Runner.Enqueue refuses the kind.
|
||||
type handler struct {
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
serviceOnly bool
|
||||
}
|
||||
|
||||
// Runner owns the job handlers and the worker pool.
|
||||
@@ -259,11 +281,41 @@ func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
|
||||
return k.r.enqueue(ctx, k.name, data)
|
||||
}
|
||||
|
||||
// EnqueueTx stores a queued job with payload in tx and returns its id, so
|
||||
// the job exists exactly when what else tx writes does. Once tx has
|
||||
// committed, call Wake so an idle worker runs it.
|
||||
func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
||||
}
|
||||
return createJob(ctx, db.New(tx), k.name, data)
|
||||
}
|
||||
|
||||
// ServiceOnly makes Runner.Enqueue refuse the kind with ErrServiceOnly, so
|
||||
// its jobs are enqueued only through k, by the service that checks what
|
||||
// must hold before one starts. It must be called before Start.
|
||||
func (k *Kind[P]) ServiceOnly() error {
|
||||
k.r.mu.Lock()
|
||||
defer k.r.mu.Unlock()
|
||||
if k.r.started {
|
||||
return fmt.Errorf("jobs: service-only %q: runner already started", k.name)
|
||||
}
|
||||
h := k.r.handlers[k.name]
|
||||
h.serviceOnly = true
|
||||
k.r.handlers[k.name] = h
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wake tells an idle worker to look for a job, as Enqueue does itself:
|
||||
// call it after committing the transaction a job was enqueued in.
|
||||
func (k *Kind[P]) Wake() { k.r.wakeOne() }
|
||||
|
||||
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||
// ErrUnknownKind for a kind with no registered handler and
|
||||
// ErrInvalidPayload for a payload that does not decode into the kind's
|
||||
// payload type; neither stores a job.
|
||||
// ErrUnknownKind for a kind with no registered handler, ErrServiceOnly for
|
||||
// a ServiceOnly kind and ErrInvalidPayload for a payload that does not
|
||||
// decode into the kind's payload type; none stores a job.
|
||||
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
||||
r.mu.Lock()
|
||||
h, ok := r.handlers[kind]
|
||||
@@ -271,6 +323,9 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa
|
||||
if !ok {
|
||||
return "", ErrUnknownKind
|
||||
}
|
||||
if h.serviceOnly {
|
||||
return "", ErrServiceOnly
|
||||
}
|
||||
if !h.check(payload) {
|
||||
return "", ErrInvalidPayload
|
||||
}
|
||||
@@ -280,9 +335,20 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa
|
||||
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
||||
// worker.
|
||||
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
||||
id, err := createJob(ctx, db.New(r.st.Writer), kind, payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r.wakeOne()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// createJob stores a queued job of kind with its encoded payload through q
|
||||
// and returns its id.
|
||||
func createJob(ctx context.Context, q *db.Queries, kind string, payload []byte) (string, error) {
|
||||
id := store.NewID()
|
||||
now := store.Now().String()
|
||||
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
||||
if err := q.CreateJob(ctx, db.CreateJobParams{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Payload: string(payload),
|
||||
@@ -291,11 +357,15 @@ func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (stri
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// wakeOne gives an idle worker a token to look for a queued job.
|
||||
func (r *Runner) wakeOne() {
|
||||
select {
|
||||
case r.wake <- struct{}{}:
|
||||
default: // every worker already has a token and will look again
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Start fails every job a previous process left running with
|
||||
@@ -357,16 +427,16 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
// already queued is not stranded by a transient error.
|
||||
func (r *Runner) work(ctx context.Context) {
|
||||
defer r.wg.Done()
|
||||
backoff := claimRetryMin
|
||||
backoff := retryMin
|
||||
for ctx.Err() == nil {
|
||||
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
|
||||
switch {
|
||||
case err == nil:
|
||||
backoff = claimRetryMin
|
||||
backoff = retryMin
|
||||
r.run(ctx, job)
|
||||
continue
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
backoff = claimRetryMin
|
||||
backoff = retryMin
|
||||
select {
|
||||
case <-r.wake:
|
||||
case <-ctx.Done():
|
||||
@@ -382,7 +452,7 @@ func (r *Runner) work(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
}
|
||||
backoff = min(2*backoff, claimRetryMax)
|
||||
backoff = min(2*backoff, retryMax)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,14 +460,13 @@ func (r *Runner) work(ctx context.Context) {
|
||||
// ctx has been cancelled, so a handler that finishes during shutdown is
|
||||
// recorded.
|
||||
func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||
write := context.WithoutCancel(ctx)
|
||||
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
|
||||
q := db.New(r.st.Writer)
|
||||
|
||||
h, ok := r.handlers[job.Kind] // read-only once started
|
||||
if !ok {
|
||||
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
|
||||
r.fail(write, logger, job.ID, CodeUnknownKind)
|
||||
r.fail(ctx, logger, job.ID, CodeUnknownKind)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -419,7 +488,7 @@ func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||
result, code, reason := h.run(ctx, job.Payload, publish)
|
||||
if code != "" {
|
||||
if ctx.Err() != nil {
|
||||
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
|
||||
logger.WarnContext(context.WithoutCancel(ctx), "job interrupted by shutdown; it fails at the next start")
|
||||
return
|
||||
}
|
||||
level := slog.LevelInfo
|
||||
@@ -427,35 +496,61 @@ func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||
level = slog.LevelError
|
||||
}
|
||||
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
|
||||
r.fail(write, logger, job.ID, code)
|
||||
r.fail(ctx, logger, job.ID, code)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := q.SucceedJob(write, db.SucceedJobParams{
|
||||
Result: sql.NullString{String: string(result), Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: job.ID,
|
||||
r.record(ctx, logger, "record job success", func(write context.Context) (int64, error) {
|
||||
return q.SucceedJob(write, db.SucceedJobParams{
|
||||
Result: sql.NullString{String: string(result), Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: job.ID,
|
||||
})
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("job is not running")
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(write, "record job success", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fail records the job as failed with code. The record errors it logs come
|
||||
// from the database, never from a handler.
|
||||
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
|
||||
n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{
|
||||
Error: sql.NullString{String: code, Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: id,
|
||||
r.record(ctx, logger, "record job failure", func(write context.Context) (int64, error) {
|
||||
return db.New(r.st.Writer).FailJob(write, db.FailJobParams{
|
||||
Error: sql.NullString{String: code, Valid: true},
|
||||
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
ID: id,
|
||||
})
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("job is not running")
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "record job failure", "error", err)
|
||||
}
|
||||
|
||||
// record makes a job's final write, which must change the one running job.
|
||||
// A database error is retried with backoff until the write lands, so a
|
||||
// worker never moves on from a job it has finished while leaving it
|
||||
// running. Each attempt is made with FinalWriteContext, so even when ctx
|
||||
// has been cancelled, and is bounded by FinalWriteTimeout; once ctx is
|
||||
// cancelled, a failed or timed-out attempt is not retried and the job
|
||||
// stays running, so the next Start fails it as interrupted.
|
||||
func (r *Runner) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) {
|
||||
backoff := retryMin
|
||||
for {
|
||||
attempt, cancel := FinalWriteContext(ctx)
|
||||
n, err := write(attempt)
|
||||
cancel()
|
||||
if err == nil {
|
||||
if n != 1 {
|
||||
logger.ErrorContext(ctx, what, "error", "job is not running")
|
||||
}
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
logger.ErrorContext(context.WithoutCancel(ctx), what+" failed during shutdown; it fails at the next start", "error", err)
|
||||
return
|
||||
}
|
||||
logger.ErrorContext(ctx, what+" failed; retrying", "error", err, "retry_in", backoff)
|
||||
timer := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
}
|
||||
backoff = min(2*backoff, retryMax)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +187,46 @@ func TestJobSucceeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueTxStoresJobOnlyWithItsTransaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
start(t, r)
|
||||
ctx := context.Background()
|
||||
|
||||
var rolledBack string
|
||||
refused := errors.New("refused")
|
||||
if err := st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
rolledBack, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 1})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return refused
|
||||
}); !errors.Is(err, refused) {
|
||||
t.Fatalf("WithTx = %v, want the refusal", err)
|
||||
}
|
||||
if _, err := r.Get(ctx, rolledBack); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("job of a rolled-back transaction: Get = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
var id string
|
||||
if err := st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
id, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 2})
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count.Wake()
|
||||
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded || string(job.Result) != `{"done":2}` {
|
||||
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":2}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobFailsWithFailureCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
@@ -569,6 +609,98 @@ func TestClaimErrorIsRetried(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalWriteErrorIsRetried(t *testing.T) {
|
||||
st := migratedStore(t)
|
||||
logs := &logBuffer{}
|
||||
r := newRunnerLogging(t, st, 1, logs)
|
||||
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
if _, err := st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_end BEFORE UPDATE OF status ON jobs WHEN NEW.status IN ('succeeded', 'failed') BEGIN SELECT RAISE(ABORT, 'end refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
id := enqueue(t, k, countPayload{Steps: 3})
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !strings.Contains(logs.String(), "record job success") {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("job success never recorded")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if _, err := st.Writer.ExecContext(t.Context(), `DROP TRIGGER refuse_end`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
if out := logs.String(); !strings.Contains(out, "record job success failed; retrying") {
|
||||
t.Fatalf("final write error not logged as retried: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedFinalWriteDoesNotHoldShutdown(t *testing.T) {
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
started, finish := make(chan struct{}), make(chan struct{})
|
||||
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
close(started)
|
||||
<-finish
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
stop := start(t, r)
|
||||
id := enqueue(t, k, countPayload{Steps: 3})
|
||||
<-started
|
||||
|
||||
// Hold the only writer connection, so the job's final write waits for it.
|
||||
conn, err := st.Writer.Conn(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waits := st.Writer.Stats().WaitCount
|
||||
close(finish)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for st.Writer.Stats().WaitCount == waits {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("final write never waited for the writer")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
stop()
|
||||
close(stopped)
|
||||
}()
|
||||
// The final write's attempt ends after 5 seconds; well past that, the
|
||||
// worker is waiting for the writer with no bound.
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(15 * time.Second):
|
||||
conn.Close()
|
||||
<-stopped
|
||||
t.Fatal("worker waited for a held writer through shutdown")
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if job := waitFor(t, r, id, func(Job) bool { return true }); job.Status != StatusRunning {
|
||||
t.Fatalf("after shutdown job = %+v, want left running", job)
|
||||
}
|
||||
second := newRunner(t, st, 1)
|
||||
register(t, second, "count", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
|
||||
return countResult{}, nil
|
||||
})
|
||||
start(t, second)
|
||||
if job := waitFor(t, second, id, finished); job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInterrupted {
|
||||
t.Errorf("interrupted job = %+v, want failed with %q", job, CodeInterrupted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerEnqueueByKindName(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
@@ -604,3 +736,39 @@ func TestRunnerEnqueueByKindName(t *testing.T) {
|
||||
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceOnlyKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
k := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
if err := k.ServiceOnly(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`)); !errors.Is(err, ErrServiceOnly) {
|
||||
t.Errorf("Runner.Enqueue error = %v, want ErrServiceOnly", err)
|
||||
}
|
||||
var n int
|
||||
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("refused enqueue stored %d jobs, want 0", n)
|
||||
}
|
||||
|
||||
id, err := k.Enqueue(ctx, countPayload{Steps: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
|
||||
t.Errorf("job enqueued through its Kind = %+v, want succeeded", job)
|
||||
}
|
||||
if err := k.ServiceOnly(); err == nil {
|
||||
t.Error("ServiceOnly after Start succeeded, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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/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 each day's calendar
|
||||
// event id; 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.
|
||||
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))
|
||||
for _, d := range rows {
|
||||
pool[d.ID] = d.Status
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// Package plan keeps the household's weekly plans: the Draft board the AI
|
||||
// drafts from the Regulars and the Quest log, and the swaps the household
|
||||
// makes on it. A draft runs as a job: draft_week plans seven dinners, one
|
||||
// a day from Monday to Sunday, and the job stores them as the week's plan,
|
||||
// replacing any draft before it unless the household edited the plan
|
||||
// while draft_week ran. A plan is draft until Push to Calendar
|
||||
// syncs it (Feature 16); an edit of a synced plan makes it changed.
|
||||
//
|
||||
// The Rotation check follows a plan day: MarkTried moves the Quest log
|
||||
// dinners planned on it to tried, and the household answers "Keep it in
|
||||
// rotation?" for each (D-021). Only their keep makes a dinner a Regular.
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"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"
|
||||
)
|
||||
|
||||
// KindDraftWeek is the job that drafts a week's plan.
|
||||
const KindDraftWeek = "draft_week"
|
||||
|
||||
// Plan statuses (the week_plans.status column).
|
||||
const (
|
||||
StatusDraft = "draft"
|
||||
StatusSynced = "synced"
|
||||
StatusChanged = "changed"
|
||||
)
|
||||
|
||||
// Reasons a draft failed, stored as the KindDraftWeek job's failure code.
|
||||
// Any other failure, such as the database's, is the runner's
|
||||
// internal_error.
|
||||
const (
|
||||
// ReasonNotEnoughDinners: the Regulars and Quest log hold fewer than
|
||||
// seven dinners to draft from.
|
||||
ReasonNotEnoughDinners = "not_enough_dinners"
|
||||
// ReasonAIFailed: draft_week failed; no week is made up instead.
|
||||
ReasonAIFailed = "ai_failed"
|
||||
// ReasonConfirmRequired: the plan was synced after the draft was
|
||||
// asked for without confirm, so the draft does not replace it.
|
||||
ReasonConfirmRequired = "confirm_required"
|
||||
// ReasonPlanChanged: the plan was edited while draft_week ran, so the
|
||||
// draft does not replace the edit.
|
||||
ReasonPlanChanged = "plan_changed"
|
||||
// ReasonPoolChanged: a dinner the draft planned left the pool while
|
||||
// draft_week ran (archived, tried, or its Quest log dinner planned on
|
||||
// another week), so the draft is not stored; drafting again uses the
|
||||
// pool as it is now.
|
||||
ReasonPoolChanged = "pool_changed"
|
||||
)
|
||||
|
||||
// Reasons are every draft failure reason.
|
||||
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged}
|
||||
|
||||
// ContextBudget is the character budget of the household context
|
||||
// draft_week is given (see harness.BuildContext).
|
||||
const ContextBudget = 16000
|
||||
|
||||
// plannable are the dinner statuses a day may be swapped to.
|
||||
var plannable = []string{dinners.StatusRegular, dinners.StatusQuestLog}
|
||||
|
||||
// ErrNotFound is returned for a week with no plan.
|
||||
var ErrNotFound = errors.New("plan not found")
|
||||
|
||||
// ErrDinnerNotFound is a swap to an unknown dinner.
|
||||
var ErrDinnerNotFound = errors.New("dinner not found")
|
||||
|
||||
// ErrDinnerAlreadyPlanned is a swap to a dinner already on another day of
|
||||
// the week.
|
||||
var ErrDinnerAlreadyPlanned = errors.New("the dinner is already on another day of this week")
|
||||
|
||||
// ErrConfirmRequired is a draft over a synced or changed plan asked for
|
||||
// without confirm.
|
||||
var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm")
|
||||
|
||||
// ErrDraftRunning is a confirmed draft asked for while a draft of the week
|
||||
// asked for without confirm is queued or running.
|
||||
var ErrDraftRunning = errors.New("a draft of the week without confirm is already running; ask again when it finishes")
|
||||
|
||||
// InvalidWeekError is a week start that is not a Monday date.
|
||||
type InvalidWeekError struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func (e *InvalidWeekError) Error() string {
|
||||
return fmt.Sprintf("week start %q is not a Monday date (YYYY-MM-DD)", e.Value)
|
||||
}
|
||||
|
||||
// InvalidDayError is a swap of a date outside the plan's week.
|
||||
type InvalidDayError struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func (e *InvalidDayError) Error() string {
|
||||
return fmt.Sprintf("date %q is not a day of the week (YYYY-MM-DD)", e.Value)
|
||||
}
|
||||
|
||||
// DinnerNotPlannableError is a swap to a dinner that is neither a Regular
|
||||
// nor in the Quest log.
|
||||
type DinnerNotPlannableError struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
func (e *DinnerNotPlannableError) Error() string {
|
||||
return fmt.Sprintf("the dinner is %s; only Regulars and Quest log dinners can be planned", e.Status)
|
||||
}
|
||||
|
||||
// ParseWeekStart parses s, a household-local Monday as YYYY-MM-DD.
|
||||
func ParseWeekStart(s string) (store.LocalDate, error) {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, &InvalidWeekError{Value: s}
|
||||
}
|
||||
if _, err := household.WeekDays(d); err != nil {
|
||||
return store.LocalDate{}, &InvalidWeekError{Value: s}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Drafter runs draft_week on in; scope names the week. In production it is
|
||||
// skills.DraftWeek with the household's harness.Config.
|
||||
type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error)
|
||||
|
||||
// Service drafts, reads and edits weekly plans.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
draft Drafter
|
||||
logger *slog.Logger
|
||||
draftJob *jobs.Kind[DraftJob]
|
||||
}
|
||||
|
||||
// NewService returns a service over st that drafts with draft and
|
||||
// registers the KindDraftWeek job on runner, which must not have started
|
||||
// yet. The kind is service-only: StartDraft, which keeps one draft in
|
||||
// flight per week, is the only way to enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("plan: nil store")
|
||||
case draft == nil:
|
||||
return nil, errors.New("plan: nil drafter")
|
||||
case runner == nil:
|
||||
return nil, errors.New("plan: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("plan: nil logger")
|
||||
}
|
||||
s := &Service{st: st, draft: draft, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.draftJob = kind
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Plan is a week's plan as the API shows it.
|
||||
type Plan struct {
|
||||
ID string `json:"id"`
|
||||
WeekStart string `json:"week_start"`
|
||||
// WeekLabel is e.g. "Week 41 · Oct 5 – 11".
|
||||
WeekLabel string `json:"week_label"`
|
||||
Status string `json:"status"`
|
||||
AINote *string `json:"ai_note"`
|
||||
CalendarSyncedAt *string `json:"calendar_synced_at"`
|
||||
Days []Day `json:"days"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Day is one day of a plan.
|
||||
type Day struct {
|
||||
// Date is household-local, YYYY-MM-DD.
|
||||
Date string `json:"date"`
|
||||
Dinner Dinner `json:"dinner"`
|
||||
// IsNew is true for a dinner planned from the Quest log.
|
||||
IsNew bool `json:"is_new"`
|
||||
// StartBy is when a crock-pot dinner must start to be ready at the
|
||||
// household's dinner time; null for any other dinner and for a
|
||||
// crock-pot dinner whose minutes are unknown.
|
||||
StartBy *time.Time `json:"start_by"`
|
||||
}
|
||||
|
||||
// Dinner is a planned dinner as a plan day shows it.
|
||||
type Dinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Photo *string `json:"photo"`
|
||||
Minutes *int64 `json:"minutes"`
|
||||
Method *string `json:"method"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
||||
// Get returns the plan of the week starting weekStart, or ErrNotFound.
|
||||
// The plan and the household settings come from one read transaction.
|
||||
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
return readPlan(ctx, db.New(tx), weekStart)
|
||||
}
|
||||
|
||||
// readPlan reads the plan of the week starting weekStart through q, with
|
||||
// each day's start_by from the household's dinner time and zone.
|
||||
func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) {
|
||||
label, err := household.WeekLabel(weekStart)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
row, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Plan{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||
}
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
rows, err := q.ListPlanDays(ctx, row.ID)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: read days of %s: %w", row.ID, err)
|
||||
}
|
||||
dates, err := household.WeekDays(weekStart)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
if len(rows) != len(dates) {
|
||||
return Plan{}, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates))
|
||||
}
|
||||
p := Plan{
|
||||
ID: row.ID,
|
||||
WeekStart: row.WeekStart,
|
||||
WeekLabel: label,
|
||||
Status: row.Status,
|
||||
AINote: nullString(row.AiNote),
|
||||
CalendarSyncedAt: nullString(row.CalendarSyncedAt),
|
||||
Days: make([]Day, len(rows)),
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
for i, r := range rows {
|
||||
if r.Date != dates[i].String() {
|
||||
return Plan{}, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i])
|
||||
}
|
||||
startBy, err := startBy(zone, dates[i], settings.DinnerTime, r.Dinner)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("plan: start by of %s: %w", r.Date, err)
|
||||
}
|
||||
p.Days[i] = Day{Date: r.Date, Dinner: fromDinner(r.Dinner), IsNew: r.IsNew == 1, StartBy: startBy}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// startBy returns when crock-pot dinner d must start on date to be ready at
|
||||
// dinnerTime in zone, or nil for any other dinner and for a crock-pot
|
||||
// dinner whose minutes are unknown.
|
||||
func startBy(zone *time.Location, date store.LocalDate, dinnerTime string, d db.Dinner) (*time.Time, error) {
|
||||
if !d.Method.Valid || d.Method.String != "crockpot" || !d.MinutesTotal.Valid || d.MinutesTotal.Int64 <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
at, err := household.LocalTime(zone, date, dinnerTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at = at.Add(-time.Duration(d.MinutesTotal.Int64) * time.Minute)
|
||||
return &at, nil
|
||||
}
|
||||
|
||||
// Draft is a draft asked for: the job drafting the week, and whether this
|
||||
// call enqueued it rather than found one already queued or running.
|
||||
type Draft struct {
|
||||
JobID string `json:"jobId"`
|
||||
Created bool `json:"-"`
|
||||
}
|
||||
|
||||
// StartDraft enqueues the KindDraftWeek job drafting the week starting
|
||||
// weekStart, unless one is already queued or running for it, which it
|
||||
// returns instead with Created false, so a week has at most one draft in
|
||||
// flight. A confirmed draft joins only a confirmed job: while the week's
|
||||
// job was asked for without confirm, it fails with ErrDraftRunning and
|
||||
// enqueues nothing. Drafting over a synced or changed plan needs confirm,
|
||||
// else it fails with ErrConfirmRequired and enqueues nothing.
|
||||
func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
|
||||
}
|
||||
var d Draft
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
case err != nil:
|
||||
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||
case cur.Status != StatusDraft && !confirm:
|
||||
return ErrConfirmRequired
|
||||
}
|
||||
active, err := q.GetActiveDraftJob(ctx, db.GetActiveDraftJobParams{Kind: KindDraftWeek, WeekStart: weekStart.String()})
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
case err != nil:
|
||||
return fmt.Errorf("plan: read draft job of %s: %w", weekStart, err)
|
||||
case confirm && active.Confirmed != 1:
|
||||
return ErrDraftRunning
|
||||
default:
|
||||
d = Draft{JobID: active.ID}
|
||||
return nil
|
||||
}
|
||||
id, err := s.draftJob.EnqueueTx(ctx, tx, DraftJob{WeekStart: weekStart.String(), Confirm: confirm})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: enqueue draft of %s: %w", weekStart, err)
|
||||
}
|
||||
d = Draft{JobID: id, Created: true}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
if d.Created {
|
||||
s.draftJob.Wake()
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Swap plans dinnerID on date of the week starting weekStart, marking the
|
||||
// day new when the dinner is in the Quest log. Swapping a synced plan
|
||||
// makes it changed. The week must have a plan (else ErrNotFound), date
|
||||
// must be one of its days (else *InvalidDayError), the dinner must be a
|
||||
// Regular or in the Quest log (else ErrDinnerNotFound or
|
||||
// *DinnerNotPlannableError), and it must not be on another day of the
|
||||
// week (else ErrDinnerAlreadyPlanned). It returns the plan as swapped.
|
||||
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
|
||||
dates, err := household.WeekDays(weekStart)
|
||||
if err != nil {
|
||||
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
|
||||
}
|
||||
day, err := store.ParseLocalDate(date)
|
||||
if err != nil || !slices.Contains(dates[:], day) {
|
||||
return Plan{}, &InvalidDayError{Value: date}
|
||||
}
|
||||
var p Plan
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||
}
|
||||
d, err := q.GetDinner(ctx, dinnerID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrDinnerNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
|
||||
}
|
||||
if !slices.Contains(plannable, d.Status) {
|
||||
return &DinnerNotPlannableError{Status: d.Status}
|
||||
}
|
||||
days, err := q.ListPlanDays(ctx, cur.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: read days of %s: %w", cur.ID, err)
|
||||
}
|
||||
if slices.ContainsFunc(days, func(r db.ListPlanDaysRow) bool { return r.Date != day.String() && r.Dinner.ID == d.ID }) {
|
||||
return ErrDinnerAlreadyPlanned
|
||||
}
|
||||
now := store.Now().String()
|
||||
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
|
||||
DinnerID: sql.NullString{String: d.ID, Valid: true},
|
||||
IsNew: isNew(d.Status),
|
||||
UpdatedAt: now,
|
||||
PlanID: cur.ID,
|
||||
Date: day.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: swap %s of %s: %w", day, cur.ID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("plan: %s has no day %s", cur.ID, day)
|
||||
}
|
||||
if err := q.MarkWeekPlanEdited(ctx, db.MarkWeekPlanEditedParams{UpdatedAt: now, ID: cur.ID}); err != nil {
|
||||
return fmt.Errorf("plan: mark %s edited: %w", cur.ID, err)
|
||||
}
|
||||
p, err = readPlan(ctx, q, weekStart)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log.
|
||||
func isNew(status string) int64 {
|
||||
if status == dinners.StatusQuestLog {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func fromDinner(d db.Dinner) Dinner {
|
||||
out := Dinner{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Method: nullString(d.Method),
|
||||
Origin: d.Origin,
|
||||
}
|
||||
if d.MinutesTotal.Valid {
|
||||
out.Minutes = &d.MinutesTotal.Int64
|
||||
}
|
||||
if d.PhotoID.Valid {
|
||||
u := dinners.PhotoURL(d.PhotoID.String)
|
||||
out.Photo = &u
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
package plan_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"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/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// week is the Monday the tests draft, and weekDates its days.
|
||||
var (
|
||||
week = mustDate("2026-10-12")
|
||||
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||
)
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type env struct {
|
||||
svc *plan.Service
|
||||
st *store.Store
|
||||
runner *jobs.Runner
|
||||
model *llm.Fake
|
||||
// ids are the seeded dinners' ids by name.
|
||||
ids map[string]string
|
||||
|
||||
mu sync.Mutex
|
||||
inputs []skills.WeekRequest
|
||||
}
|
||||
|
||||
// newEnv returns a plan service over a migrated store in zone
|
||||
// America/New_York with dinner time 17:30, the Regulars below and Quest
|
||||
// log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner
|
||||
// with two workers. It drafts through the real harness and draft_week with
|
||||
// the scripted e.model.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
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/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &env{st: st, model: &llm.Fake{}, ids: map[string]string{}}
|
||||
for _, d := range []struct {
|
||||
name, status, method string
|
||||
minutes int64
|
||||
}{
|
||||
{"Beef Chili", "regular", "crockpot", 240},
|
||||
{"Pot Roast", "regular", "crockpot", 0},
|
||||
{"Chicken Fajitas", "regular", "stovetop", 30},
|
||||
{"Spaghetti Bolognese", "regular", "stovetop", 45},
|
||||
{"Sheet Pan Salmon", "regular", "oven", 35},
|
||||
{"Pad Thai", "quest_log", "stovetop", 35},
|
||||
{"Shawarma Bowls", "quest_log", "oven", 40},
|
||||
} {
|
||||
e.ids[d.name] = e.dinner(t, d.name, d.status, d.method, d.minutes)
|
||||
}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
|
||||
e.mu.Lock()
|
||||
e.inputs = append(e.inputs, in)
|
||||
e.mu.Unlock()
|
||||
return skills.DraftWeek(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
// Two workers, as cmd/server runs, so drafts of a week could overlap.
|
||||
e.runner, err = jobs.NewRunner(st, logger, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// input returns the request of the ith draft_week run.
|
||||
func (e *env) input(i int) skills.WeekRequest {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.inputs[i]
|
||||
}
|
||||
|
||||
// start starts the env's job runner, stopping it when the test ends.
|
||||
func (e *env) start(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// dinner stores a dinner and returns its id; minutes 0 is unknown.
|
||||
func (e *env) dinner(t *testing.T, name, status, method string, minutes int64) string {
|
||||
t.Helper()
|
||||
now := store.Now().String()
|
||||
origin := "regular"
|
||||
if status != "regular" {
|
||||
origin = "side_quest"
|
||||
}
|
||||
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: status,
|
||||
Origin: origin,
|
||||
Method: sql.NullString{String: method, Valid: method != ""},
|
||||
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// reply scripts one draft_week reply planning the dinners named names on
|
||||
// weekDates, in order.
|
||||
func (e *env) reply(note string, names ...string) {
|
||||
e.model.QueueGenerate(e.weekReply(note, names...))
|
||||
}
|
||||
|
||||
// weekReply is the draft_week reply planning the dinners named names on
|
||||
// weekDates, in order.
|
||||
func (e *env) weekReply(note string, names ...string) llm.GenerateResult {
|
||||
return e.weekReplyOn(weekDates, note, names...)
|
||||
}
|
||||
|
||||
// weekReplyOn is the draft_week reply planning the dinners named names on
|
||||
// dates, in order.
|
||||
func (e *env) weekReplyOn(dates []string, note string, names ...string) llm.GenerateResult {
|
||||
var days []string
|
||||
for i, name := range names {
|
||||
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], e.ids[name]))
|
||||
}
|
||||
js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
|
||||
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
||||
}
|
||||
|
||||
// fullWeek plans every seeded dinner, Beef Chili on Monday and Pot Roast
|
||||
// on Tuesday.
|
||||
var fullWeek = []string{"Beef Chili", "Pot Roast", "Pad Thai", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
|
||||
|
||||
// waitJob waits for job id to finish and returns it.
|
||||
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := e.runner.Get(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
return job
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s still %s", id, job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// draft asks for a draft of week with confirm and waits for its job.
|
||||
func (e *env) draft(t *testing.T, confirm bool) jobs.Job {
|
||||
t.Helper()
|
||||
d, err := e.svc.StartDraft(t.Context(), week, confirm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.Created {
|
||||
t.Fatalf("draft job %s was already queued", d.JobID)
|
||||
}
|
||||
return e.waitJob(t, d.JobID)
|
||||
}
|
||||
|
||||
// mustDraft drafts week with confirm, requiring the job to succeed, and
|
||||
// returns the plan.
|
||||
func (e *env) mustDraft(t *testing.T, confirm bool) plan.Plan {
|
||||
t.Helper()
|
||||
job := e.draft(t, confirm)
|
||||
if job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("draft job %s, error %v", job.Status, *job.Error)
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res struct {
|
||||
WeekStart string `json:"week_start"`
|
||||
PlanID string `json:"plan_id"`
|
||||
}
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.WeekStart != week.String() || res.PlanID != p.ID {
|
||||
t.Fatalf("draft result = %+v, want week %s plan %s", res, week, p.ID)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// setStatus sets the plan of week to status, as Push to Calendar would.
|
||||
func (e *env) setStatus(t *testing.T, status string) {
|
||||
t.Helper()
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?, calendar_synced_at = ? WHERE week_start = ?`,
|
||||
status, store.Now().String(), week.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func dayNames(p plan.Plan) []string {
|
||||
var names []string
|
||||
for _, d := range p.Days {
|
||||
names = append(names, d.Dinner.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestDraftCreatesPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("Five Regulars and two from your Quest log.", fullWeek...)
|
||||
|
||||
p := e.mustDraft(t, false)
|
||||
|
||||
if p.Status != plan.StatusDraft || p.WeekStart != "2026-10-12" || p.WeekLabel != "Week 42 · Oct 12 – 18" {
|
||||
t.Errorf("plan = %s %s %q, want draft 2026-10-12 \"Week 42 · Oct 12 – 18\"", p.Status, p.WeekStart, p.WeekLabel)
|
||||
}
|
||||
if p.AINote == nil || *p.AINote != "Five Regulars and two from your Quest log." {
|
||||
t.Errorf("ai_note = %v", p.AINote)
|
||||
}
|
||||
if p.CalendarSyncedAt != nil {
|
||||
t.Errorf("calendar_synced_at = %v, want null", *p.CalendarSyncedAt)
|
||||
}
|
||||
if got := dayNames(p); !slices.Equal(got, fullWeek) {
|
||||
t.Errorf("days = %v, want %v", got, fullWeek)
|
||||
}
|
||||
for i, d := range p.Days {
|
||||
if d.Date != weekDates[i] {
|
||||
t.Errorf("day %d date = %s, want %s", i, d.Date, weekDates[i])
|
||||
}
|
||||
wantNew := d.Dinner.Name == "Pad Thai" || d.Dinner.Name == "Shawarma Bowls"
|
||||
if d.IsNew != wantNew {
|
||||
t.Errorf("%s is_new = %v, want %v", d.Dinner.Name, d.IsNew, wantNew)
|
||||
}
|
||||
if d.Dinner.ID != e.ids[d.Dinner.Name] {
|
||||
t.Errorf("%s id = %s", d.Dinner.Name, d.Dinner.ID)
|
||||
}
|
||||
}
|
||||
chili := p.Days[0]
|
||||
if chili.Dinner.Method == nil || *chili.Dinner.Method != "crockpot" || chili.Dinner.Minutes == nil || *chili.Dinner.Minutes != 240 || chili.Dinner.Origin != "regular" {
|
||||
t.Errorf("Beef Chili dinner = %+v", chili.Dinner)
|
||||
}
|
||||
|
||||
// The pool is every Regular and Quest log dinner; last week has no
|
||||
// plan.
|
||||
in := e.input(0)
|
||||
var pool []string
|
||||
for _, c := range in.Pool {
|
||||
pool = append(pool, c.Name+"/"+c.Kind)
|
||||
}
|
||||
slices.Sort(pool)
|
||||
want := []string{"Beef Chili/regular", "Chicken Fajitas/regular", "Pad Thai/quest_log", "Pot Roast/regular",
|
||||
"Shawarma Bowls/quest_log", "Sheet Pan Salmon/regular", "Spaghetti Bolognese/regular"}
|
||||
if !slices.Equal(pool, want) {
|
||||
t.Errorf("pool = %v, want %v", pool, want)
|
||||
}
|
||||
if in.WeekStart != week || in.DinnerTime != "17:30" || len(in.LastWeek) != 0 {
|
||||
t.Errorf("request week %s dinner time %s last week %v", in.WeekStart, in.DinnerTime, in.LastWeek)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("", fullWeek...)
|
||||
p := e.mustDraft(t, false)
|
||||
|
||||
newYork, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Beef Chili: crock pot, 240 minutes, dinner at 17:30 EDT.
|
||||
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 13, 30, 0, 0, newYork); got == nil || !got.Equal(want) {
|
||||
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
|
||||
}
|
||||
// Pot Roast is a crock-pot dinner with no minutes; the rest are not
|
||||
// crock-pot dinners.
|
||||
for _, d := range p.Days[1:] {
|
||||
if d.StartBy != nil {
|
||||
t.Errorf("%s start_by = %v, want null", d.Dinner.Name, *d.StartBy)
|
||||
}
|
||||
}
|
||||
if p.AINote != nil {
|
||||
t.Errorf("ai_note = %q, want null for an empty note", *p.AINote)
|
||||
}
|
||||
|
||||
// start_by follows the household's dinner time.
|
||||
dinnerTime := "18:45"
|
||||
if _, err := household.NewSettingsService(e.st).Update(t.Context(), household.SettingsPatch{DinnerTime: &dinnerTime}, "member-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err = e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 14, 45, 0, 0, newYork); got == nil || !got.Equal(want) {
|
||||
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
|
||||
}
|
||||
if b, err := json.Marshal(p.Days[0]); err != nil || !strings.Contains(string(b), `"start_by":"2026-10-12T14:45:00-04:00"`) {
|
||||
t.Errorf("day JSON = %s, %v; want start_by in the household zone", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// New York leaves daylight saving time at 02:00 on Sunday 2026-11-01.
|
||||
// Beef Chili (240 minutes) is planned Monday to Saturday and a 20-hour
|
||||
// crock-pot dinner on Sunday, so its start crosses the change.
|
||||
stew := e.dinner(t, "Overnight Stew", "regular", "crockpot", 1200)
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
id := store.NewID()
|
||||
dst := mustDate("2026-10-26")
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: dst.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dates := []string{"2026-10-26", "2026-10-27", "2026-10-28", "2026-10-29", "2026-10-30", "2026-10-31", "2026-11-01"}
|
||||
for i, date := range dates {
|
||||
dinner := e.ids["Beef Chili"]
|
||||
if i == 6 {
|
||||
dinner = stew
|
||||
}
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date,
|
||||
DinnerID: sql.NullString{String: dinner, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Saturday's dinner is 17:30 EDT; Sunday's is 17:30 EST, 22:30 UTC, and
|
||||
// twenty hours before it is 22:30 EDT on Saturday.
|
||||
for _, tc := range []struct {
|
||||
day int
|
||||
want string
|
||||
}{
|
||||
{5, `"2026-10-31T13:30:00-04:00"`},
|
||||
{6, `"2026-10-31T22:30:00-04:00"`},
|
||||
} {
|
||||
b, err := json.Marshal(p.Days[tc.day].StartBy)
|
||||
if err != nil || string(b) != tc.want {
|
||||
t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want)
|
||||
}
|
||||
}
|
||||
// Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST.
|
||||
ribs := e.dinner(t, "Short Ribs", "regular", "crockpot", 240)
|
||||
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", ribs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, err = e.svc.Get(t.Context(), dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` {
|
||||
t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftGivesLastWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
last := mustDate("2026-10-05")
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
id := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: last.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: "2026-10-06",
|
||||
DinnerID: sql.NullString{String: e.ids["Chicken Fajitas"], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
if got := e.input(0).LastWeek; !slices.Equal(got, []string{e.ids["Chicken Fajitas"]}) {
|
||||
t.Errorf("last week = %v, want Chicken Fajitas", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
// Pad Thai is on a later week's plan, so the Quest log no longer
|
||||
// offers it; an eighth dinner keeps the pool at seven.
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
later := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: later, WeekStart: "2099-01-05", CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: later, Date: "2099-01-05",
|
||||
DinnerID: sql.NullString{String: e.ids["Pad Thai"], Valid: true}, IsNew: 1, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "", 0)
|
||||
names := []string{"Beef Chili", "Pot Roast", "Fish Tacos", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
|
||||
e.reply("", names...)
|
||||
p := e.mustDraft(t, false)
|
||||
if got := dayNames(p); !slices.Equal(got, names) {
|
||||
t.Errorf("days = %v", got)
|
||||
}
|
||||
for _, c := range e.input(0).Pool {
|
||||
if c.Name == "Pad Thai" {
|
||||
t.Errorf("pool has Pad Thai, planned on a later week")
|
||||
}
|
||||
}
|
||||
// A redraft of the week keeps offering its own Quest log dinners.
|
||||
e.reply("", names...)
|
||||
e.mustDraft(t, false)
|
||||
if !slices.ContainsFunc(e.input(1).Pool, func(c skills.Candidate) bool { return c.Name == "Shawarma Bowls" }) {
|
||||
t.Errorf("redraft pool left out Shawarma Bowls, planned on this week")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftFailsWhenPoolDinnerLeavesWhileDrafting(t *testing.T) {
|
||||
for _, status := range []string{"archived", "tried"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
// Pad Thai leaves the Quest log while draft_week plans it.
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
r := e.weekReply("", fullWeek...)
|
||||
r.Started, r.Release = started, release
|
||||
e.model.QueueGenerate(r)
|
||||
d, err := e.svc.StartDraft(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-started
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = ? WHERE id = ?`, status, e.ids["Pad Thai"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(release)
|
||||
job := e.waitJob(t, d.JobID)
|
||||
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
|
||||
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
|
||||
}
|
||||
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftsOfTwoWeeksDoNotShareAQuestLogDinner(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
// This week's draft plans Pad Thai; while draft_week runs, the other
|
||||
// worker drafts a later week with Pad Thai too and stores it first.
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
r := e.weekReply("", fullWeek...)
|
||||
r.Started, r.Release = started, release
|
||||
e.model.QueueGenerate(r)
|
||||
d, err := e.svc.StartDraft(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-started
|
||||
later := mustDate("2099-01-05")
|
||||
laterDates := []string{"2099-01-05", "2099-01-06", "2099-01-07", "2099-01-08", "2099-01-09", "2099-01-10", "2099-01-11"}
|
||||
e.model.QueueGenerate(e.weekReplyOn(laterDates, "", fullWeek...))
|
||||
other, err := e.svc.StartDraft(t.Context(), later, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job := e.waitJob(t, other.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("later week's draft %s %v", job.Status, job.Error)
|
||||
}
|
||||
close(release)
|
||||
job := e.waitJob(t, d.JobID)
|
||||
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
|
||||
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
|
||||
}
|
||||
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftFailsWithNotEnoughDinners(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := e.draft(t, false)
|
||||
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonNotEnoughDinners {
|
||||
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonNotEnoughDinners)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 0 {
|
||||
t.Errorf("model called %d times", n)
|
||||
}
|
||||
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||
t.Errorf("Get = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftFailsWhenAIFails(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
job := e.draft(t, false)
|
||||
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonAIFailed {
|
||||
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonAIFailed)
|
||||
}
|
||||
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||
t.Errorf("Get = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartDraftIsSingleFlight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
first, err := e.svc.StartDraft(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := e.svc.StartDraft(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.Created || again.Created || again.JobID != first.JobID {
|
||||
t.Fatalf("drafts = %+v then %+v, want the second to join the first", first, again)
|
||||
}
|
||||
other, err := e.svc.StartDraft(t.Context(), mustDate("2026-10-19"), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !other.Created || other.JobID == first.JobID {
|
||||
t.Errorf("another week's draft = %+v, want its own job", other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimultaneousStartDraftsShareOneJob(t *testing.T) {
|
||||
for _, confirmFirst := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("first confirm %v", confirmFirst), func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
first, err := e.svc.StartDraft(t.Context(), week, confirmFirst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Twenty simultaneous drafts, half of them confirmed: every
|
||||
// one joins the queued job, except a confirmed one while the
|
||||
// job is unconfirmed, which is refused.
|
||||
const n = 20
|
||||
drafts := make([]plan.Draft, n)
|
||||
errs := make([]error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
wg.Go(func() {
|
||||
drafts[i], errs[i] = e.svc.StartDraft(context.Background(), week, i%2 == 1)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
for i, d := range drafts {
|
||||
if i%2 == 1 && !confirmFirst {
|
||||
if !errors.Is(errs[i], plan.ErrDraftRunning) {
|
||||
t.Errorf("confirmed draft %d = %+v, %v, want ErrDraftRunning", i, d, errs[i])
|
||||
}
|
||||
continue
|
||||
}
|
||||
if errs[i] != nil || d.JobID != first.JobID || d.Created {
|
||||
t.Errorf("draft %d = %+v, %v, want it to join %s", i, d, errs[i], first.JobID)
|
||||
}
|
||||
}
|
||||
var queued int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queued != 1 {
|
||||
t.Errorf("%d draft jobs, want 1", queued)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmedDraftWhileUnconfirmedDraftRuns(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("first", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
|
||||
// An unconfirmed redraft is running when the plan is synced; the
|
||||
// household then confirms a redraft. With a second worker idle, a
|
||||
// second job would draft alongside the first, so the confirmed draft
|
||||
// is refused and only the first job runs draft_week.
|
||||
reversed := slices.Clone(fullWeek)
|
||||
slices.Reverse(reversed)
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
r := e.weekReply("unconfirmed", fullWeek...)
|
||||
r.Started, r.Release = started, release
|
||||
e.model.QueueGenerate(r)
|
||||
e.reply("concurrent", reversed...)
|
||||
unconfirmed, err := e.svc.StartDraft(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-started
|
||||
e.setStatus(t, plan.StatusSynced)
|
||||
if d, err := e.svc.StartDraft(t.Context(), week, true); !errors.Is(err, plan.ErrDraftRunning) {
|
||||
t.Fatalf("confirmed draft = %+v, %v, want ErrDraftRunning", d, err)
|
||||
}
|
||||
close(release)
|
||||
if job := e.waitJob(t, unconfirmed.JobID); job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
|
||||
t.Errorf("unconfirmed job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
|
||||
}
|
||||
var queued int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queued != 2 {
|
||||
t.Errorf("%d draft jobs, want the first and the unconfirmed redraft", queued)
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) {
|
||||
t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p))
|
||||
}
|
||||
|
||||
// Asked again once the unconfirmed job has finished, the confirmed
|
||||
// draft replaces the plan.
|
||||
if p := e.mustDraft(t, true); p.Status != plan.StatusChanged || *p.AINote != "concurrent" || !slices.Equal(dayNames(p), reversed) {
|
||||
t.Errorf("plan = %s %q %v, want the confirmed draft, changed", p.Status, *p.AINote, dayNames(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("first", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
|
||||
// The household swaps Wednesday while draft_week runs; the draft must
|
||||
// not erase the swap.
|
||||
reversed := slices.Clone(fullWeek)
|
||||
slices.Reverse(reversed)
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
r := e.weekReply("second", reversed...)
|
||||
r.Started, r.Release = started, release
|
||||
e.model.QueueGenerate(r)
|
||||
d, err := e.svc.StartDraft(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-started
|
||||
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(release)
|
||||
job := e.waitJob(t, d.JobID)
|
||||
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPlanChanged {
|
||||
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPlanChanged)
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := slices.Clone(fullWeek)
|
||||
want[2] = "Fish Tacos"
|
||||
if *p.AINote != "first" || !slices.Equal(dayNames(p), want) {
|
||||
t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want)
|
||||
}
|
||||
|
||||
// A draft after the swap replaces the plan.
|
||||
e.reply("third", reversed...)
|
||||
if p := e.mustDraft(t, false); *p.AINote != "third" || !slices.Equal(dayNames(p), reversed) {
|
||||
t.Errorf("redraft after the swap = %q %v, want the third draft", *p.AINote, dayNames(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedraftOverDraftNeedsNoConfirm(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("first", fullWeek...)
|
||||
first := e.mustDraft(t, false)
|
||||
reversed := slices.Clone(fullWeek)
|
||||
slices.Reverse(reversed)
|
||||
e.reply("second", reversed...)
|
||||
second := e.mustDraft(t, false)
|
||||
if second.ID != first.ID || second.Status != plan.StatusDraft || *second.AINote != "second" {
|
||||
t.Errorf("redraft = %s %s %v, want plan %s draft \"second\"", second.ID, second.Status, *second.AINote, first.ID)
|
||||
}
|
||||
if got := dayNames(second); !slices.Equal(got, reversed) {
|
||||
t.Errorf("days = %v, want %v", got, reversed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) {
|
||||
for _, status := range []string{plan.StatusSynced, plan.StatusChanged} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("first", fullWeek...)
|
||||
first := e.mustDraft(t, false)
|
||||
e.setStatus(t, status)
|
||||
// Push to Calendar records each day's event id.
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE plan_days SET calendar_event_id = 'ev-' || date`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.svc.StartDraft(t.Context(), week, false); !errors.Is(err, plan.ErrConfirmRequired) {
|
||||
t.Fatalf("StartDraft without confirm = %v, want ErrConfirmRequired", err)
|
||||
}
|
||||
var queued int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queued != 1 {
|
||||
t.Errorf("%d draft jobs, want only the first", queued)
|
||||
}
|
||||
|
||||
reversed := slices.Clone(fullWeek)
|
||||
slices.Reverse(reversed)
|
||||
e.reply("second", reversed...)
|
||||
p := e.mustDraft(t, true)
|
||||
if p.ID != first.ID || p.Status != plan.StatusChanged || p.CalendarSyncedAt == nil {
|
||||
t.Errorf("confirmed redraft = %s %s synced %v, want plan %s changed, still synced once", p.ID, p.Status, p.CalendarSyncedAt, first.ID)
|
||||
}
|
||||
if got := dayNames(p); !slices.Equal(got, reversed) {
|
||||
t.Errorf("days = %v, want %v", got, reversed)
|
||||
}
|
||||
var events int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if events != 7 {
|
||||
t.Errorf("%d days kept their calendar event id, want 7", events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftFailsWhenPlanSyncedAfterAskingWithoutConfirm(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("first", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
|
||||
// The redraft is asked for while the plan is draft; the plan is synced
|
||||
// while draft_week runs, so the draft must not replace it.
|
||||
reversed := slices.Clone(fullWeek)
|
||||
slices.Reverse(reversed)
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
r := e.weekReply("second", reversed...)
|
||||
r.Started, r.Release = started, release
|
||||
e.model.QueueGenerate(r)
|
||||
d, err := e.svc.StartDraft(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-started
|
||||
e.setStatus(t, plan.StatusSynced)
|
||||
close(release)
|
||||
job := e.waitJob(t, d.JobID)
|
||||
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
|
||||
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) {
|
||||
t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWeekStart(t *testing.T) {
|
||||
if d, err := plan.ParseWeekStart("2026-10-12"); err != nil || d != week {
|
||||
t.Errorf("ParseWeekStart(Monday) = %v, %v", d, err)
|
||||
}
|
||||
// 2026-02-30 and 2027-02-29 would be Mondays were they dates.
|
||||
for _, s := range []string{"2026-10-13", "2026-10-18", "2026-10-5", "2026-02-30", "2027-02-29", "2026-13-05", "next-week", ""} {
|
||||
var invalid *plan.InvalidWeekError
|
||||
if _, err := plan.ParseWeekStart(s); !errors.As(err, &invalid) {
|
||||
t.Errorf("ParseWeekStart(%q) = %v, want *InvalidWeekError", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapDay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||
e.ids["Ramen"] = e.dinner(t, "Ramen", "quest_log", "crockpot", 300)
|
||||
|
||||
// A Quest log dinner is new, and a crock-pot one gets its start_by.
|
||||
p, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Ramen"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
day := p.Days[2]
|
||||
if day.Dinner.Name != "Ramen" || !day.IsNew || day.StartBy == nil || day.StartBy.Format("15:04") != "12:30" {
|
||||
t.Errorf("Wednesday = %+v, want Ramen, new, start by 12:30", day)
|
||||
}
|
||||
if p.Status != plan.StatusDraft {
|
||||
t.Errorf("status = %s, want a swapped draft to stay draft", p.Status)
|
||||
}
|
||||
|
||||
// A Regular in place of a Quest log dinner is not new.
|
||||
p, err = e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if day := p.Days[2]; day.Dinner.Name != "Fish Tacos" || day.IsNew || day.StartBy != nil {
|
||||
t.Errorf("Wednesday = %+v, want Fish Tacos, not new, no start_by", day)
|
||||
}
|
||||
want := slices.Clone(fullWeek)
|
||||
want[2] = "Fish Tacos"
|
||||
if got := dayNames(p); !slices.Equal(got, want) {
|
||||
t.Errorf("days = %v, want %v", got, want)
|
||||
}
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(dayNames(got), want) {
|
||||
t.Errorf("Get days = %v, want %v", dayNames(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapStatusTransitions(t *testing.T) {
|
||||
for _, tc := range []struct{ from, want string }{
|
||||
{plan.StatusDraft, plan.StatusDraft},
|
||||
{plan.StatusSynced, plan.StatusChanged},
|
||||
{plan.StatusChanged, plan.StatusChanged},
|
||||
} {
|
||||
t.Run(tc.from, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
e.setStatus(t, tc.from)
|
||||
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != tc.want {
|
||||
t.Errorf("status after swap = %s, want %s", p.Status, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapRefusals(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
e.setStatus(t, plan.StatusSynced)
|
||||
suggested := e.dinner(t, "Laksa", "suggested", "stovetop", 30)
|
||||
archived := e.dinner(t, "Old Stew", "archived", "", 0)
|
||||
|
||||
var notPlannable *plan.DinnerNotPlannableError
|
||||
var badDay *plan.InvalidDayError
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
week store.LocalDate
|
||||
date, id string
|
||||
wantError func(error) bool
|
||||
}{
|
||||
{"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }},
|
||||
{"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }},
|
||||
{"dinner on another day", week, "2026-10-12", e.ids["Pot Roast"], func(err error) bool { return errors.Is(err, plan.ErrDinnerAlreadyPlanned) }},
|
||||
{"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, ¬Plannable) }},
|
||||
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, ¬Plannable) }},
|
||||
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
|
||||
{"not a date", week, "monday", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
|
||||
{"impossible date", week, "2026-10-32", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
|
||||
} {
|
||||
if _, err := e.svc.Swap(t.Context(), tc.week, tc.date, tc.id); !tc.wantError(err) {
|
||||
t.Errorf("%s: Swap = %v", tc.name, err)
|
||||
}
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != plan.StatusSynced || !slices.Equal(dayNames(p), fullWeek) {
|
||||
t.Errorf("plan = %s %v, want it unchanged by refused swaps", p.Status, dayNames(p))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"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/store"
|
||||
)
|
||||
|
||||
// Rotation check decisions (the rotation_reviews.decision column), the
|
||||
// household's answer to "Keep it in rotation?" (D-021).
|
||||
const (
|
||||
// DecisionKeep makes the tried dinner a Regular.
|
||||
DecisionKeep = "keep"
|
||||
// DecisionDrop ("Not for us") archives it; its name stays among the
|
||||
// passed names, so it is never suggested again.
|
||||
DecisionDrop = "drop"
|
||||
// DecisionNotMade ("We didn't make it") returns it to the Quest log.
|
||||
DecisionNotMade = "not_made"
|
||||
)
|
||||
|
||||
// decisionStatus is the status each decision moves a tried dinner to. Keep
|
||||
// is the only way a tried dinner becomes a Regular: nothing automatic
|
||||
// promotes one (D-021).
|
||||
var decisionStatus = map[string]string{
|
||||
DecisionKeep: dinners.StatusRegular,
|
||||
DecisionDrop: dinners.StatusArchived,
|
||||
DecisionNotMade: dinners.StatusQuestLog,
|
||||
}
|
||||
|
||||
// ErrReviewNotFound is a decision on a dinner that has never had a
|
||||
// Rotation check: it was never tried.
|
||||
var ErrReviewNotFound = errors.New("the dinner has no Rotation check")
|
||||
|
||||
// ErrNoPendingReview is a decision on a dinner whose Rotation checks are
|
||||
// all decided, such as a second decision.
|
||||
var ErrNoPendingReview = errors.New("the dinner has no pending Rotation check")
|
||||
|
||||
// ErrDinnerNotTried is a decision on a pending Rotation check whose dinner
|
||||
// is no longer tried, such as one archived meanwhile.
|
||||
var ErrDinnerNotTried = errors.New("the dinner is no longer tried")
|
||||
|
||||
// InvalidDecisionError is a decision that is not keep, drop or not_made.
|
||||
type InvalidDecisionError struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func (e *InvalidDecisionError) Error() string {
|
||||
return fmt.Sprintf("decision %q is not one of keep, drop, not_made", e.Value)
|
||||
}
|
||||
|
||||
// ReviewDinner is the dinner of a Rotation check.
|
||||
type ReviewDinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Photo *string `json:"photo"`
|
||||
}
|
||||
|
||||
// Review is a pending Rotation check: the dinner tried and the plan date
|
||||
// it was planned on.
|
||||
type Review struct {
|
||||
Dinner ReviewDinner `json:"dinner"`
|
||||
// PlanDate is household-local, YYYY-MM-DD.
|
||||
PlanDate string `json:"plan_date"`
|
||||
}
|
||||
|
||||
// Decided is a Rotation check as decided.
|
||||
type Decided struct {
|
||||
Dinner ReviewDinner `json:"dinner"`
|
||||
PlanDate string `json:"plan_date"`
|
||||
Decision string `json:"decision"`
|
||||
// DinnerStatus is the status the decision moved the dinner to.
|
||||
DinnerStatus string `json:"dinner_status"`
|
||||
DecidedAt string `json:"decided_at"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
// MarkTried moves every dinner in the Quest log planned on date, the
|
||||
// household-local day just past, on a plan of any status, to tried and
|
||||
// opens a pending Rotation check of it for date. It is idempotent: a
|
||||
// dinner already checked for date is left as it is, so a later run never
|
||||
// moves one returned to the Quest log ("We didn't make it") again. It
|
||||
// returns how many dinners it moved.
|
||||
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
|
||||
var n int
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
ids, err := q.ListTriedCandidates(ctx, date.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: list Quest log dinners planned on %s: %w", date, err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
for _, id := range ids {
|
||||
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: mark %s tried: %w", id, err)
|
||||
}
|
||||
if moved != 1 {
|
||||
return fmt.Errorf("plan: dinner %s left the Quest log while marking it tried", id)
|
||||
}
|
||||
if err := q.CreateRotationReview(ctx, db.CreateRotationReviewParams{
|
||||
ID: store.NewID(), DinnerID: id, PlanDate: date.String(), CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
|
||||
}
|
||||
}
|
||||
n = len(ids)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// PendingReviews returns the pending Rotation checks, oldest plan date
|
||||
// first.
|
||||
func (s *Service) PendingReviews(ctx context.Context) ([]Review, error) {
|
||||
rows, err := db.New(s.st.Reader).ListPendingRotationReviews(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan: list pending Rotation checks: %w", err)
|
||||
}
|
||||
out := make([]Review, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = Review{Dinner: reviewDinner(r.Dinner), PlanDate: r.PlanDate}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecideReview applies decision to the pending Rotation check of dinnerID
|
||||
// and closes it, recording updatedBy: keep makes the dinner a Regular,
|
||||
// drop archives it and not_made returns it to the Quest log; its origin is
|
||||
// kept; a drop records the dinner's name on the check, so renaming the
|
||||
// dinner later never takes it off the passed names. An unknown dinner is
|
||||
// ErrDinnerNotFound, a dinner never checked ErrReviewNotFound, one whose
|
||||
// checks are all decided (such as a second decision) ErrNoPendingReview,
|
||||
// and one no longer tried ErrDinnerNotTried; none of them changes anything.
|
||||
func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedBy string) (Decided, error) {
|
||||
status, ok := decisionStatus[decision]
|
||||
if !ok {
|
||||
return Decided{}, &InvalidDecisionError{Value: decision}
|
||||
}
|
||||
if updatedBy == "" {
|
||||
return Decided{}, errors.New("plan: decide Rotation check: no member to record as updated_by")
|
||||
}
|
||||
var out Decided
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
d, err := q.GetDinner(ctx, dinnerID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrDinnerNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
|
||||
}
|
||||
review, err := q.GetPendingRotationReview(ctx, d.ID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
checks, err := q.CountRotationReviews(ctx, d.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: count Rotation checks of %s: %w", d.ID, err)
|
||||
}
|
||||
if checks == 0 {
|
||||
return ErrReviewNotFound
|
||||
}
|
||||
return ErrNoPendingReview
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: read Rotation check of %s: %w", d.ID, err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
moved, err := q.MoveTriedDinner(ctx, db.MoveTriedDinnerParams{
|
||||
Status: status,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: d.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: move %s to %s: %w", d.ID, status, err)
|
||||
}
|
||||
if moved != 1 {
|
||||
return ErrDinnerNotTried
|
||||
}
|
||||
closed, err := q.DecideRotationReview(ctx, db.DecideRotationReviewParams{
|
||||
Decision: sql.NullString{String: decision, Valid: true},
|
||||
Name: sql.NullString{String: d.Name, Valid: decision == DecisionDrop},
|
||||
Now: sql.NullString{String: now, Valid: true},
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
ID: review.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: close Rotation check %s: %w", review.ID, err)
|
||||
}
|
||||
if closed != 1 {
|
||||
return fmt.Errorf("plan: Rotation check %s was decided while deciding it", review.ID)
|
||||
}
|
||||
out = Decided{
|
||||
Dinner: reviewDinner(d),
|
||||
PlanDate: review.PlanDate,
|
||||
Decision: decision,
|
||||
DinnerStatus: status,
|
||||
DecidedAt: now,
|
||||
UpdatedBy: updatedBy,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Decided{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func reviewDinner(d db.Dinner) ReviewDinner {
|
||||
out := ReviewDinner{ID: d.ID, Name: d.Name}
|
||||
if d.PhotoID.Valid {
|
||||
u := dinners.PhotoURL(d.PhotoID.String)
|
||||
out.Photo = &u
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package plan_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const reviewer = "member-1"
|
||||
|
||||
// Pad Thai and Shawarma Bowls, the seeded Quest log dinners, are planned
|
||||
// on these days of fullWeek.
|
||||
var (
|
||||
padThaiDay = mustDate("2026-10-14")
|
||||
shawarmaDay = mustDate("2026-10-16")
|
||||
)
|
||||
|
||||
// putWeek stores fullWeek as the plan of week with status, as a draft and
|
||||
// Push to Calendar would.
|
||||
func (e *env) putWeek(t *testing.T, status string) {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
id := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, name := range fullWeek {
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: weekDates[i],
|
||||
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
e.setStatus(t, status)
|
||||
}
|
||||
|
||||
func (e *env) dinnerRow(t *testing.T, name string) db.Dinner {
|
||||
t.Helper()
|
||||
d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// review is a rotation_reviews row as stored.
|
||||
type review struct {
|
||||
planDate, status string
|
||||
decision, name, decidedAt, updatedBy sql.NullString
|
||||
}
|
||||
|
||||
func (e *env) reviews(t *testing.T, name string) []review {
|
||||
t.Helper()
|
||||
rows, err := e.st.Reader.QueryContext(t.Context(),
|
||||
`SELECT plan_date, status, decision, name, decided_at, updated_by FROM rotation_reviews WHERE dinner_id = ? ORDER BY plan_date`, e.ids[name])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []review
|
||||
for rows.Next() {
|
||||
var r review
|
||||
if err := rows.Scan(&r.planDate, &r.status, &r.decision, &r.name, &r.decidedAt, &r.updatedBy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *env) markTried(t *testing.T, date store.LocalDate, want int) {
|
||||
t.Helper()
|
||||
n, err := e.svc.MarkTried(t.Context(), date)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != want {
|
||||
t.Fatalf("MarkTried(%s) = %d, want %d", date, n, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkTried(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// Any plan status: a plan pushed to Calendar and edited still counts.
|
||||
e.putWeek(t, plan.StatusChanged)
|
||||
|
||||
e.markTried(t, padThaiDay, 1)
|
||||
if got := e.dinnerRow(t, "Pad Thai").Status; got != "tried" {
|
||||
t.Errorf("Pad Thai status = %s, want tried", got)
|
||||
}
|
||||
got := e.reviews(t, "Pad Thai")
|
||||
if len(got) != 1 || got[0].planDate != padThaiDay.String() || got[0].status != "pending" ||
|
||||
got[0].decision.Valid || got[0].name.Valid || got[0].decidedAt.Valid || got[0].updatedBy.Valid {
|
||||
t.Fatalf("Pad Thai reviews = %+v, want one pending for %s", got, padThaiDay)
|
||||
}
|
||||
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
|
||||
t.Errorf("Shawarma Bowls, planned on another day, status = %s, want quest_log", got)
|
||||
}
|
||||
|
||||
// Idempotent: a second run moves nothing and opens no second check.
|
||||
e.markTried(t, padThaiDay, 0)
|
||||
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" {
|
||||
t.Errorf("Pad Thai reviews after a second run = %+v, want the one pending", got)
|
||||
}
|
||||
|
||||
// A day planning a Regular, or with no plan, moves nothing.
|
||||
e.markTried(t, week, 0)
|
||||
e.markTried(t, mustDate("2026-10-20"), 0)
|
||||
if got := e.dinnerRow(t, "Beef Chili").Status; got != "regular" {
|
||||
t.Errorf("Beef Chili status = %s, want regular", got)
|
||||
}
|
||||
if got := e.reviews(t, "Beef Chili"); len(got) != 0 {
|
||||
t.Errorf("Beef Chili reviews = %+v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A dinner returned to the Quest log ("We didn't make it") is not marked
|
||||
// tried again for the same day.
|
||||
func TestMarkTriedAfterNotMade(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.putWeek(t, plan.StatusDraft)
|
||||
e.markTried(t, padThaiDay, 1)
|
||||
if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], plan.DecisionNotMade, reviewer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.markTried(t, padThaiDay, 0)
|
||||
if got := e.dinnerRow(t, "Pad Thai").Status; got != "quest_log" {
|
||||
t.Errorf("Pad Thai status = %s, want quest_log", got)
|
||||
}
|
||||
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "decided" {
|
||||
t.Errorf("Pad Thai reviews = %+v, want the one decided", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingReviews(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.putWeek(t, plan.StatusDraft)
|
||||
got, err := e.svc.PendingReviews(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("pending before MarkTried = %+v, want none", got)
|
||||
}
|
||||
|
||||
e.markTried(t, shawarmaDay, 1)
|
||||
e.markTried(t, padThaiDay, 1)
|
||||
got, err = e.svc.PendingReviews(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []plan.Review{
|
||||
{Dinner: plan.ReviewDinner{ID: e.ids["Pad Thai"], Name: "Pad Thai"}, PlanDate: padThaiDay.String()},
|
||||
{Dinner: plan.ReviewDinner{ID: e.ids["Shawarma Bowls"], Name: "Shawarma Bowls"}, PlanDate: shawarmaDay.String()},
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("pending = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideReview(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
decision, status string
|
||||
}{
|
||||
{plan.DecisionKeep, "regular"},
|
||||
{plan.DecisionDrop, "archived"},
|
||||
{plan.DecisionNotMade, "quest_log"},
|
||||
} {
|
||||
t.Run(tc.decision, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.putWeek(t, plan.StatusDraft)
|
||||
e.markTried(t, padThaiDay, 1)
|
||||
|
||||
d, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], tc.decision, reviewer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Dinner.ID != e.ids["Pad Thai"] || d.Dinner.Name != "Pad Thai" || d.PlanDate != padThaiDay.String() ||
|
||||
d.Decision != tc.decision || d.DinnerStatus != tc.status || d.DecidedAt == "" || d.UpdatedBy != reviewer {
|
||||
t.Errorf("decided = %+v", d)
|
||||
}
|
||||
row := e.dinnerRow(t, "Pad Thai")
|
||||
if row.Status != tc.status || row.Origin != "side_quest" || row.Name != "Pad Thai" || row.UpdatedBy.String != reviewer {
|
||||
t.Errorf("Pad Thai = status %s origin %s name %s updated_by %v, want %s side_quest Pad Thai %s",
|
||||
row.Status, row.Origin, row.Name, row.UpdatedBy, tc.status, reviewer)
|
||||
}
|
||||
// Only a drop records the dinner's name.
|
||||
wantPassed := tc.decision == plan.DecisionDrop
|
||||
wantName := sql.NullString{}
|
||||
if wantPassed {
|
||||
wantName = sql.NullString{String: "Pad Thai", Valid: true}
|
||||
}
|
||||
got := e.reviews(t, "Pad Thai")
|
||||
if len(got) != 1 || got[0].status != "decided" || got[0].decision.String != tc.decision ||
|
||||
got[0].decidedAt.String != d.DecidedAt || got[0].updatedBy.String != reviewer ||
|
||||
got[0].name != wantName {
|
||||
t.Errorf("reviews = %+v, want one decided %s by %s at %s, named only by a drop", got, tc.decision, reviewer, d.DecidedAt)
|
||||
}
|
||||
if pending, err := e.svc.PendingReviews(t.Context()); err != nil || len(pending) != 0 {
|
||||
t.Errorf("pending after deciding = %+v, %v, want none", pending, err)
|
||||
}
|
||||
|
||||
// A second decision, of any kind, changes nothing.
|
||||
for _, again := range []string{plan.DecisionKeep, plan.DecisionDrop, plan.DecisionNotMade} {
|
||||
if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], again, reviewer); !errors.Is(err, plan.ErrNoPendingReview) {
|
||||
t.Errorf("second decision %s: %v, want ErrNoPendingReview", again, err)
|
||||
}
|
||||
}
|
||||
if got := e.dinnerRow(t, "Pad Thai").Status; got != tc.status {
|
||||
t.Errorf("status after a second decision = %s, want %s", got, tc.status)
|
||||
}
|
||||
|
||||
passed, err := db.New(e.st.Reader).ListPassedNames(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recent, err := db.New(e.st.Reader).ListRecentPassedNames(t.Context(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(passed, "Pad Thai") != wantPassed || slices.Contains(recent, "Pad Thai") != wantPassed {
|
||||
t.Errorf("passed names = %v, recent %v; want Pad Thai among them: %t", passed, recent, wantPassed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideReviewRefused(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.putWeek(t, plan.StatusDraft)
|
||||
e.markTried(t, padThaiDay, 1)
|
||||
ctx := t.Context()
|
||||
|
||||
var invalid *plan.InvalidDecisionError
|
||||
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], "promote", reviewer); !errors.As(err, &invalid) {
|
||||
t.Errorf("decision promote: %v, want *InvalidDecisionError", err)
|
||||
}
|
||||
if _, err := e.svc.DecideReview(ctx, "nope", plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotFound) {
|
||||
t.Errorf("unknown dinner: %v, want ErrDinnerNotFound", err)
|
||||
}
|
||||
// Never tried: Shawarma Bowls is in the Quest log with no check.
|
||||
if _, err := e.svc.DecideReview(ctx, e.ids["Shawarma Bowls"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrReviewNotFound) {
|
||||
t.Errorf("dinner never tried: %v, want ErrReviewNotFound", err)
|
||||
}
|
||||
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
|
||||
t.Errorf("Shawarma Bowls status = %s, want quest_log", got)
|
||||
}
|
||||
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, ""); err == nil {
|
||||
t.Error("decision with no member succeeded")
|
||||
}
|
||||
|
||||
// A tried dinner archived meanwhile is neither kept nor listed.
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotTried) {
|
||||
t.Errorf("archived dinner: %v, want ErrDinnerNotTried", err)
|
||||
}
|
||||
if got := e.dinnerRow(t, "Pad Thai").Status; got != "archived" {
|
||||
t.Errorf("Pad Thai status = %s, want archived", got)
|
||||
}
|
||||
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" {
|
||||
t.Errorf("Pad Thai reviews = %+v, want the one still pending", got)
|
||||
}
|
||||
if pending, err := e.svc.PendingReviews(ctx); err != nil || len(pending) != 0 {
|
||||
t.Errorf("pending = %+v, %v, want none for an archived dinner", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnlyKeepPromotesToRegular guards D-021: no code path but the Rotation
|
||||
// check's keep may make an existing dinner a Regular. Every query writing
|
||||
// dinners.status and every non-test use of the regular status must be one
|
||||
// of those reviewed below; a new one fails here until it is reviewed.
|
||||
func TestOnlyKeepPromotesToRegular(t *testing.T) {
|
||||
// Each query writing dinners.status, and why it cannot promote.
|
||||
statusWriters := map[string]string{
|
||||
"CreateDinner": "inserts a new dinner; only CreateRegular inserts one as a Regular",
|
||||
"UpdateDinner": "dinners.Service.Update writes the status it read back",
|
||||
"ArchiveDinner": "sets archived",
|
||||
"MoveSuggestedDinner": "Try it / Pass move a suggested dinner to quest_log or archived",
|
||||
"MarkDinnerTried": "sets tried",
|
||||
"MoveTriedDinner": "DecideReview's decisionStatus: only keep maps to regular",
|
||||
}
|
||||
files, err := filepath.Glob("../../migrations/queries/*.sql")
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no query files: %v", err)
|
||||
}
|
||||
writes := regexp.MustCompile(`(?is)^\s*(INSERT\s+INTO\s+dinners\b|UPDATE\s+dinners\s+SET\b.*\bstatus\s*=)`)
|
||||
nameRe := regexp.MustCompile(`^\s*(\w+)`)
|
||||
found := map[string]bool{}
|
||||
for _, f := range files {
|
||||
src, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range strings.Split(string(src), "-- name:")[1:] {
|
||||
name := nameRe.FindStringSubmatch(stmt)[1]
|
||||
var body []string
|
||||
for _, line := range strings.Split(stmt, "\n")[1:] {
|
||||
if !strings.HasPrefix(strings.TrimSpace(line), "--") {
|
||||
body = append(body, line)
|
||||
}
|
||||
}
|
||||
sqlText := strings.Join(body, "\n")
|
||||
if !writes.MatchString(sqlText) {
|
||||
continue
|
||||
}
|
||||
found[name] = true
|
||||
if _, ok := statusWriters[name]; !ok {
|
||||
t.Errorf("%s: query %s writes dinners.status; review it against D-021 and list it here", f, name)
|
||||
}
|
||||
if regexp.MustCompile(`(?i)\bstatus\s*=\s*'regular'`).MatchString(sqlText) {
|
||||
t.Errorf("%s: query %s sets status regular outright", f, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range statusWriters {
|
||||
if !found[name] {
|
||||
t.Errorf("reviewed query %s no longer writes dinners.status; drop it from the list", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Each non-test Go file naming the regular status, and how often.
|
||||
uses := map[string]int{
|
||||
"dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin
|
||||
"plan/plan.go": 1, // plannable
|
||||
"plan/rotation.go": 1, // decisionStatus[DecisionKeep]
|
||||
"suggest/batch.go": 1, // reads which dinners are Regulars to cite
|
||||
"ai/harness/context.go": 1, // reads the Regulars
|
||||
"ai/skills/draft_week.go": 1, // KindRegular, a planned day's label
|
||||
}
|
||||
regular := regexp.MustCompile(`StatusRegular\b|"regular"`)
|
||||
got := map[string]int{}
|
||||
err = filepath.WalkDir("..", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && d.Name() == "db" {
|
||||
return filepath.SkipDir // generated; the queries are checked above
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n := len(regular.FindAllIndex(src, -1)); n > 0 {
|
||||
got[filepath.ToSlash(strings.TrimPrefix(path, ".."+string(filepath.Separator)))] = n
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, n := range got {
|
||||
if uses[path] != n {
|
||||
t.Errorf("%s names the regular status %d times, reviewed %d; review it against D-021", path, n, uses[path])
|
||||
}
|
||||
}
|
||||
for path, n := range uses {
|
||||
if got[path] == 0 {
|
||||
t.Errorf("%s no longer names the regular status (reviewed %d); update the list", path, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -564,9 +564,11 @@ func TestFetchCacheIsNotSharedWithCallers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchCanceledWaitHoldsNoSlot covers a burst of fetches whose
|
||||
// TestFetchCanceledWaitHoldsNoSlot covers a burst of requests whose
|
||||
// rate-limit waits end with their context: they must not push back the
|
||||
// next request that is allowed through.
|
||||
// next request that is allowed through. The burst calls waitTurn, the wait
|
||||
// turnConn's first Write makes, directly: through Fetch, net/http races the
|
||||
// failed write against its read loop, so the error it returns varies.
|
||||
func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -577,14 +579,24 @@ func TestFetchCanceledWaitHoldsNoSlot(t *testing.T) {
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/first"); err != nil {
|
||||
t.Fatalf("first Fetch: %v", err)
|
||||
}
|
||||
firstSent := clock.now()
|
||||
env.f.sleep = func(context.Context, time.Duration) error { return context.DeadlineExceeded }
|
||||
for i := range 5 {
|
||||
if _, err := env.f.Fetch(t.Context(), fmt.Sprintf("https://example.com/burst/%d", i)); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("burst Fetch %d error = %v, want context.DeadlineExceeded", i, err)
|
||||
// Each request comes later in the interval, so a slot it took
|
||||
// would delay the request after the burst.
|
||||
clock.advance(HostInterval / 10)
|
||||
if _, err := env.f.waitTurn(t.Context(), "example.com"); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("burst waitTurn %d error = %v, want context.DeadlineExceeded", i, err)
|
||||
}
|
||||
}
|
||||
env.f.mu.Lock()
|
||||
last := env.f.lastSent["example.com"]
|
||||
env.f.mu.Unlock()
|
||||
if !last.Equal(firstSent) {
|
||||
t.Errorf("host slot taken at %s after the burst, want the first send at %s", last, firstSent)
|
||||
}
|
||||
env.f.sleep = clock.sleep
|
||||
clock.advance(HostInterval)
|
||||
clock.advance(firstSent.Add(HostInterval).Sub(clock.now()))
|
||||
if _, err := env.f.Fetch(t.Context(), "https://example.com/after"); err != nil {
|
||||
t.Fatalf("Fetch after the burst: %v", err)
|
||||
}
|
||||
|
||||
+104
-34
@@ -25,6 +25,13 @@ var (
|
||||
// ErrTotalTimeNotPositive rejects a recipe whose page gives a total
|
||||
// time of zero minutes, which normalize_recipe refuses.
|
||||
ErrTotalTimeNotPositive = errors.New("recipes: recipe total time is not positive")
|
||||
// ErrSearchFailed wraps every failure to search: the search provider
|
||||
// could not be built (ErrSearchNotConfigured among them) or its search
|
||||
// failed.
|
||||
ErrSearchFailed = errors.New("recipes: recipe search failed")
|
||||
// ErrNormalizeFailed wraps a normalize_recipe failure that is not the
|
||||
// recipe's: the model provider could not be built or called.
|
||||
ErrNormalizeFailed = errors.New("recipes: normalize_recipe failed")
|
||||
)
|
||||
|
||||
// ErrUngroundable is returned when no recipe page could ground a dish or a
|
||||
@@ -118,43 +125,16 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded
|
||||
if strings.TrimSpace(dishName) == "" {
|
||||
return Grounded{}, errors.New("recipes: ground: dish name is empty")
|
||||
}
|
||||
provider, err := g.search.Provider(ctx)
|
||||
found, err := g.searchRanked(ctx, dishName, query)
|
||||
if err != nil {
|
||||
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
|
||||
}
|
||||
cands, err := provider.Search(ctx, query, RankDepth)
|
||||
if err != nil {
|
||||
return Grounded{}, fmt.Errorf("recipes: ground %q: search: %w", dishName, err)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: []error{ErrNoCandidates}}
|
||||
}
|
||||
cands = cands[:min(len(cands), RankDepth)]
|
||||
stored := make(map[string]storedRecipe)
|
||||
for _, c := range cands {
|
||||
sr, ok, err := g.storedRecipe(ctx, c.URL)
|
||||
if err != nil {
|
||||
return Grounded{}, fmt.Errorf("recipes: ground %q: %w", dishName, err)
|
||||
}
|
||||
if ok {
|
||||
stored[c.URL] = sr
|
||||
}
|
||||
}
|
||||
ranked, rankErr := rank(ctx, dishName, cands, func(ctx context.Context, c Candidate) (Recipe, error) {
|
||||
if sr, ok := stored[c.URL]; ok {
|
||||
return sr.recipe, nil
|
||||
}
|
||||
return fetchRecipe(ctx, g.pages, c.URL)
|
||||
})
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Grounded{}, err
|
||||
}
|
||||
var reasons []error
|
||||
if rankErr != nil {
|
||||
reasons = append(reasons, rankErr)
|
||||
if found.rankErr != nil {
|
||||
reasons = append(reasons, found.rankErr)
|
||||
}
|
||||
for _, r := range ranked {
|
||||
if sr, ok := stored[r.Candidate.URL]; ok {
|
||||
for _, r := range found.ranked {
|
||||
if sr, ok := found.stored[r.Candidate.URL]; ok {
|
||||
return sr.grounded, nil
|
||||
}
|
||||
gr, reason, err := g.ground(ctx, r.Candidate.URL, r.Recipe)
|
||||
@@ -169,6 +149,96 @@ func (g *Grounder) Ground(ctx context.Context, dishName, query string) (Grounded
|
||||
return Grounded{}, &ErrUngroundable{Target: dishName, Reasons: reasons}
|
||||
}
|
||||
|
||||
// rankedSearch is a search's candidates ranked: the ranked ones best first,
|
||||
// those already stored by URL, and why any were not ranked.
|
||||
type rankedSearch struct {
|
||||
ranked []Ranked
|
||||
stored map[string]storedRecipe
|
||||
// rankErr is Rank's error, or ErrNoCandidates when the search found
|
||||
// nothing.
|
||||
rankErr error
|
||||
}
|
||||
|
||||
// searchRanked searches for query and ranks the top RankDepth candidates
|
||||
// for dishName as Ground describes. A search, database or context failure
|
||||
// is its error; candidates that do not rank are only rankErr's.
|
||||
func (g *Grounder) searchRanked(ctx context.Context, dishName, query string) (rankedSearch, error) {
|
||||
provider, err := g.search.Provider(ctx)
|
||||
if err != nil {
|
||||
return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err)
|
||||
}
|
||||
cands, err := provider.Search(ctx, query, RankDepth)
|
||||
if err != nil {
|
||||
return rankedSearch{}, fmt.Errorf("%w: %w", ErrSearchFailed, err)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return rankedSearch{rankErr: ErrNoCandidates}, nil
|
||||
}
|
||||
cands = cands[:min(len(cands), RankDepth)]
|
||||
stored := make(map[string]storedRecipe)
|
||||
for _, c := range cands {
|
||||
sr, ok, err := g.storedRecipe(ctx, c.URL)
|
||||
if err != nil {
|
||||
return rankedSearch{}, err
|
||||
}
|
||||
if ok {
|
||||
stored[c.URL] = sr
|
||||
}
|
||||
}
|
||||
ranked, rankErr := rank(ctx, dishName, cands, func(ctx context.Context, c Candidate) (Recipe, error) {
|
||||
if sr, ok := stored[c.URL]; ok {
|
||||
return sr.recipe, nil
|
||||
}
|
||||
return fetchRecipe(ctx, g.pages, c.URL)
|
||||
})
|
||||
if err := ctx.Err(); err != nil {
|
||||
return rankedSearch{}, err
|
||||
}
|
||||
return rankedSearch{ranked: ranked, stored: stored, rankErr: rankErr}, nil
|
||||
}
|
||||
|
||||
// Offer is a ranked candidate offered for the household to ground a dish
|
||||
// in, with GroundURL on its URL.
|
||||
type Offer struct {
|
||||
URL string
|
||||
Title string
|
||||
SiteName string
|
||||
// PhotoID is the cached photo of a candidate already stored, "" for
|
||||
// one that is not: a candidate's photo is cached only when it grounds.
|
||||
PhotoID string
|
||||
// Minutes is a stored candidate's normalized minutes_total, else its
|
||||
// page's total time; nil when unknown.
|
||||
Minutes *int
|
||||
}
|
||||
|
||||
// Candidates searches for query and returns up to n of the candidates
|
||||
// Ground would try for dishName, best first. It fetches and extracts pages
|
||||
// but downloads no photo and runs no model. Candidates that hold no usable
|
||||
// recipe are not offered, so the list may be empty; a search that fails is
|
||||
// an error wrapping ErrSearchFailed.
|
||||
func (g *Grounder) Candidates(ctx context.Context, dishName, query string, n int) ([]Offer, error) {
|
||||
if strings.TrimSpace(dishName) == "" {
|
||||
return nil, errors.New("recipes: candidates: dish name is empty")
|
||||
}
|
||||
if n < 1 {
|
||||
return nil, fmt.Errorf("recipes: candidates: n is %d, want at least 1", n)
|
||||
}
|
||||
found, err := g.searchRanked(ctx, dishName, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recipes: candidates for %q: %w", dishName, err)
|
||||
}
|
||||
offers := make([]Offer, 0, min(n, len(found.ranked)))
|
||||
for _, r := range found.ranked[:min(n, len(found.ranked))] {
|
||||
o := Offer{URL: r.Candidate.URL, Title: r.Recipe.Title, SiteName: r.Recipe.SiteName, Minutes: r.Recipe.TotalMinutes}
|
||||
if sr, ok := found.stored[r.Candidate.URL]; ok {
|
||||
o.PhotoID = sr.grounded.PhotoID
|
||||
o.Minutes = sr.grounded.MinutesTotal
|
||||
}
|
||||
offers = append(offers, o)
|
||||
}
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
// GroundURL grounds the recipe on the page at rawURL, a pasted URL, as
|
||||
// Ground does a candidate. An already stored rawURL is returned without
|
||||
// fetching it. A page that cannot be fetched, holds no usable recipe, or
|
||||
@@ -196,7 +266,7 @@ func (g *Grounder) GroundURL(ctx context.Context, rawURL string) (Grounded, erro
|
||||
}
|
||||
gr, reason, err := g.ground(ctx, rawURL, r)
|
||||
if err != nil {
|
||||
return Grounded{}, fmt.Errorf("recipes: ground %s: %w", rawURL, err)
|
||||
return Grounded{}, fmt.Errorf("recipes: ground pasted link: %w", err)
|
||||
}
|
||||
if reason != nil {
|
||||
return ungroundable(reason)
|
||||
@@ -344,7 +414,7 @@ func (g *Grounder) ground(ctx context.Context, sourceURL string, r Recipe) (gr G
|
||||
return Grounded{}, fmt.Errorf("normalize: %w", err), nil
|
||||
}
|
||||
if err != nil {
|
||||
return Grounded{}, nil, fmt.Errorf("normalize: %w", err)
|
||||
return Grounded{}, nil, fmt.Errorf("%w: %w", ErrNormalizeFailed, err)
|
||||
}
|
||||
if len(norm.Ingredients) != len(r.Ingredients) {
|
||||
return Grounded{}, nil, fmt.Errorf("normalize: %d ingredients for %d lines", len(norm.Ingredients), len(r.Ingredients))
|
||||
|
||||
@@ -516,3 +516,38 @@ func TestGroundManyLimitsConcurrency(t *testing.T) {
|
||||
t.Errorf("max in flight %d of %d calls, want %d of 5", search.maxActive, search.callCount(), GroundConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesOffersBestFirstUpToN(t *testing.T) {
|
||||
t.Parallel()
|
||||
var cands []Candidate
|
||||
pages := map[string][]byte{}
|
||||
for i, title := range []string{"Fish Tacos", "Fish Taco Bowls", "Baja Fish Tacos", "Tacos"} {
|
||||
u := fmt.Sprintf("https://site%d.example/tacos", i)
|
||||
cands = append(cands, Candidate{URL: u})
|
||||
pages[u] = groundPage(title, "/img/tacos.jpg")
|
||||
}
|
||||
search := &fakeSearch{results: map[string][]Candidate{"fish tacos": cands}}
|
||||
env := newGroundEnv(t, pages, nil, search)
|
||||
|
||||
got, err := env.g.Candidates(t.Context(), "Fish Tacos", "fish tacos", 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 || got[0].URL != cands[0].URL || got[0].Title != "Fish Tacos" || got[0].SiteName != "Example Kitchen" ||
|
||||
got[0].PhotoID != "" || got[0].Minutes == nil || *got[0].Minutes != 40 {
|
||||
t.Fatalf("Candidates = %+v, want 3 with Fish Tacos first", got)
|
||||
}
|
||||
if env.images.requests.Load() != 0 || len(env.llm.Requests()) != 0 {
|
||||
t.Errorf("Candidates downloaded a photo or ran the model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesSearchFailureWrapsErrSearchFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
search := &fakeSearch{errs: map[string]error{"stew": ErrSearchRateLimited}}
|
||||
env := newGroundEnv(t, nil, nil, search)
|
||||
_, err := env.g.Candidates(t.Context(), "Stew", "stew", 3)
|
||||
if !errors.Is(err, ErrSearchFailed) || !errors.Is(err, ErrSearchRateLimited) {
|
||||
t.Errorf("err = %v, want ErrSearchFailed wrapping ErrSearchRateLimited", err)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-10
@@ -65,22 +65,28 @@ var photoDecoders = map[string]func([]byte) (image.Image, error){
|
||||
"webp": func(b []byte) (image.Image, error) { return webp.Decode(bytes.NewReader(b)) },
|
||||
}
|
||||
|
||||
// PhotoCache downloads photos through a Fetcher and stores each at every
|
||||
// ImageFetcher fetches an image; *Fetcher satisfies it.
|
||||
type ImageFetcher interface {
|
||||
FetchImage(ctx context.Context, rawURL string) (Page, error)
|
||||
}
|
||||
|
||||
// PhotoCache downloads photos through an ImageFetcher and stores each at every
|
||||
// PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row.
|
||||
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
|
||||
// PhotoCache is safe for concurrent use.
|
||||
type PhotoCache struct {
|
||||
fetcher *Fetcher
|
||||
store *store.Store
|
||||
images ImageFetcher
|
||||
store *store.Store
|
||||
// dir is the absolute photo directory.
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewPhotoCache returns a PhotoCache storing photos under dataDir, creating
|
||||
// the photo directory if it is absent.
|
||||
func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
|
||||
if fetcher == nil {
|
||||
return nil, errors.New("recipes: nil fetcher")
|
||||
// NewPhotoCache returns a PhotoCache downloading images with images and
|
||||
// storing photos under dataDir, creating the photo directory if it is
|
||||
// absent.
|
||||
func NewPhotoCache(images ImageFetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
|
||||
if images == nil {
|
||||
return nil, errors.New("recipes: nil image fetcher")
|
||||
}
|
||||
if st == nil {
|
||||
return nil, errors.New("recipes: nil store")
|
||||
@@ -92,7 +98,7 @@ func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCac
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("recipes: create photo directory: %w", err)
|
||||
}
|
||||
return &PhotoCache{fetcher: fetcher, store: st, dir: dir}, nil
|
||||
return &PhotoCache{images: images, store: st, dir: dir}, nil
|
||||
}
|
||||
|
||||
// DownloadedPhoto is an image DownloadPhoto has downloaded and decoded,
|
||||
@@ -108,7 +114,7 @@ type DownloadedPhoto struct {
|
||||
// whose shorter side is under PhotoMinShortSide. It records nothing, so a
|
||||
// caller can try another image before opening a transaction.
|
||||
func (c *PhotoCache) DownloadPhoto(ctx context.Context, rawURL string) (DownloadedPhoto, error) {
|
||||
page, err := c.fetcher.FetchImage(ctx, rawURL)
|
||||
page, err := c.images.FetchImage(ctx, rawURL)
|
||||
if err != nil {
|
||||
return DownloadedPhoto{}, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package recipestest provides fakes for tests that ground dinners outside
|
||||
// package recipes: a search, page and image fetchers serving fixtures, and
|
||||
// a Grounder over them that normalizes through the real harness and
|
||||
// normalize_recipe skill with a scripted model.
|
||||
package recipestest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Search is a recipes.SearchSource and recipes.SearchProvider answering
|
||||
// each query from Results. Err, when set, fails every search. Gate, when
|
||||
// set, makes every search wait for it to close.
|
||||
type Search struct {
|
||||
Results map[string][]recipes.Candidate
|
||||
Err error
|
||||
Gate chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
queries []string
|
||||
}
|
||||
|
||||
func (s *Search) Provider(context.Context) (recipes.SearchProvider, error) { return s, nil }
|
||||
|
||||
func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Candidate, error) {
|
||||
s.mu.Lock()
|
||||
s.queries = append(s.queries, query)
|
||||
s.mu.Unlock()
|
||||
if s.Gate != nil {
|
||||
select {
|
||||
case <-s.Gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
c := s.Results[query]
|
||||
return c[:min(n, len(c))], nil
|
||||
}
|
||||
|
||||
// Queries returns every query searched, in order.
|
||||
func (s *Search) Queries() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.queries...)
|
||||
}
|
||||
|
||||
// Pages is a recipes.PageFetcher serving Bodies by URL; any other URL is a
|
||||
// 404. Gate, when set, makes every fetch wait for it to close.
|
||||
type Pages struct {
|
||||
Bodies map[string][]byte
|
||||
Gate chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
fetched []string
|
||||
}
|
||||
|
||||
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
|
||||
p.mu.Lock()
|
||||
p.fetched = append(p.fetched, rawURL)
|
||||
p.mu.Unlock()
|
||||
if p.Gate != nil {
|
||||
select {
|
||||
case <-p.Gate:
|
||||
case <-ctx.Done():
|
||||
return recipes.Page{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
body, ok := p.Bodies[rawURL]
|
||||
if !ok {
|
||||
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
|
||||
}
|
||||
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
|
||||
}
|
||||
|
||||
// Fetched returns every URL fetched, in order.
|
||||
func (p *Pages) Fetched() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]string(nil), p.fetched...)
|
||||
}
|
||||
|
||||
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
|
||||
// a 404.
|
||||
type Images struct {
|
||||
JPEGs map[string][]byte
|
||||
}
|
||||
|
||||
func (i *Images) FetchImage(_ context.Context, rawURL string) (recipes.Page, error) {
|
||||
body, ok := i.JPEGs[rawURL]
|
||||
if !ok {
|
||||
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
|
||||
}
|
||||
return recipes.Page{URL: rawURL, ContentType: "image/jpeg", Body: body}, nil
|
||||
}
|
||||
|
||||
// JPEG is a w×h JPEG whose pixels depend on seed, so different seeds give
|
||||
// different photos.
|
||||
func JPEG(t testing.TB, w, h int, seed uint8) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
img.Set(x, y, color.RGBA{uint8(x), uint8(y), seed, 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// RecipePage is a page whose schema.org Recipe is title, from site, with
|
||||
// two ingredients, two steps, a total time of minutes and the image at
|
||||
// imageURL.
|
||||
func RecipePage(title, site, imageURL string, minutes int) []byte {
|
||||
return fmt.Appendf(nil, `<!DOCTYPE html><html><head>
|
||||
<meta property="og:site_name" content=%q>
|
||||
<script type="application/ld+json">{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": %q,
|
||||
"image": %q,
|
||||
"recipeIngredient": ["1 onion", "2 cloves garlic, minced"],
|
||||
"recipeInstructions": ["Chop the onion.", "Fry with the garlic."],
|
||||
"totalTime": "PT%dM"
|
||||
}</script></head><body></body></html>`, site, title, imageURL, minutes)
|
||||
}
|
||||
|
||||
// NormalizedReply is a usable normalize_recipe reply for a RecipePage with
|
||||
// method and minutes.
|
||||
func NormalizedReply(method string, minutes int) llm.GenerateResult {
|
||||
js := fmt.Sprintf(`{"ingredients":[
|
||||
{"name":"onion","qty":"1","unit":"","note":""},
|
||||
{"name":"garlic","qty":"2","unit":"cloves","note":"minced"}
|
||||
],"method":%q,"difficulty":null,"minutes_total":%d}`, method, minutes)
|
||||
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
||||
}
|
||||
|
||||
// NewGrounder returns a Grounder storing in st, with photos cached under
|
||||
// dataDir, normalizing through the real harness with model.
|
||||
func NewGrounder(t testing.TB, st *store.Store, dataDir string, search recipes.SearchSource, pages recipes.PageFetcher, images recipes.ImageFetcher, model llm.Provider) *recipes.Grounder {
|
||||
t.Helper()
|
||||
photos, err := recipes.NewPhotoCache(images, st, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test"},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
normalize := func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
|
||||
return skills.NormalizeRecipe(ctx, cfg, scope, in)
|
||||
}
|
||||
g, err := recipes.NewGrounder(st, search, pages, photos, normalize)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
@@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure
|
||||
type SearchSettings struct {
|
||||
// Provider is "searxng" or "brave", nil until first chosen.
|
||||
Provider *string `json:"provider"`
|
||||
// BaseURL is the SearXNG instance's URL, nil when not set.
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
APIKeyLast4 *string `json:"api_key_last4"`
|
||||
// BaseURL is the saved SearXNG instance's URL, nil when not set.
|
||||
BaseURL *string `json:"base_url"`
|
||||
// DefaultBaseURL is the deployment's SearXNG URL
|
||||
// (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is
|
||||
// not set. It is never saved by itself: only a PATCH of base_url saves
|
||||
// a URL. Nil when the deployment names none.
|
||||
DefaultBaseURL *string `json:"default_base_url"`
|
||||
APIKeySet bool `json:"api_key_set"`
|
||||
APIKeyLast4 *string `json:"api_key_last4"`
|
||||
// UpdatedAt is nil until the settings are first saved.
|
||||
UpdatedAt *string `json:"updated_at"`
|
||||
}
|
||||
@@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string {
|
||||
type SearchSettingsService struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
// defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none.
|
||||
defaultBaseURL string
|
||||
// newProvider is NewSearchProvider; tests point Brave elsewhere.
|
||||
newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error)
|
||||
}
|
||||
|
||||
// NewSearchSettingsService returns the service over st, sealing keys with
|
||||
// box.
|
||||
func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) {
|
||||
// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set
|
||||
// one must be a valid SearXNG URL.
|
||||
func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("recipes: nil store")
|
||||
}
|
||||
if box == nil {
|
||||
return nil, errors.New("recipes: nil secret box")
|
||||
}
|
||||
return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil
|
||||
if defaultBaseURL != "" {
|
||||
if _, err := NewSearXNG(defaultBaseURL); err != nil {
|
||||
return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: "))
|
||||
}
|
||||
}
|
||||
return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil
|
||||
}
|
||||
|
||||
// Get returns the settings; before any are saved every field is unset.
|
||||
// Get returns the settings; before any are saved every field but
|
||||
// DefaultBaseURL is unset.
|
||||
func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) {
|
||||
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SearchSettings{}, nil
|
||||
return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err)
|
||||
}
|
||||
return searchSettingsFrom(row), nil
|
||||
return s.settingsFrom(row), nil
|
||||
}
|
||||
|
||||
func (s *SearchSettingsService) defaultBase() *string {
|
||||
if s.defaultBaseURL == "" {
|
||||
return nil
|
||||
}
|
||||
u := s.defaultBaseURL
|
||||
return &u
|
||||
}
|
||||
|
||||
// Update applies patch and returns the settings it leaves. The first update
|
||||
@@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings
|
||||
if err != nil {
|
||||
return SearchSettings{}, err
|
||||
}
|
||||
return searchSettingsFrom(saved), nil
|
||||
return s.settingsFrom(saved), nil
|
||||
}
|
||||
|
||||
// apply returns row with patch applied, validated.
|
||||
@@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) {
|
||||
return len(cands), nil
|
||||
}
|
||||
|
||||
func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings {
|
||||
func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings {
|
||||
provider, updated := row.Provider, row.UpdatedAt
|
||||
return SearchSettings{
|
||||
Provider: &provider,
|
||||
BaseURL: nullString(row.BaseUrl),
|
||||
APIKeySet: len(row.ApiKeyCt) > 0,
|
||||
APIKeyLast4: nullString(row.ApiKeyLast4),
|
||||
UpdatedAt: &updated,
|
||||
Provider: &provider,
|
||||
BaseURL: nullString(row.BaseUrl),
|
||||
DefaultBaseURL: s.defaultBase(),
|
||||
APIKeySet: len(row.ApiKeyCt) > 0,
|
||||
APIKeyLast4: nullString(row.ApiKeyLast4),
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ import (
|
||||
const testBraveKey = "BSA-secret-brave-key-9f3c"
|
||||
|
||||
func newTestSearchSettings(t *testing.T) *SearchSettingsService {
|
||||
t.Helper()
|
||||
return newTestSearchSettingsWithDefault(t, "")
|
||||
}
|
||||
|
||||
// newTestSearchSettingsWithDefault is newTestSearchSettings with
|
||||
// defaultBaseURL as the deployment's SearXNG URL.
|
||||
func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService {
|
||||
t.Helper()
|
||||
st, err := store.Open(t.Context(), t.TempDir())
|
||||
if err != nil {
|
||||
@@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewSearchSettingsService(st, box)
|
||||
svc, err := NewSearchSettingsService(st, box, defaultBaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSettingsDefaultBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080")
|
||||
got, err := svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil {
|
||||
t.Errorf("Get before any update = %+v, want only default_base_url set", got)
|
||||
}
|
||||
if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) {
|
||||
t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err)
|
||||
}
|
||||
|
||||
// The default is a suggestion: SearXNG still needs a saved URL.
|
||||
if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil {
|
||||
t.Error("choosing SearXNG without a URL succeeded with a default set")
|
||||
}
|
||||
|
||||
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" {
|
||||
t.Errorf("Update result = %+v, want the saved URL beside the default", got)
|
||||
}
|
||||
got, err = svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *got.BaseURL != "https://search.example" {
|
||||
t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL)
|
||||
}
|
||||
row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.BaseUrl.String != "https://search.example" {
|
||||
t.Errorf("stored base_url = %q", row.BaseUrl.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, err := store.Open(t.Context(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} {
|
||||
if _, err := NewSearchSettingsService(st, box, u); err == nil {
|
||||
t.Errorf("NewSearchSettingsService(%q) succeeded", u)
|
||||
} else if strings.Contains(err.Error(), "pw") {
|
||||
t.Errorf("error for %q echoes the URL: %v", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSettingsTestSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// apiKeyBody is the PUT /settings/ai/api-key body. An empty api_key removes
|
||||
// the stored key.
|
||||
type apiKeyBody struct {
|
||||
APIKey *string `json:"api_key"`
|
||||
}
|
||||
|
||||
// modelsResponse is the GET /settings/ai/models body.
|
||||
type modelsResponse struct {
|
||||
Models []aisettings.Model `json:"models"`
|
||||
}
|
||||
|
||||
// No response from these handlers holds the API key: the settings carry
|
||||
// only its last four characters, and provider errors and model lists are
|
||||
// redacted by package aisettings.
|
||||
|
||||
func getAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get AI settings", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
func patchAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
patch, err := decodePatch[aisettings.Patch](http.MaxBytesReader(w, r.Body, maxAISettingsBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeAISettings(w, r, logger, "update AI settings")(svc.Update(r.Context(), patch))
|
||||
}
|
||||
}
|
||||
|
||||
// putAIKey takes the API key in api_key; the response, like GET's, holds
|
||||
// only its last four characters.
|
||||
func putAIKey(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[apiKeyBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
||||
if err == nil && body.APIKey == nil {
|
||||
err = errors.New("api_key: is required")
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeAISettings(w, r, logger, "store AI API key")(svc.SetAPIKey(r.Context(), *body.APIKey))
|
||||
}
|
||||
}
|
||||
|
||||
func postAIResetPrompt(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
writeAISettings(w, r, logger, "reset AI system prompt")(svc.ResetPrompt(r.Context()))
|
||||
}
|
||||
}
|
||||
|
||||
// postAIVerify checks the stored key with the provider and returns the
|
||||
// settings with key_verified_at, or the failure (see writeAIFailure).
|
||||
func postAIVerify(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := svc.Verify(r.Context())
|
||||
if err != nil {
|
||||
writeAIFailure(w, r, logger, "verify AI key", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// getAIModels returns the provider's live model list, or the failure (see
|
||||
// writeAIFailure).
|
||||
func getAIModels(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
models, err := svc.Models(r.Context())
|
||||
if err != nil {
|
||||
writeAIFailure(w, r, logger, "list AI models", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, modelsResponse{Models: models})
|
||||
}
|
||||
}
|
||||
|
||||
// writeAISettings returns a func writing a Service result: the settings,
|
||||
// 400 invalid_settings for a *aisettings.ValidationError, or 500.
|
||||
func writeAISettings(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string) func(aisettings.Settings, error) {
|
||||
return func(settings aisettings.Settings, err error) {
|
||||
var invalid *aisettings.ValidationError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
default:
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeAIFailure writes a Verify or Models error: 409 not_configured when
|
||||
// the settings cannot build a provider, 409 settings_changed when they
|
||||
// changed during verification, and for a failed provider call a 502 (504
|
||||
// for a timeout) whose code names the llm typed error: auth_failed,
|
||||
// rate_limited, timeout, bad_output or provider_error. Rate limits and
|
||||
// timeouts are retryable. Anything else is 500.
|
||||
func writeAIFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, aisettings.ErrNotConfigured):
|
||||
writeError(w, r, logger, http.StatusConflict, "not_configured", err.Error())
|
||||
case errors.Is(err, aisettings.ErrChanged):
|
||||
writeError(w, r, logger, http.StatusConflict, "settings_changed", err.Error())
|
||||
case errors.Is(err, aisettings.ErrProviderFailed):
|
||||
logger.WarnContext(r.Context(), action+" failed", "error", err)
|
||||
status, e := http.StatusBadGateway, apiError{Code: "provider_error", Message: err.Error()}
|
||||
switch {
|
||||
case errors.Is(err, llm.ErrAuth):
|
||||
e.Code = "auth_failed"
|
||||
case errors.Is(err, llm.ErrRateLimited):
|
||||
e.Code, e.Retryable = "rate_limited", true
|
||||
case errors.Is(err, llm.ErrTimeout):
|
||||
status, e.Code, e.Retryable = http.StatusGatewayTimeout, "timeout", true
|
||||
case errors.Is(err, llm.ErrBadOutput):
|
||||
e.Code = "bad_output"
|
||||
}
|
||||
writeAPIError(w, r, logger, status, e)
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
const (
|
||||
aiPath = "/api/v1/settings/ai"
|
||||
aiKeyPath = "/api/v1/settings/ai/api-key"
|
||||
aiVerifyPath = "/api/v1/settings/ai/verify"
|
||||
aiModelsPath = "/api/v1/settings/ai/models"
|
||||
aiResetPromptPath = "/api/v1/settings/ai/reset-prompt"
|
||||
testAIKey = "sk-test-secret-ai-key-7d21"
|
||||
testWrongAIKey = "sk-test-wrong-ai-key-0b9e"
|
||||
)
|
||||
|
||||
// newAIRouter returns a router with a fresh, seeded AI settings service.
|
||||
func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// newModelsServer is an OpenAI-compatible server whose GET /models accepts
|
||||
// only testAIKey. While echo is set it answers 500 with a body quoting the
|
||||
// Authorization header it received, so a response that passed the vendor
|
||||
// body through unredacted would leak the key. Its model list holds a model
|
||||
// whose ID and name quote the key.
|
||||
func newModelsServer(t *testing.T, echo *atomic.Bool) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
switch {
|
||||
case r.URL.Path != "/v1/models":
|
||||
http.NotFound(w, r)
|
||||
case echo.Load():
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
io.WriteString(w, `{"error":{"message":"upstream failed for `+auth+`"}}`)
|
||||
case auth != "Bearer "+testAIKey:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
io.WriteString(w, `{"error":{"message":"invalid key `+auth+`"}}`)
|
||||
default:
|
||||
io.WriteString(w, `{"data":[{"id":"gpt-test"},{"id":"gpt-test-mini","name":"GPT Test Mini"},`+
|
||||
`{"id":"gpt-`+testAIKey+`","name":"Leaky `+testAIKey+`"}]}`)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func errorCode(body map[string]any) any {
|
||||
e, _ := body["error"].(map[string]any)
|
||||
return e["code"]
|
||||
}
|
||||
|
||||
// TestAISettingsAPI drives every /settings/ai route and scans every
|
||||
// response body for the API keys.
|
||||
func TestAISettingsAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newAIRouter(t)
|
||||
var echo atomic.Bool
|
||||
vendor := newModelsServer(t, &echo)
|
||||
var bodies []string
|
||||
call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
rec, decoded := serve(t, router, signedIn(method, target, r))
|
||||
bodies = append(bodies, rec.Body.String())
|
||||
return rec, decoded
|
||||
}
|
||||
|
||||
// The seed: anthropic, the default persona, no key.
|
||||
rec, body := call(http.MethodGet, aiPath, "")
|
||||
if rec.Code != http.StatusOK || body["provider"] != "anthropic" || body["system_prompt"] != household.DefaultPersonaPrompt ||
|
||||
body["temperature"] != 0.7 || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
|
||||
t.Errorf("GET seed = %d %v", rec.Code, body)
|
||||
}
|
||||
for _, field := range []string{"provider", "base_url", "model", "temperature", "system_prompt", "api_key_last4", "key_verified_at"} {
|
||||
if _, ok := body[field]; !ok {
|
||||
t.Errorf("GET has no %s field: %v", field, body)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{aiVerifyPath, aiModelsPath} {
|
||||
method := http.MethodPost
|
||||
if path == aiModelsPath {
|
||||
method = http.MethodGet
|
||||
}
|
||||
if rec, body := call(method, path, ""); rec.Code != http.StatusConflict || errorCode(body) != "not_configured" {
|
||||
t.Errorf("%s %s without a key = %d %v, want 409 not_configured", method, path, rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
for _, bad := range []string{
|
||||
`{}`,
|
||||
`{"provider":"google"}`,
|
||||
`{"provider":"openai_compatible","base_url":"not a url"}`,
|
||||
`{"base_url":"https://evil.example"}`,
|
||||
`{"temperature":1.1}`,
|
||||
`{"temperature":-0.1}`,
|
||||
`{"temperature":0.25}`,
|
||||
`{"temperature":"0.5"}`,
|
||||
`{"model":""}`,
|
||||
`{"model":null}`,
|
||||
`{"system_prompt":"` + strings.Repeat("é", 8001) + `"}`,
|
||||
`{"system_prompt":"` + strings.Repeat("€", 8001) + `"}`,
|
||||
`{"system_prompt":"` + strings.Repeat("a", maxAISettingsBody) + `"}`,
|
||||
`{"api_key":"` + testAIKey + `"}`,
|
||||
} {
|
||||
if rec, body := call(http.MethodPatch, aiPath, bad); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("PATCH %.80s = %d %v, want 400", bad, rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The longest prompt fits the body limit however its 8,000 characters
|
||||
// are written: three-byte, four-byte, or as \u-escaped surrogate pairs.
|
||||
for _, char := range []string{"€", "🍲", `\ud83c\udf72`} {
|
||||
rec, body := call(http.MethodPatch, aiPath, `{"system_prompt":"`+strings.Repeat(char, 8000)+`"}`)
|
||||
if prompt, _ := body["system_prompt"].(string); rec.Code != http.StatusOK || utf8.RuneCountInString(prompt) != 8000 {
|
||||
t.Errorf("PATCH 8,000 × %s = %d, %d characters", char, rec.Code, utf8.RuneCountInString(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodPatch, aiPath, `{"provider":"openai_compatible","base_url":"`+vendor.URL+`/v1","model":"gpt-test","temperature":0.3,"system_prompt":"`+strings.Repeat("é", 8000)+`"}`)
|
||||
if rec.Code != http.StatusOK || body["provider"] != "openai_compatible" || body["base_url"] != vendor.URL+"/v1" ||
|
||||
body["model"] != "gpt-test" || body["temperature"] != 0.3 {
|
||||
t.Errorf("PATCH openai_compatible = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
for _, bad := range []string{`{}`, `{"api_key":"short"}`, `{"api_key":"sk-line\nbreak"}`, `{"api_key":null}`, `{"key":"` + testAIKey + `"}`} {
|
||||
if rec, body := call(http.MethodPut, aiKeyPath, bad); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("PUT api-key %s = %d %v, want 400", bad, rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected key is a typed auth failure and records nothing.
|
||||
rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testWrongAIKey+`"}`)
|
||||
if rec.Code != http.StatusOK || body["api_key_last4"] != "0b9e" || body["key_verified_at"] != nil {
|
||||
t.Errorf("PUT wrong key = %d %v", rec.Code, body)
|
||||
}
|
||||
if rec, body := call(http.MethodPost, aiVerifyPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" {
|
||||
t.Errorf("POST verify with the wrong key = %d %v, want 502 auth_failed", rec.Code, body)
|
||||
}
|
||||
if rec, body := call(http.MethodGet, aiModelsPath, ""); rec.Code != http.StatusBadGateway || errorCode(body) != "auth_failed" {
|
||||
t.Errorf("GET models with the wrong key = %d %v, want 502 auth_failed", rec.Code, body)
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodPut, aiKeyPath, `{"api_key":"`+testAIKey+`"}`)
|
||||
if rec.Code != http.StatusOK || body["api_key_last4"] != "7d21" {
|
||||
t.Errorf("PUT key = %d %v", rec.Code, body)
|
||||
}
|
||||
if _, ok := body["api_key"]; ok {
|
||||
t.Errorf("PUT response has an api_key field: %v", body)
|
||||
}
|
||||
|
||||
// The vendor quoting the key in an error body: the body excerpt
|
||||
// reaches the response redacted.
|
||||
echo.Store(true)
|
||||
for _, c := range []struct{ method, path string }{{http.MethodPost, aiVerifyPath}, {http.MethodGet, aiModelsPath}} {
|
||||
rec, body := call(c.method, c.path, "")
|
||||
if rec.Code != http.StatusBadGateway || errorCode(body) != "provider_error" || !strings.Contains(rec.Body.String(), "upstream failed for Bearer [REDACTED]") {
|
||||
t.Errorf("%s %s against an echoing vendor = %d %v, want 502 provider_error", c.method, c.path, rec.Code, body)
|
||||
}
|
||||
}
|
||||
echo.Store(false)
|
||||
|
||||
rec, body = call(http.MethodPost, aiVerifyPath, "")
|
||||
if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["api_key_last4"] != "7d21" {
|
||||
t.Errorf("POST verify = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodGet, aiPath, "")
|
||||
if rec.Code != http.StatusOK || body["key_verified_at"] == nil || body["model"] != "gpt-test" || body["temperature"] != 0.3 {
|
||||
t.Errorf("GET after verify = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodGet, aiModelsPath, "")
|
||||
models, _ := body["models"].([]any)
|
||||
if rec.Code != http.StatusOK || len(models) != 3 {
|
||||
t.Fatalf("GET models = %d %v", rec.Code, body)
|
||||
}
|
||||
if m := models[1].(map[string]any); m["id"] != "gpt-test-mini" || m["display_name"] != "GPT Test Mini" {
|
||||
t.Errorf("GET models[1] = %v", m)
|
||||
}
|
||||
if m := models[2].(map[string]any); m["id"] != "gpt-[REDACTED]" || m["display_name"] != "Leaky [REDACTED]" {
|
||||
t.Errorf("GET models[2] = %v, want the key redacted", m)
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodPatch, aiPath, `{"system_prompt":"Be brief."}`)
|
||||
if rec.Code != http.StatusOK || body["system_prompt"] != "Be brief." || body["key_verified_at"] == nil {
|
||||
t.Errorf("PATCH system_prompt = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPost, aiResetPromptPath, "")
|
||||
if rec.Code != http.StatusOK || body["system_prompt"] != household.DefaultPersonaPrompt {
|
||||
t.Errorf("POST reset-prompt = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
// The stored key is never pointed at another server or provider.
|
||||
for _, patch := range []string{`{"base_url":"https://attacker.example/v1"}`, `{"provider":"openai","base_url":""}`} {
|
||||
if rec, body := call(http.MethodPatch, aiPath, patch); rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_settings" {
|
||||
t.Errorf("PATCH %s with a key stored = %d %v, want 400 invalid_settings", patch, rec.Code, body)
|
||||
}
|
||||
}
|
||||
if rec, body := call(http.MethodPut, aiKeyPath, `{"api_key":""}`); rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
|
||||
t.Errorf("PUT empty key = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPatch, aiPath, `{"base_url":"https://llm.example/v1"}`)
|
||||
if rec.Code != http.StatusOK || body["base_url"] != "https://llm.example/v1" {
|
||||
t.Errorf("PATCH base_url without a key = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
if len(bodies) < 30 {
|
||||
t.Fatalf("scanned only %d response bodies", len(bodies))
|
||||
}
|
||||
for _, b := range bodies {
|
||||
for _, key := range []string{testAIKey, testWrongAIKey} {
|
||||
if strings.Contains(b, key) {
|
||||
t.Errorf("response holds an API key: %s", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISettingsWritesNeedCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newAIRouter(t)
|
||||
for _, c := range []struct{ method, path, body string }{
|
||||
{http.MethodPatch, aiPath, `{"temperature":0.5}`},
|
||||
{http.MethodPut, aiKeyPath, `{"api_key":"` + testAIKey + `"}`},
|
||||
{http.MethodPost, aiVerifyPath, ""},
|
||||
{http.MethodPost, aiResetPromptPath, ""},
|
||||
} {
|
||||
req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s without the CSRF header = %d, want 403", c.method, c.path, rec.Code)
|
||||
}
|
||||
}
|
||||
rec, body := serve(t, router, signedIn(http.MethodGet, aiPath, nil))
|
||||
if rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["temperature"] != 0.7 {
|
||||
t.Errorf("GET after refused writes = %d %v, want the seed", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
)
|
||||
|
||||
// maxDinnerBody bounds a POST or PATCH /dinners request body.
|
||||
const maxDinnerBody = 16 << 10
|
||||
|
||||
// maxGroundBody bounds a POST /dinners/{id}/ground body, which may carry a
|
||||
// manual recipe of dinners.MaxManualLines lines of each kind.
|
||||
const maxGroundBody = 512 << 10
|
||||
|
||||
// dinnerCreate is the POST /dinners body: a name, with or without a
|
||||
// recipe_url.
|
||||
type dinnerCreate struct {
|
||||
Name *string `json:"name"`
|
||||
RecipeURL *string `json:"recipe_url"`
|
||||
}
|
||||
|
||||
// dinnerPatch is the PATCH /dinners/{id} body. Every field is optional;
|
||||
// unknown fields and null values are refused.
|
||||
type dinnerPatch struct {
|
||||
Name *string `json:"name"`
|
||||
Method *string `json:"method"`
|
||||
MinutesTotal *int `json:"minutes_total"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
}
|
||||
|
||||
type dinnerList struct {
|
||||
Dinners []dinners.Dinner `json:"dinners"`
|
||||
}
|
||||
|
||||
// dinnerGround is the POST /dinners/{id}/ground body: exactly one of
|
||||
// candidate_id, recipe_url and manual.
|
||||
type dinnerGround struct {
|
||||
CandidateID *string `json:"candidate_id"`
|
||||
RecipeURL *string `json:"recipe_url"`
|
||||
Manual *dinnerManualRecipe `json:"manual"`
|
||||
}
|
||||
|
||||
type dinnerManualRecipe struct {
|
||||
Ingredients []string `json:"ingredients"`
|
||||
Steps []string `json:"steps"`
|
||||
Minutes *int `json:"minutes"`
|
||||
Method *string `json:"method"`
|
||||
}
|
||||
|
||||
type candidateList struct {
|
||||
Candidates []dinners.Candidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// getDinners answers GET /dinners?status=regular|quest_log.
|
||||
func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := svc.List(r.Context(), r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "list dinners", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, dinnerList{Dinners: list})
|
||||
}
|
||||
}
|
||||
|
||||
// postDinner adds a Regular, recording the session's actor as updated_by,
|
||||
// and answers 201 with it and its grounding_job_id without waiting for the
|
||||
// job. When the job cannot be enqueued it still answers 201, with the dinner
|
||||
// grounding_status failed and grounding_job_id null.
|
||||
func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[dinnerCreate](http.MaxBytesReader(w, r.Body, maxDinnerBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
d, err := svc.CreateRegular(r.Context(), dinners.NewRegular(body), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "create dinner", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusCreated, d)
|
||||
}
|
||||
}
|
||||
|
||||
// patchDinner edits a dinner, recording the session's actor as updated_by.
|
||||
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
patch, err := decodePatch[dinnerPatch](http.MaxBytesReader(w, r.Body, maxDinnerBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
d, err := svc.Update(r.Context(), chi.URLParam(r, "id"), dinners.Patch(patch), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "update dinner", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// getDinnerCandidates answers GET /dinners/{id}/recipe-candidates with up
|
||||
// to dinners.MaxCandidates recipe pages for the dinner's name.
|
||||
func getDinnerCandidates(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := svc.Candidates(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "list recipe candidates", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, candidateList{Candidates: list})
|
||||
}
|
||||
}
|
||||
|
||||
// postDinnerGround grounds a dinner by hand in a candidate, a pasted link
|
||||
// or a recipe typed in, recording the session's actor as updated_by.
|
||||
func postDinnerGround(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[dinnerGround](http.MaxBytesReader(w, r.Body, maxGroundBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
fix := dinners.Fix{CandidateID: body.CandidateID, RecipeURL: body.RecipeURL}
|
||||
if body.Manual != nil {
|
||||
m := dinners.ManualRecipe(*body.Manual)
|
||||
fix.Manual = &m
|
||||
}
|
||||
d, err := svc.Ground(r.Context(), chi.URLParam(r, "id"), fix, actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "ground dinner", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteDinner archives a dinner; the row is never deleted.
|
||||
func deleteDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := svc.Archive(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "archive dinner", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// postDinnerFavorite toggles a dinner's is_favorite.
|
||||
func postDinnerFavorite(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := svc.ToggleFavorite(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "toggle dinner favorite", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// actorOrFail returns the session's actor, or answers 500 when the request
|
||||
// has none, which behind requireSession is a wiring bug.
|
||||
func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (auth.Actor, bool) {
|
||||
actor, ok := auth.ActorFrom(r.Context())
|
||||
if !ok {
|
||||
logger.ErrorContext(r.Context(), r.Method+" "+r.URL.Path+" without a session")
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
return actor, ok
|
||||
}
|
||||
|
||||
// writeDinnerError maps a dinners.Service error to its API error. A page
|
||||
// that cannot ground the dinner is 422 ungroundable; recipe search not set
|
||||
// up is 409 not_configured; a search or model failure is 502; grounding by
|
||||
// hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
|
||||
// an archived dinner is 409 dinner_archived and one changed while grounding
|
||||
// 409 ground_conflict. Any other grounding failure, by candidate, link or
|
||||
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
|
||||
// and dinner id only, as is any other failure finding candidates, which
|
||||
// wraps dinners.ErrCandidatesFailed.
|
||||
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var invalid *dinners.ValidationError
|
||||
var dup *dinners.DuplicateError
|
||||
var ungroundable *recipes.ErrUngroundable
|
||||
switch {
|
||||
case errors.As(err, &ungroundable):
|
||||
// The reasons quote dependency errors, so they are neither returned
|
||||
// nor logged.
|
||||
writeError(w, r, logger, http.StatusUnprocessableEntity, "ungroundable", "no usable recipe was found at that link")
|
||||
case errors.Is(err, recipes.ErrSearchNotConfigured):
|
||||
writeError(w, r, logger, http.StatusConflict, "not_configured", "recipe search is not configured")
|
||||
case errors.Is(err, recipes.ErrSearchFailed):
|
||||
// The error may quote the search provider's response, so only the
|
||||
// reason is logged.
|
||||
logger.WarnContext(r.Context(), action, "reason", "search_failed")
|
||||
writeError(w, r, logger, http.StatusBadGateway, "search_failed", "recipe search failed")
|
||||
case errors.Is(err, recipes.ErrNormalizeFailed):
|
||||
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
|
||||
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
|
||||
case errors.Is(err, dinners.ErrGroundFailed):
|
||||
// The error may quote the pasted link, which can carry a personal
|
||||
// token, and dependency errors, so only the reason and dinner are
|
||||
// logged.
|
||||
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
case errors.Is(err, dinners.ErrCandidatesFailed):
|
||||
// The error may quote a candidate's URL, which can carry a
|
||||
// personal token, and dependency errors, so only the reason and
|
||||
// dinner are logged.
|
||||
logger.ErrorContext(r.Context(), action, "reason", "candidates_failed", "dinner_id", chi.URLParam(r, "id"))
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
case errors.Is(err, dinners.ErrArchived):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
|
||||
case errors.Is(err, dinners.ErrGroundConflict):
|
||||
writeError(w, r, logger, http.StatusConflict, "ground_conflict", "the dinner changed while its recipe was grounding; reload and try again")
|
||||
case errors.Is(err, dinners.ErrGroundTimeout):
|
||||
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
|
||||
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_dinner", invalid.Error())
|
||||
case errors.As(err, &dup):
|
||||
writeError(w, r, logger, http.StatusConflict, "duplicate_dinner", dup.Error())
|
||||
case errors.Is(err, dinners.ErrNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"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/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const dinnersPath = "/api/v1/dinners"
|
||||
|
||||
// newDinnersRouter returns a router whose dinners service runs over the
|
||||
// returned store. Its job runner is not started, so grounding jobs stay
|
||||
// queued.
|
||||
func newDinnersRouter(t *testing.T) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
e := newDinnersEnv(t)
|
||||
return e.h, e.st
|
||||
}
|
||||
|
||||
// dinnersEnv is a router whose dinners service, job runner and grounder
|
||||
// share one store, grounding with fake search, pages, images and model.
|
||||
type dinnersEnv struct {
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
runner *jobs.Runner
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
model *llm.Fake
|
||||
// logs holds everything the router and services log.
|
||||
logs *logBuffer
|
||||
}
|
||||
|
||||
// logBuffer is a bytes.Buffer safe for concurrent log writes.
|
||||
type logBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *logBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *logBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
// The Quest log is read against the household's current week.
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &dinnersEnv{
|
||||
st: st,
|
||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
||||
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
||||
model: &llm.Fake{},
|
||||
logs: &logBuffer{},
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(e.logs, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.runner = runner
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// start starts the env's job runner, stopping it when the test ends.
|
||||
func (e *dinnersEnv) start(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// callDinners sends a signed-in request with body (none when empty).
|
||||
func callDinners(t *testing.T, h http.Handler, method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
return serve(t, h, signedIn(method, target, r))
|
||||
}
|
||||
|
||||
// createDinner adds a Regular and returns its id.
|
||||
func createDinner(t *testing.T, h http.Handler, body string) string {
|
||||
t.Helper()
|
||||
rec, got := callDinners(t, h, http.MethodPost, dinnersPath, body)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST %s = %d: %s", body, rec.Code, rec.Body)
|
||||
}
|
||||
return got["id"].(string)
|
||||
}
|
||||
|
||||
// listNames returns the names GET /dinners?status=status lists, in order.
|
||||
func listNames(t *testing.T, h http.Handler, status string) []string {
|
||||
t.Helper()
|
||||
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status="+status, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status=%s = %d: %s", status, rec.Code, rec.Body)
|
||||
}
|
||||
list, ok := body["dinners"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("GET status=%s has no dinners list: %v", status, body)
|
||||
}
|
||||
names := make([]string, len(list))
|
||||
for i, d := range list {
|
||||
names[i] = d.(map[string]any)["name"].(string)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestCreateDinnerByName(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":" Chicken Tikka Masala "}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for k, v := range map[string]any{
|
||||
"name": "Chicken Tikka Masala",
|
||||
"status": "regular",
|
||||
"origin": "regular",
|
||||
"grounding_status": "pending",
|
||||
"is_favorite": false,
|
||||
"photo_url": nil,
|
||||
"method": nil,
|
||||
"minutes_total": nil,
|
||||
"difficulty": nil,
|
||||
"updated_by": testSession.Subject,
|
||||
} {
|
||||
if got, ok := body[k]; !ok || got != v {
|
||||
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
||||
}
|
||||
}
|
||||
if got, ok := body["recipe_url"]; ok {
|
||||
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
|
||||
}
|
||||
var normalized, updatedBy string
|
||||
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if normalized != "chicken tikka masala" || updatedBy != testSession.Subject {
|
||||
t.Errorf("stored normalized_name, updated_by = %q, %q", normalized, updatedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDinnerByURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, _ := newDinnersRouter(t)
|
||||
|
||||
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stir fry night","recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for k, v := range map[string]any{
|
||||
"name": "Stir fry night",
|
||||
"status": "regular",
|
||||
"origin": "regular",
|
||||
"grounding_status": "pending",
|
||||
"updated_by": testSession.Subject,
|
||||
} {
|
||||
if body[k] != v {
|
||||
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
||||
}
|
||||
}
|
||||
// The link stays on the server for grounding; responses never carry it.
|
||||
if got, ok := body["recipe_url"]; ok {
|
||||
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
|
||||
}
|
||||
|
||||
// The same link again is a duplicate even though its name would differ.
|
||||
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef and broccoli","recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
||||
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ name, body string }{
|
||||
{"plain http", `{"name":"Chili","recipe_url":"http://example.com/recipes/chili"}`},
|
||||
{"not a URL", `{"name":"Chili","recipe_url":"chili"}`},
|
||||
{"credentials", `{"name":"Chili","recipe_url":"https://me:pw@example.com/recipes/chili"}`},
|
||||
{"link without a name", `{"recipe_url":"https://example.com/recipes/chili"}`},
|
||||
{"filler name with a link", `{"name":"The Best Easy Recipe","recipe_url":"https://example.com/recipes/chili"}`},
|
||||
{"neither field", `{}`},
|
||||
} {
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
||||
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDinnerResponsesHideRecipeURL: a pasted link may carry a personal
|
||||
// token, so neither the create response nor a later list read shows any
|
||||
// part of it, while the stored link still awaits grounding.
|
||||
func TestDinnerResponsesHideRecipeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
|
||||
const link = "https://c.example/recipes/stew?access_token=personal-token-abc123"
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stew","recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||
}
|
||||
created := rec.Body.String()
|
||||
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", rec.Body.String()}} {
|
||||
for _, secret := range []string{link, "c.example", "access_token", "personal-token-abc123", "recipe_url"} {
|
||||
if strings.Contains(resp.body, secret) {
|
||||
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stored string
|
||||
if err := st.Reader.QueryRow(`SELECT recipe_url FROM dinners WHERE id = ?`, body["id"]).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != link {
|
||||
t.Errorf("stored recipe_url = %q, want %q kept for grounding", stored, link)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDinnerResponsesHideTokenInLinkPath: a personal token can sit
|
||||
// anywhere in a link's path, even as plain letters, so a dinner added from
|
||||
// a link is named only by the household and neither the create response, a
|
||||
// later list read nor the duplicate error for the same link shows any of
|
||||
// the link.
|
||||
func TestDinnerResponsesHideTokenInLinkPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, _ := newDinnersRouter(t)
|
||||
|
||||
const link = "https://c.example/recipes/stew-key-abcdefghijklmno"
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Sunday stew","recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body["name"] != "Sunday stew" {
|
||||
t.Errorf("name = %v, want Sunday stew", body["name"])
|
||||
}
|
||||
created := rec.Body.String()
|
||||
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
listed := rec.Body.String()
|
||||
rec, _ = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef stew","recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("same link again = %d, want 409: %s", rec.Code, rec.Body)
|
||||
}
|
||||
// Each secret holds a character no ULID has (Crockford base32 has no I,
|
||||
// L, O, U, "-", "." or "/"), so the dinner's id never matches one.
|
||||
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
|
||||
lower := strings.ToLower(resp.body)
|
||||
for _, secret := range []string{link, "c.example", "recipes/stew", "stew-key", "abcdefghijklmno"} {
|
||||
if strings.Contains(lower, secret) {
|
||||
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDinnerDuplicate(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
createDinner(t, router, `{"name":"Chicken Tikka Masala"}`)
|
||||
|
||||
for _, name := range []string{
|
||||
"chicken tikka masala",
|
||||
"Easy Chicken Tikka-Masala!",
|
||||
"The Best Homemade Chicken Tikka Masala Recipe",
|
||||
} {
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"`+name+`"}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
||||
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
|
||||
}
|
||||
}
|
||||
// A name repeating the dish is a duplicate with a link too.
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Chicken tikka masala","recipe_url":"https://example.com/other-curry.html"}`)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("named duplicate with a link = %d %v, want 409", rec.Code, body)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := st.Reader.QueryRow(`SELECT count(*) FROM dinners`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("dinners rows = %d, want 1", n)
|
||||
}
|
||||
|
||||
// A different dish sharing words is not a duplicate.
|
||||
createDinner(t, router, `{"name":"Chicken Tikka Wraps"}`)
|
||||
}
|
||||
|
||||
func TestCreateDinnerRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, _ := newDinnersRouter(t)
|
||||
for _, tc := range []struct{ name, body, code string }{
|
||||
{"empty name", `{"name":" "}`, "invalid_dinner"},
|
||||
{"filler only", `{"name":"The Best Easy Recipe"}`, "invalid_dinner"},
|
||||
{"too long", `{"name":"` + strings.Repeat("x", dinners.MaxNameChars+1) + `"}`, "invalid_dinner"},
|
||||
{"null name", `{"name":null}`, "invalid_request"},
|
||||
{"unknown field", `{"name":"Chili","status":"quest_log"}`, "invalid_request"},
|
||||
{"not an object", `"Chili"`, "invalid_request"},
|
||||
} {
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
|
||||
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDinnersFiltersByStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
tacos := createDinner(t, router, `{"name":"Tacos"}`)
|
||||
createDinner(t, router, `{"name":"Lasagna"}`)
|
||||
pho := createDinner(t, router, `{"name":"Pho"}`)
|
||||
gone := createDinner(t, router, `{"name":"Meatloaf"}`)
|
||||
|
||||
ctx := context.Background()
|
||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, pho); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Writer.ExecContext(ctx,
|
||||
`INSERT INTO photos (id, source_url, local_path, width, height, sha256, fetched_at, created_at, updated_at)
|
||||
VALUES ('photo1', 'https://example.com/tacos.jpg', 'media/photos/photo1', 1200, 800, 'abc', 't', 't', 't')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = 'photo1', grounding_status = 'grounded' WHERE id = ?`, tacos); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec, _ := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+gone, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE = %d", rec.Code)
|
||||
}
|
||||
|
||||
if got := strings.Join(listNames(t, router, "regular"), ","); got != "Lasagna,Tacos" {
|
||||
t.Errorf("regular = %s, want Lasagna,Tacos", got)
|
||||
}
|
||||
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Pho" {
|
||||
t.Errorf("quest_log = %s, want Pho", got)
|
||||
}
|
||||
|
||||
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
for _, d := range body["dinners"].([]any) {
|
||||
d := d.(map[string]any)
|
||||
switch d["name"] {
|
||||
case "Tacos":
|
||||
if d["photo_url"] != "/media/photos/photo1-400.jpg" || d["grounding_status"] != "grounded" {
|
||||
t.Errorf("Tacos photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
|
||||
}
|
||||
case "Lasagna":
|
||||
if d["photo_url"] != nil || d["grounding_status"] != "pending" {
|
||||
t.Errorf("Lasagna photo_url, grounding_status = %v, %v", d["photo_url"], d["grounding_status"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty list is [] rather than null.
|
||||
empty, _ := newDinnersRouter(t)
|
||||
rec, _ := callDinners(t, empty, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
if got := rec.Body.String(); got != `{"dinners":[]}` {
|
||||
t.Errorf("empty list = %s", got)
|
||||
}
|
||||
|
||||
for _, q := range []string{"", "?status=archived", "?status=suggested", "?status=bogus"} {
|
||||
rec, body := callDinners(t, router, http.MethodGet, dinnersPath+q, "")
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
||||
t.Errorf("GET %q = %d %v, want 400 invalid_dinner", q, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Quest log leaves out a dinner on the plan for the household's current
|
||||
// week or a later one: it is scheduled already. A dinner on an earlier
|
||||
// week's plan only, or on none, is listed.
|
||||
func TestQuestLogLeavesOutPlannedDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
ctx := context.Background()
|
||||
names := []string{"Bibimbap", "Gumbo", "Pho", "Ramen", "Tagine"}
|
||||
ids := map[string]string{}
|
||||
for _, name := range names {
|
||||
id := createDinner(t, router, `{"name":"`+name+`"}`)
|
||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[name] = id
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
|
||||
// day is the date days after the Monday weeks after this week's.
|
||||
day := func(weeks, days int) string {
|
||||
y, m, d := thisWeek.Date()
|
||||
return time.Date(y, m, d+7*weeks+days, 0, 0, 0, 0, time.UTC).Format(time.DateOnly)
|
||||
}
|
||||
// Gumbo is on last week's plan only; Pho on this week's; Ramen on the
|
||||
// week after next; Tagine on last week's and this week's.
|
||||
plan := func(weeks int, dinners ...string) {
|
||||
t.Helper()
|
||||
weekStart := day(weeks, 0)
|
||||
planID := "plan-" + weekStart
|
||||
if _, err := st.Writer.ExecContext(ctx,
|
||||
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at) VALUES (?, ?, 'draft', 't', 't')`,
|
||||
planID, weekStart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, name := range dinners {
|
||||
if _, err := st.Writer.ExecContext(ctx,
|
||||
`INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, 1, 't', 't')`,
|
||||
planID+"-"+name, planID, day(weeks, i), ids[name]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
plan(-1, "Gumbo", "Tagine")
|
||||
plan(0, "Pho", "Tagine")
|
||||
plan(2, "Ramen")
|
||||
|
||||
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Bibimbap,Gumbo" {
|
||||
t.Errorf("quest_log = %s, want Bibimbap,Gumbo", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
id := createDinner(t, router, `{"name":"Chili"}`)
|
||||
createDinner(t, router, `{"name":"Pot Roast"}`)
|
||||
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id,
|
||||
`{"name":"White Chicken Chili","method":"crockpot","minutes_total":480,"difficulty":"easy"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for k, v := range map[string]any{
|
||||
"name": "White Chicken Chili",
|
||||
"method": "crockpot",
|
||||
"minutes_total": float64(480),
|
||||
"difficulty": "easy",
|
||||
"status": "regular",
|
||||
"updated_by": testSession.Subject,
|
||||
} {
|
||||
if body[k] != v {
|
||||
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
||||
}
|
||||
}
|
||||
var normalized string
|
||||
if err := st.Reader.QueryRow(`SELECT normalized_name FROM dinners WHERE id = ?`, id).Scan(&normalized); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if normalized != "white chicken chili" {
|
||||
t.Errorf("normalized_name = %q", normalized)
|
||||
}
|
||||
|
||||
// A partial patch leaves the other fields.
|
||||
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"difficulty":"medium"}`)
|
||||
if rec.Code != http.StatusOK || body["method"] != "crockpot" || body["difficulty"] != "medium" || body["name"] != "White Chicken Chili" {
|
||||
t.Errorf("partial patch = %d %v", rec.Code, body)
|
||||
}
|
||||
// Renaming to its own name, respelled, is not a duplicate of itself.
|
||||
if rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"Easy white chicken chili"}`); rec.Code != http.StatusOK {
|
||||
t.Errorf("self rename = %d %v", rec.Code, body)
|
||||
}
|
||||
// Renaming onto another dinner is.
|
||||
rec, body = callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, `{"name":"The Best Pot Roast"}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
||||
t.Errorf("rename onto Pot Roast = %d %v, want 409", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchDinnerValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, _ := newDinnersRouter(t)
|
||||
id := createDinner(t, router, `{"name":"Chili"}`)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, body, code, in string
|
||||
}{
|
||||
{"bad method", `{"method":"microwave"}`, "invalid_dinner", "method"},
|
||||
{"crock pot spelling", `{"method":"crock pot"}`, "invalid_dinner", "method"},
|
||||
{"bad difficulty", `{"difficulty":"expert"}`, "invalid_dinner", "difficulty"},
|
||||
{"zero minutes", `{"minutes_total":0}`, "invalid_dinner", "minutes_total"},
|
||||
{"negative minutes", `{"minutes_total":-5}`, "invalid_dinner", "minutes_total"},
|
||||
{"too many minutes", `{"minutes_total":1441}`, "invalid_dinner", "minutes_total"},
|
||||
{"empty name", `{"name":""}`, "invalid_dinner", "name"},
|
||||
{"every problem", `{"method":"x","difficulty":"y","minutes_total":0}`, "invalid_dinner", "method: must be one of stovetop, oven, crockpot, grill, other; minutes_total"},
|
||||
{"empty patch", `{}`, "invalid_dinner", "no fields"},
|
||||
{"fractional minutes", `{"minutes_total":30.5}`, "invalid_request", "minutes_total"},
|
||||
{"string minutes", `{"minutes_total":"30"}`, "invalid_request", "minutes_total"},
|
||||
{"null method", `{"method":null}`, "invalid_request", "method"},
|
||||
{"read-only field", `{"status":"archived"}`, "invalid_request", "status"},
|
||||
{"favorite field", `{"is_favorite":true}`, "invalid_request", "is_favorite"},
|
||||
} {
|
||||
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/"+id, tc.body)
|
||||
msg, _ := body["error"].(map[string]any)["message"].(string)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code || !strings.Contains(msg, tc.in) {
|
||||
t.Errorf("%s: %d %v, want 400 %s containing %q", tc.name, rec.Code, body, tc.code, tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
_, body := callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
d := body["dinners"].([]any)[0].(map[string]any)
|
||||
if d["method"] != nil || d["difficulty"] != nil || d["minutes_total"] != nil || d["name"] != "Chili" {
|
||||
t.Errorf("a rejected patch changed the dinner: %v", d)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, router, http.MethodPatch, dinnersPath+"/nope", `{"method":"oven"}`)
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDinnerArchives(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
id := createDinner(t, router, `{"name":"Shepherd's Pie"}`)
|
||||
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, router, http.MethodDelete, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusOK || body["status"] != "archived" || body["updated_by"] != testSession.Subject {
|
||||
t.Fatalf("DELETE = %d %v", rec.Code, body)
|
||||
}
|
||||
var status, name, updatedBy string
|
||||
if err := st.Reader.QueryRow(`SELECT status, name, updated_by FROM dinners WHERE id = ?`, id).Scan(&status, &name, &updatedBy); err != nil {
|
||||
t.Fatalf("archived row is gone: %v", err)
|
||||
}
|
||||
if status != "archived" || name != "Shepherd's Pie" || updatedBy != testSession.Subject {
|
||||
t.Errorf("row = %s %q %s", status, name, updatedBy)
|
||||
}
|
||||
if got := listNames(t, router, "regular"); len(got) != 0 {
|
||||
t.Errorf("regular after archive = %v", got)
|
||||
}
|
||||
// An archived name may be added again.
|
||||
createDinner(t, router, `{"name":"Shepherds Pie"}`)
|
||||
|
||||
rec, body = callDinners(t, router, http.MethodDelete, dinnersPath+"/nope", "")
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoriteDinnerToggles(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
id := createDinner(t, router, `{"name":"Fish Tacos"}`)
|
||||
if _, err := st.Writer.Exec(`UPDATE dinners SET updated_by = 'someone-else' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, want := range []bool{true, false, true} {
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/"+id+"/favorite", "")
|
||||
if rec.Code != http.StatusOK || body["is_favorite"] != want || body["updated_by"] != testSession.Subject {
|
||||
t.Fatalf("favorite = %d %v, want is_favorite %v", rec.Code, body, want)
|
||||
}
|
||||
}
|
||||
var fav int
|
||||
if err := st.Reader.QueryRow(`SELECT is_favorite FROM dinners WHERE id = ?`, id).Scan(&fav); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fav != 1 {
|
||||
t.Errorf("stored is_favorite = %d, want 1", fav)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath+"/nope/favorite", "")
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("unknown id = %d %v, want 404", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDinnerWritesRequireCSRF sends every dinners write without the CSRF
|
||||
// header and with a wrong one; each is refused and changes nothing.
|
||||
func TestDinnerWritesRequireCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
id := createDinner(t, router, `{"name":"Chili"}`)
|
||||
|
||||
var before string
|
||||
snapshot := `SELECT group_concat(id || name || status || is_favorite || coalesce(method, '') || updated_at, '|') FROM dinners`
|
||||
if err := st.Reader.QueryRow(snapshot).Scan(&before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writes := []struct{ method, target, body string }{
|
||||
{http.MethodPost, dinnersPath, `{"name":"Lasagna"}`},
|
||||
{http.MethodPatch, dinnersPath + "/" + id, `{"method":"oven"}`},
|
||||
{http.MethodDelete, dinnersPath + "/" + id, ""},
|
||||
{http.MethodPost, dinnersPath + "/" + id + "/favorite", ""},
|
||||
}
|
||||
for _, w := range writes {
|
||||
for _, header := range []*string{nil, ptr("wrong-token")} {
|
||||
req := httptest.NewRequest(w.method, w.target, strings.NewReader(w.body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
if header != nil {
|
||||
req.Header.Set(CSRFHeader, *header)
|
||||
}
|
||||
rec, body := serve(t, router, req)
|
||||
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
|
||||
t.Errorf("%s %s (header %v) = %d %v, want 403 csrf", w.method, w.target, header != nil, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var after string
|
||||
if err := st.Reader.QueryRow(snapshot).Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != before {
|
||||
t.Errorf("refused writes changed dinners:\n before %s\n after %s", before, after)
|
||||
}
|
||||
|
||||
// Without a session, the list is 401 too.
|
||||
req := httptest.NewRequest(http.MethodGet, dinnersPath+"?status=regular", nil)
|
||||
if rec, body := serve(t, router, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
|
||||
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -0,0 +1,558 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
)
|
||||
|
||||
// Fixture pages: two curries found by searching "Chicken curry" and a stew
|
||||
// at a pasted link.
|
||||
const (
|
||||
curryPage = "https://a.example/chicken-curry"
|
||||
curry2Page = "https://b.example/curry"
|
||||
stewPage = "https://c.example/recipes/beef-stew"
|
||||
)
|
||||
|
||||
// withRecipes adds the fixture pages, their photos and the search for
|
||||
// "Chicken curry" to e.
|
||||
func (e *dinnersEnv) withRecipes(t *testing.T) *dinnersEnv {
|
||||
t.Helper()
|
||||
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: curryPage}, {URL: curry2Page}}
|
||||
e.pages.Bodies[curryPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[curry2Page] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
|
||||
e.pages.Bodies[stewPage] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150)
|
||||
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
|
||||
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
||||
e.images.JPEGs["https://c.example/stew.jpg"] = recipestest.JPEG(t, 400, 320, 3)
|
||||
return e
|
||||
}
|
||||
|
||||
// waitJob polls GET /jobs/{id} until the job ends and returns its body.
|
||||
func waitJob(t *testing.T, h http.Handler, id string) map[string]any {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
rec, body := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET job = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if s := body["status"]; s == "succeeded" || s == "failed" {
|
||||
return body
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job %s never finished; last %v", id, body)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// listedDinner returns dinner id as GET /dinners?status=regular lists it.
|
||||
func listedDinner(t *testing.T, h http.Handler, id string) map[string]any {
|
||||
t.Helper()
|
||||
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"?status=regular", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET dinners = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, d := range body["dinners"].([]any) {
|
||||
if d := d.(map[string]any); d["id"] == id {
|
||||
return d
|
||||
}
|
||||
}
|
||||
t.Fatalf("dinner %s not listed", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// candidateID returns the candidate_id GET recipe-candidates offers dinner
|
||||
// id for the page titled title.
|
||||
func candidateID(t *testing.T, h http.Handler, id, title string) string {
|
||||
t.Helper()
|
||||
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET candidates = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, c := range body["candidates"].([]any) {
|
||||
if c := c.(map[string]any); c["title"] == title {
|
||||
cid := c["candidate_id"].(string)
|
||||
if strings.Contains(cid, "://") {
|
||||
t.Fatalf("candidate_id = %q, want an opaque id, not the page URL", cid)
|
||||
}
|
||||
return cid
|
||||
}
|
||||
}
|
||||
t.Fatalf("candidates = %v, want one titled %q", body, title)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestPostDinnerReturnsBeforeGroundingCompletes(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
e.search.Gate = make(chan struct{})
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
e.start(t)
|
||||
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
jobID, _ := created["grounding_job_id"].(string)
|
||||
if created["grounding_status"] != "pending" || created["grounding_error"] != nil || jobID == "" {
|
||||
t.Fatalf("created = %v, want pending with a grounding_job_id", created)
|
||||
}
|
||||
// The search is held, so grounding cannot have completed.
|
||||
_, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
|
||||
if s := job["status"]; s != "queued" && s != "running" {
|
||||
t.Fatalf("job right after POST = %v, want queued or running", job)
|
||||
}
|
||||
if d := listedDinner(t, e.h, created["id"].(string)); d["grounding_status"] != "pending" {
|
||||
t.Fatalf("dinner right after POST = %v, want pending", d)
|
||||
}
|
||||
|
||||
close(e.search.Gate)
|
||||
job = waitJob(t, e.h, jobID)
|
||||
if job["status"] != "succeeded" || job["kind"] != "ground_dinner" {
|
||||
t.Fatalf("job = %v, want ground_dinner succeeded", job)
|
||||
}
|
||||
d := listedDinner(t, e.h, created["id"].(string))
|
||||
photo, _ := d["photo_url"].(string)
|
||||
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || !strings.HasSuffix(photo, "-400.jpg") ||
|
||||
d["method"] != "stovetop" || d["minutes_total"] != 40.0 || d["difficulty"] != "easy" || d["grounding_error"] != nil {
|
||||
t.Errorf("dinner = %v, want grounded with recipe, photo, method, minutes and difficulty", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostDinnerGroundingFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
e.start(t)
|
||||
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
job := waitJob(t, e.h, created["grounding_job_id"].(string))
|
||||
if job["status"] != "failed" || job["error"] != "no_recipe_found" {
|
||||
t.Errorf("job = %v, want failed with no_recipe_found", job)
|
||||
}
|
||||
d := listedDinner(t, e.h, created["id"].(string))
|
||||
if d["grounding_status"] != "failed" || d["grounding_error"] != "no_recipe_found" || d["recipe_id"] != nil {
|
||||
t.Errorf("dinner = %v, want failed with no_recipe_found", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecipeCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
list, _ := body["candidates"].([]any)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("candidates = %v, want 2", body)
|
||||
}
|
||||
first := list[0].(map[string]any)
|
||||
if cid, _ := first["candidate_id"].(string); cid == "" || strings.Contains(cid, "a.example") {
|
||||
t.Errorf("candidate_id = %#v, want an opaque id", first["candidate_id"])
|
||||
}
|
||||
want := map[string]any{
|
||||
"candidate_id": first["candidate_id"],
|
||||
"title": "Chicken Curry",
|
||||
"site_name": "Curry House",
|
||||
"photo_url": nil,
|
||||
"minutes": 40.0,
|
||||
}
|
||||
if len(first) != len(want) {
|
||||
t.Errorf("candidate keys = %v, want exactly %v", first, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if got, ok := first[k]; !ok || got != v {
|
||||
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
||||
}
|
||||
}
|
||||
|
||||
rec, body = callDinners(t, e.h, http.MethodGet, dinnersPath+"/nope/recipe-candidates", "")
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecipeCandidatesSearchFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
// The provider's text must reach neither the response nor the logs.
|
||||
const leak = "provider-said-token-abc123"
|
||||
e.search.Err = fmt.Errorf("%w: %s", recipes.ErrSearchRefused, leak)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
if rec.Code != http.StatusBadGateway || errorCode(body) != "search_failed" {
|
||||
t.Errorf("GET = %d %v, want 502 search_failed", rec.Code, body)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), leak) {
|
||||
t.Errorf("response quotes the provider: %s", rec.Body)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, leak) || !strings.Contains(logs, "reason=search_failed") {
|
||||
t.Errorf("logs = %q, want reason=search_failed and no provider text", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRecipeCandidatesStoredLookupFailure: a candidate whose stored
|
||||
// recipe cannot be read, or is stored incomplete, is a 500 logged by reason
|
||||
// and dinner id, never with the candidate's URL, which can carry a personal
|
||||
// token, or the database error.
|
||||
func TestGetRecipeCandidatesStoredLookupFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
token = "personal-token-abc123"
|
||||
host = "secret-candidate.example"
|
||||
page = "https://" + host + "/curry?access_token=" + token
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name, sabotage string
|
||||
leaks []string
|
||||
}{
|
||||
// Renaming a column the lookup selects fails the read.
|
||||
{"lookup", `ALTER TABLE recipes RENAME COLUMN yield TO yield_gone`, []string{"no such column", "yield"}},
|
||||
// A recipe stored without its photo, method or difficulty is
|
||||
// incomplete.
|
||||
{"incomplete", `INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
|
||||
VALUES ('r-incomplete', '` + page + `', 'Curry', '{}', 'now', 'now', 'now')`, []string{"r-incomplete", "no photo"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: page}}
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
||||
t.Errorf("GET = %d %s, want 500 internal", rec.Code, rec.Body)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
for _, secret := range append([]string{page, host, token, "access_token"}, tc.leaks...) {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Errorf("response contains %q: %s", secret, rec.Body)
|
||||
}
|
||||
if strings.Contains(logs, secret) {
|
||||
t.Errorf("logs contain %q: %s", secret, logs)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs, "reason=candidates_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||
t.Errorf("logs = %q, want reason=candidates_failed and the dinner id", logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostGroundViaCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
|
||||
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+cid+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
photo, _ := d["photo_url"].(string)
|
||||
if d["grounding_status"] != "grounded" || d["recipe_id"] == nil || photo == "" ||
|
||||
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["difficulty"] != "easy" || d["updated_by"] != testSession.Subject {
|
||||
t.Errorf("dinner = %v", d)
|
||||
}
|
||||
// The candidate is now stored, so it is offered with its photo.
|
||||
_, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
for _, c := range body["candidates"].([]any) {
|
||||
if c := c.(map[string]any); c["candidate_id"] == cid && c["photo_url"] != photo {
|
||||
t.Errorf("grounded candidate photo_url = %v, want %s", c["photo_url"], photo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecipeCandidatesHideTokenURLs: a candidate page whose URL carries a
|
||||
// token in its path or query is offered by an opaque id alone, which
|
||||
// grounds the dinner in that page.
|
||||
func TestRecipeCandidatesHideTokenURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
const (
|
||||
pathToken = "path-token-abc123"
|
||||
queryToken = "query-token-def456"
|
||||
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
|
||||
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
|
||||
)
|
||||
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: pathPage}, {URL: queryPage}}
|
||||
e.pages.Bodies[pathPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
|
||||
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if list, _ := body["candidates"].([]any); len(list) != 2 {
|
||||
t.Fatalf("candidates = %v, want 2", body)
|
||||
}
|
||||
for _, secret := range []string{pathPage, queryPage, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token"} {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Errorf("candidates response contains %q: %s", secret, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+cid+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), queryToken) {
|
||||
t.Errorf("ground response contains the token: %s", rec.Body)
|
||||
}
|
||||
var source string
|
||||
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = ?`, d["recipe_id"]).Scan(&source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d["grounding_status"] != "grounded" || source != queryPage {
|
||||
t.Errorf("dinner = %v in recipe %s, want grounded in %s", d, source, queryPage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundRejectsCandidateNotOffered: a candidate_id is only one
|
||||
// offered for that dinner; a page URL, or another dinner's candidate, is
|
||||
// refused.
|
||||
func TestPostGroundRejectsCandidateNotOffered(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
||||
other := createDinner(t, e.h, `{"name":"Pad thai"}`)
|
||||
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
||||
for _, tc := range []struct{ name, dinner, cid string }{
|
||||
{"page URL", id, curry2Page},
|
||||
{"another dinner's", other, cid},
|
||||
} {
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+tc.dinner+"/ground", `{"candidate_id":"`+tc.cid+`"}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
||||
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostGroundViaRecipeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
|
||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if d["grounding_status"] != "grounded" || d["photo_url"] == nil || d["method"] != "oven" || d["minutes_total"] != 150.0 {
|
||||
t.Errorf("dinner = %v", d)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"https://gone.example/soup"}`)
|
||||
if rec.Code != http.StatusUnprocessableEntity || errorCode(body) != "ungroundable" || strings.Contains(rec.Body.String(), "gone.example") {
|
||||
t.Errorf("unusable link = %d %v, want 422 ungroundable", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundViaRecipeURLStorageFailure: a pasted link that fails for a
|
||||
// reason other than its recipe is a 500 logged by reason and dinner id,
|
||||
// never with the link, which can carry a personal token, or the dependency
|
||||
// error.
|
||||
func TestPostGroundViaRecipeURLStorageFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
const token = "personal-token-abc123"
|
||||
link := stewPage + "?access_token=" + token
|
||||
e.pages.Bodies[link] = e.pages.Bodies[stewPage]
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+link+`"}`)
|
||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" || strings.Contains(rec.Body.String(), token) {
|
||||
t.Errorf("POST = %d %s, want 500 internal without the link", rec.Code, rec.Body)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, token) || strings.Contains(logs, "c.example") || strings.Contains(logs, "disk full") ||
|
||||
!strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the link or dependency error", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundViaRecipeURLDinnerWriteFailure: a pasted link whose recipe
|
||||
// is stored but whose dinner cannot be written is a 500 logged by reason
|
||||
// and dinner id, never with the dependency error.
|
||||
func TestPostGroundViaRecipeURLDinnerWriteFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`CREATE TRIGGER refuse_grounding BEFORE UPDATE OF recipe_id ON dinners WHEN NEW.recipe_id IS NOT NULL BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
|
||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
||||
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
|
||||
}
|
||||
var stored int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM recipes WHERE source_url = ?`, stewPage).Scan(&stored); err != nil || stored != 1 {
|
||||
t.Fatalf("recipes stored for the link = %d (%v), want 1: the failure must be the dinner write", stored, err)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, "disk full") || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the dependency error", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundUnexpectedFailures: grounding by hand that fails reading
|
||||
// the dinner, or storing a manual recipe, is a 500 logged by reason and
|
||||
// dinner id, never with the database error.
|
||||
func TestPostGroundUnexpectedFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
// Renaming a column every dinner read selects fails the read.
|
||||
breakRead = `ALTER TABLE dinners RENAME COLUMN cuisine TO cuisine_gone`
|
||||
// The trigger's message stands in for the database error.
|
||||
breakStore = `CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`
|
||||
manual = `{"manual":{"ingredients":["1 lb beef"],"steps":["Brown."],"minutes":240,"method":"crockpot"}}`
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name, sabotage, fix, leak string
|
||||
}{
|
||||
{"link read", breakRead, `{"recipe_url":"` + stewPage + `"}`, "cuisine"},
|
||||
{"manual read", breakRead, manual, "cuisine"},
|
||||
{"manual store", breakStore, manual, "disk full"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", tc.fix)
|
||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
||||
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
|
||||
}
|
||||
logs := e.logs.String()
|
||||
if strings.Contains(logs, tc.leak) || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the database error", logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundArchived: grounding an archived dinner by hand is a 409.
|
||||
func TestPostGroundArchived(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef"],"minutes":240,"method":"crockpot"}}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_archived" {
|
||||
t.Errorf("POST = %d %v, want 409 dinner_archived", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostGroundViaManual(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
||||
|
||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if d["grounding_status"] != "manual" || d["recipe_id"] == nil || d["photo_url"] != nil ||
|
||||
d["method"] != "crockpot" || d["minutes_total"] != 240.0 || d["grounding_error"] != nil {
|
||||
t.Errorf("dinner = %v", d)
|
||||
}
|
||||
var lines int
|
||||
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM recipe_ingredients WHERE recipe_id = ?`, d["recipe_id"]).Scan(&lines); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lines != 2 {
|
||||
t.Errorf("stored %d ingredient lines, want 2", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostGroundRejects(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Pad thai"}`)
|
||||
path := dinnersPath + "/" + id + "/ground"
|
||||
|
||||
for _, tc := range []struct{ name, body, code string }{
|
||||
{"empty", `{}`, "invalid_dinner"},
|
||||
{"two kinds", `{"candidate_id":"https://a.example/x","recipe_url":"https://a.example/x"}`, "invalid_dinner"},
|
||||
{"candidate not offered", `{"candidate_id":"c1"}`, "invalid_dinner"},
|
||||
{"candidate a page URL", `{"candidate_id":"https://a.example/x"}`, "invalid_dinner"},
|
||||
{"http link", `{"recipe_url":"http://a.example/x"}`, "invalid_dinner"},
|
||||
{"null", `{"recipe_url":null}`, "invalid_request"},
|
||||
{"unknown field", `{"url":"https://a.example/x"}`, "invalid_request"},
|
||||
{"unknown manual field", `{"manual":{"ingredients":["x"],"minutes":5,"method":"oven","serves":4}}`, "invalid_request"},
|
||||
{"manual without method", `{"manual":{"ingredients":["x"],"minutes":5}}`, "invalid_dinner"},
|
||||
{"manual without ingredients", `{"manual":{"ingredients":[],"minutes":5,"method":"oven"}}`, "invalid_dinner"},
|
||||
{"manual minutes too many", `{"manual":{"ingredients":["x"],"minutes":5000,"method":"oven"}}`, "invalid_dinner"},
|
||||
{"not an object", `[]`, "invalid_request"},
|
||||
} {
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, path, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != tc.code {
|
||||
t.Errorf("%s: %d %v, want 400 %s", tc.name, rec.Code, body, tc.code)
|
||||
}
|
||||
}
|
||||
if d := listedDinner(t, e.h, id); d["grounding_status"] != "pending" {
|
||||
t.Errorf("dinner after rejected fixes = %v, want unchanged", d)
|
||||
}
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/nope/ground",
|
||||
`{"manual":{"ingredients":["x"],"minutes":5,"method":"oven"}}`)
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("unknown dinner = %d %v, want 404", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := created["id"].(string)
|
||||
if id == "" || created["grounding_status"] != "failed" || created["grounding_error"] != "grounding_error" || created["grounding_job_id"] != nil {
|
||||
t.Fatalf("created = %v, want its id, failed (grounding_error) and a null grounding_job_id", created)
|
||||
}
|
||||
if got := listedDinner(t, e.h, id); got["grounding_status"] != "failed" {
|
||||
t.Errorf("listed = %v, want failed", got)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@ type jobCreated struct {
|
||||
}
|
||||
|
||||
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
|
||||
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
|
||||
// that is not service-only and returns 202 with its id, which
|
||||
// GET /api/v1/jobs/{id} reports on.
|
||||
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
|
||||
@@ -43,6 +44,9 @@ func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
case errors.Is(err, jobs.ErrUnknownKind):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
|
||||
return
|
||||
case errors.Is(err, jobs.ErrServiceOnly):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "service_only_kind", "job kind is started only by its own endpoint")
|
||||
return
|
||||
case errors.Is(err, jobs.ErrInvalidPayload):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
|
||||
return
|
||||
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// maxPlanBody bounds a POST /plans/{weekStart}/draft or PUT
|
||||
// /plans/{weekStart}/days/{date} body.
|
||||
const maxPlanBody = 4 << 10
|
||||
|
||||
// planDraft is the POST /plans/{weekStart}/draft body. confirm is needed
|
||||
// to draft over a plan pushed to Calendar.
|
||||
type planDraft struct {
|
||||
Confirm *bool `json:"confirm"`
|
||||
}
|
||||
|
||||
// planDaySwap is the PUT /plans/{weekStart}/days/{date} body.
|
||||
type planDaySwap struct {
|
||||
DinnerID *string `json:"dinner_id"`
|
||||
}
|
||||
|
||||
// getPlan answers GET /plans/{weekStart} with the week's plan.
|
||||
func getPlan(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, err := svc.Get(r.Context(), week)
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "read plan", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, p)
|
||||
}
|
||||
}
|
||||
|
||||
// postPlanDraft enqueues the draft of the week and answers 202 with the
|
||||
// jobId that GET /jobs/{id} reports on, without waiting for the job. When
|
||||
// a draft of the week is already queued or running, it answers 202 with
|
||||
// that job instead, or 409 when this draft is confirmed and that one is
|
||||
// not.
|
||||
func postPlanDraft(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[planDraft](http.MaxBytesReader(w, r.Body, maxPlanBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
d, err := svc.StartDraft(r.Context(), week, body.Confirm != nil && *body.Confirm)
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "start plan draft", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, d)
|
||||
}
|
||||
}
|
||||
|
||||
// putPlanDay swaps the dinner of one day of the week's plan and answers
|
||||
// with the plan as swapped.
|
||||
func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[planDaySwap](http.MaxBytesReader(w, r.Body, maxPlanBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if body.DinnerID == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "dinner_id: required")
|
||||
return
|
||||
}
|
||||
p, err := svc.Swap(r.Context(), week, chi.URLParam(r, "date"), *body.DinnerID)
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "swap plan day", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, p)
|
||||
}
|
||||
}
|
||||
|
||||
// weekStartOrFail parses the {weekStart} path parameter, or answers 400
|
||||
// when it is not a Monday date.
|
||||
func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) {
|
||||
week, err := plan.ParseWeekStart(chi.URLParam(r, "weekStart"))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", err.Error())
|
||||
return store.LocalDate{}, false
|
||||
}
|
||||
return week, true
|
||||
}
|
||||
|
||||
// writePlanError maps a plan.Service error to its API error: a week with
|
||||
// no plan or an unknown dinner is 404, a date outside the week is 400, and
|
||||
// drafting over a synced plan without confirm, a confirmed draft while an
|
||||
// unconfirmed one runs, planning a dinner that is neither a Regular nor in
|
||||
// the Quest log, or one already on another day of the week, is 409.
|
||||
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var day *plan.InvalidDayError
|
||||
var week *plan.InvalidWeekError
|
||||
var notPlannable *plan.DinnerNotPlannableError
|
||||
switch {
|
||||
case errors.As(err, &day):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_date", day.Error())
|
||||
case errors.As(err, &week):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
|
||||
case errors.As(err, ¬Plannable):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
|
||||
case errors.Is(err, plan.ErrDinnerAlreadyPlanned):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_already_planned", err.Error())
|
||||
case errors.Is(err, plan.ErrConfirmRequired):
|
||||
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
|
||||
case errors.Is(err, plan.ErrDraftRunning):
|
||||
writeError(w, r, logger, http.StatusConflict, "draft_running", err.Error())
|
||||
case errors.Is(err, plan.ErrNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
|
||||
case errors.Is(err, plan.ErrDinnerNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"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/db"
|
||||
"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/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const plansPath = "/api/v1/plans/"
|
||||
|
||||
// plansEnv is a router whose plan service has an unstarted job runner, so
|
||||
// drafts stay queued, over a store holding a draft plan of the week of
|
||||
// 2026-10-12: Beef Chili (crock pot, 240 minutes) on Monday and a Regular
|
||||
// on every other day. questLog is the id of a Quest log dinner on no plan.
|
||||
type plansEnv struct {
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
svc *plan.Service
|
||||
questLog string
|
||||
}
|
||||
|
||||
func newPlansRouter(t *testing.T) plansEnv {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
ctx := t.Context()
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := plan.NewService(st, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
q := db.New(st.Writer)
|
||||
now := store.Now().String()
|
||||
dinner := func(name, status, method string, minutes int64) string {
|
||||
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: "regular",
|
||||
Method: sql.NullString{String: method, Valid: method != ""},
|
||||
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12",
|
||||
AiNote: sql.NullString{String: "Mostly Regulars.", Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, date := range []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} {
|
||||
id := dinner("Regular "+date, "regular", "stovetop", 30)
|
||||
if i == 0 {
|
||||
id = dinner("Beef Chili", "regular", "crockpot", 240)
|
||||
}
|
||||
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
|
||||
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
|
||||
}
|
||||
|
||||
func (e plansEnv) setStatus(t *testing.T, status string) {
|
||||
t.Helper()
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?`, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-12", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET plan = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body["status"] != "draft" || body["week_start"] != "2026-10-12" || body["week_label"] != "Week 42 · Oct 12 – 18" ||
|
||||
body["ai_note"] != "Mostly Regulars." || body["calendar_synced_at"] != nil {
|
||||
t.Errorf("plan = %v", body)
|
||||
}
|
||||
days, _ := body["days"].([]any)
|
||||
if len(days) != 7 {
|
||||
t.Fatalf("days = %v, want 7", body["days"])
|
||||
}
|
||||
monday := days[0].(map[string]any)
|
||||
if monday["date"] != "2026-10-12" || monday["is_new"] != false || monday["start_by"] != "2026-10-12T13:30:00-04:00" {
|
||||
t.Errorf("Monday = %v", monday)
|
||||
}
|
||||
d := monday["dinner"].(map[string]any)
|
||||
for _, key := range []string{"id", "name", "photo", "minutes", "method", "origin"} {
|
||||
if _, ok := d[key]; !ok {
|
||||
t.Errorf("dinner has no %s: %v", key, d)
|
||||
}
|
||||
}
|
||||
if d["name"] != "Beef Chili" || d["minutes"] != 240.0 || d["method"] != "crockpot" || d["origin"] != "regular" || d["photo"] != nil {
|
||||
t.Errorf("Monday dinner = %v", d)
|
||||
}
|
||||
if tuesday := days[1].(map[string]any); tuesday["start_by"] != nil {
|
||||
t.Errorf("Tuesday start_by = %v, want null for a stovetop dinner", tuesday["start_by"])
|
||||
}
|
||||
|
||||
rec, body = callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-19", "")
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("GET week with no plan = %d %v, want 404 not_found", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanWeekStartMustBeMonday(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
|
||||
for _, tc := range []struct{ method, target, body string }{
|
||||
{http.MethodGet, plansPath + "2026-10-13", ""},
|
||||
{http.MethodGet, plansPath + "not-a-date", ""},
|
||||
{http.MethodGet, plansPath + "2026-02-30", ""},
|
||||
{http.MethodPost, plansPath + "2027-02-29/draft", "{}"},
|
||||
{http.MethodPost, plansPath + "2026-10-18/draft", "{}"},
|
||||
{http.MethodPut, plansPath + "2026-10-14/days/2026-10-14", `{"dinner_id":"` + e.questLog + `"}`},
|
||||
} {
|
||||
rec, body := callDinners(t, e.h, tc.method, tc.target, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_week" {
|
||||
t.Errorf("%s %s = %d %v, want 400 invalid_week", tc.method, tc.target, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostPlanDraft(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}")
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
jobID, _ := body["jobId"].(string)
|
||||
if jobID == "" || len(body) != 1 {
|
||||
t.Fatalf("POST draft = %v, want only a jobId", body)
|
||||
}
|
||||
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
|
||||
if rec.Code != http.StatusOK || job["kind"] != plan.KindDraftWeek || job["status"] != "queued" {
|
||||
t.Errorf("GET job = %d %v, want a queued %s job", rec.Code, job, plan.KindDraftWeek)
|
||||
}
|
||||
// A second draft of the week joins the queued one.
|
||||
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":false}`)
|
||||
if rec.Code != http.StatusAccepted || body["jobId"] != jobID {
|
||||
t.Errorf("second POST draft = %d %v, want 202 with job %s", rec.Code, body, jobID)
|
||||
}
|
||||
// A week with no plan needs no confirm.
|
||||
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-19/draft", "{}")
|
||||
if rec.Code != http.StatusAccepted || body["jobId"] == jobID {
|
||||
t.Errorf("POST draft of a new week = %d %v, want 202 with its own job", rec.Code, body)
|
||||
}
|
||||
|
||||
// A confirmed draft does not join the unconfirmed one and enqueues
|
||||
// nothing.
|
||||
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "draft_running" {
|
||||
t.Errorf("confirmed POST draft = %d %v, want 409 draft_running", rec.Code, body)
|
||||
}
|
||||
var queued int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queued != 2 {
|
||||
t.Errorf("%d draft jobs, want one per week", queued)
|
||||
}
|
||||
|
||||
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":"yes"}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("POST draft with a bad confirm = %d %v, want 400", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDraftWeekNotEnqueuedThroughJobs checks POST /api/v1/jobs refuses a
|
||||
// draft_week job, so a confirmed draft cannot run beside the week's
|
||||
// unconfirmed one: POST /plans/{weekStart}/draft is the only way to start
|
||||
// a draft.
|
||||
func TestDraftWeekNotEnqueuedThroughJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
|
||||
rec, _ := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}")
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, "/api/v1/jobs",
|
||||
`{"kind":"`+plan.KindDraftWeek+`","payload":{"week_start":"2026-10-12","confirm":true}}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
||||
t.Errorf("POST job %s = %d %v, want 400 service_only_kind", plan.KindDraftWeek, rec.Code, body)
|
||||
}
|
||||
var queued int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queued != 1 {
|
||||
t.Errorf("%d draft jobs, want only the one POST draft enqueued", queued)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimultaneousPostPlanDraftsShareOneJob(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
|
||||
const n = 10
|
||||
recs := make([]*httptest.ResponseRecorder, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
wg.Go(func() {
|
||||
recs[i] = httptest.NewRecorder()
|
||||
e.h.ServeHTTP(recs[i], signedIn(http.MethodPost, plansPath+"2026-10-12/draft", strings.NewReader("{}")))
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
jobIDs := map[any]int{}
|
||||
for i, rec := range recs {
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST draft %d = %d %s, want 202", i, rec.Code, rec.Body)
|
||||
}
|
||||
jobIDs[body["jobId"]]++
|
||||
}
|
||||
var queued int
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(jobIDs) != 1 || jobIDs[nil] != 0 || queued != 1 {
|
||||
t.Errorf("jobIds = %v, %d draft jobs; want one jobId for all %d drafts", jobIDs, queued, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostPlanDraftOverSyncedPlanNeedsConfirm(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, status := range []string{"synced", "changed"} {
|
||||
e := newPlansRouter(t)
|
||||
e.setStatus(t, status)
|
||||
for _, b := range []string{"{}", `{"confirm":false}`} {
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", b)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "confirm_required" {
|
||||
t.Errorf("%s plan: POST draft %s = %d %v, want 409 confirm_required", status, b, rec.Code, body)
|
||||
}
|
||||
}
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
|
||||
if rec.Code != http.StatusAccepted || body["jobId"] == nil {
|
||||
t.Errorf("%s plan: POST draft with confirm = %d %v, want 202", status, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutPlanDay(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.setStatus(t, "synced")
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT day = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body["status"] != "changed" {
|
||||
t.Errorf("status = %v, want a swapped synced plan changed", body["status"])
|
||||
}
|
||||
wednesday := body["days"].([]any)[2].(map[string]any)
|
||||
if wednesday["date"] != "2026-10-14" || wednesday["is_new"] != true || wednesday["dinner"].(map[string]any)["name"] != "Pad Thai" {
|
||||
t.Errorf("Wednesday = %v, want Pad Thai, new", wednesday)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
target, body string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"2026-10-12/days/2026-10-14", `{}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"2026-10-12/days/2026-10-14", `{"dinner_id":7}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"2026-10-12/days/2026-10-14", `{"dinner_id":"x","extra":1}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"2026-10-12/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
|
||||
{"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
|
||||
{"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"},
|
||||
{"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"},
|
||||
{"2026-10-12/days/2026-10-15", `{"dinner_id":"` + e.questLog + `"}`, http.StatusConflict, "dinner_already_planned"},
|
||||
} {
|
||||
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body)
|
||||
if rec.Code != tc.status || errorCode(body) != tc.code {
|
||||
t.Errorf("PUT %s %s = %d %v, want %d %s", tc.target, tc.body, rec.Code, body, tc.status, tc.code)
|
||||
}
|
||||
}
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.questLog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, body = callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-15", `{"dinner_id":"`+e.questLog+`"}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_not_plannable" {
|
||||
t.Errorf("PUT archived dinner = %d %v, want 409 dinner_not_plannable", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
rec, body := call(http.MethodGet, recipeSearchPath, "")
|
||||
if rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false {
|
||||
if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil {
|
||||
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPost, recipeSearchTestPath, "")
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
)
|
||||
|
||||
// maxRotationReviewBody bounds a POST /dinners/{id}/rotation-review body.
|
||||
const maxRotationReviewBody = 1 << 10
|
||||
|
||||
// rotationDecision is the POST /dinners/{id}/rotation-review body:
|
||||
// keep, drop ("Not for us") or not_made ("We didn't make it").
|
||||
type rotationDecision struct {
|
||||
Decision *string `json:"decision"`
|
||||
}
|
||||
|
||||
type rotationReviewList struct {
|
||||
Reviews []plan.Review `json:"reviews"`
|
||||
}
|
||||
|
||||
// getRotationReviews answers GET /rotation-reviews with the pending
|
||||
// Rotation checks, oldest plan date first.
|
||||
func getRotationReviews(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := svc.PendingReviews(r.Context())
|
||||
if err != nil {
|
||||
writeRotationError(w, r, logger, "list Rotation checks", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, rotationReviewList{Reviews: list})
|
||||
}
|
||||
}
|
||||
|
||||
// postRotationReview applies the household's answer to "Keep it in
|
||||
// rotation?" for a tried dinner, recording the session's actor as
|
||||
// updated_by, and answers with the Rotation check as decided.
|
||||
func postRotationReview(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[rotationDecision](http.MaxBytesReader(w, r.Body, maxRotationReviewBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if body.Decision == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "decision: required")
|
||||
return
|
||||
}
|
||||
d, err := svc.DecideReview(r.Context(), chi.URLParam(r, "id"), *body.Decision, actor.Subject)
|
||||
if err != nil {
|
||||
writeRotationError(w, r, logger, "decide Rotation check", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// writeRotationError maps a Rotation check error to its API error: a
|
||||
// decision other than keep, drop or not_made is 400, an unknown dinner or
|
||||
// a dinner never checked 404, and a dinner whose checks are all decided
|
||||
// (such as a second decision) or no longer tried 409.
|
||||
func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var invalid *plan.InvalidDecisionError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_decision", invalid.Error())
|
||||
case errors.Is(err, plan.ErrDinnerNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
||||
case errors.Is(err, plan.ErrReviewNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "rotation check not found")
|
||||
case errors.Is(err, plan.ErrNoPendingReview):
|
||||
writeError(w, r, logger, http.StatusConflict, "no_pending_review", err.Error())
|
||||
case errors.Is(err, plan.ErrDinnerNotTried):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_not_tried", err.Error())
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const rotationReviewsPath = "/api/v1/rotation-reviews"
|
||||
|
||||
// triedPadThai plans the Quest log dinner Pad Thai on Wednesday 2026-10-14
|
||||
// and marks the day tried, so Pad Thai has a pending Rotation check.
|
||||
func triedPadThai(t *testing.T, e plansEnv) {
|
||||
t.Helper()
|
||||
rec, _ := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("swap Pad Thai in = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
day, err := store.ParseLocalDate("2026-10-14")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := e.svc.MarkTried(t.Context(), day); err != nil || n != 1 {
|
||||
t.Fatalf("MarkTried = %d, %v, want 1", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRotationReviews(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if reviews, ok := body["reviews"].([]any); !ok || len(reviews) != 0 {
|
||||
t.Errorf("GET before MarkTried = %v, want an empty reviews list", body)
|
||||
}
|
||||
|
||||
triedPadThai(t, e)
|
||||
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
reviews, _ := body["reviews"].([]any)
|
||||
if len(reviews) != 1 {
|
||||
t.Fatalf("reviews = %v, want one", body)
|
||||
}
|
||||
r := reviews[0].(map[string]any)
|
||||
d, _ := r["dinner"].(map[string]any)
|
||||
if len(r) != 2 || r["plan_date"] != "2026-10-14" || len(d) != 3 ||
|
||||
d["id"] != e.questLog || d["name"] != "Pad Thai" || d["photo"] != nil {
|
||||
t.Errorf("review = %v, want {dinner {id, name, photo}, plan_date}", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostRotationReview(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ decision, status string }{
|
||||
{"keep", "regular"},
|
||||
{"drop", "archived"},
|
||||
{"not_made", "quest_log"},
|
||||
} {
|
||||
t.Run(tc.decision, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
triedPadThai(t, e)
|
||||
target := dinnersPath + "/" + e.questLog + "/rotation-review"
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, target, `{"decision":"`+tc.decision+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body["decision"] != tc.decision || body["dinner_status"] != tc.status || body["plan_date"] != "2026-10-14" ||
|
||||
body["updated_by"] != testSession.Subject || body["decided_at"] == "" {
|
||||
t.Errorf("decided = %v", body)
|
||||
}
|
||||
var status, origin, updatedBy string
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status, origin, updated_by FROM dinners WHERE id = ?`, e.questLog).
|
||||
Scan(&status, &origin, &updatedBy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != tc.status || origin != "regular" || updatedBy != testSession.Subject {
|
||||
t.Errorf("dinner = %s %s %s, want %s regular %s", status, origin, updatedBy, tc.status, testSession.Subject)
|
||||
}
|
||||
|
||||
rec, body = callDinners(t, e.h, http.MethodPost, target, `{"decision":"keep"}`)
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "no_pending_review" {
|
||||
t.Errorf("second decision = %d %v, want 409 no_pending_review", rec.Code, body)
|
||||
}
|
||||
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
|
||||
if reviews, _ := body["reviews"].([]any); rec.Code != http.StatusOK || len(reviews) != 0 {
|
||||
t.Errorf("GET after deciding = %d %v, want no reviews", rec.Code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A dinner that has never had a Rotation check is 404; a second decision
|
||||
// on one already decided is 409 (TestPostRotationReview).
|
||||
func TestPostRotationReviewNeverChecked(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+e.questLog+"/rotation-review", `{"decision":"keep"}`)
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("never checked = %d %v, want 404 not_found", rec.Code, body)
|
||||
}
|
||||
var status string
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "quest_log" {
|
||||
t.Errorf("status after a refused decision = %s, want quest_log", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostRotationReviewRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
triedPadThai(t, e)
|
||||
target := dinnersPath + "/" + e.questLog + "/rotation-review"
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, target, body string
|
||||
code int
|
||||
errCode string
|
||||
}{
|
||||
{"no decision", target, `{}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"null decision", target, `{"decision":null}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"unknown field", target, `{"decision":"keep","status":"regular"}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"unknown decision", target, `{"decision":"promote"}`, http.StatusBadRequest, "invalid_decision"},
|
||||
{"unknown dinner", dinnersPath + "/nope/rotation-review", `{"decision":"keep"}`, http.StatusNotFound, "not_found"},
|
||||
} {
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, tc.target, tc.body)
|
||||
if rec.Code != tc.code || errorCode(body) != tc.errCode {
|
||||
t.Errorf("%s = %d %v, want %d %s", tc.name, rec.Code, body, tc.code, tc.errCode)
|
||||
}
|
||||
}
|
||||
var status string
|
||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "tried" {
|
||||
t.Errorf("status after refused decisions = %s, want tried", status)
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,15 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"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/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
// reservedPrefixes are owned by the API router and are never answered with the
|
||||
@@ -31,18 +35,40 @@ type RouteMounter interface {
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; settings serves /api/v1/settings/household; search serves
|
||||
// /api/v1/settings/recipe-search and its /test; runner serves
|
||||
// /api/v1/settings/recipe-search and its /test; ai serves
|
||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
||||
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
|
||||
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
||||
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
|
||||
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
|
||||
// /api/v1/plans/{weekStart}/draft, PUT
|
||||
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
||||
// POST /api/v1/dinners/{id}/rotation-review; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
if search == nil {
|
||||
return nil, errors.New("server: nil recipe search settings service")
|
||||
}
|
||||
if ai == nil {
|
||||
return nil, errors.New("server: nil AI settings service")
|
||||
}
|
||||
if dinnerSvc == nil {
|
||||
return nil, errors.New("server: nil dinners service")
|
||||
}
|
||||
if suggestSvc == nil {
|
||||
return nil, errors.New("server: nil suggestions service")
|
||||
}
|
||||
if planSvc == nil {
|
||||
return nil, errors.New("server: nil plan service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -80,6 +106,28 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
|
||||
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
|
||||
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
|
||||
apiV1.Get("/settings/ai", getAISettings(logger, ai))
|
||||
apiV1.Patch("/settings/ai", patchAISettings(logger, ai))
|
||||
apiV1.Put("/settings/ai/api-key", putAIKey(logger, ai))
|
||||
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
|
||||
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
|
||||
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
||||
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
||||
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
|
||||
apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc))
|
||||
apiV1.Post("/suggestions/batches", postSuggestionBatch(logger, suggestSvc))
|
||||
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
|
||||
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
|
||||
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
|
||||
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
|
||||
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
||||
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
||||
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
|
||||
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
+137
-12
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
@@ -16,11 +17,18 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"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/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -42,7 +50,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -131,24 +139,114 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache {
|
||||
// configured, over a migrated temp database and a random secret box.
|
||||
func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
search, err := recipes.NewSearchSettingsService(newTestStore(t), box)
|
||||
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return search
|
||||
}
|
||||
|
||||
// newTestDinners returns a dinners service over a migrated temp database,
|
||||
// with its own unstarted job runner and a grounder that finds nothing.
|
||||
func newTestDinners(t *testing.T) *dinners.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestSuggest returns a suggestions service over a migrated temp
|
||||
// database, with its own unstarted job runner, a grounder that finds
|
||||
// nothing and a suggester that always fails.
|
||||
func newTestSuggest(t *testing.T) *suggest.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestPlan returns a plan service over a migrated temp database, with
|
||||
// its own unstarted job runner and a drafter that always fails.
|
||||
func newTestPlan(t *testing.T) *plan.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := plan.NewService(st, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestBox returns a secret box under a random key.
|
||||
func newTestBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
// newTestAI returns an AI settings service over a migrated temp database
|
||||
// with the seed AI settings and a random secret box.
|
||||
func newTestAI(t *testing.T) *aisettings.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedAISettings(context.Background(), st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ai, err := aisettings.New(st, newTestBox(t), "https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ai
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -157,7 +255,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -166,16 +264,43 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,21 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
// maxSettingsBody bounds a PATCH /settings/household or
|
||||
// /settings/recipe-search request body.
|
||||
// /settings/recipe-search, or PUT /settings/ai/api-key, request body.
|
||||
const maxSettingsBody = 16 << 10
|
||||
|
||||
// maxAISettingsBody bounds a PATCH /settings/ai request body. It fits the
|
||||
// longest valid system prompt at 12 bytes of JSON per character (a
|
||||
// \u-escaped surrogate pair) on top of maxSettingsBody for the other
|
||||
// fields, so only aisettings.MaxSystemPromptChars limits the prompt.
|
||||
const maxAISettingsBody = maxSettingsBody + 12*aisettings.MaxSystemPromptChars
|
||||
|
||||
// settingsPatch is the PATCH /settings/household body. Every field is
|
||||
// optional; unknown fields and null values are refused.
|
||||
type settingsPatch struct {
|
||||
@@ -30,6 +37,9 @@ type settingsPatch struct {
|
||||
SuggestionsTime *string `json:"suggestions_time"`
|
||||
ChatAutosendDow *int `json:"chat_autosend_dow"`
|
||||
ChatAutosendTime *string `json:"chat_autosend_time"`
|
||||
// OnboardingCompleted true sets onboarding_completed_at, which is
|
||||
// itself read-only.
|
||||
OnboardingCompleted *bool `json:"onboarding_completed"`
|
||||
}
|
||||
|
||||
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||
@@ -103,7 +113,7 @@ func decodePatch[T any](body io.Reader) (T, error) {
|
||||
if errors.As(err, &typeErr) {
|
||||
return patch, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
|
||||
}
|
||||
return patch, fmt.Errorf("invalid settings: %v", err)
|
||||
return patch, fmt.Errorf("invalid request body: %v", err)
|
||||
}
|
||||
return patch, nil
|
||||
}
|
||||
@@ -124,13 +134,18 @@ func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, stat
|
||||
|
||||
// writeError responds with status and a non-retryable API error.
|
||||
func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) {
|
||||
body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}})
|
||||
writeAPIError(w, r, logger, status, apiError{Code: code, Message: message})
|
||||
}
|
||||
|
||||
// writeAPIError responds with status and e.
|
||||
func writeAPIError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, e apiError) {
|
||||
body, err := json.Marshal(errorBody{Error: e})
|
||||
if err != nil {
|
||||
panic(err) // string and bool fields cannot fail to marshal
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
|
||||
logger.WarnContext(r.Context(), "write error response", "code", e.Code, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"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"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
@@ -115,6 +117,63 @@ func TestPatchHouseholdSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newOnboardingRouter returns a router whose settings store holds regulars
|
||||
// Regulars.
|
||||
func newOnboardingRouter(t *testing.T, regulars int) http.Handler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range regulars {
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: fmt.Sprintf("Dinner %d", i), NormalizedName: fmt.Sprintf("dinner %d", i),
|
||||
Status: "regular", Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return newTestRouterWith(t, "dev", household.NewSettingsService(st), newTestRunner(t))
|
||||
}
|
||||
|
||||
func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec, body := serve(t, newOnboardingRouter(t, household.MinRegulars), signedIn(http.MethodPatch, settingsPath,
|
||||
strings.NewReader(`{"onboarding_completed":true}`)))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if at, ok := body["onboarding_completed_at"].(string); !ok || at != body["updated_at"] {
|
||||
t.Errorf("onboarding_completed_at = %#v, want the update time %#v", body["onboarding_completed_at"], body["updated_at"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatchHouseholdSettingsOnboardingNeedsRegulars: completing onboarding
|
||||
// with fewer than three Regulars is refused and leaves it incomplete.
|
||||
func TestPatchHouseholdSettingsOnboardingNeedsRegulars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for regulars := range household.MinRegulars {
|
||||
t.Run(fmt.Sprintf("%d Regulars", regulars), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newOnboardingRouter(t, regulars)
|
||||
rec, _ := serve(t, router, signedIn(http.MethodPatch, settingsPath,
|
||||
strings.NewReader(`{"onboarding_completed":true}`)))
|
||||
if rec.Code != http.StatusBadRequest ||
|
||||
!strings.Contains(rec.Body.String(), `"code":"invalid_settings"`) ||
|
||||
!strings.Contains(rec.Body.String(), "onboarding_completed: add at least 3 Regulars first") {
|
||||
t.Fatalf("PATCH = %d %s, want 400 invalid_settings on onboarding_completed", rec.Code, rec.Body)
|
||||
}
|
||||
_, body := serve(t, router, signedIn(http.MethodGet, settingsPath, nil))
|
||||
if body["onboarding_completed_at"] != nil {
|
||||
t.Errorf("onboarding_completed_at = %#v after a refused completion, want null", body["onboarding_completed_at"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -136,6 +195,8 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
|
||||
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
|
||||
{"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"},
|
||||
{"read-only onboarding time", `{"onboarding_completed_at":"2026-10-06T12:00:00.000Z"}`, "invalid_request", "onboarding_completed_at"},
|
||||
{"onboarding undone", `{"onboarding_completed":false}`, "invalid_settings", "onboarding_completed"},
|
||||
{"not an object", `["timezone"]`, "invalid_request", "JSON object"},
|
||||
{"null body", `null`, "invalid_request", "JSON object"},
|
||||
{"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"},
|
||||
@@ -216,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
// postSuggestionBatch starts a manual suggestion batch and answers 202 with
|
||||
// its batch_id and the job_id that GET /jobs/{id} reports on, without
|
||||
// waiting for the job. When a batch is already pending or running it
|
||||
// answers 202 with that batch instead of starting another.
|
||||
func postSuggestionBatch(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := svc.StartBatch(r.Context(), suggest.TriggerManual)
|
||||
if err != nil {
|
||||
writeSuggestionError(w, r, logger, "start suggestion batch", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, b)
|
||||
}
|
||||
}
|
||||
|
||||
// getSuggestionDeck answers GET /suggestions/deck with the pending
|
||||
// suggestions, oldest first, and the batch pending or running, or null.
|
||||
func getSuggestionDeck(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := svc.Deck(r.Context())
|
||||
if err != nil {
|
||||
writeSuggestionError(w, r, logger, "list suggestion deck", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// postSuggestionDecision records Try it (accept) or Pass (decline) on a
|
||||
// suggestion, recording the session's actor as its dinner's updated_by.
|
||||
func postSuggestionDecision(logger *slog.Logger, svc *suggest.Service, accept bool) http.HandlerFunc {
|
||||
decide, action := svc.Decline, "decline suggestion"
|
||||
if accept {
|
||||
decide, action = svc.Accept, "accept suggestion"
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := decide(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
||||
if err != nil {
|
||||
writeSuggestionError(w, r, logger, action, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// writeSuggestionError maps a suggest.Service error to its API error: an
|
||||
// unknown suggestion is 404, and a decision on one already decided the
|
||||
// other way, or whose dinner left the deck, is 409.
|
||||
func writeSuggestionError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var decided *suggest.DecidedError
|
||||
switch {
|
||||
case errors.As(err, &decided):
|
||||
writeError(w, r, logger, http.StatusConflict, "suggestion_decided", decided.Error())
|
||||
case errors.Is(err, suggest.ErrDinnerNotSuggested):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_not_suggested", err.Error())
|
||||
case errors.Is(err, suggest.ErrNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "suggestion not found")
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
const suggestionsPath = "/api/v1/suggestions"
|
||||
|
||||
// newSuggestionsRouter returns a router whose suggestions service has an
|
||||
// unstarted job runner, so batches stay pending, and a deck seeded with
|
||||
// pending suggestions of dinners named names, in order, whose ids it
|
||||
// returns.
|
||||
func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.Store, []string) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
q := db.New(st.Writer)
|
||||
ctx := t.Context()
|
||||
now := store.Now().String()
|
||||
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ids []string
|
||||
for i, name := range names {
|
||||
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "suggested", Origin: "side_quest",
|
||||
Difficulty: sql.NullString{String: "easy", Valid: true}, GroundingStatus: "grounded", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := store.NewID()
|
||||
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||||
ID: id, DinnerID: d.ID, BatchID: batch.ID, Name: name, Reason: fmt.Sprintf("Reason %d", i), CitedRegularIds: "[]",
|
||||
CreatedAt: store.Now().String(), UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return h, st, ids
|
||||
}
|
||||
|
||||
func TestSuggestionDeck(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen")
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
list, _ := body["suggestions"].([]any)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("deck = %v, want 2 suggestions", body)
|
||||
}
|
||||
for i, name := range []string{"Pho", "Ramen"} {
|
||||
s := list[i].(map[string]any)
|
||||
if s["id"] != ids[i] || s["name"] != name || s["difficulty"] != "easy" || s["reason"] != fmt.Sprintf("Reason %d", i) || s["status"] != "pending" {
|
||||
t.Errorf("deck[%d] = %v, want %s", i, s, name)
|
||||
}
|
||||
if _, ok := s["photo_url"]; !ok {
|
||||
t.Errorf("deck[%d] has no photo_url key", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestionDecisions(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu", "Udon")
|
||||
|
||||
for _, tc := range []struct {
|
||||
target string
|
||||
status int
|
||||
code string
|
||||
sugStatus string
|
||||
dinnerStatus string
|
||||
}{
|
||||
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
|
||||
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
|
||||
{ids[0] + "/decline", http.StatusConflict, "suggestion_decided", "", ""},
|
||||
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
|
||||
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
|
||||
{ids[1] + "/accept", http.StatusConflict, "suggestion_decided", "", ""},
|
||||
{"nope/accept", http.StatusNotFound, "not_found", "", ""},
|
||||
{"nope/decline", http.StatusNotFound, "not_found", "", ""},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+tc.target, "")
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("POST %s = %d, want %d: %s", tc.target, rec.Code, tc.status, rec.Body)
|
||||
}
|
||||
if tc.code != "" {
|
||||
if errorCode(body) != tc.code {
|
||||
t.Errorf("POST %s error = %v, want %s", tc.target, body, tc.code)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s, _ := body["suggestion"].(map[string]any)
|
||||
if s["status"] != tc.sugStatus || s["dinner_status"] != tc.dinnerStatus {
|
||||
t.Errorf("POST %s = %v, want %s with dinner %s", tc.target, body, tc.sugStatus, tc.dinnerStatus)
|
||||
}
|
||||
if body["replenish_job_id"] != nil {
|
||||
t.Errorf("POST %s replenished with 5 left: %v", tc.target, body["replenish_job_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// A third decision leaves 4: a replenish batch starts.
|
||||
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[2]+"/decline", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("decline = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
replenish, _ := body["replenish_job_id"].(string)
|
||||
if replenish == "" {
|
||||
t.Fatalf("decline leaving 4 = %v, want a replenish_job_id", body)
|
||||
}
|
||||
|
||||
// A fourth decision joins the batch already pending and reports its job.
|
||||
rec, body = callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[3]+"/accept", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("accept = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body["replenish_job_id"] != replenish {
|
||||
t.Errorf("accept leaving 3 = %v, want replenish_job_id %s", body, replenish)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestionDeckActiveBatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newSuggestionsRouter(t, "Pho")
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if active, ok := body["active_batch"]; !ok || active != nil {
|
||||
t.Fatalf("deck = %v, want active_batch null", body)
|
||||
}
|
||||
if failed, ok := body["failed_batch"]; !ok || failed != nil {
|
||||
t.Fatalf("deck = %v, want failed_batch null", body)
|
||||
}
|
||||
|
||||
rec, started := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec, body = callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
active, _ := body["active_batch"].(map[string]any)
|
||||
if active == nil || active["batch_id"] != started["batch_id"] || active["job_id"] != started["job_id"] || active["status"] != "pending" {
|
||||
t.Errorf("deck active_batch = %v, want the started batch %v", body["active_batch"], started)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSuggestionBatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, _ := newSuggestionsRouter(t)
|
||||
|
||||
rec, first := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if first["batch_id"] == nil || first["job_id"] == nil || first["trigger"] != "manual" || first["status"] != "pending" {
|
||||
t.Errorf("POST batches = %v, want a pending manual batch with its job", first)
|
||||
}
|
||||
var kind string
|
||||
if err := st.Reader.QueryRow(`SELECT kind FROM jobs WHERE id = ?`, first["job_id"]).Scan(&kind); err != nil || kind != suggest.KindSuggestionBatch {
|
||||
t.Errorf("job kind = %q (%v), want %s", kind, err, suggest.KindSuggestionBatch)
|
||||
}
|
||||
|
||||
rec, again := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
||||
if rec.Code != http.StatusAccepted || again["batch_id"] != first["batch_id"] || again["job_id"] != first["job_id"] {
|
||||
t.Errorf("second POST batches = %d %v, want the pending batch %v", rec.Code, again, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestionWritesNeedCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, ids := newSuggestionsRouter(t, "Pho")
|
||||
for _, target := range []string{"/batches", "/" + ids[0] + "/accept", "/" + ids[0] + "/decline"} {
|
||||
req := httptest.NewRequest(http.MethodPost, suggestionsPath+target, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec, body := serve(t, h, req)
|
||||
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
|
||||
t.Errorf("POST %s without CSRF = %d %v, want 403 csrf", target, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -175,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -557,3 +558,163 @@ func TestMigrateAIRunsStatus(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateSuggestionName upgrades a database at 0011 holding a pending
|
||||
// and a declined suggestion and checks 0012 keeps both, gives each its
|
||||
// dinner's name, and refuses a suggestion with no name.
|
||||
func TestMigrateSuggestionName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
for _, name := range []string{
|
||||
"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql",
|
||||
"0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql",
|
||||
"0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql",
|
||||
"0011_ground_dinner_job_revision.sql",
|
||||
} {
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
|
||||
VALUES ('dinner-1', 'Pho', 'pho', 'suggested', 'side_quest', 0, 'grounded', 'c', 'u'),
|
||||
('dinner-2', 'Ramen', 'ramen', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`,
|
||||
`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
|
||||
VALUES ('batch-1', 'manual', 'done', 'c', 'u')`,
|
||||
`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status, decided_at, created_at, updated_at)
|
||||
VALUES ('sugg-1', 'dinner-1', 'batch-1', 'why 1', '[]', 'pending', NULL, 'c1', 'u1'),
|
||||
('sugg-2', 'dinner-2', 'batch-1', 'why 2', '["dinner-1"]', 'declined', 'd2', 'c2', 'u2')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.Reader.Query(`SELECT id, dinner_id, batch_id, name, reason, cited_regular_ids, status, COALESCE(decided_at, ''), created_at, updated_at
|
||||
FROM suggestions ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got [][]string
|
||||
for rows.Next() {
|
||||
r := make([]string, 10)
|
||||
if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, r)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := [][]string{
|
||||
{"sugg-1", "dinner-1", "batch-1", "Pho", "why 1", "[]", "pending", "", "c1", "u1"},
|
||||
{"sugg-2", "dinner-2", "batch-1", "Ramen", "why 2", `["dinner-1"]`, "declined", "d2", "c2", "u2"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("suggestions after 0012 =\n %v\nwant\n %v", got, want)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE suggestions SET name = '' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
|
||||
t.Errorf("empty suggestion name: error %v, want CHECK constraint failure", err)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE suggestions SET dinner_id = 'nope' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
|
||||
t.Errorf("unknown dinner: error %v, want FOREIGN KEY constraint failure", err)
|
||||
}
|
||||
if tableExists(t, s, "suggestions_copy") {
|
||||
t.Error("suggestions_copy left behind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateRotationReviewName upgrades a database at 0013 holding a
|
||||
// pending, a kept and a dropped Rotation check and checks 0014 keeps all
|
||||
// three, gives only the drop its dinner's name, and refuses a drop with no
|
||||
// name or a name on any other check.
|
||||
func TestMigrateRotationReviewName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
before := fstest.MapFS{}
|
||||
for _, name := range []string{
|
||||
"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql",
|
||||
"0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql",
|
||||
"0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql",
|
||||
"0011_ground_dinner_job_revision.sql", "0012_suggestion_name.sql", "0013_week_plan_revision.sql",
|
||||
} {
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
|
||||
VALUES ('dinner-1', 'Pho', 'pho', 'tried', 'side_quest', 0, 'grounded', 'c', 'u'),
|
||||
('dinner-2', 'Ramen', 'ramen', 'regular', 'side_quest', 0, 'grounded', 'c', 'u'),
|
||||
('dinner-3', 'Laksa', 'laksa', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`,
|
||||
`INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, decision, decided_at, updated_by, created_at, updated_at)
|
||||
VALUES ('review-1', 'dinner-1', '2026-10-12', 'pending', NULL, NULL, NULL, 'c1', 'u1'),
|
||||
('review-2', 'dinner-2', '2026-10-13', 'decided', 'keep', 'd2', 'member-1', 'c2', 'u2'),
|
||||
('review-3', 'dinner-3', '2026-10-14', 'decided', 'drop', 'd3', 'member-1', 'c3', 'u3')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.Reader.Query(`SELECT id, dinner_id, plan_date, status, COALESCE(decision, ''), COALESCE(name, ''),
|
||||
COALESCE(decided_at, ''), COALESCE(updated_by, ''), created_at, updated_at
|
||||
FROM rotation_reviews ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got [][]string
|
||||
for rows.Next() {
|
||||
r := make([]string, 10)
|
||||
if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, r)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := [][]string{
|
||||
{"review-1", "dinner-1", "2026-10-12", "pending", "", "", "", "", "c1", "u1"},
|
||||
{"review-2", "dinner-2", "2026-10-13", "decided", "keep", "", "d2", "member-1", "c2", "u2"},
|
||||
{"review-3", "dinner-3", "2026-10-14", "decided", "drop", "Laksa", "d3", "member-1", "c3", "u3"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("rotation_reviews after 0014 =\n %v\nwant\n %v", got, want)
|
||||
}
|
||||
for _, tc := range []struct{ name, stmt, want string }{
|
||||
{"drop with no name", `UPDATE rotation_reviews SET name = NULL WHERE id = 'review-3'`, "CHECK constraint failed"},
|
||||
{"empty name", `UPDATE rotation_reviews SET name = '' WHERE id = 'review-3'`, "CHECK constraint failed"},
|
||||
{"name on a keep", `UPDATE rotation_reviews SET name = 'Ramen' WHERE id = 'review-2'`, "CHECK constraint failed"},
|
||||
{"name on a pending check", `UPDATE rotation_reviews SET name = 'Pho' WHERE id = 'review-1'`, "CHECK constraint failed"},
|
||||
{"unknown dinner", `UPDATE rotation_reviews SET dinner_id = 'nope' WHERE id = 'review-1'`, "FOREIGN KEY constraint failed"},
|
||||
{"second check of a dinner and date", `INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
|
||||
VALUES ('review-4', 'dinner-1', '2026-10-12', 'pending', 'c', 'u')`, "UNIQUE constraint failed"},
|
||||
} {
|
||||
if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: error %v, want %s", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
if tableExists(t, s, "rotation_reviews_copy") {
|
||||
t.Error("rotation_reviews_copy left behind")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestRepositoryInsertReadUpdate(t *testing.T) {
|
||||
parents: []string{"dinners", "suggestion_batches"},
|
||||
row: repoRow{"suggestions", map[string]any{
|
||||
"id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1",
|
||||
"reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
|
||||
"name": "White Chicken Chili", "reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
|
||||
"status": "pending", "decided_at": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
|
||||
@@ -110,6 +110,7 @@ func testRow(table string, i int) map[string]any {
|
||||
case "suggestions":
|
||||
r["dinner_id"] = rowID("dinners", 0)
|
||||
r["batch_id"] = rowID("suggestion_batches", 0)
|
||||
r["name"] = fmt.Sprintf("Side quest %d", i)
|
||||
r["reason"] = "Like your chili."
|
||||
r["cited_regular_ids"] = "[]"
|
||||
r["status"] = "pending"
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package suggest
|
||||
|
||||
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/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// BatchJob is the KindSuggestionBatch payload: the batch to run.
|
||||
type BatchJob struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
}
|
||||
|
||||
// Batch stages, as the job's result reports them.
|
||||
const (
|
||||
StageSuggesting = "suggesting"
|
||||
StageGrounding = "grounding"
|
||||
StageDone = "done"
|
||||
)
|
||||
|
||||
// batchResult is the KindSuggestionBatch result, published as the batch
|
||||
// goes: its stage, the suggestions stored so far, and how many Side
|
||||
// quests were dropped, by suggest_side_quests' rules, because no recipe
|
||||
// grounds them, or because they repeat a dinner added meanwhile.
|
||||
type batchResult struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
Stage string `json:"stage"`
|
||||
SuggestionIDs []string `json:"suggestion_ids"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize
|
||||
// Side quests given the household context, the deck and every passed
|
||||
// name, then grounds them in the model's order, at most the number still
|
||||
// wanted at a time, until BatchSize are kept or none are left. A Side quest
|
||||
// no recipe grounds is dropped; any other grounding failure fails the
|
||||
// batch, as does suggest_side_quests failing, with no Side quest made up
|
||||
// instead. Each round's grounded Side quests are stored before the next
|
||||
// round starts, so the deck fills as the batch goes. The batch ends done
|
||||
// or failed; Side quests stored before a failure stay in the deck. When
|
||||
// recording the batch's end fails, so does the job; when it times out at
|
||||
// shutdown, the job is left running and the batch with it, for the next
|
||||
// start to fail.
|
||||
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
|
||||
n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: p.BatchID,
|
||||
})
|
||||
if err != nil {
|
||||
return batchResult{}, fmt.Errorf("suggest: start batch %s: %w", p.BatchID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
||||
}
|
||||
res, err := s.fill(ctx, p.BatchID, publish)
|
||||
if err == nil {
|
||||
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed)
|
||||
if ctx.Err() != nil {
|
||||
return batchResult{}, errors.Join(ctx.Err(), finishErr)
|
||||
}
|
||||
var failure *jobs.Failure
|
||||
if errors.As(err, &failure) {
|
||||
// Only the code is logged: the error may quote a model's or
|
||||
// search provider's response.
|
||||
s.logger.InfoContext(ctx, "suggestion batch failed", "batch_id", p.BatchID, "reason", failure.Code)
|
||||
}
|
||||
return batchResult{}, errors.Join(err, finishErr)
|
||||
}
|
||||
|
||||
// fill does runBatch's work, returning a *jobs.Failure for a failure the
|
||||
// household may see.
|
||||
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) {
|
||||
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
in, err := s.request(ctx)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
out, err := s.suggest(ctx, batchID, in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return batchResult{}, ctx.Err()
|
||||
}
|
||||
return batchResult{}, &jobs.Failure{Code: ReasonAIFailed}
|
||||
}
|
||||
res.Stage = StageGrounding
|
||||
res.Dropped = len(out.Dropped)
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
left := out.Suggestions
|
||||
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
|
||||
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
|
||||
left = left[len(round):]
|
||||
grounded, err := s.ground(ctx, round)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
res.Dropped += len(round) - len(grounded)
|
||||
ids, err := s.store(ctx, batchID, grounded)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
res.Dropped += len(grounded) - len(ids)
|
||||
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// request reads what suggest_side_quests is given: the household context,
|
||||
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
|
||||
// fails with ReasonNoRegulars.
|
||||
func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) {
|
||||
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||
if err != nil {
|
||||
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: %w", err)
|
||||
}
|
||||
if len(hctx.Regulars) == 0 {
|
||||
return skills.SideQuestsRequest{}, &jobs.Failure{Code: ReasonNoRegulars}
|
||||
}
|
||||
q := db.New(s.st.Reader)
|
||||
deck, err := q.ListDeck(ctx)
|
||||
if err != nil {
|
||||
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list deck: %w", err)
|
||||
}
|
||||
pending := []string{}
|
||||
for _, row := range deck {
|
||||
pending = append(pending, row.Dinner.Name)
|
||||
}
|
||||
declined, err := q.ListPassedNames(ctx)
|
||||
if err != nil {
|
||||
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list passed names: %w", err)
|
||||
}
|
||||
return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil
|
||||
}
|
||||
|
||||
// groundedQuest is a Side quest with the recipe that grounds it.
|
||||
type groundedQuest struct {
|
||||
quest skills.SideQuest
|
||||
grounded recipes.Grounded
|
||||
}
|
||||
|
||||
// ground grounds quests by their search queries and returns those a
|
||||
// recipe grounds, in order. A Side quest no recipe grounds is left out;
|
||||
// any other failure fails the batch.
|
||||
func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]groundedQuest, error) {
|
||||
reqs := make([]recipes.GroundRequest, len(quests))
|
||||
for i, q := range quests {
|
||||
reqs[i] = recipes.GroundRequest{DishName: q.Name, Query: q.SearchQuery}
|
||||
}
|
||||
results := s.grounder.GroundMany(ctx, reqs)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []groundedQuest
|
||||
for i, r := range results {
|
||||
var ungroundable *recipes.ErrUngroundable
|
||||
switch {
|
||||
case r.Err == nil:
|
||||
out = append(out, groundedQuest{quest: quests[i], grounded: r.Grounded})
|
||||
case errors.As(r.Err, &ungroundable):
|
||||
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
|
||||
return nil, &jobs.Failure{Code: ReasonSearchNotConfigured}
|
||||
case errors.Is(r.Err, recipes.ErrSearchFailed):
|
||||
return nil, &jobs.Failure{Code: ReasonSearchFailed}
|
||||
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
|
||||
return nil, &jobs.Failure{Code: ReasonAIFailed}
|
||||
default:
|
||||
return nil, fmt.Errorf("suggest: ground %q: %w", quests[i].Name, r.Err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// store stores each grounded Side quest of batchID as a dinner with status
|
||||
// suggested and origin side_quest, grounded in its recipe, and a pending
|
||||
// suggestion citing its Regulars by id, all in one transaction, and
|
||||
// returns the suggestion ids. A Side quest is left out when, since
|
||||
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
|
||||
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
|
||||
// or archived. Passed names are read in the same transaction as the store,
|
||||
// so a Pass or drop committed before it is never missed.
|
||||
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
|
||||
ids := []string{}
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
existing, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
||||
}
|
||||
names, err := q.ListPassedNames(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: list passed names for dedupe: %w", err)
|
||||
}
|
||||
regulars := map[string][]string{}
|
||||
for _, row := range existing {
|
||||
names = append(names, row.Dinner.Name)
|
||||
if row.Dinner.Status == dinners.StatusRegular {
|
||||
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
|
||||
}
|
||||
}
|
||||
for _, gq := range quests {
|
||||
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
|
||||
if !ok || repeats(gq.quest.Name, names) {
|
||||
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
|
||||
continue
|
||||
}
|
||||
citedJSON, err := json.Marshal(cited)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: encode cited Regulars: %w", err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
gr := gq.grounded
|
||||
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: gq.quest.Name,
|
||||
NormalizedName: harness.Normalize(gq.quest.Name),
|
||||
Status: dinners.StatusSuggested,
|
||||
Origin: "side_quest",
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
Cuisine: sql.NullString{String: gq.quest.Cuisine, Valid: true},
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
GroundingStatus: dinners.GroundingGrounded,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: create dinner: %w", err)
|
||||
}
|
||||
id := store.NewID()
|
||||
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||||
ID: id,
|
||||
DinnerID: d.ID,
|
||||
BatchID: batchID,
|
||||
Name: d.Name,
|
||||
Reason: gq.quest.Reason,
|
||||
CitedRegularIds: string(citedJSON),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("suggest: create suggestion: %w", err)
|
||||
}
|
||||
names = append(names, d.Name)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// citedIDs returns the ids of the Regulars named cited, false when one
|
||||
// names no Regular or more than one.
|
||||
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
|
||||
ids := make([]string, len(cited))
|
||||
for i, name := range cited {
|
||||
if len(regulars[name]) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
ids[i] = regulars[name][0]
|
||||
}
|
||||
return ids, true
|
||||
}
|
||||
|
||||
// repeats reports whether name is harness.Similar to any of names.
|
||||
func repeats(name string, names []string) bool {
|
||||
for _, n := range names {
|
||||
if harness.Similar(name, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nullInt(v *int) sql.NullInt64 {
|
||||
if v == nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: int64(*v), Valid: true}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
// Package suggest keeps the household's Side quest deck: batches of new
|
||||
// dinners the AI suggests, each grounded in a recipe, and the household's
|
||||
// Try it / Pass decision on each. A batch runs as a job: suggest_side_quests
|
||||
// proposes Side quests, each is grounded with the recipe grounding pipeline
|
||||
// (dropping those no recipe grounds), and every one kept is stored as a
|
||||
// dinner with status suggested and origin side_quest and a pending
|
||||
// suggestion. Only one batch is pending or running at a time.
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"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/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// KindSuggestionBatch is the job that runs a suggestion batch.
|
||||
const KindSuggestionBatch = "suggestion_batch"
|
||||
|
||||
const (
|
||||
// BatchSize is how many Side quests a batch keeps at most.
|
||||
BatchSize = 5
|
||||
// ReplenishBelow is the deck size under which a decision starts a
|
||||
// replenish batch.
|
||||
ReplenishBelow = 5
|
||||
// ContextBudget is the character budget of the household context
|
||||
// suggest_side_quests is given (see harness.BuildContext).
|
||||
ContextBudget = 16000
|
||||
)
|
||||
|
||||
// Batch triggers (the suggestion_batches.trigger column).
|
||||
const (
|
||||
TriggerScheduled = "scheduled"
|
||||
TriggerManual = "manual"
|
||||
TriggerReplenish = "replenish"
|
||||
)
|
||||
|
||||
// Batch statuses (the suggestion_batches.status column).
|
||||
const (
|
||||
BatchPending = "pending"
|
||||
BatchRunning = "running"
|
||||
BatchDone = "done"
|
||||
BatchFailed = "failed"
|
||||
)
|
||||
|
||||
// Suggestion statuses (the suggestions.status column).
|
||||
const (
|
||||
StatusPending = "pending"
|
||||
StatusAccepted = "accepted"
|
||||
StatusDeclined = "declined"
|
||||
)
|
||||
|
||||
// Reasons a batch failed, stored as the KindSuggestionBatch job's failure
|
||||
// code. Any other failure, such as the database's, is the runner's
|
||||
// internal_error.
|
||||
const (
|
||||
// ReasonNoRegulars: the household has no Regulars for Side quests to
|
||||
// be based on.
|
||||
ReasonNoRegulars = "no_regulars"
|
||||
// ReasonAIFailed: suggest_side_quests, or normalize_recipe while
|
||||
// grounding, failed; no Side quest is made up instead (D-017).
|
||||
ReasonAIFailed = "ai_failed"
|
||||
// ReasonSearchNotConfigured: recipe search is not set up.
|
||||
ReasonSearchNotConfigured = "search_not_configured"
|
||||
// ReasonSearchFailed: the search provider failed.
|
||||
ReasonSearchFailed = "search_failed"
|
||||
)
|
||||
|
||||
// Reasons are every batch failure reason.
|
||||
var Reasons = []string{ReasonNoRegulars, ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed}
|
||||
|
||||
var triggers = []string{TriggerScheduled, TriggerManual, TriggerReplenish}
|
||||
|
||||
// ErrNotFound is returned for an unknown suggestion id.
|
||||
var ErrNotFound = errors.New("suggestion not found")
|
||||
|
||||
// ErrDinnerNotSuggested is a decision on a pending suggestion whose dinner
|
||||
// was archived or moved out of the deck some other way.
|
||||
var ErrDinnerNotSuggested = errors.New("the suggestion's dinner is no longer suggested")
|
||||
|
||||
// DecidedError is a decision on a suggestion already decided the other
|
||||
// way.
|
||||
type DecidedError struct {
|
||||
// Status is the suggestion's decision: accepted or declined.
|
||||
Status string
|
||||
}
|
||||
|
||||
func (e *DecidedError) Error() string {
|
||||
return "the suggestion was already " + e.Status
|
||||
}
|
||||
|
||||
// Suggester runs suggest_side_quests on in; scope names the batch. In
|
||||
// production it is skills.SuggestSideQuests with the household's
|
||||
// harness.Config.
|
||||
type Suggester func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error)
|
||||
|
||||
// Service runs suggestion batches and records decisions.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
grounder *recipes.Grounder
|
||||
suggest Suggester
|
||||
logger *slog.Logger
|
||||
batchJob *jobs.Kind[BatchJob]
|
||||
}
|
||||
|
||||
// NewService returns a service over st that suggests with suggest, grounds
|
||||
// with grounder and registers the KindSuggestionBatch job on runner, which
|
||||
// must not have started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("suggest: nil store")
|
||||
case grounder == nil:
|
||||
return nil, errors.New("suggest: nil grounder")
|
||||
case suggest == nil:
|
||||
return nil, errors.New("suggest: nil suggester")
|
||||
case runner == nil:
|
||||
return nil, errors.New("suggest: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("suggest: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.batchJob = kind
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Batch is a suggestion batch with the id of the job running it.
|
||||
type Batch struct {
|
||||
ID string `json:"batch_id"`
|
||||
JobID string `json:"job_id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
// Created says whether this call created the batch rather than found
|
||||
// it already pending or running.
|
||||
Created bool `json:"-"`
|
||||
}
|
||||
|
||||
// StartBatch creates a batch with trigger and enqueues the
|
||||
// KindSuggestionBatch job that runs it, unless a batch is already pending
|
||||
// or running, which it returns instead with Created false.
|
||||
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
|
||||
if !slices.Contains(triggers, trigger) {
|
||||
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
|
||||
}
|
||||
var b Batch
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
b, err = s.startBatch(ctx, tx, trigger)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return Batch{}, err
|
||||
}
|
||||
if b.Created {
|
||||
s.batchJob.Wake()
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// activeBatch returns the batch pending or running in q's transaction, or
|
||||
// nil when there is none. A batch still pending or running whose job is no
|
||||
// longer queued or running is failed first, so a batch whose job ended
|
||||
// without recording its end (the process stopped, or the write failed) is
|
||||
// never reported as active and never blocks a later one.
|
||||
func (s *Service) activeBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
|
||||
n, err := q.FailOrphanedSuggestionBatches(ctx, db.FailOrphanedSuggestionBatchesParams{
|
||||
UpdatedAt: store.Now().String(),
|
||||
Kind: KindSuggestionBatch,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: fail orphaned batches: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
s.logger.WarnContext(ctx, "failed suggestion batches whose job ended", "count", n)
|
||||
}
|
||||
cur, err := q.GetActiveSuggestionBatch(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: read active batch: %w", err)
|
||||
}
|
||||
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
|
||||
}
|
||||
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
|
||||
}
|
||||
|
||||
// failedBatch returns the newest batch in q's transaction when it failed,
|
||||
// or nil when it did not or there is none: a later batch, done or under
|
||||
// way, supersedes an earlier failure. Run after activeBatch, so a batch
|
||||
// whose job ended without recording its end reads as failed.
|
||||
func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
|
||||
cur, err := q.GetLatestSuggestionBatch(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: read latest batch: %w", err)
|
||||
}
|
||||
if cur.Status != BatchFailed {
|
||||
return nil, nil
|
||||
}
|
||||
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
|
||||
}
|
||||
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
|
||||
}
|
||||
|
||||
// startBatch does StartBatch's work in tx: the batch and the job that runs
|
||||
// it are committed together, so a batch is never seen without its job. The
|
||||
// caller wakes the job runner once tx has committed and Created is true.
|
||||
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
|
||||
q := db.New(tx)
|
||||
cur, err := s.activeBatch(ctx, q)
|
||||
if err != nil {
|
||||
return Batch{}, err
|
||||
}
|
||||
if cur != nil {
|
||||
return *cur, nil
|
||||
}
|
||||
now := store.Now().String()
|
||||
row, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
|
||||
ID: store.NewID(),
|
||||
Trigger: trigger,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
|
||||
}
|
||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
|
||||
}
|
||||
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
|
||||
}
|
||||
|
||||
// finishBatch records running batch id as status, with
|
||||
// jobs.FinalWriteContext: the write is made even when ctx has been
|
||||
// cancelled, but a writer that stays busy cannot hold its worker past
|
||||
// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
|
||||
// never ends succeeded with the batch still running; the runner keeps only
|
||||
// a code, so the error is logged too.
|
||||
func (s *Service) finishBatch(ctx context.Context, id, status string) error {
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
|
||||
Status: status,
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: id,
|
||||
})
|
||||
if err == nil && n != 1 {
|
||||
err = errors.New("batch is not running")
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err)
|
||||
return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Suggestion is a Side quest suggestion as the API shows it, with its
|
||||
// dinner.
|
||||
type Suggestion struct {
|
||||
ID string `json:"id"`
|
||||
DinnerID string `json:"dinner_id"`
|
||||
Name string `json:"name"`
|
||||
PhotoURL *string `json:"photo_url"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
Method *string `json:"method"`
|
||||
MinutesTotal *int64 `json:"minutes_total"`
|
||||
Cuisine *string `json:"cuisine"`
|
||||
// Reason is the "Why this?".
|
||||
Reason string `json:"reason"`
|
||||
CitedRegularIDs []string `json:"cited_regular_ids"`
|
||||
Status string `json:"status"`
|
||||
DinnerStatus string `json:"dinner_status"`
|
||||
DecidedAt *string `json:"decided_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Deck is the household's deck: the pending suggestions whose dinner is
|
||||
// still suggested, oldest first; the batch pending or running, if any, so
|
||||
// a client can follow the job producing more; and the newest batch when it
|
||||
// failed, so a client that was not following its job still learns of the
|
||||
// failure. At most one of ActiveBatch and FailedBatch is set: a batch is
|
||||
// only started when none is pending or running, so the active batch is
|
||||
// always the newest.
|
||||
type Deck struct {
|
||||
Suggestions []Suggestion `json:"suggestions"`
|
||||
ActiveBatch *Batch `json:"active_batch"`
|
||||
FailedBatch *Batch `json:"failed_batch"`
|
||||
}
|
||||
|
||||
// Deck reads the deck and its active and failed batches in one
|
||||
// transaction, so all come from the same snapshot. It is a write
|
||||
// transaction because reading the active batch first fails any batch whose
|
||||
// job has ended (see activeBatch), which is then the failed batch.
|
||||
func (s *Service) Deck(ctx context.Context) (Deck, error) {
|
||||
var deck Deck
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
rows, err := q.ListDeck(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: list deck: %w", err)
|
||||
}
|
||||
deck.Suggestions = make([]Suggestion, len(rows))
|
||||
for i, row := range rows {
|
||||
deck.Suggestions[i], err = fromRow(row.Suggestion, row.Dinner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
deck.ActiveBatch, err = s.activeBatch(ctx, q)
|
||||
if err != nil || deck.ActiveBatch != nil {
|
||||
return err
|
||||
}
|
||||
deck.FailedBatch, err = failedBatch(ctx, q)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return Deck{}, err
|
||||
}
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
// Decision is a suggestion once decided, with the id of the replenish
|
||||
// batch's job when the decision called for one: the batch it started, or
|
||||
// the one already pending or running that it joined.
|
||||
type Decision struct {
|
||||
Suggestion Suggestion `json:"suggestion"`
|
||||
ReplenishJobID *string `json:"replenish_job_id"`
|
||||
}
|
||||
|
||||
// Accept records Try it on suggestion id, moving its dinner to the Quest
|
||||
// log and recording updatedBy on it.
|
||||
func (s *Service) Accept(ctx context.Context, id, updatedBy string) (Decision, error) {
|
||||
return s.decide(ctx, id, StatusAccepted, dinners.StatusQuestLog, updatedBy)
|
||||
}
|
||||
|
||||
// Decline records Pass on suggestion id, archiving its dinner and
|
||||
// recording updatedBy on it. The suggestion keeps the name its dish was
|
||||
// suggested under, whatever the dinner is later renamed, and every later
|
||||
// batch reads it as declined, so the dish is never suggested again.
|
||||
func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision, error) {
|
||||
return s.decide(ctx, id, StatusDeclined, dinners.StatusArchived, updatedBy)
|
||||
}
|
||||
|
||||
// decide records decision on suggestion id and moves its dinner to
|
||||
// dinnerStatus. Repeating a decision changes nothing and returns the
|
||||
// suggestion as it is; the opposite decision is a *DecidedError. A
|
||||
// decision that leaves the deck under ReplenishBelow starts a replenish
|
||||
// batch, or joins the one already pending or running, in the decision's own
|
||||
// transaction, and returns its job id either way: the decision is never recorded without the replenish it
|
||||
// calls for, and a failure to start the batch records neither.
|
||||
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
|
||||
if updatedBy == "" {
|
||||
return Decision{}, errors.New("suggest: decide: no member to record as updated_by")
|
||||
}
|
||||
var row db.GetSuggestionRow
|
||||
var replenish *Batch
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetSuggestion(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
|
||||
}
|
||||
switch cur.Suggestion.Status {
|
||||
case decision:
|
||||
row = cur
|
||||
return nil
|
||||
case StatusPending:
|
||||
default:
|
||||
return &DecidedError{Status: cur.Suggestion.Status}
|
||||
}
|
||||
if cur.Dinner.Status != dinners.StatusSuggested {
|
||||
return ErrDinnerNotSuggested
|
||||
}
|
||||
now := store.Now().String()
|
||||
if _, err := q.DecideSuggestion(ctx, db.DecideSuggestionParams{
|
||||
Status: decision,
|
||||
Now: sql.NullString{String: now, Valid: true},
|
||||
ID: id,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("suggest: decide %s: %w", id, err)
|
||||
}
|
||||
if _, err := q.MoveSuggestedDinner(ctx, db.MoveSuggestedDinnerParams{
|
||||
Status: dinnerStatus,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: cur.Dinner.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("suggest: move dinner %s: %w", cur.Dinner.ID, err)
|
||||
}
|
||||
row, err = q.GetSuggestion(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
|
||||
}
|
||||
left, err := q.CountDeck(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: count deck: %w", err)
|
||||
}
|
||||
if left >= ReplenishBelow {
|
||||
return nil
|
||||
}
|
||||
b, err := s.startBatch(ctx, tx, TriggerReplenish)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replenish = &b
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if replenish != nil && replenish.Created {
|
||||
s.batchJob.Wake()
|
||||
}
|
||||
sug, err := fromRow(row.Suggestion, row.Dinner)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
d := Decision{Suggestion: sug}
|
||||
if replenish != nil {
|
||||
d.ReplenishJobID = &replenish.JobID
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func fromRow(sg db.Suggestion, d db.Dinner) (Suggestion, error) {
|
||||
out := Suggestion{
|
||||
ID: sg.ID,
|
||||
DinnerID: d.ID,
|
||||
Name: d.Name,
|
||||
Difficulty: nullString(d.Difficulty),
|
||||
Method: nullString(d.Method),
|
||||
Cuisine: nullString(d.Cuisine),
|
||||
Reason: sg.Reason,
|
||||
Status: sg.Status,
|
||||
DinnerStatus: d.Status,
|
||||
DecidedAt: nullString(sg.DecidedAt),
|
||||
CreatedAt: sg.CreatedAt,
|
||||
}
|
||||
if err := json.Unmarshal([]byte(sg.CitedRegularIds), &out.CitedRegularIDs); err != nil || out.CitedRegularIDs == nil {
|
||||
return Suggestion{}, fmt.Errorf("suggest: suggestion %s cited_regular_ids is not a JSON array: %q", sg.ID, sg.CitedRegularIds)
|
||||
}
|
||||
if d.MinutesTotal.Valid {
|
||||
out.MinutesTotal = &d.MinutesTotal.Int64
|
||||
}
|
||||
if d.PhotoID.Valid {
|
||||
u := dinners.PhotoURL(d.PhotoID.String)
|
||||
out.PhotoURL = &u
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
-- 0008_dinner_recipe_url: a Regular added from a pasted recipe link keeps
|
||||
-- that link until grounding resolves it to a recipe (recipe_id). Nothing
|
||||
-- else records the link: recipes rows exist only for fetched pages.
|
||||
|
||||
ALTER TABLE dinners ADD COLUMN recipe_url TEXT;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 0009_dinner_grounding_error: a dinner whose grounding failed keeps why,
|
||||
-- as one of the reason codes package dinners defines (no_recipe_found,
|
||||
-- recipe_unusable, search_not_configured, search_failed, ai_failed,
|
||||
-- interrupted, grounding_error). It is NULL unless grounding_status is
|
||||
-- failed.
|
||||
|
||||
ALTER TABLE dinners ADD COLUMN grounding_error TEXT;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 0010_dinner_grounding_revision: each ground_dinner job carries the
|
||||
-- dinner's grounding_revision when it was enqueued, and records its result
|
||||
-- only while the dinner still has that revision. Renaming a pending dinner
|
||||
-- raises the revision before enqueuing the job for the new name, so an
|
||||
-- earlier job — queued or running — can no longer write the dinner.
|
||||
|
||||
ALTER TABLE dinners ADD COLUMN grounding_revision INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 0011_ground_dinner_job_revision: every dinner starts at grounding
|
||||
-- revision 0 (0010), so a ground_dinner job enqueued before 0010, whose
|
||||
-- payload has only dinner_id, is for revision 0. Recording it lets the boot
|
||||
-- cleanup, which matches a pending dinner to its job by revision, see a job
|
||||
-- still queued for its dinner. This is a migration of its own, not part of
|
||||
-- 0010, so it also runs on a database that has already applied 0010.
|
||||
|
||||
UPDATE jobs
|
||||
SET payload = json_set(payload, '$.revision', 0)
|
||||
WHERE kind = 'ground_dinner'
|
||||
AND json_type(payload, '$.revision') IS NULL;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 0012_suggestion_name: a suggestion keeps the name its dish was suggested
|
||||
-- under. Its dinner can be renamed, even once archived by a Pass, so the
|
||||
-- declined names every later batch refuses are read from here, never from
|
||||
-- the dinner. A suggestion stored before this takes its dinner's name now.
|
||||
-- SQLite cannot add a NOT NULL column without a default, so the table is
|
||||
-- rebuilt as 0005 does: copy the rows aside, drop the table, create it
|
||||
-- again, copy the rows back and drop the copy. Nothing references
|
||||
-- suggestions.
|
||||
|
||||
CREATE TABLE suggestions_copy AS SELECT * FROM suggestions;
|
||||
|
||||
DROP TABLE suggestions;
|
||||
|
||||
CREATE TABLE suggestions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
dinner_id TEXT NOT NULL REFERENCES dinners (id),
|
||||
batch_id TEXT NOT NULL REFERENCES suggestion_batches (id),
|
||||
name TEXT NOT NULL CHECK (name <> ''),
|
||||
reason TEXT NOT NULL,
|
||||
cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
decided_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO suggestions (
|
||||
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, decided_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
suggestions_copy.id, suggestions_copy.dinner_id, suggestions_copy.batch_id, dinners.name,
|
||||
suggestions_copy.reason, suggestions_copy.cited_regular_ids, suggestions_copy.status,
|
||||
suggestions_copy.decided_at, suggestions_copy.created_at, suggestions_copy.updated_at
|
||||
FROM suggestions_copy
|
||||
JOIN dinners ON dinners.id = suggestions_copy.dinner_id;
|
||||
|
||||
DROP TABLE suggestions_copy;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 0013_week_plan_revision: every edit of a week's plan — a swap or a
|
||||
-- stored draft — raises its revision. A draft_week job reads the revision
|
||||
-- before draft_week runs and stores its week only while the plan still
|
||||
-- has it, so a draft never overwrites an edit made while the AI ran.
|
||||
|
||||
ALTER TABLE week_plans ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 0014_rotation_review_name: a Rotation check decided drop ("Not for us")
|
||||
-- keeps the name its dinner had when the household dropped it. The dinner
|
||||
-- can be renamed, even once archived, so the passed names every later
|
||||
-- batch refuses are read from here, never from the dinner, as 0012 does
|
||||
-- for a Pass. Only a drop has a name. A check dropped before this takes
|
||||
-- its dinner's name now.
|
||||
-- SQLite cannot add a column with a CHECK that existing drops would fail
|
||||
-- before they are filled in, so the table is rebuilt as 0012 does: copy
|
||||
-- the rows aside, drop the table, create it again, copy the rows back and
|
||||
-- drop the copy. Nothing references rotation_reviews.
|
||||
|
||||
CREATE TABLE rotation_reviews_copy AS SELECT * FROM rotation_reviews;
|
||||
|
||||
DROP TABLE rotation_reviews;
|
||||
|
||||
CREATE TABLE rotation_reviews (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
dinner_id TEXT NOT NULL REFERENCES dinners (id),
|
||||
plan_date TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'decided')),
|
||||
decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')),
|
||||
name TEXT CHECK (name <> ''), -- the dinner's name when dropped
|
||||
decided_at TEXT,
|
||||
updated_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (dinner_id, plan_date),
|
||||
CHECK ((decision IS 'drop') = (name IS NOT NULL))
|
||||
);
|
||||
|
||||
INSERT INTO rotation_reviews (
|
||||
id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
rotation_reviews_copy.id, rotation_reviews_copy.dinner_id, rotation_reviews_copy.plan_date,
|
||||
rotation_reviews_copy.status, rotation_reviews_copy.decision,
|
||||
CASE WHEN rotation_reviews_copy.decision = 'drop' THEN dinners.name END,
|
||||
rotation_reviews_copy.decided_at, rotation_reviews_copy.updated_by,
|
||||
rotation_reviews_copy.created_at, rotation_reviews_copy.updated_at
|
||||
FROM rotation_reviews_copy
|
||||
JOIN dinners ON dinners.id = rotation_reviews_copy.dinner_id;
|
||||
|
||||
DROP TABLE rotation_reviews_copy;
|
||||
@@ -1,12 +1,12 @@
|
||||
-- name: CreateDinner :one
|
||||
INSERT INTO dinners (
|
||||
id, name, normalized_name, status, origin, method, minutes_total,
|
||||
difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status,
|
||||
updated_by, created_at, updated_at
|
||||
difficulty, cuisine, is_favorite, recipe_id, recipe_url, photo_id,
|
||||
grounding_status, updated_by, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?
|
||||
?, ?, ?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
@@ -19,6 +19,33 @@ SELECT * FROM dinners
|
||||
WHERE status = ?
|
||||
ORDER BY normalized_name, id;
|
||||
|
||||
-- name: ListUnplannedQuestLogDinners :many
|
||||
-- The Quest log: dinners with status quest_log that no plan for the week
|
||||
-- starting from_week_start or later has on any day.
|
||||
SELECT * FROM dinners
|
||||
WHERE status = 'quest_log'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM plan_days
|
||||
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||
WHERE plan_days.dinner_id = dinners.id
|
||||
AND week_plans.week_start >= sqlc.arg(from_week_start)
|
||||
)
|
||||
ORDER BY normalized_name, id;
|
||||
|
||||
-- name: CountRegularDinners :one
|
||||
-- How many Regulars the household has; archived dinners are not counted.
|
||||
SELECT COUNT(*) FROM dinners
|
||||
WHERE status = 'regular';
|
||||
|
||||
-- name: ListUnarchivedDinners :many
|
||||
-- Every dinner a new or renamed dinner must not repeat, with the
|
||||
-- source_url of the recipe it is grounded in (NULL while it has none).
|
||||
SELECT sqlc.embed(dinners), recipes.source_url AS recipe_source_url
|
||||
FROM dinners
|
||||
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
|
||||
WHERE dinners.status <> 'archived'
|
||||
ORDER BY dinners.id;
|
||||
|
||||
-- name: UpdateDinner :one
|
||||
-- An unknown id returns sql.ErrNoRows.
|
||||
UPDATE dinners
|
||||
@@ -31,8 +58,10 @@ SET name = ?,
|
||||
difficulty = ?,
|
||||
cuisine = ?,
|
||||
recipe_id = ?,
|
||||
recipe_url = ?,
|
||||
photo_id = ?,
|
||||
grounding_status = ?,
|
||||
grounding_revision = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
@@ -55,3 +84,100 @@ SET is_favorite = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: ToggleDinnerFavorite :one
|
||||
-- An unknown id returns sql.ErrNoRows.
|
||||
UPDATE dinners
|
||||
SET is_favorite = 1 - is_favorite,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: GroundDinner :one
|
||||
-- Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
-- grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
-- link. It writes only an unarchived dinner still as it was observed
|
||||
-- before grounding (same name, normalized_name, grounding_status,
|
||||
-- grounding_revision and recipe_id), so a dinner renamed, archived or
|
||||
-- grounded some other way meanwhile is left as it is. That dinner, or an
|
||||
-- unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
||||
-- no longer as observed is kept: a member edited it while it was grounding.
|
||||
UPDATE dinners
|
||||
SET recipe_id = sqlc.arg(recipe_id),
|
||||
recipe_url = NULL,
|
||||
photo_id = sqlc.arg(photo_id),
|
||||
method = CASE WHEN method IS sqlc.narg(observed_method)
|
||||
THEN sqlc.arg(method) ELSE method END,
|
||||
minutes_total = CASE WHEN minutes_total IS sqlc.narg(observed_minutes_total)
|
||||
THEN sqlc.arg(minutes_total) ELSE minutes_total END,
|
||||
difficulty = CASE WHEN difficulty IS sqlc.narg(observed_difficulty)
|
||||
THEN sqlc.arg(difficulty) ELSE difficulty END,
|
||||
grounding_status = sqlc.arg(grounding_status),
|
||||
grounding_error = NULL,
|
||||
updated_by = sqlc.arg(updated_by),
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND status <> 'archived'
|
||||
AND name = sqlc.arg(observed_name)
|
||||
AND normalized_name = sqlc.arg(observed_normalized_name)
|
||||
AND grounding_status = sqlc.arg(observed_grounding_status)
|
||||
AND grounding_revision = sqlc.arg(observed_grounding_revision)
|
||||
AND recipe_id IS sqlc.narg(observed_recipe_id)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GroundPendingDinner :execrows
|
||||
-- Records the background grounding of an unarchived dinner still pending at
|
||||
-- the grounding_revision it was grounded for; a dinner grounded some other
|
||||
-- way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
-- minutes_total or difficulty already set is kept: a pending dinner has
|
||||
-- them only when a member edited them while it was grounding. Like
|
||||
-- GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
UPDATE dinners
|
||||
SET recipe_id = sqlc.arg(recipe_id),
|
||||
recipe_url = NULL,
|
||||
photo_id = sqlc.arg(photo_id),
|
||||
method = COALESCE(method, sqlc.arg(method)),
|
||||
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
|
||||
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = sqlc.arg(grounding_revision);
|
||||
|
||||
-- name: FailPendingDinnerGrounding :execrows
|
||||
-- Records why the background grounding of an unarchived dinner still
|
||||
-- pending at the grounding_revision it was grounded for failed; a dinner
|
||||
-- grounded some other way, renamed or archived meanwhile is left as it is
|
||||
-- (0 rows).
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?;
|
||||
|
||||
-- name: FailOrphanedPendingDinnerGroundings :execrows
|
||||
-- Fails every unarchived dinner still pending whose grounding job for its
|
||||
-- current grounding_revision is no longer queued or running: the process
|
||||
-- stopped before the job, or before its handler recorded a result. An
|
||||
-- archived dinner is left as it is; archiving stopped its grounding. Run at
|
||||
-- boot, after the job runner fails the jobs a previous process left running.
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = sqlc.arg(grounding_error),
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE grounding_status = 'pending'
|
||||
AND status <> 'archived'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs
|
||||
WHERE jobs.kind = sqlc.arg(kind)
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
||||
);
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
-- 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
|
||||
-- name: ListRecentPassedNames :many
|
||||
-- The most recent names the household turned down, newest first, as
|
||||
-- ListPassedNames has them.
|
||||
SELECT name FROM (
|
||||
SELECT name, decided_at, id
|
||||
FROM suggestions
|
||||
WHERE status = 'declined'
|
||||
UNION ALL
|
||||
SELECT name, decided_at, id
|
||||
FROM rotation_reviews
|
||||
WHERE decision = 'drop'
|
||||
)
|
||||
ORDER BY decided_at DESC, id DESC
|
||||
LIMIT ?;
|
||||
|
||||
-- name: ListPlannedDinners :many
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
-- name: GetWeekPlan :one
|
||||
-- The plan of the week starting week_start. Returns sql.ErrNoRows when
|
||||
-- there is none.
|
||||
SELECT * FROM week_plans
|
||||
WHERE week_start = ?;
|
||||
|
||||
-- name: ListPlanDays :many
|
||||
-- The days of plan plan_id with their dinners, Monday first. A day with
|
||||
-- no dinner is left out.
|
||||
SELECT plan_days.date, plan_days.is_new, sqlc.embed(dinners)
|
||||
FROM plan_days
|
||||
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||
WHERE plan_days.plan_id = ?
|
||||
ORDER BY plan_days.date;
|
||||
|
||||
-- name: CreateWeekPlan :exec
|
||||
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
|
||||
VALUES (?, ?, 'draft', ?, ?, ?);
|
||||
|
||||
-- name: RedraftWeekPlan :exec
|
||||
-- Records a new draft over plan id: its status and note, raising its
|
||||
-- revision.
|
||||
UPDATE week_plans
|
||||
SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: PutPlanDay :exec
|
||||
-- Plans dinner_id on date of plan_id, keeping the day's calendar event id
|
||||
-- when the day exists.
|
||||
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (plan_id, date) DO UPDATE
|
||||
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at;
|
||||
|
||||
-- name: SwapPlanDay :execrows
|
||||
-- Plans dinner_id on an existing day of plan_id; a date the plan does not
|
||||
-- have is left as it is (0 rows).
|
||||
UPDATE plan_days
|
||||
SET dinner_id = ?, is_new = ?, updated_at = ?
|
||||
WHERE plan_id = ? AND date = ?;
|
||||
|
||||
-- name: MarkWeekPlanEdited :exec
|
||||
-- Records an edit of plan id, raising its revision: a synced plan becomes
|
||||
-- changed, any other keeps its status.
|
||||
UPDATE week_plans
|
||||
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
|
||||
revision = revision + 1,
|
||||
updated_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: ListDraftPool :many
|
||||
-- The dinners a draft of the week starting week_start may plan: every
|
||||
-- Regular, and every Quest log dinner no other plan for the week starting
|
||||
-- from_week_start or later has on any day.
|
||||
SELECT * FROM dinners
|
||||
WHERE status = 'regular'
|
||||
OR (status = 'quest_log' AND NOT EXISTS (
|
||||
SELECT 1 FROM plan_days
|
||||
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||
WHERE plan_days.dinner_id = dinners.id
|
||||
AND week_plans.week_start >= sqlc.arg(from_week_start)
|
||||
AND week_plans.week_start <> sqlc.arg(week_start)
|
||||
))
|
||||
ORDER BY normalized_name, id;
|
||||
|
||||
-- name: ListWeekPlanDinnerIDs :many
|
||||
-- The ids of the dinners planned in the week starting week_start, Monday
|
||||
-- first.
|
||||
SELECT plan_days.dinner_id
|
||||
FROM plan_days
|
||||
JOIN week_plans ON week_plans.id = plan_days.plan_id
|
||||
WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL
|
||||
ORDER BY plan_days.date;
|
||||
|
||||
-- name: GetActiveDraftJob :one
|
||||
-- The job of kind queued or running for the week starting week_start,
|
||||
-- with whether its payload has confirm. StartDraft enqueues a draft only
|
||||
-- when there is none, so there is at most one. Returns sql.ErrNoRows when
|
||||
-- there is none.
|
||||
SELECT id, CAST(json_extract(payload, '$.confirm') = 1 AS INTEGER) AS confirmed
|
||||
FROM jobs
|
||||
WHERE kind = sqlc.arg(kind)
|
||||
AND status IN ('queued', 'running')
|
||||
AND json_extract(payload, '$.week_start') = sqlc.arg(week_start)
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
@@ -69,3 +69,15 @@ ORDER BY position, id;
|
||||
-- name: DeleteRecipeSteps :exec
|
||||
DELETE FROM recipe_steps
|
||||
WHERE recipe_id = ?;
|
||||
|
||||
-- name: CreateManualRecipe :one
|
||||
-- A recipe the household typed in. Its source_url is a mealquest:manual:
|
||||
-- URN rather than a page, so it never matches a fetched page.
|
||||
INSERT INTO recipes (
|
||||
id, source_url, title, total_minutes, raw_jsonld, method,
|
||||
minutes_total, fetched_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
-- name: ListTriedCandidates :many
|
||||
-- The ids of the dinners with status quest_log planned on date, on a plan
|
||||
-- of any status, that have no Rotation check for date yet.
|
||||
SELECT DISTINCT dinners.id
|
||||
FROM plan_days
|
||||
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||
WHERE plan_days.date = sqlc.arg(date)
|
||||
AND dinners.status = 'quest_log'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM rotation_reviews
|
||||
WHERE rotation_reviews.dinner_id = dinners.id
|
||||
AND rotation_reviews.plan_date = plan_days.date
|
||||
)
|
||||
ORDER BY dinners.id;
|
||||
|
||||
-- name: MarkDinnerTried :execrows
|
||||
-- Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||
-- status is left as it is (0 rows).
|
||||
UPDATE dinners
|
||||
SET status = 'tried', updated_at = ?
|
||||
WHERE id = ? AND status = 'quest_log';
|
||||
|
||||
-- name: CreateRotationReview :exec
|
||||
INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?);
|
||||
|
||||
-- name: ListPendingRotationReviews :many
|
||||
-- The pending Rotation checks whose dinner is still tried, oldest plan
|
||||
-- date first, with their dinners.
|
||||
SELECT rotation_reviews.plan_date, sqlc.embed(dinners)
|
||||
FROM rotation_reviews
|
||||
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
|
||||
WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried'
|
||||
ORDER BY rotation_reviews.plan_date, rotation_reviews.id;
|
||||
|
||||
-- name: GetPendingRotationReview :one
|
||||
-- The pending Rotation check of dinner_id. MarkTried makes one only for a
|
||||
-- dinner in the Quest log, which it moves to tried, so a dinner has at
|
||||
-- most one. Returns sql.ErrNoRows when there is none.
|
||||
SELECT * FROM rotation_reviews
|
||||
WHERE dinner_id = ? AND status = 'pending';
|
||||
|
||||
-- name: CountRotationReviews :one
|
||||
-- How many Rotation checks dinner_id has had, pending or decided.
|
||||
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?;
|
||||
|
||||
-- name: DecideRotationReview :execrows
|
||||
-- Records the household's decision on a pending Rotation check, with the
|
||||
-- dinner's name for a drop and NULL otherwise; one already decided is left
|
||||
-- as it is (0 rows).
|
||||
UPDATE rotation_reviews
|
||||
SET status = 'decided',
|
||||
decision = sqlc.arg(decision),
|
||||
name = sqlc.narg(name),
|
||||
decided_at = sqlc.arg(now),
|
||||
updated_by = sqlc.arg(updated_by),
|
||||
updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'pending';
|
||||
|
||||
-- name: MoveTriedDinner :execrows
|
||||
-- Moves a dinner still tried to status (regular, archived or quest_log)
|
||||
-- by a Rotation check decision, recording updated_by; its origin is kept.
|
||||
-- A dinner no longer tried is left as it is (0 rows).
|
||||
UPDATE dinners
|
||||
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id) AND status = 'tried';
|
||||
@@ -0,0 +1,118 @@
|
||||
-- name: CreateSuggestionBatch :one
|
||||
INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetActiveSuggestionBatch :one
|
||||
-- The batch still pending or running, oldest first. Returns sql.ErrNoRows
|
||||
-- when there is none.
|
||||
SELECT * FROM suggestion_batches
|
||||
WHERE status IN ('pending', 'running')
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetLatestSuggestionBatch :one
|
||||
-- The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
||||
-- is none.
|
||||
SELECT * FROM suggestion_batches
|
||||
ORDER BY id DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetSuggestionBatchJobID :one
|
||||
-- The id of the job that runs batch batch_id.
|
||||
SELECT id FROM jobs
|
||||
WHERE kind = sqlc.arg(kind)
|
||||
AND json_extract(payload, '$.batch_id') = sqlc.arg(batch_id)
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
|
||||
-- name: StartSuggestionBatch :execrows
|
||||
-- Marks a pending batch running; a batch in any other status is left as
|
||||
-- it is (0 rows).
|
||||
UPDATE suggestion_batches
|
||||
SET status = 'running', updated_at = ?
|
||||
WHERE id = ? AND status = 'pending';
|
||||
|
||||
-- name: FinishSuggestionBatch :execrows
|
||||
-- Records a pending or running batch as done or failed; a batch already
|
||||
-- finished is left as it is (0 rows).
|
||||
UPDATE suggestion_batches
|
||||
SET status = ?, updated_at = ?
|
||||
WHERE id = ? AND status IN ('pending', 'running');
|
||||
|
||||
-- name: FailOrphanedSuggestionBatches :execrows
|
||||
-- Fails every batch still pending or running whose job is no longer queued
|
||||
-- or running: the process stopped, or recording the batch's end failed,
|
||||
-- before its handler recorded a result. Run before reading the active batch.
|
||||
UPDATE suggestion_batches
|
||||
SET status = 'failed', updated_at = sqlc.arg(updated_at)
|
||||
WHERE status IN ('pending', 'running')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs
|
||||
WHERE jobs.kind = sqlc.arg(kind)
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id
|
||||
);
|
||||
|
||||
-- name: CreateSuggestion :exec
|
||||
-- name is the name the dish is suggested under, kept whatever its dinner is
|
||||
-- later renamed.
|
||||
INSERT INTO suggestions (
|
||||
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, 'pending', ?, ?
|
||||
);
|
||||
|
||||
-- name: GetSuggestion :one
|
||||
-- A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
|
||||
SELECT sqlc.embed(suggestions), sqlc.embed(dinners)
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.id = ?;
|
||||
|
||||
-- name: ListDeck :many
|
||||
-- The pending suggestions whose dinner is still suggested, oldest first,
|
||||
-- with their dinners.
|
||||
SELECT sqlc.embed(suggestions), sqlc.embed(dinners)
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
|
||||
ORDER BY suggestions.created_at, suggestions.id;
|
||||
|
||||
-- name: CountDeck :one
|
||||
-- How many suggestions ListDeck returns.
|
||||
SELECT COUNT(*)
|
||||
FROM suggestions
|
||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
|
||||
|
||||
-- name: ListPassedNames :many
|
||||
-- Every name the household turned down, oldest decision first: the name
|
||||
-- each declined suggestion was suggested under, which renaming its dinner
|
||||
-- does not change, and the name each dinner a Rotation check dropped
|
||||
-- ("Not for us") had when dropped, which renaming it does not change.
|
||||
SELECT name FROM (
|
||||
SELECT name, decided_at, id
|
||||
FROM suggestions
|
||||
WHERE status = 'declined'
|
||||
UNION ALL
|
||||
SELECT name, decided_at, id
|
||||
FROM rotation_reviews
|
||||
WHERE decision = 'drop'
|
||||
)
|
||||
ORDER BY decided_at, id;
|
||||
|
||||
-- name: DecideSuggestion :execrows
|
||||
-- Records the household's decision on a pending suggestion; one already
|
||||
-- decided is left as it is (0 rows).
|
||||
UPDATE suggestions
|
||||
SET status = sqlc.arg(status), decided_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'pending';
|
||||
|
||||
-- name: MoveSuggestedDinner :execrows
|
||||
-- Moves a dinner still suggested to status (quest_log or archived),
|
||||
-- recording updated_by; a dinner no longer suggested is left as it is
|
||||
-- (0 rows).
|
||||
UPDATE dinners
|
||||
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id) AND status = 'suggested';
|
||||
+185
-25
@@ -1,11 +1,13 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
||||
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
@@ -19,12 +21,30 @@ function renderAt(path: string, location = fakeLocation(path)) {
|
||||
|
||||
function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<InDataRouter path={path}>
|
||||
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
|
||||
</MemoryRouter>,
|
||||
</InDataRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
// The routes that render a built screen rather than a placeholder page.
|
||||
const SCREENS = {
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/discover': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
'/settings/regulars': true,
|
||||
}
|
||||
|
||||
// SIGNED_IN answers what every route reads first: the session, then the
|
||||
// household settings, onboarding finished.
|
||||
const SIGNED_IN = {
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
}
|
||||
|
||||
describe('App routes', () => {
|
||||
it('registers every handoff route plus the extra settings pages', () => {
|
||||
expect(routes.map((route) => route.path)).toEqual([
|
||||
@@ -46,29 +66,132 @@ describe('App routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
||||
it.each(routes.filter(({ path }) => !Object.hasOwn(SCREENS, path)).map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
||||
'%s renders its placeholder page inside the shell',
|
||||
async (path, title) => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt(path)
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: title })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('/settings renders the settings home inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
...SETTINGS_API,
|
||||
})
|
||||
renderAt('/settings')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
|
||||
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||
})
|
||||
renderAt('/settings/ai')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/discover renders the Side quests deck inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [], active_batch: null, failed_batch: null }),
|
||||
'/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
|
||||
})
|
||||
renderAt('/discover')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Side quests' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Quest log · 0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings/household renders the household settings screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt('/settings/household')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
|
||||
})
|
||||
|
||||
it('/settings/regulars renders the Regular dinners screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [dinnerBody('d1', 'Beef tacos')] }),
|
||||
})
|
||||
renderAt('/settings/regulars')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Regular dinners' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App first-run redirect', () => {
|
||||
const FIRST_RUN = {
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }),
|
||||
}
|
||||
|
||||
it.each(routes.filter(({ path }) => path !== '/onboarding').map(({ path }) => path.replace(':id', 'abc123')))(
|
||||
'%s redirects to /onboarding while onboarding is not finished',
|
||||
async (path) => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(FIRST_RUN)
|
||||
renderAt(path)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('stays on an onboarding step', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({ ...FIRST_RUN, '/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [] }) })
|
||||
renderAt('/onboarding/regulars')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the routes once onboarding is finished', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt('/quick')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/quick')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App bootstrap', () => {
|
||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||
mockViewport(390)
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
renderAt('/plan')
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/quick')
|
||||
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||
})
|
||||
@@ -96,7 +219,7 @@ describe('App bootstrap', () => {
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
@@ -116,7 +239,7 @@ describe('App offline bootstrap', () => {
|
||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||
// member, then the app is closed.
|
||||
async function visitOnline() {
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
const { unmount } = renderAt('/')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
unmount()
|
||||
@@ -128,8 +251,15 @@ describe('App offline bootstrap', () => {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
|
||||
})
|
||||
|
||||
// A test that emits an offline event leaves TanStack Query's online
|
||||
// manager, shared by every test, offline until an online event.
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
window.dispatchEvent(new Event('online'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/plan', 'Plan'],
|
||||
['/quick', 'Quick dinners'],
|
||||
['/dinner/01J9ZK', 'Dinner'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
@@ -142,18 +272,43 @@ describe('App offline bootstrap', () => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the saved page when the browser has emitted an offline event', async () => {
|
||||
await visitOnline()
|
||||
// /me is still loading, with the app mounted, when the browser emits
|
||||
// its offline event; only then does /me fail.
|
||||
let failMe!: () => void
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>().mockImplementationOnce(
|
||||
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
||||
).mockRejectedValue(new TypeError('Failed to fetch')),
|
||||
)
|
||||
renderAt('/quick')
|
||||
await vi.waitFor(() => expect(failMe).toBeDefined())
|
||||
onLine = false
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
})
|
||||
act(() => failMe())
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
||||
await visitOnline()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
renderAt('/plan')
|
||||
renderAt('/quick')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
})
|
||||
|
||||
it('reports the failure offline when this device never verified a session', async () => {
|
||||
goOffline()
|
||||
renderAt('/plan')
|
||||
renderAt('/quick')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
@@ -162,12 +317,12 @@ describe('App offline bootstrap', () => {
|
||||
it('reports the failure offline after the session was refused', async () => {
|
||||
await visitOnline()
|
||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const { location, unmount } = renderAt('/plan')
|
||||
const { location, unmount } = renderAt('/quick')
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
||||
unmount()
|
||||
|
||||
goOffline()
|
||||
renderAt('/plan')
|
||||
renderAt('/quick')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -175,22 +330,27 @@ describe('App offline bootstrap', () => {
|
||||
it('loads /me again on reconnect and refuses writes until it has', async () => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<InDataRouter path="/quick">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</MemoryRouter>,
|
||||
</InDataRouter>,
|
||||
)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
const fetchMock = stubFetchByPath(SIGNED_IN)
|
||||
onLine = true
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('online'))
|
||||
})
|
||||
await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }))
|
||||
await vi.waitFor(() =>
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||
'/api/v1/me',
|
||||
'/api/v1/settings/household',
|
||||
]),
|
||||
)
|
||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+33
-7
@@ -1,11 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
import { PlanPage } from './plan/PlanPage.tsx'
|
||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
@@ -27,14 +35,31 @@ export const routes = [
|
||||
{ path: '/settings/calendar', title: 'Calendar' },
|
||||
] as const
|
||||
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan': <PlanPage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
'/settings/regulars': <RegularsSettingsPage />,
|
||||
}
|
||||
|
||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||
// Onboarding is a full screen of its own, outside the shell; until it is
|
||||
// finished, OnboardingGate sends every other route to it.
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
{routes.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
|
||||
))}
|
||||
<Route element={<OnboardingGate />}>
|
||||
<Route path={`${ONBOARDING_PATH}/*`} element={<OnboardingPage />} />
|
||||
<Route element={<AppShell />}>
|
||||
{routes
|
||||
.filter(({ path }) => path !== ONBOARDING_PATH)
|
||||
.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
|
||||
))}
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
@@ -50,8 +75,9 @@ function reloadPage() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
|
||||
// MemoryRouter in tests).
|
||||
// App expects to be rendered by a data router's splat route
|
||||
// (createBrowserRouter in main.tsx, createMemoryRouter in tests), so pages can
|
||||
// block navigation.
|
||||
export default function App({ api, serviceWorker }: AppProps) {
|
||||
const [queryClient] = useState(createQueryClient)
|
||||
return (
|
||||
|
||||
@@ -41,6 +41,11 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
// LoadingScreen is the full-screen skeleton shown while MealQuest loads.
|
||||
export function LoadingScreen() {
|
||||
return (
|
||||
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
|
||||
<div className={styles.loading}>
|
||||
|
||||
@@ -225,4 +225,14 @@ describe('network status', () => {
|
||||
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
|
||||
expect(api.network.offline()).toBe(false)
|
||||
})
|
||||
|
||||
it('says whether a read is the service worker\'s cached copy', async () => {
|
||||
const cached = jsonResponse(200, { id: '01J9ZR' })
|
||||
cached.headers.set(cacheFallbackHeader, '1')
|
||||
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
|
||||
await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: true })
|
||||
await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: false })
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user