Skip to content

Commit a2116ec

Browse files
ALai57claude
andcommitted
Hardening from final review: scraper guards + test tightening
- extract-with-llm: guard against a non-array "sections" value (e.g. a string) so it takes the same empty-sections fallback path as missing/empty; rename the shadowing `empty?` binding to `no-sections?` - parse-json-ld: return nil for a Recipe node with no (or blank) name, so it falls through to the LLM path instead of yielding a nil title that 500s on ScrapeResult response coercion - extend llm-fallback-empty-sections-guard-test to cover both the empty-array and non-array "sections" payloads - add json-ld-without-name-is-not-a-recipe-test for the nil-title guard - assert the dropped-header warning text in header-ingredient-lines-trigger-grouping-test - add an HTTP-level named-sections create/retrieve round-trip test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6ad3b20 commit a2116ec

3 files changed

Lines changed: 52 additions & 27 deletions

File tree

src/kaleidoscope/api/recipe_scraper.clj

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -201,19 +201,20 @@
201201
vec))
202202

203203
(defn parse-json-ld
204-
"Extract verbatim recipe facts from JSON-LD, or nil if no Recipe found.
205-
Facts, not a draft: `scrape` decides how facts become sections."
204+
"Extract verbatim recipe facts from JSON-LD, or nil if no Recipe (or none with
205+
a name) found. Facts, not a draft: `scrape` decides how facts become sections."
206206
[html]
207207
(when-let [node (find-recipe-node (ld-json-blocks html))]
208-
(let [{:keys [steps section-names]} (parse-instructions (:recipeInstructions node))]
209-
{:title (:name node)
210-
:ingredients (vec (:recipeIngredient node))
211-
:steps steps
212-
:section-names section-names
213-
:servings (some-> (first-or-self (:recipeYield node)) str)
214-
:prep-time-minutes (iso-duration->minutes (:prepTime node))
215-
:cook-time-minutes (iso-duration->minutes (:cookTime node))
216-
:suggested-labels (->suggested-labels node)})))
208+
(when-not (str/blank? (:name node))
209+
(let [{:keys [steps section-names]} (parse-instructions (:recipeInstructions node))]
210+
{:title (:name node)
211+
:ingredients (vec (:recipeIngredient node))
212+
:steps steps
213+
:section-names section-names
214+
:servings (some-> (first-or-self (:recipeYield node)) str)
215+
:prep-time-minutes (iso-duration->minutes (:prepTime node))
216+
:cook-time-minutes (iso-duration->minutes (:cookTime node))
217+
:suggested-labels (->suggested-labels node)}))))
217218

218219
(defn- single-section
219220
[{:keys [ingredients steps]}]
@@ -342,22 +343,24 @@
342343
:messages [{:role "user" :content text}]})
343344
raw (-> response :content first :text)
344345
parsed (json/decode (llm/extract-json raw) true)]
345-
(let [sections (mapv (fn [{:keys [name ingredients steps]}]
346-
{:name name
347-
:ingredients (vec ingredients)
348-
:steps (vec steps)})
349-
(:sections parsed))
350-
empty? (empty? sections)]
346+
(let [raw-sections (:sections parsed)
347+
sections (when (sequential? raw-sections)
348+
(mapv (fn [{:keys [name ingredients steps]}]
349+
{:name name
350+
:ingredients (vec ingredients)
351+
:steps (vec steps)})
352+
raw-sections))
353+
no-sections? (empty? sections)]
351354
{:recipe {:title (:title parsed)
352-
:sections (if empty?
355+
:sections (if no-sections?
353356
[{:name nil :ingredients [] :steps []}]
354357
sections)
355358
:servings (:servings parsed)
356359
:prep-time-minutes (:prep_time_minutes parsed)
357360
:cook-time-minutes (:cook_time_minutes parsed)}
358361
:suggested-labels (vec (:suggested_labels parsed))
359362
:extraction-method "llm"
360-
:warnings (if empty? ["LLM returned no sections"] [])})))
363+
:warnings (if no-sections? ["LLM returned no sections"] [])})))
361364

362365
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
363366
;; Entry point

test/kaleidoscope/api/recipe_scraper_test.clj

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@
7979
(is (nil? (scraper/parse-json-ld "<html><body>just a blog</body></html>")))
8080
(is (nil? (scraper/parse-json-ld "<script type='application/ld+json'>{not valid json</script>")))))
8181

