ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Gemeni (#480)

* first run at gemeni critiques

* oops

* trimming is whack yo

* fix up tests

* yuck

* start storign critiques

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
9fec3e6f 49352436

+713 -4
+1 -1
AGENTS.md
··· 48 48 - Keep commits scoped and reviewable; avoid mixing refactors with feature changes unless necessary. 49 49 50 50 ## Security & Configuration Notes 51 - - Required env vars: `KROGER_CLIENT_ID`, `KROGER_CLIENT_SECRET`, `AI_API_KEY`; optional `CLARITY_PROJECT_ID`, `GOOGLE_TAG_ID`, `GOOGLE_CONVERSION_LABEL`, `HISTORY_PATH`. Azure logging uses `AZURE_STORAGE_ACCOUNT_NAME` and `AZURE_STORAGE_PRIMARY_ACCOUNT_KEY`. 51 + - Required env vars: `KROGER_CLIENT_ID`, `KROGER_CLIENT_SECRET`, `AI_API_KEY`; optional `GEMINI_API_KEY`, `GEMINI_CRITIQUE_MODEL`, `CLARITY_PROJECT_ID`, `GOOGLE_TAG_ID`, `GOOGLE_CONVERSION_LABEL`, `HISTORY_PATH`. Azure logging uses `AZURE_STORAGE_ACCOUNT_NAME` and `AZURE_STORAGE_PRIMARY_ACCOUNT_KEY`. 52 52 - Never commit secrets or generated recipe outputs. If testing against real APIs, use minimal scopes and rotate keys promptly. 53 53 - Any handler that lets you see data from multiple users should go behind the /admin mux to secure it.
+3 -1
README.md
··· 11 11 ### Mandatory 12 12 - `KROGER_CLIENT_ID` - Kroger API client ID (required) 13 13 - `KROGER_CLIENT_SECRET` - Kroger API client secret (required) 14 - - `AI_API_KEY` - OpenAI or Anthropic API key (required) 14 + - `AI_API_KEY` - OpenAI API key for recipe generation and chat (required) 15 15 ### Optional 16 + - `GEMINI_API_KEY` - Gemini API key for cached recipe critique generation 17 + - `GEMINI_CRITIQUE_MODEL` - Gemini model for recipe critique (defaults to `gemini-2.5-flash`) 16 18 - `CLARITY_PROJECT_ID` - Microsoft Clarity project ID for web analytics (optional) 17 19 - `GOOGLE_TAG_ID` - Google Ads/gtag ID for web analytics (optional) 18 20 - `GOOGLE_CONVERSION_LABEL` - Google Ads conversion label used on `/auth/establish?signup=true` (optional)
+1
docs/cache-layout.md
··· 35 35 | `recipe_selection/` | JSON `recipeSelection` (`saved_hashes`, `dismissed_hashes`, `updated_at`) keyed by `<user_id>/<origin_hash>` | `internal/recipes/selection.go` (`saveRecipeSelection`) via `internal/recipes/server.go` (`handleSaveRecipe`, `handleDismissRecipe`) | `internal/recipes/selection.go` (`loadRecipeSelection`) via `internal/recipes/server.go` (`handleRegenerate`, `handleFinalize`, `handleRecipes`) | 36 36 | `recipe_thread/` | JSON `[]RecipeThreadEntry` (Q/A thread for a recipe hash) | `internal/recipes/thread.go` (`SaveThread`) | `internal/recipes/thread.go` (`ThreadFromCache`) | 37 37 | `recipe_feedback/` | JSON `feedback.Feedback` (`cooked`, `stars`, `comment`, `updated_at`) per recipe hash | `internal/recipes/feedback.go` (`SaveFeedback`) using `internal/recipes/feedback/model.go` (`Marshal`) via `internal/recipes/server.go` (`handleFeedback`) | `internal/recipes/feedback.go` (`FeedbackFromCache`) using `internal/recipes/feedback/model.go` (`Decode`) and `internal/recipes/server.go` (`handleSingle`, `handleFeedback`) | 38 + | `recipe_critiques/` | JSON `ai.RecipeCritique` (`schema_version`, `overall_score`, `summary`, `strengths`, `issues`, `suggested_fixes`, `model`, `critiqued_at`) per recipe hash | `internal/recipes/critique.go` (`SaveCritique`) via `internal/recipes/generator.go` (`GenerateRecipes`) after OpenAI recipe generation/regeneration | `internal/recipes/critique.go` (`CritiqueFromCache`) for internal analysis and future tuning workflows | 38 39 | `users/` | JSON `users/types.User` by user ID | `internal/users/storage.go` (`Update`) | `internal/users/storage.go` (`GetByID`, `List`) | 39 40 | `email2user/` | Plain text user ID keyed by normalized email | `internal/users/storage.go` (`FindOrCreateFromClerk`) | `internal/users/storage.go` (`GetByEmail`) | 40 41 | `location-store-requests/` | JSON `{store_id, zip, requested_at}` for stores present in location search but not yet supported for staples | `internal/locations/locations.go` (`POST /locations/request-store`) | `internal/locations/locations.go` (`RequestedStoreIDs`) and operational triage from shared cache/blob storage |
+12
go.mod
··· 24 24 golang.org/x/crypto v0.49.0 25 25 golang.org/x/net v0.52.0 26 26 golang.org/x/sync v0.20.0 27 + google.golang.org/genai v1.53.0 27 28 k8s.io/api v0.35.3 28 29 k8s.io/apimachinery v0.35.3 29 30 k8s.io/client-go v0.35.3 30 31 ) 31 32 32 33 require ( 34 + cloud.google.com/go v0.116.0 // indirect 35 + cloud.google.com/go/auth v0.9.3 // indirect 36 + cloud.google.com/go/compute/metadata v0.5.0 // indirect 33 37 code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c // indirect 34 38 filippo.io/edwards25519 v1.1.0 // indirect 35 39 github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect ··· 44 48 github.com/gobwas/httphead v0.1.0 // indirect 45 49 github.com/gobwas/pool v0.2.1 // indirect 46 50 github.com/gofrs/uuid v3.3.0+incompatible // indirect 51 + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 47 52 github.com/google/gnostic-models v0.7.0 // indirect 53 + github.com/google/go-cmp v0.7.0 // indirect 54 + github.com/google/s2a-go v0.1.8 // indirect 55 + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 56 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 48 57 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 49 58 github.com/json-iterator/go v1.1.12 // indirect 50 59 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect ··· 55 64 github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 56 65 github.com/woodsbury/decimal128 v1.3.0 // indirect 57 66 github.com/x448/float16 v0.8.4 // indirect 67 + go.opencensus.io v0.24.0 // indirect 58 68 go.yaml.in/yaml/v2 v2.4.3 // indirect 59 69 go.yaml.in/yaml/v3 v3.0.4 // indirect 60 70 golang.org/x/oauth2 v0.30.0 // indirect 61 71 golang.org/x/sys v0.42.0 // indirect 62 72 golang.org/x/term v0.41.0 // indirect 63 73 golang.org/x/time v0.9.0 // indirect 74 + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect 75 + google.golang.org/grpc v1.66.2 // indirect 64 76 google.golang.org/protobuf v1.36.8 // indirect 65 77 gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect 66 78 gopkg.in/inf.v0 v0.9.1 // indirect
+73
go.sum
··· 1 1 c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= 2 2 c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= 3 + cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 4 + cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= 5 + cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= 6 + cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= 7 + cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= 8 + cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= 9 + cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= 3 10 code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c h1:5eeuG0BHx1+DHeT3AP+ISKZ2ht1UjGhm581ljqYpVeQ= 4 11 code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= 5 12 filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= ··· 18 25 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= 19 26 github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= 20 27 github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= 28 + github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 21 29 github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= 22 30 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 23 31 github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= ··· 28 36 github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= 29 37 github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 30 38 github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 39 + github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 31 40 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 32 41 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 33 42 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 34 43 github.com/clerk/clerk-sdk-go/v2 v2.5.1 h1:RsakGNW6ie83b9KIRtKzqDXBJ//cURy9SJUbGhrsIKg= 35 44 github.com/clerk/clerk-sdk-go/v2 v2.5.1/go.mod h1:ncFmsPwmD5WpGCNW5bJve862j/HQfpkzsshXYV/quJ8= 45 + github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 46 + github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 36 47 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 37 48 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 38 49 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= ··· 42 53 github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= 43 54 github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= 44 55 github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 56 + github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 57 + github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 58 + github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 59 + github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 45 60 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 46 61 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 47 62 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= ··· 79 94 github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 80 95 github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= 81 96 github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 97 + github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 98 + github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 99 + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 100 + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 101 + github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 82 102 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 103 + github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 83 104 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 84 105 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 85 106 github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 86 107 github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 87 108 github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 109 + github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 88 110 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 111 + github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 89 112 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 90 113 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 91 114 github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= 92 115 github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= 116 + github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 93 117 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 94 118 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 95 119 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 120 + github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 121 + github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 96 122 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 97 123 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 98 124 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= ··· 101 127 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 102 128 github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= 103 129 github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 130 + github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= 131 + github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= 132 + github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 104 133 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 105 134 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 135 + github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= 136 + github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= 137 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 138 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 106 139 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 107 140 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 108 141 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= ··· 186 219 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 187 220 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 188 221 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 222 + github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 189 223 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 190 224 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 191 225 github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= ··· 240 274 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 241 275 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 242 276 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 277 + go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 278 + go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 243 279 go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= 244 280 go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 245 281 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= ··· 251 287 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 252 288 golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= 253 289 golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 290 + golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 291 + golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 292 + golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 293 + golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 254 294 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 255 295 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 256 296 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 257 297 golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= 258 298 golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 299 + golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 300 + golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 259 301 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 302 + golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 303 + golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 260 304 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 261 305 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 262 306 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 263 307 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 308 + golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 264 309 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 265 310 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 266 311 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 269 314 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 270 315 golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= 271 316 golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 317 + golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 272 318 golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 273 319 golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 274 320 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 321 + golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 275 322 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 276 323 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 277 324 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 278 325 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 279 326 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 280 327 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 328 + golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 281 329 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 282 330 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 283 331 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 319 367 golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 320 368 golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 321 369 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 370 + golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 371 + golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 372 + golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 373 + golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 322 374 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 323 375 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 324 376 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= ··· 329 381 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 330 382 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 331 383 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 384 + google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 385 + google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 386 + google.golang.org/genai v1.53.0 h1:8tR9MuO/TdaXSc8PEFamohQKxRz5M/qctbyzhV2YwMM= 387 + google.golang.org/genai v1.53.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= 388 + google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 389 + google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 390 + google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 391 + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= 392 + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= 393 + google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 394 + google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 395 + google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 396 + google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 397 + google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 398 + google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo= 399 + google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= 332 400 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 333 401 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 334 402 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 335 403 google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 336 404 google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 405 + google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 337 406 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 407 + google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 408 + google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 338 409 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 339 410 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 340 411 google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= ··· 361 432 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 362 433 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 363 434 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 435 + honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 436 + honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 364 437 k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= 365 438 k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= 366 439 k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8=
+195
internal/ai/critique.go
··· 1 + package ai 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "strings" 9 + "time" 10 + 11 + "github.com/invopop/jsonschema" 12 + "google.golang.org/genai" 13 + ) 14 + 15 + const ( 16 + // https://ai.google.dev/gemini-api/docs/models 17 + defaultGeminiCritiqueModel = "gemini-3.1-pro-preview" //"gemini-2.5-flash" 18 + recipeCritiqueSchemaV1 = "recipe-critique-v1" 19 + ) 20 + 21 + const recipeCritiqueSystemInstruction = ` 22 + You are a strict recipe editor reviewing AI-generated recipes before they are given to human cooks and used for future fine tuning. 23 + 24 + Judge the recipe like an experienced chef helping create recipes to teach home cooks: 25 + - is it realistic to cook as written 26 + - are the instructions coherent and complete 27 + - are the applications of salt, acid, fat, and heat appropriate 28 + - are the timing and cost estimates plausible 29 + - does the dish sound balanced, appealing, and well plated 30 + - are there any food safety or recipe logic issues 31 + 32 + Be concise and concrete. Return JSON only.` 33 + 34 + type RecipeCritiqueIssue struct { 35 + Severity string `json:"severity" jsonschema:"enum=low,enum=medium,enum=high"` 36 + Category string `json:"category" jsonschema:"enum=cookability,enum=safety,enum=clarity,enum=flavor,enum=timing,enum=cost,enum=nutrition,enum=ingredient_usage,enum=presentation"` 37 + Detail string `json:"detail"` 38 + } 39 + 40 + type RecipeCritique struct { 41 + SchemaVersion string `json:"schema_version" jsonschema:"enum=recipe-critique-v1"` 42 + OverallScore int `json:"overall_score" jsonschema:"minimum=1,maximum=10"` 43 + // creativity and practicality scores? 44 + Summary string `json:"summary"` 45 + Strengths []string `json:"strengths"` 46 + Issues []RecipeCritiqueIssue `json:"issues"` 47 + SuggestedFixes []string `json:"suggested_fixes"` 48 + Model string `json:"model,omitempty" jsonschema:"-"` 49 + CritiquedAt time.Time `json:"critiqued_at,omitempty" jsonschema:"-"` 50 + } 51 + 52 + type Critiquer struct { 53 + apiKey string 54 + model string 55 + schema map[string]any 56 + } 57 + 58 + func NewCritiquer(apiKey, model string) *Critiquer { 59 + model = strings.TrimSpace(model) 60 + if model == "" { 61 + model = defaultGeminiCritiqueModel 62 + } 63 + return &Critiquer{ 64 + apiKey: strings.TrimSpace(apiKey), 65 + model: model, 66 + schema: recipeCritiqueJSONSchema(), 67 + } 68 + } 69 + 70 + func (c *Critiquer) Ready(ctx context.Context) error { 71 + if c == nil || c.apiKey == "" { 72 + return fmt.Errorf("gemini critique client is not configured") 73 + } 74 + client, err := c.newClient(ctx) 75 + if err != nil { 76 + return err 77 + } 78 + for _, err := range client.Models.All(ctx) { 79 + return err 80 + } 81 + return fmt.Errorf("model not found: %s", c.model) 82 + /* expensive? 83 + resp, err := client.Models.GenerateContent(ctx, c.model, genai.Text("Reply with ready."), &genai.GenerateContentConfig{ 84 + Temperature: genai.Ptr[float32](0), 85 + MaxOutputTokens: 8, 86 + }) 87 + if err != nil { 88 + return err 89 + } 90 + if strings.TrimSpace(resp.Text()) == "" { 91 + return fmt.Errorf("empty response from Gemini critique model") 92 + } 93 + */ 94 + } 95 + 96 + func (c *Critiquer) CritiqueRecipe(ctx context.Context, recipe Recipe) (*RecipeCritique, error) { 97 + if c == nil || c.apiKey == "" { 98 + return nil, fmt.Errorf("gemini critique client is not configured") 99 + } 100 + prompt, err := buildRecipeCritiquePrompt(recipe) 101 + if err != nil { 102 + return nil, fmt.Errorf("failed to build recipe critique prompt: %w", err) 103 + } 104 + client, err := c.newClient(ctx) 105 + if err != nil { 106 + return nil, err 107 + } 108 + resp, err := client.Models.GenerateContent(ctx, c.model, genai.Text(prompt), &genai.GenerateContentConfig{ 109 + SystemInstruction: genai.NewContentFromText(recipeCritiqueSystemInstruction, genai.RoleUser), 110 + // Temperature: genai.Ptr[float32](0), 111 + // MaxOutputTokens: 768, 112 + ResponseMIMEType: "application/json", 113 + ResponseJsonSchema: c.schema, 114 + }) 115 + if err != nil { 116 + return nil, fmt.Errorf("failed to critique recipe: %w", err) 117 + } 118 + slog.InfoContext(ctx, "Gemini critique usage", 119 + "model", c.model, 120 + "model_version", resp.ModelVersion, 121 + "response_id", resp.ResponseID, 122 + "usage", resp.UsageMetadata, 123 + ) 124 + 125 + critique, err := parseRecipeCritique(resp.Text()) 126 + if err != nil { 127 + return nil, err 128 + } 129 + critique.Model = resp.ModelVersion 130 + critique.CritiquedAt = time.Now().UTC() 131 + return critique, nil 132 + } 133 + 134 + func (c *Critiquer) newClient(ctx context.Context) (*genai.Client, error) { 135 + client, err := genai.NewClient(ctx, &genai.ClientConfig{ 136 + APIKey: c.apiKey, 137 + Backend: genai.BackendGeminiAPI, 138 + }) 139 + if err != nil { 140 + return nil, fmt.Errorf("create Gemini client: %w", err) 141 + } 142 + return client, nil 143 + } 144 + 145 + func parseRecipeCritique(body string) (*RecipeCritique, error) { 146 + body = strings.TrimSpace(body) 147 + if body == "" { 148 + return nil, fmt.Errorf("empty critique response from Gemini") 149 + } 150 + var critique RecipeCritique 151 + if err := json.Unmarshal([]byte(body), &critique); err != nil { 152 + return nil, fmt.Errorf("failed to parse Gemini critique: %w", err) 153 + } 154 + critique.SchemaVersion = recipeCritiqueSchemaV1 155 + 156 + if critique.Summary == "" { 157 + return nil, fmt.Errorf("gemini critique summary is required") 158 + } 159 + if critique.OverallScore < 1 || critique.OverallScore > 10 { 160 + return nil, fmt.Errorf("gemini critique overall score must be between 1 and 10") 161 + } 162 + return &critique, nil 163 + } 164 + 165 + func buildRecipeCritiquePrompt(recipe Recipe) (string, error) { 166 + payload := recipe 167 + payload.OriginHash = "" 168 + payload.Saved = false 169 + body, err := json.MarshalIndent(payload, "", " ") 170 + if err != nil { 171 + return "", fmt.Errorf("marshal recipe critique payload: %w", err) 172 + } 173 + return fmt.Sprintf( 174 + "Critique this generated recipe for correctness and usefulness to a home cook.\nReturn JSON only using schema_version %q.\nRecipe JSON:\n%s", 175 + recipeCritiqueSchemaV1, 176 + string(body), 177 + ), nil 178 + } 179 + 180 + func recipeCritiqueJSONSchema() map[string]any { 181 + r := jsonschema.Reflector{ 182 + DoNotReference: true, 183 + ExpandedStruct: true, 184 + } 185 + schema := r.Reflect(&RecipeCritique{}) 186 + body, err := json.Marshal(schema) 187 + if err != nil { 188 + panic(fmt.Sprintf("marshal recipe critique schema: %v", err)) 189 + } 190 + var out map[string]any 191 + if err := json.Unmarshal(body, &out); err != nil { 192 + panic(fmt.Sprintf("decode recipe critique schema: %v", err)) 193 + } 194 + return out 195 + }
+91
internal/ai/critique_test.go
··· 1 + package ai 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + "github.com/stretchr/testify/require" 8 + ) 9 + 10 + func TestBuildRecipeCritiquePrompt(t *testing.T) { 11 + recipe := Recipe{ 12 + Title: "Roast Chicken", 13 + Description: "Crisp skin and herbs.", 14 + CookTime: "45 minutes", 15 + CostEstimate: "$18-24", 16 + Ingredients: []Ingredient{ 17 + {Name: "Chicken", Quantity: "1 whole", Price: "$12"}, 18 + {Name: "Lemon", Quantity: "1", Price: "$1"}, 19 + }, 20 + Instructions: []string{"Roast until golden.", "Finish with lemon juice."}, 21 + Health: "Balanced dinner", 22 + DrinkPairing: "Pinot Noir", 23 + OriginHash: "internal-metadata", 24 + Saved: true, 25 + } 26 + 27 + prompt, err := buildRecipeCritiquePrompt(recipe) 28 + require.NoError(t, err) 29 + for _, want := range []string{ 30 + `"title": "Roast Chicken"`, 31 + `"cook_time": "45 minutes"`, 32 + `"name": "Chicken"`, 33 + `"quantity": "1 whole"`, 34 + `"price": "$12"`, 35 + `"instructions": [`, 36 + `"Roast until golden."`, 37 + `Recipe JSON:`, 38 + `Return JSON only using schema_version "recipe-critique-v1".`, 39 + } { 40 + assert.Contains(t, prompt, want) 41 + } 42 + for _, unwanted := range []string{ 43 + `"origin_hash"`, 44 + `"previously_saved"`, 45 + } { 46 + assert.NotContains(t, prompt, unwanted) 47 + } 48 + } 49 + 50 + func TestParseRecipeCritique(t *testing.T) { 51 + critique, err := parseRecipeCritique(`{ 52 + "schema_version": "recipe-critique-v1", 53 + "overall_score": 8, 54 + "summary": "Strong draft.", 55 + "strengths": ["balanced flavors"], 56 + "issues": [{"severity": "HIGH", "category": "Timing", "detail": "Reduce the sauce longer."}], 57 + "suggested_fixes": [" simmer longer "] 58 + }`) 59 + require.NoError(t, err) 60 + assert.Equal(t, "Strong draft.", critique.Summary) 61 + require.Len(t, critique.Strengths, 1) 62 + assert.Equal(t, "balanced flavors", critique.Strengths[0]) 63 + require.Len(t, critique.Issues, 1) 64 + assert.Equal(t, "HIGH", critique.Issues[0].Severity) 65 + assert.Equal(t, "Timing", critique.Issues[0].Category) 66 + assert.Equal(t, "Reduce the sauce longer.", critique.Issues[0].Detail) 67 + require.Len(t, critique.SuggestedFixes, 1) 68 + assert.Equal(t, " simmer longer ", critique.SuggestedFixes[0]) 69 + } 70 + 71 + func TestParseRecipeCritiqueRequiresScoreRange(t *testing.T) { 72 + _, err := parseRecipeCritique(`{"schema_version":"recipe-critique-v1","overall_score":11,"summary":"too high","strengths":[],"issues":[],"suggested_fixes":[]}`) 73 + require.Error(t, err) 74 + assert.Contains(t, err.Error(), "overall score") 75 + } 76 + 77 + func TestRecipeCritiqueJSONSchemaTracksStruct(t *testing.T) { 78 + schema := recipeCritiqueJSONSchema() 79 + 80 + properties, ok := schema["properties"].(map[string]any) 81 + require.True(t, ok, "expected top-level properties object, got %#v", schema["properties"]) 82 + assert.Contains(t, properties, "schema_version") 83 + assert.Contains(t, properties, "overall_score") 84 + assert.NotContains(t, properties, "model") 85 + assert.NotContains(t, properties, "critiqued_at") 86 + 87 + overallScore, ok := properties["overall_score"].(map[string]any) 88 + require.True(t, ok, "expected overall_score schema object, got %#v", properties["overall_score"]) 89 + assert.Equal(t, float64(1), overallScore["minimum"]) 90 + assert.Equal(t, float64(10), overallScore["maximum"]) 91 + }
+14
internal/config/config.go
··· 18 18 19 19 type Config struct { 20 20 AI AIConfig `json:"ai"` 21 + Gemini GeminiConfig `json:"gemini"` 21 22 Kroger KrogerConfig `json:"kroger"` 22 23 Walmart WalmartConfig `json:"walmart"` 23 24 Aldi AldiConfig `json:"aldi"` ··· 35 36 36 37 type AIConfig struct { 37 38 APIKey string `json:"api_key"` 39 + } 40 + 41 + type GeminiConfig struct { 42 + APIKey string `json:"api_key"` 43 + CritiqueModel string `json:"critique_model"` 44 + } 45 + 46 + func (c *GeminiConfig) IsEnabled() bool { 47 + return strings.TrimSpace(c.APIKey) != "" 38 48 } 39 49 40 50 type KrogerConfig struct { ··· 153 163 config := &Config{ 154 164 AI: AIConfig{ 155 165 APIKey: os.Getenv("AI_API_KEY"), 166 + }, 167 + Gemini: GeminiConfig{ 168 + APIKey: os.Getenv("GEMINI_API_KEY"), 169 + CritiqueModel: os.Getenv("GEMINI_CRITIQUE_MODEL"), 156 170 }, 157 171 Kroger: KrogerConfig{ 158 172 ClientID: os.Getenv("KROGER_CLIENT_ID"),
+24
internal/config/config_test.go
··· 117 117 } 118 118 } 119 119 120 + func TestLoadReadsGeminiCritiqueConfig(t *testing.T) { 121 + resetStoreEnvs(t) 122 + t.Setenv("ENABLE_MOCKS", "1") 123 + t.Setenv("GEMINI_API_KEY", "gemini-key") 124 + t.Setenv("GEMINI_CRITIQUE_MODEL", "gemini-2.5-pro") 125 + 126 + cfg, err := Load() 127 + if err != nil { 128 + t.Fatalf("Load() error = %v", err) 129 + } 130 + 131 + if got, want := cfg.Gemini.APIKey, "gemini-key"; got != want { 132 + t.Fatalf("expected Gemini API key %q, got %q", want, got) 133 + } 134 + if got, want := cfg.Gemini.CritiqueModel, "gemini-2.5-pro"; got != want { 135 + t.Fatalf("expected Gemini critique model %q, got %q", want, got) 136 + } 137 + if !cfg.Gemini.IsEnabled() { 138 + t.Fatal("expected Gemini critique config to be enabled") 139 + } 140 + } 141 + 120 142 func TestResolvedPublicOriginDefaultsToLocalhostOutsideProd(t *testing.T) { 121 143 cfg := &Config{} 122 144 if got, want := cfg.ResolvedPublicOrigin(), "http://localhost:8080"; got != want { ··· 168 190 "BRIGHTDATA_PROXY_PORT", 169 191 "BRIGHTDATA_PROXY_USERNAME", 170 192 "BRIGHTDATA_PROXY_PASSWORD", 193 + "GEMINI_API_KEY", 194 + "GEMINI_CRITIQUE_MODEL", 171 195 "PUBLIX_ENABLE", 172 196 "HEB_ENABLE", 173 197 } {
+40
internal/recipes/critique.go
··· 1 + package recipes 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + 8 + "careme/internal/ai" 9 + "careme/internal/cache" 10 + ) 11 + 12 + const recipeCritiquesCachePrefix = "recipe_critiques/" 13 + 14 + func recipeCritiqueCacheKey(hash string) string { 15 + return recipeCritiquesCachePrefix + hash 16 + } 17 + 18 + func (rio recipeio) CritiqueFromCache(ctx context.Context, hash string) (*ai.RecipeCritique, error) { 19 + critiqueReader, err := rio.Cache.Get(ctx, recipeCritiqueCacheKey(hash)) 20 + if err != nil { 21 + return nil, err 22 + } 23 + defer func() { 24 + _ = critiqueReader.Close() 25 + }() 26 + var critique ai.RecipeCritique 27 + err = json.NewDecoder(critiqueReader).Decode(&critique) 28 + return &critique, err 29 + } 30 + 31 + func (rio recipeio) SaveCritique(ctx context.Context, hash string, critique *ai.RecipeCritique) error { 32 + if critique == nil { 33 + return fmt.Errorf("recipe critique is required") 34 + } 35 + body, err := json.Marshal(critique) 36 + if err != nil { 37 + return err 38 + } 39 + return rio.Cache.Put(ctx, recipeCritiqueCacheKey(hash), string(body), cache.Unconditional()) 40 + }
+59 -2
internal/recipes/generator.go
··· 33 33 Ready(ctx context.Context) error 34 34 } 35 35 36 + type recipeCritiquer interface { 37 + CritiqueRecipe(ctx context.Context, recipe ai.Recipe) (*ai.RecipeCritique, error) 38 + Ready(ctx context.Context) error 39 + } 40 + 36 41 type ingredientio interface { 37 42 SaveIngredients(ctx context.Context, hash string, ingredients []kroger.Ingredient) error 38 43 IngredientsFromCache(ctx context.Context, hash string) ([]kroger.Ingredient, error) 39 44 } 40 45 46 + type critiqueIO interface { 47 + SaveCritique(ctx context.Context, hash string, critique *ai.RecipeCritique) error 48 + } 49 + 41 50 type Generator struct { 42 51 config *config.Config 43 52 aiClient aiClient 53 + critiquer recipeCritiquer 44 54 staplesProvider staplesProvider 45 55 io ingredientio 56 + cio critiqueIO // pull this out? 46 57 } 47 58 48 - func NewGenerator(cfg *config.Config, io ingredientio) (generatorPlus, error) { 59 + type allIO interface { 60 + ingredientio 61 + critiqueIO 62 + } 63 + 64 + func NewGenerator(cfg *config.Config, io allIO) (generatorPlus, error) { 49 65 if cfg.Mocks.Enable { 50 66 return mock{}, nil 51 67 } ··· 55 71 return nil, fmt.Errorf("failed to create staples provider: %w", err) 56 72 } 57 73 74 + var critiquer recipeCritiquer 75 + if cfg.Gemini.IsEnabled() { 76 + critiquer = ai.NewCritiquer(cfg.Gemini.APIKey, cfg.Gemini.CritiqueModel) 77 + } 78 + 58 79 return &Generator{ 59 80 io: io, 81 + cio: io, // pull this out? 60 82 config: cfg, 61 83 aiClient: ai.NewClient(cfg.AI.APIKey, "TODOMODEL"), 84 + critiquer: critiquer, 62 85 staplesProvider: stapesProvider, 63 86 }, nil 64 87 } ··· 141 164 if err != nil { 142 165 return nil, fmt.Errorf("failed to regenerate recipes with AI: %w", err) 143 166 } 167 + if err := g.cacheRecipeCritiques(ctx, shoppingList.Recipes); err != nil { 168 + return nil, fmt.Errorf("failed to cache recipe critiques: %w", err) 169 + } 144 170 // Include saved recipes in the shopping list 145 171 shoppingList.Recipes = append(shoppingList.Recipes, p.Saved...) 146 172 ··· 159 185 if err != nil { 160 186 return nil, fmt.Errorf("failed to generate recipes with AI: %w", err) 161 187 } 188 + if err := g.cacheRecipeCritiques(ctx, shoppingList.Recipes); err != nil { 189 + return nil, fmt.Errorf("failed to cache recipe critiques: %w", err) 190 + } 191 + // how to pipe this back to ai client? should ai client hjave its own critiquer or do we just call regenerate once? 162 192 163 193 // should never happen? How do you get save on first generte? 164 194 // shoppingList.Recipes = append(shoppingList.Recipes, p.Saved...) ··· 214 244 } 215 245 216 246 func (g *Generator) Ready(ctx context.Context) error { 217 - return g.aiClient.Ready(ctx) 247 + if err := g.aiClient.Ready(ctx); err != nil { 248 + return err 249 + } 250 + if err := g.critiquer.Ready(ctx); err != nil { 251 + return fmt.Errorf("gemini critique client not ready: %w", err) 252 + } 253 + return nil 218 254 } 219 255 220 256 // this is a little expnsive so unlike ready above needs to be protected by a once by. ··· 262 298 } 263 299 return lo.Uniq(titles) 264 300 } 301 + 302 + func (g *Generator) cacheRecipeCritiques(ctx context.Context, recipes []ai.Recipe) error { 303 + if g.critiquer == nil || g.cio == nil { 304 + // yuck refactor tests to make this alway present 305 + return nil 306 + } 307 + _, err := parallelism.MapWithErrors(recipes, func(recipe ai.Recipe) (int, error) { 308 + hash := recipe.ComputeHash() 309 + critique, err := g.critiquer.CritiqueRecipe(ctx, recipe) 310 + if err != nil { 311 + slog.ErrorContext(ctx, "failed to critique recipe", "recipe", recipe.Title, "hash", hash, "error", err) 312 + return 0, fmt.Errorf("critique recipe %q (%s): %w", recipe.Title, hash, err) 313 + } 314 + if err := g.cio.SaveCritique(ctx, hash, critique); err != nil { 315 + slog.ErrorContext(ctx, "failed to cache recipe critique", "recipe", recipe.Title, "hash", hash, "error", err) 316 + return 0, fmt.Errorf("cache critique for recipe %q (%s): %w", recipe.Title, hash, err) 317 + } 318 + return 0, nil 319 + }) 320 + return err 321 + }
+168
internal/recipes/generator_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "slices" 6 7 "sync" 7 8 "testing" ··· 24 25 instructions []string 25 26 conversationID string 26 27 shoppingList *ai.ShoppingList 28 + } 29 + 30 + type captureGenerateAIClient struct { 31 + shoppingList *ai.ShoppingList 32 + } 33 + 34 + type captureCritiquer struct { 35 + mu sync.Mutex 36 + err error 37 + recipes []ai.Recipe 27 38 } 28 39 29 40 type captureWineStaplesProvider struct { ··· 93 104 return nil 94 105 } 95 106 107 + func (c *captureGenerateAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 108 + if c.shoppingList != nil { 109 + return c.shoppingList, nil 110 + } 111 + return &ai.ShoppingList{}, nil 112 + } 113 + 114 + func (c *captureGenerateAIClient) Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) { 115 + panic("unexpected call to Regenerate") 116 + } 117 + 118 + func (c *captureGenerateAIClient) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 119 + panic("unexpected call to AskQuestion") 120 + } 121 + 122 + func (c *captureGenerateAIClient) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 123 + panic("unexpected call to GenerateRecipeImage") 124 + } 125 + 126 + func (c *captureGenerateAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) { 127 + panic("unexpected call to PickWine") 128 + } 129 + 130 + func (c *captureGenerateAIClient) Ready(ctx context.Context) error { 131 + return nil 132 + } 133 + 134 + func (c *captureCritiquer) CritiqueRecipe(ctx context.Context, recipe ai.Recipe) (*ai.RecipeCritique, error) { 135 + c.mu.Lock() 136 + c.recipes = append(c.recipes, recipe) 137 + c.mu.Unlock() 138 + if c.err != nil { 139 + return nil, c.err 140 + } 141 + return &ai.RecipeCritique{ 142 + SchemaVersion: "recipe-critique-v1", 143 + OverallScore: 7, 144 + Summary: "Solid draft.", 145 + Strengths: []string{"clear direction"}, 146 + Issues: []ai.RecipeCritiqueIssue{{Severity: "medium", Category: "timing", Detail: "Timing could be tighter."}}, 147 + SuggestedFixes: []string{"tighten the timing"}, 148 + }, nil 149 + } 150 + 151 + func (c *captureCritiquer) Ready(ctx context.Context) error { 152 + return c.err 153 + } 154 + 96 155 func (s *captureWineStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 97 156 panic("unexpected call to FetchStaples") 98 157 } ··· 263 322 } 264 323 if got.Recipes[0].Title != "Brand New Dinner" || got.Recipes[1].Title != "Already Saved" || got.Recipes[2].Title != "Newly Saved" { 265 324 t.Fatalf("unexpected recipe order after regenerate: %+v", got.Recipes) 325 + } 326 + } 327 + 328 + func TestGenerateRecipes_SavesCritiquesForGeneratedRecipes(t *testing.T) { 329 + generated := []ai.Recipe{ 330 + {Title: "Roast Chicken", Description: "Crisp and simple", Instructions: []string{"Roast the chicken."}}, 331 + {Title: "Pasta Primavera", Description: "Vegetable pasta", Instructions: []string{"Boil pasta.", "Toss with vegetables."}}, 332 + } 333 + 334 + cacheStore := cache.NewFileCache(t.TempDir()) 335 + io := IO(cacheStore) 336 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 337 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 338 + t.Fatalf("failed to seed ingredients cache: %v", err) 339 + } 340 + 341 + aiStub := &captureGenerateAIClient{ 342 + shoppingList: &ai.ShoppingList{ 343 + ConversationID: "conv-123", 344 + Recipes: generated, 345 + }, 346 + } 347 + critiquer := &captureCritiquer{} 348 + g := &Generator{ 349 + io: io, 350 + cio: io, 351 + aiClient: aiStub, 352 + critiquer: critiquer, 353 + } 354 + 355 + got, err := g.GenerateRecipes(t.Context(), params) 356 + if err != nil { 357 + t.Fatalf("GenerateRecipes returned error: %v", err) 358 + } 359 + if got.ConversationID != "conv-123" { 360 + t.Fatalf("expected conversation id to survive, got %q", got.ConversationID) 361 + } 362 + if len(critiquer.recipes) != len(generated) { 363 + t.Fatalf("expected %d critiques, got %d", len(generated), len(critiquer.recipes)) 364 + } 365 + for _, recipe := range generated { 366 + critique, err := io.CritiqueFromCache(t.Context(), recipe.ComputeHash()) 367 + if err != nil { 368 + t.Fatalf("expected critique for %q: %v", recipe.Title, err) 369 + } 370 + if critique.Summary != "Solid draft." { 371 + t.Fatalf("unexpected critique summary for %q: %#v", recipe.Title, critique) 372 + } 373 + } 374 + } 375 + 376 + func TestGenerateRecipes_CritiqueFailuresFailGeneration(t *testing.T) { 377 + cacheStore := cache.NewFileCache(t.TempDir()) 378 + io := IO(cacheStore) 379 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 380 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 381 + t.Fatalf("failed to seed ingredients cache: %v", err) 382 + } 383 + 384 + recipe := ai.Recipe{Title: "Roast Chicken", Description: "Crisp and simple", Instructions: []string{"Roast the chicken."}} 385 + g := &Generator{ 386 + io: io, 387 + cio: io, 388 + aiClient: &captureGenerateAIClient{ 389 + shoppingList: &ai.ShoppingList{ 390 + ConversationID: "conv-123", 391 + Recipes: []ai.Recipe{recipe}, 392 + }, 393 + }, 394 + critiquer: &captureCritiquer{err: errors.New("gemini down")}, 395 + } 396 + 397 + got, err := g.GenerateRecipes(t.Context(), params) 398 + if err == nil { 399 + t.Fatal("expected GenerateRecipes to fail when critique caching fails") 400 + } 401 + if got != nil { 402 + t.Fatalf("expected no shopping list on critique failure, got %+v", got) 403 + } 404 + if _, err := io.CritiqueFromCache(t.Context(), recipe.ComputeHash()); !errors.Is(err, cache.ErrNotFound) { 405 + t.Fatalf("expected no cached critique after failure, got %v", err) 406 + } 407 + } 408 + 409 + func TestGenerateRecipes_RegenerateCritiquesOnlyFreshRecipes(t *testing.T) { 410 + alreadySaved := ai.Recipe{Title: "Already Saved", Description: "Saved earlier"} 411 + newResult := ai.Recipe{Title: "Brand New Dinner", Description: "Fresh idea"} 412 + 413 + critiquer := &captureCritiquer{} 414 + g := &Generator{ 415 + io: IO(cache.NewInMemoryCache()), 416 + cio: IO(cache.NewInMemoryCache()), 417 + aiClient: &captureRegenerateAIClient{shoppingList: &ai.ShoppingList{ConversationID: "conv-123", Recipes: []ai.Recipe{newResult}}}, 418 + critiquer: critiquer, 419 + } 420 + 421 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 422 + params.ConversationID = "conv-123" 423 + params.Saved = []ai.Recipe{alreadySaved} 424 + 425 + got, err := g.GenerateRecipes(t.Context(), params) 426 + if err != nil { 427 + t.Fatalf("GenerateRecipes returned error: %v", err) 428 + } 429 + if got == nil || len(got.Recipes) != 2 { 430 + t.Fatalf("expected regenerated list plus saved recipes, got %+v", got) 431 + } 432 + if len(critiquer.recipes) != 1 || critiquer.recipes[0].Title != "Brand New Dinner" { 433 + t.Fatalf("expected only the newly generated recipe to be critiqued, got %+v", critiquer.recipes) 266 434 } 267 435 } 268 436
+32
internal/recipes/io_test.go
··· 177 177 } 178 178 } 179 179 180 + func TestSaveCritique_UsesPrefixedKey(t *testing.T) { 181 + tmpDir := t.TempDir() 182 + cacheStore := cache.NewFileCache(tmpDir) 183 + rio := IO(cacheStore) 184 + 185 + hash := "recipe-hash" 186 + critique := &ai.RecipeCritique{ 187 + SchemaVersion: "recipe-critique-v1", 188 + OverallScore: 8, 189 + Summary: "Strong draft.", 190 + Strengths: []string{"balanced"}, 191 + Issues: []ai.RecipeCritiqueIssue{{Severity: "low", Category: "clarity", Detail: "One step could be tighter."}}, 192 + SuggestedFixes: []string{"tighten one step"}, 193 + } 194 + 195 + if err := rio.SaveCritique(t.Context(), hash, critique); err != nil { 196 + t.Fatalf("SaveCritique failed: %v", err) 197 + } 198 + 199 + if _, err := os.Stat(filepath.Join(tmpDir, recipeCritiquesCachePrefix, hash)); err != nil { 200 + t.Fatalf("expected recipe critique at prefixed key: %v", err) 201 + } 202 + 203 + got, err := rio.CritiqueFromCache(t.Context(), hash) 204 + if err != nil { 205 + t.Fatalf("CritiqueFromCache failed: %v", err) 206 + } 207 + if got.Summary != "Strong draft." { 208 + t.Fatalf("unexpected cached critique: %#v", got) 209 + } 210 + } 211 + 180 212 func loPtr(v string) *string { 181 213 return &v 182 214 }