Background grounding and recipe fix-up API (#90)
CI / ci (push) Successful in 2m24s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #90.
This commit is contained in:
2026-10-06 16:39:10 -04:00
committed by approver_agent
parent 729c5bd0fe
commit 423b898f54
23 changed files with 2424 additions and 83 deletions
+90 -2
View File
@@ -9,11 +9,16 @@ import (
"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 or a recipe_url.
type dinnerCreate struct {
Name *string `json:"name"`
@@ -33,6 +38,25 @@ 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) {
@@ -45,7 +69,10 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
}
}
// postDinner adds a Regular, recording the session's actor as updated_by.
// 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)
@@ -87,6 +114,46 @@ func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
}
}
// 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) {
@@ -130,11 +197,32 @@ func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (a
return actor, ok
}
// writeDinnerError maps a dinners.Service error to its API error.
// 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.
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.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):