82+
(deftest json-ld-without-name-is-not-a-recipe-test
83+
(testing "a Recipe node with no name yields nil facts — falls through to the LLM path"
84+
(is (nil? (scraper/parse-json-ld "<script type='application/ld+json'>{\"@type\":\"Recipe\",\"recipeIngredient\":[\"salt\"],\"recipeInstructions\":\"Mix\"}</script>")))))
85+
8286
(deftest ssrf-rejection-test
8387
(testing "loopback, private, link-local, metadata, and non-http schemes are rejected"
8488
(is (match? {:reason :blocked-url}
@@ -114,13 +118,14 @@
114118
(scraper/scrape {:api-key "sk-test"} "http://example.com/stew"))))))
115119

116120
(deftest llm-fallback-empty-sections-guard-test
117-
(testing "an LLM response with no sections still satisfies the min-1-section shape"
118-
(with-redefs [scraper/fetch-direct (fn [_] "<html><body>vague food blog</body></html>")
119-
llm/post-anthropic-sync
120-
(fn [_ _] {:content [{:text "{\"title\":\"Mystery\",\"sections\":[],\"suggested_labels\":[]}"}]})]
121-
(is (match? {:recipe {:title "Mystery" :sections [{:ingredients [] :steps []}]}
122-
:warnings [#"no sections"]}
123-
(scraper/scrape {:api-key "sk-test"} "http://example.com/mystery"))))))
121+
(testing "an LLM response with no (or non-array) sections still satisfies the min-1-section shape"
122+
(doseq [sections-json ["\"sections\":[]" "\"sections\":\"none\""]]
123+
(with-redefs [scraper/fetch-direct (fn [_] "<html><body>vague food blog</body></html>")
124+
llm/post-anthropic-sync
125+
(fn [_ _] {:content [{:text (str "{\"title\":\"Mystery\"," sections-json ",\"suggested_labels\":[]}")}]})]
126+
(is (match? {:recipe {:title "Mystery" :sections [{:ingredients [] :steps []}]}
127+
:warnings [#"no sections"]}
128+
(scraper/scrape {:api-key "sk-test"} "http://example.com/mystery")))))))
124129

125130
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
126131
;; Bot-block fallback to the rendering fetcher
@@ -203,7 +208,8 @@
203208
llm/post-anthropic-sync (fn [_ _] {:content [{:text grouping}]})]
204209
(is (match? {:recipe {:sections [{:name "Cake" :ingredients ["2 cups flour"] :steps ["Mix"]}
205210
{:name "Frosting" :ingredients ["1 cup butter"] :steps ["Whip"]}]}
206-
:extraction-method "json-ld+llm-sections"}
211+
:extraction-method "json-ld+llm-sections"
212+
:warnings [#"For the cake:.*For the frosting:"]}
207213
(scraper/scrape {:api-key "sk-test"} public-url)))))))
208214

209215
(deftest sectioned-without-api-key-flattens-with-warning-test

test/kaleidoscope/http_api/recipes_test.clj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,19 @@
162162
as-writer
163163
(mock/json-body (assoc example-body
164164
:label-ids [(:id l1) (:id l2)]))))))))))
165+
166+
(deftest named-sections-round-trip-http-test
167+
(let [app (make-app "custom-authenticated-user")
168+
body {:content {:title "Layer Cake"
169+
:sections [{:name "Cake" :ingredients ["2 cups flour"] :steps ["Mix" "Bake"]}
170+
{:name "Frosting" :ingredients ["1 cup butter"] :steps ["Whip"]}]}
171+
:public-visibility true}]
172+
(testing "named sections survive create → retrieve through the router"
173+
(is (match? {:status 200 :body {:recipe-url "layer-cake"}}
174+
(app (-> (mock/request :post "https://andrewslai.com/recipes")
175+
as-writer
176+
(mock/json-body body)))))
177+
(is (match? {:status 200
178+
:body {:content {:sections [{:name "Cake" :ingredients ["2 cups flour"] :steps ["Mix" "Bake"]}
179+
{:name "Frosting" :ingredients ["1 cup butter"] :steps ["Whip"]}]}}}
180+
(app (mock/request :get "https://andrewslai.com/recipes/layer-cake")))))))

0 commit comments

Comments
 (0)